From 4940ebc4199dc4ff05f14fda7b93942f266caaa5 Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Thu, 16 Jul 2026 15:34:58 +0800 Subject: [PATCH] feat(approval): add safe risk disposition workflow --- .../CONCEPT.md | 10 + .../TODO.md | 15 +- ...proval-action-concurrency-and-risk-gate.md | 10 + .../risk-observation-access-and-lifecycle.md | 10 + .../20260716_0010_approval_action_protocol.py | 95 +++ .../20260716_0011_risk_disposition.py | 233 ++++++ .../api/v1/endpoints/approval_workbench.py | 31 + .../reimbursement_approval_actions.py | 163 +++++ .../app/api/v1/endpoints/reimbursements.py | 120 +--- .../app/api/v1/endpoints/risk_observations.py | 162 ++++- server/src/app/api/v1/router.py | 7 +- server/src/app/db/base.py | 5 + server/src/app/db/migration_preflight.py | 50 +- server/src/app/db/schema_ownership.py | 3 + server/src/app/models/__init__.py | 5 + server/src/app/models/approval_action.py | 64 ++ server/src/app/models/risk_disposition.py | 174 +++++ server/src/app/models/risk_observation.py | 11 + server/src/app/schemas/approval_workbench.py | 58 ++ server/src/app/schemas/reimbursement.py | 27 +- server/src/app/schemas/risk_disposition.py | 96 +++ server/src/app/schemas/risk_observation.py | 3 + .../app/services/approval_action_protocol.py | 270 +++++++ server/src/app/services/approval_workbench.py | 518 ++++++++++++++ .../services/expense_claim_action_protocol.py | 120 ++++ .../services/expense_claim_approval_flow.py | 161 ++++- .../app/services/expense_claim_pagination.py | 2 - .../app/services/expense_claim_return_flow.py | 187 +++++ .../app/services/expense_claim_risk_flags.py | 115 ++- .../app/services/expense_claim_risk_gate.py | 148 ++++ .../app/services/expense_claim_risk_review.py | 16 +- .../services/expense_claim_workflow_repair.py | 30 +- server/src/app/services/expense_claims.py | 238 ++----- .../src/app/services/hermes_risk_scanner.py | 101 +-- server/src/app/services/risk_dispositions.py | 430 ++++++++++++ .../risk_observation_access_policy.py | 84 +++ server/src/app/services/risk_observations.py | 69 +- server/tests/test_alembic_migrations.py | 208 +++++- ...test_approval_risk_concurrency_postgres.py | 225 ++++++ server/tests/test_approval_workbench.py | 221 ++++++ .../test_expense_claim_action_protocol.py | 322 +++++++++ server/tests/test_expense_claim_risk_gate.py | 222 ++++++ server/tests/test_expense_claim_service.py | 275 +++++--- server/tests/test_migration_preflight.py | 8 + server/tests/test_reimbursement_endpoints.py | 248 +++++-- server/tests/test_risk_dispositions.py | 661 ++++++++++++++++++ .../tests/test_risk_observations_service.py | 98 ++- server/tests/test_schema_ownership.py | 3 + .../risk-observation-evidence-card.css | 136 ++++ .../styles/views/approval-center-view.css | 57 +- .../travel/RiskObservationEvidenceCard.vue | 204 +++++- web/src/services/approvalWorkbench.js | 66 ++ web/src/services/reimbursements.js | 13 +- web/src/services/riskObservations.js | 103 ++- web/src/views/ApprovalCenterView.vue | 24 +- web/src/views/scripts/ApprovalCenterView.js | 59 +- .../scripts/travelRequestDetailPaymentFlow.js | 23 +- .../useTravelRequestDetailApprovalFlow.js | 37 +- .../approval-center-detail-reuse.test.mjs | 2 +- web/tests/approval-workbench.test.mjs | 66 ++ .../expense-claim-action-protocol.test.mjs | 37 + .../reimbursement-list-preview-fetch.test.mjs | 4 +- web/tests/risk-disposition-ui.test.mjs | 66 ++ ...travel-request-detail-risk-advice.test.mjs | 4 +- 64 files changed, 6602 insertions(+), 631 deletions(-) create mode 100644 document/development/2026-07-16/dev-logs/bugs/approval-action-concurrency-and-risk-gate.md create mode 100644 document/development/2026-07-16/dev-logs/bugs/risk-observation-access-and-lifecycle.md create mode 100644 server/alembic/versions/20260716_0010_approval_action_protocol.py create mode 100644 server/alembic/versions/20260716_0011_risk_disposition.py create mode 100644 server/src/app/api/v1/endpoints/approval_workbench.py create mode 100644 server/src/app/api/v1/endpoints/reimbursement_approval_actions.py create mode 100644 server/src/app/models/approval_action.py create mode 100644 server/src/app/models/risk_disposition.py create mode 100644 server/src/app/schemas/approval_workbench.py create mode 100644 server/src/app/schemas/risk_disposition.py create mode 100644 server/src/app/services/approval_action_protocol.py create mode 100644 server/src/app/services/approval_workbench.py create mode 100644 server/src/app/services/expense_claim_action_protocol.py create mode 100644 server/src/app/services/expense_claim_return_flow.py create mode 100644 server/src/app/services/expense_claim_risk_gate.py create mode 100644 server/src/app/services/risk_dispositions.py create mode 100644 server/src/app/services/risk_observation_access_policy.py create mode 100644 server/tests/test_approval_risk_concurrency_postgres.py create mode 100644 server/tests/test_approval_workbench.py create mode 100644 server/tests/test_expense_claim_action_protocol.py create mode 100644 server/tests/test_expense_claim_risk_gate.py create mode 100644 server/tests/test_risk_dispositions.py create mode 100644 web/src/services/approvalWorkbench.js create mode 100644 web/tests/approval-workbench.test.mjs create mode 100644 web/tests/expense-claim-action-protocol.test.mjs create mode 100644 web/tests/risk-disposition-ui.test.mjs diff --git a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md index 7baa959..beeac74 100644 --- a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md +++ b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md @@ -133,6 +133,12 @@ - 以风险、金额、预算影响、等待时长和证据完整度排序。 - 支持批量处理低风险事项,行内保留真实按钮和键盘可访问入口。 - 高风险审批展示模型版本、规则版本、政策依据、证据和人工覆盖原因。 +- 当前首个可用切片由 `/api/v1/approval-workbench/items` 返回统一投影,优先级由风险、SLA、预算、金额和证据完整度共同计算,并返回每个分项、排序原因和仅供参考的 AI 建议;前端展示状态与服务端业务状态分离,避免乐观并发前置条件被中文标签覆盖。 +- 批准、退回和付款均使用动作协议:客户端在确认时冻结 `request_id`、预期状态和预期审批节点;服务端以租户 + 操作人 + 请求 ID 唯一账本、请求指纹、PostgreSQL advisory lock、Claim 行锁和同事务业务事件确保相同请求可安全重放,不同内容或陈旧快照以 409 拒绝。 +- 高风险门禁同时读取持久化风险观察和尚未落表的原始风险标记;未处置的严重/高危可行动风险在路由、预算和状态修改前阻断,原始重大风险持久化失败时 fail-closed。仅明确标记为 `route_review` 的路由型风险进入对应审批节点而不冒充已解决。 +- 风险处置采用判定与生命周期双状态:支持确认风险、误报、补件、开始整改、申请豁免和完成处置;每个动作绑定请求指纹、预期版本、操作人和只追加事件。完整证据只对管理员或当前审批人开放,风险池对财务/管理/预算角色开放,处置写入由管理员或锁内重新确认的当前审批人完成。 +- 并发锁顺序固定为 Claim → RiskObservation → RiskDisposition,审批动作、人工处置和 Hermes 扫描共用 Claim 锁。扫描在锁外计算、锁内校验状态与更新时间,过期快照丢弃并等待下一轮,避免旧图结果覆盖刚完成的人工处置。 +- 尚未完成边界:工作台当前仍为服务内排序后截断;SLA 仍基于申请提交时间而不是当前节点进入时间;批量审批、委托/转交、加签/会签、豁免批准/拒绝和原始响应快照重放将在本阶段后续切片完成。 #### CFO 价值看板 @@ -540,6 +546,10 @@ docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv \ ## 本轮实现记录 +- 2026-07-16(审批动作协议):批准、退回和付款统一接入租户化幂等账本、请求指纹、乐观状态/节点前置条件、advisory lock 与 Claim 行锁;完全相同的网络重试不重复扣减预算、写审批、生成事件或付款,不同内容和陈旧状态稳定返回 409。前端确认流在用户确认时冻结请求和前置条件,并保持服务端状态与展示标签分离。 +- 2026-07-16(风险门禁与处置):高危/严重可行动风险在任何审批副作用前阻断;类型化风险处置覆盖确认、误报、补件、整改、豁免申请和完成处置,具备乐观版本、权限守卫和只追加审计。门禁合并持久观察与未匹配原始风险,重大观察落库失败 fail-closed;审批、处置和 Hermes 扫描通过 Claim 公共锁协调,扫描过期快照不会覆盖新事实。 +- 2026-07-16(审批工作台):审批中心接入可解释优先级投影,展示风险、预算、金额、等待时长、证据完整度和 advisory-only AI 建议;风险证据卡读取真实持久字段、显示生命周期和明确动作,但不提供绕过风险门禁的直接审批入口。 +- 2026-07-16(审批与风险验证):容器内审批/风险/费用服务组合 174 项、迁移/所有权 54 项、前端 73 项及 Vite 生产构建通过;一次性 tmpfs PostgreSQL 17 完成 13 项迁移和 1 项真实并发验证,临时数据库自动清理且持久开发库未修改。变更 Python 文件 Ruff F/I 与 `git diff --check` 通过。 - 2026-07-16(分层组织记忆):`travel_application.transport_mode` 已形成当前输入/规则 > 企业 > 部门 > 个人的解析链。租户管理员可维护 30-365 天有效的企业/部门低敏记忆;同级冲突 fail-closed,低级覆盖、过期和撤销均返回可解释但脱敏的状态。创建、更新、撤销使用作用域锁、稳定幂等键和请求指纹,响应丢失可安全重放,请求内容变化返回冲突。 - 2026-07-16(租户化历史案例):风险观测、人工反馈、few-shot 关系数据和 Qdrant 向量统一绑定租户、场景、制度和规则版本;Hermes 按租户分别构建图与历史,风险规则生成不再回落到默认租户。报销预审与 AI 助手接入只读 `historical_case_evidence`,旧版本显式标 stale,检索故障降级为空证据。 - 2026-07-16(历史证据隐私边界):公开协议仅返回“历史已确认/历史误报,仅供复核”的固定摘要,不暴露样本 ID、费用单号、人工评论和历史结论原文;历史命中不改变 review ID、规则 findings、passed、blocking count、预算复核或审批路由。 diff --git a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md index 2ce42ce..7309a77 100644 --- a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md +++ b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md @@ -166,9 +166,16 @@ ## 7. P1 前端实现:审批例外与 AI 记忆 -- [ ] [CONCEPT: 审批例外工作台] 按风险、金额、预算影响、等待时长和证据完整度排序审批事项。 -- [ ] [CONCEPT: 审批例外工作台] 展示必要性、预算、政策、相似单、风险证据和 AI 意见草稿。 +- [x] [CONCEPT: 审批例外工作台] 按风险、金额、预算影响、等待时长和证据完整度排序审批事项。 + 证据:`GET /api/v1/approval-workbench/items` 由 `ApprovalWorkbenchService` 统一生成可解释优先级、风险/SLA/预算/金额/证据分项和排序原因;审批中心使用该投影,不再用前端临时状态覆盖服务端乐观并发状态。 +- [x] [CONCEPT: 审批例外工作台] 展示必要性、预算、政策、相似单、风险证据和 AI 意见草稿。 + 证据:工作台返回证据完整度、缺失证据、预算影响、风险摘要、历史确认标签及 `advisory_only` AI 建议;详情页风险卡读取持久化证据、政策依据、贡献度和决策轨迹,并明确禁止从风险卡直接审批。 +- [x] [CONCEPT: 审批例外工作台] 为批准、退回和付款建立幂等动作协议与乐观前置条件。 + 证据:动作请求冻结 `request_id`、`expected_status` 和 `expected_approval_stage`;服务端以租户 + 操作人 + 请求 ID 唯一账本、请求指纹、advisory lock、Claim 行锁和同事务事件保证同请求安全重放,不同内容或过期快照返回 409。 +- [x] [CONCEPT: 审批例外工作台] 建立高风险门禁与类型化风险处置生命周期。 + 证据:严重/高危可行动风险在任何审批副作用前阻断;确认风险、误报、补件、整改、豁免申请和完成处置具有乐观版本、请求指纹、租户权限、只追加事件和 Claim 共用锁。未持久化的原始高风险仍保守阻断,重大风险持久化失败 fail-closed。 - [ ] [CONCEPT: 审批例外工作台] 补齐批量审批、委托、转交、加签、会签和超时升级交互。 + 当前进度:单笔批准/退回/付款协议、优先级工作台、风险证据和处置状态已完成;批量动作、委托/转交、加签/会签、当前节点进入时间 SLA、豁免批准/拒绝及真正的数据库分页尚未实现,不以现有单笔基础冒充完成。 - [ ] [CONCEPT: 审批例外工作台] 接入真实企微/钉钉/邮件触达和处理结果回写。 - [ ] [CONCEPT: AI 记忆与自动化设置] 新增“我的 AI 记忆”,支持来源解释、修改、忘记和关闭个性化。 - [x] [CONCEPT: AI 记忆与自动化设置] 在申请核对表展示常用出行方式的记忆来源、证据数量和“忘记此偏好”,并在保存/提交后区分候选记录与已应用回执。 @@ -215,7 +222,9 @@ 证据:`test_expense_case_endpoints.py` 容器内 8 项通过,覆盖跨租户、无 Case、申请与报销关联摘要以及内部字段递归过滤。 - [ ] [CONCEPT: 测试方案] 为 Alembic baseline、升级、旧数据迁移和回滚边界补充 Postgres 集成测试。 - [x] [CONCEPT: 测试方案] 为当前 migration-owned schema 切片补充一次性 PostgreSQL 集成测试和危险 URL 防误连门禁。 - 证据:`test_alembic_migrations.py` 默认无显式 URL 时跳过,主机和库名必须带 disposable 标记;当前 0009 Head 在 tmpfs PostgreSQL 17 中 9 项通过,覆盖空库升级、重复升级、关键约束/索引、组织 active 脏数据升级前拒绝、版本化 few-shot 无损降级拒绝、外键级联、base 降级、legacy 哨兵保留、漂移拒绝和再次升级;临时容器自动清理,持久化开发库未被修改。完整 legacy baseline 仍保留在上一条未完成项中。 + 证据:`test_alembic_migrations.py` 默认无显式 URL 时跳过,主机和库名必须带 disposable 标记;当前 0011 Head 在 tmpfs PostgreSQL 17 中 13 项通过,覆盖空库升级、重复升级、审批动作账本、风险处置复合租户约束、只追加事件触发器、非空降级保护、组织 active 脏数据升级前拒绝、版本化 few-shot 无损降级拒绝、外键级联、base 降级、legacy 哨兵保留、漂移拒绝和再次升级;临时容器自动清理,持久化开发库未被修改。完整 legacy baseline 仍保留在上一条未完成项中。 +- [x] [CONCEPT: 测试方案] 验证审批动作幂等、风险门禁、风险处置权限和共同锁顺序。 + 证据:容器内审批/风险/费用服务组合 174 项、迁移/所有权 54 项、前端审批/风险 73 项通过;一次性 PostgreSQL 17 上 13 项迁移和 1 项真实并发用例通过,证明风险重新打开持有 Claim 锁时审批等待,随后按最新风险阻断且不留下动作账本或审批副作用。 - [ ] [CONCEPT: 测试方案] 为连接器幂等、重试、回执、失败恢复、重复付款和对账补充测试。 - [ ] [CONCEPT: 测试方案] 跑通申请 → 票据 → 报销 → 预审 → 审批 → 付款 → 入账 → 归档端到端。 当前进度:申请批准 → 自动报销草稿 → 票据归集 → 预审 → 报销提交已在同一 Case 中跑通;付款回执、ERP 入账和对账仍未接入。 diff --git a/document/development/2026-07-16/dev-logs/bugs/approval-action-concurrency-and-risk-gate.md b/document/development/2026-07-16/dev-logs/bugs/approval-action-concurrency-and-risk-gate.md new file mode 100644 index 0000000..f0b54da --- /dev/null +++ b/document/development/2026-07-16/dev-logs/bugs/approval-action-concurrency-and-risk-gate.md @@ -0,0 +1,10 @@ +## 修复记录 + +- 2026-07-16 15:10:42 CST:修复审批、退回和付款动作缺少稳定请求标识、预期状态与预期审批节点校验的问题。新增租户 + 操作人 + 请求 ID 唯一账本、请求指纹、PostgreSQL advisory lock 与 Claim 行锁;完全相同的重试返回原结果,请求内容变化或状态已过期返回 409,不重复扣减预算、写审批记录、生成业务事件或付款结果。 +- 2026-07-16 15:10:42 CST:修复高风险观察仅展示但不阻止审批的问题。审批流程在任何路由、预算和业务状态变更之前检查同租户高危/严重风险;只有已判定误报或完成处置的观察允许继续,阻断响应返回稳定错误码和观察状态,整笔动作账本随事务回滚。 +- 2026-07-16 15:10:42 CST:前端确认弹窗冻结请求 ID、预期单据状态和审批节点,网络结果不确定时复用同一请求,避免用户重试造成重复业务副作用。 +- 2026-07-16 15:10:42 CST:验证在 `local-x-financial-linux` 容器完成:费用服务 116 项、审批动作/风险门禁/风险处置组合 26 项、报销接口回归 22 项;一次性 tmpfs PostgreSQL 17 迁移循环 13 项通过。变更 Python 文件 Ruff F/I 与格式检查通过。 +- 2026-07-16 15:10:42 CST:Git 拉取检查已执行;上游没有本地尚未包含的新提交,当前分支相对上游 ahead 14。已有本地提交包括 `ee88a36b` 分层费用学习、`6bdf65bc` 权威预审、`ae3f02c3` 零录入票据闭环等,本次修复在这些能力之上继续演进。 +- 2026-07-16 15:33:03 CST:交叉审查后修复三项并发绕过:审批动作不再调用会隐式提交的读取修复逻辑,列表/分页 GET 也不再修改审批节点;审批中心保留服务端原始状态并单独生成展示标签;门禁将持久化风险观察与未匹配原始高风险合并,重大观察持久化失败时 fail-closed。 +- 2026-07-16 15:33:03 CST:审批、风险处置和 Hermes 扫描统一以 Claim 行锁为公共协调点,锁顺序固定为 Claim → Observation → Disposition;Hermes 锁外计算、锁内刷新并核对状态和更新时间,旧快照被丢弃。一次性 PostgreSQL 并发用例证明风险重新打开与审批竞争时,审批等待后读取最新风险并阻断,Claim 节点和动作账本均保持无副作用。 +- 2026-07-16 15:33:03 CST:最终容器回归为审批/风险/费用服务 174 项、迁移/所有权 54 项、前端审批/风险 73 项通过,Vite 生产构建通过;一次性 tmpfs PostgreSQL 17 上 13 项迁移和 1 项真实并发测试通过,临时数据库自动清理。再次执行 Git 拉取检查,上游仍无新增提交,本地 ahead 14。 diff --git a/document/development/2026-07-16/dev-logs/bugs/risk-observation-access-and-lifecycle.md b/document/development/2026-07-16/dev-logs/bugs/risk-observation-access-and-lifecycle.md new file mode 100644 index 0000000..a4790f7 --- /dev/null +++ b/document/development/2026-07-16/dev-logs/bugs/risk-observation-access-and-lifecycle.md @@ -0,0 +1,10 @@ +## 修复记录 + +- 2026-07-16 15:10:42 CST:修复风险观察列表和完整证据链读取范围过宽的问题。风险池仅允许管理员、财务、管理层和预算监控角色访问;单据完整风险证据只允许管理员或当前审批人查看,申请人和无权主体统一以 404 隐藏资源存在性,所有查询继续绑定可信租户。 +- 2026-07-16 15:10:42 CST:修复风险反馈只有自由文本状态、无法可靠表达处置过程的问题。新增类型化判定与生命周期投影,区分确认风险、误报、补件、整改、豁免申请和完成处置;使用乐观版本、请求指纹、行锁和仅追加事件表保证并发与审计一致性,已完成状态不可重新判定。 +- 2026-07-16 15:10:42 CST:修复前端读取错误 JSON 字段名导致证据、政策依据、贡献度和决策轨迹可能丢失的问题;风险证据卡改用服务端持久字段并展示处置状态、版本和明确动作,高风险卡不提供直接审批入口。 +- 2026-07-16 15:10:42 CST:验证在 `local-x-financial-linux` 容器完成:风险观察、风险处置、审批工作台与风险门禁组合 26 项,前端审批/风险/动作协议定向 72 项,Vite 生产构建通过;一次性 PostgreSQL 验证了复合租户外键、唯一约束、仅追加触发器和非空降级保护。 +- 2026-07-16 15:10:42 CST:Git 拉取检查已执行;上游没有本地尚未包含的新提交,当前分支相对上游 ahead 14。已有 ahead 提交均保留在修复上下文中,未修改用户正在维护的财务规则工作簿和历史未跟踪日志。 +- 2026-07-16 15:33:03 CST:交叉审查后收紧处置动作语义:误报、补件和豁免申请必须提供服务端校验的说明,完成处置必须提供处理结果;版本冲突返回明确消息并触发前端刷新,已完成生命周期不再展示重复动作。 +- 2026-07-16 15:33:03 CST:处置服务在 Claim 公共锁内重新确认当前审批人,Observation 和 Disposition 使用租户范围行锁及固定顺序;风险观察写入同样获取 Claim 锁。完整证据、风险池和处置权限分别执行最小授权,跨租户和无权资源继续以 404 隐藏。 +- 2026-07-16 15:33:03 CST:最终验证全部在 `local-x-financial-linux` 容器完成并设置 60 秒超时;相关后端 174 项、迁移/所有权 54 项、前端 73 项与生产构建通过,一次性 PostgreSQL 17 的结构和并发验证 14 项通过。规则工作簿和历史未跟踪日志未被改动或纳入提交。 diff --git a/server/alembic/versions/20260716_0010_approval_action_protocol.py b/server/alembic/versions/20260716_0010_approval_action_protocol.py new file mode 100644 index 0000000..b609ca6 --- /dev/null +++ b/server/alembic/versions/20260716_0010_approval_action_protocol.py @@ -0,0 +1,95 @@ +"""add transactional approval action idempotency ledger + +Revision ID: 20260716_0010 +Revises: 20260716_0009 +Create Date: 2026-07-16 16:10:00 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "20260716_0010" +down_revision: str | None = "20260716_0009" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _require_postgresql() -> None: + dialect_name = op.get_bind().dialect.name + if dialect_name != "postgresql": + raise RuntimeError( + "20260716_0010 only supports PostgreSQL; " + f"refusing to mutate {dialect_name} without transactional constraint DDL" + ) + + +def upgrade() -> None: + _require_postgresql() + op.create_table( + "approval_action_ledgers", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=64), nullable=False), + sa.Column("actor_id", sa.String(length=120), nullable=False), + sa.Column("request_id", sa.String(length=120), nullable=False), + sa.Column("claim_id", sa.String(length=36), nullable=False), + sa.Column("action", sa.String(length=20), nullable=False), + sa.Column("payload_fingerprint", sa.String(length=80), nullable=False), + sa.Column("expected_status", sa.String(length=30), nullable=False), + sa.Column("expected_approval_stage", sa.String(length=50), nullable=False), + sa.Column("result_status", sa.String(length=30), nullable=True), + sa.Column("result_approval_stage", sa.String(length=50), nullable=True), + sa.Column("response_json", sa.JSON(), nullable=True), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.CheckConstraint( + "action IN ('approve', 'return', 'pay')", + name="ck_approval_action_ledger_action", + ), + sa.CheckConstraint( + "(completed_at IS NULL AND result_status IS NULL " + "AND result_approval_stage IS NULL) OR " + "(completed_at IS NOT NULL AND result_status IS NOT NULL " + "AND result_approval_stage IS NOT NULL)", + name="ck_approval_action_ledger_completion", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "tenant_id", + "actor_id", + "request_id", + name="uq_approval_action_ledger_request", + ), + ) + op.create_index( + "ix_approval_action_ledger_claim_action", + "approval_action_ledgers", + ["tenant_id", "claim_id", "action"], + unique=False, + ) + + +def downgrade() -> None: + _require_postgresql() + ledger_count = int( + op.get_bind().scalar(sa.text("SELECT COUNT(*) FROM approval_action_ledgers")) or 0 + ) + if ledger_count: + raise RuntimeError( + "cannot downgrade approval action protocol: " + f"approval_action_ledgers contains {ledger_count} audit record(s)" + ) + op.drop_index( + "ix_approval_action_ledger_claim_action", + table_name="approval_action_ledgers", + ) + op.drop_table("approval_action_ledgers") diff --git a/server/alembic/versions/20260716_0011_risk_disposition.py b/server/alembic/versions/20260716_0011_risk_disposition.py new file mode 100644 index 0000000..2adc852 --- /dev/null +++ b/server/alembic/versions/20260716_0011_risk_disposition.py @@ -0,0 +1,233 @@ +"""add typed risk disposition state and append-only events + +Revision ID: 20260716_0011 +Revises: 20260716_0010 +Create Date: 2026-07-16 16:20:00 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "20260716_0011" +down_revision: str | None = "20260716_0010" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _require_postgresql() -> None: + dialect_name = op.get_bind().dialect.name + if dialect_name != "postgresql": + raise RuntimeError( + "20260716_0011 only supports PostgreSQL; " + f"refusing to mutate {dialect_name} without transactional constraint DDL" + ) + + +def _require_empty_audit_chain_for_downgrade() -> None: + bind = op.get_bind() + disposition_count = int(bind.scalar(sa.text("SELECT COUNT(*) FROM risk_dispositions")) or 0) + event_count = int(bind.scalar(sa.text("SELECT COUNT(*) FROM risk_disposition_events")) or 0) + if disposition_count or event_count: + raise RuntimeError( + "cannot downgrade risk dispositions: audit chain is not empty " + f"({disposition_count} dispositions, {event_count} events)" + ) + + +def upgrade() -> None: + _require_postgresql() + op.create_unique_constraint( + "uq_risk_observations_tenant_id", + "risk_observations", + ["tenant_id", "id"], + ) + op.create_table( + "risk_dispositions", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=64), nullable=False), + sa.Column("observation_id", sa.String(length=36), nullable=False), + sa.Column( + "adjudication", + sa.String(length=24), + nullable=False, + server_default="unreviewed", + ), + sa.Column( + "lifecycle_status", + sa.String(length=32), + nullable=False, + server_default="open", + ), + sa.Column("version", sa.Integer(), nullable=False, server_default="0"), + sa.Column("assignee", sa.String(length=120), nullable=True), + sa.Column("due_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("resolution", sa.Text(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.CheckConstraint( + "adjudication IN ('unreviewed', 'confirmed', 'false_positive')", + name="ck_risk_dispositions_adjudication", + ), + sa.CheckConstraint( + "lifecycle_status IN ('open', 'supplement_requested', " + "'remediation_in_progress', 'waiver_requested', 'resolved')", + name="ck_risk_dispositions_lifecycle", + ), + sa.CheckConstraint("version >= 0", name="ck_risk_dispositions_version"), + sa.ForeignKeyConstraint( + ["tenant_id", "observation_id"], + ["risk_observations.tenant_id", "risk_observations.id"], + name="fk_risk_dispositions_tenant_observation", + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "tenant_id", + "id", + name="uq_risk_dispositions_tenant_id", + ), + sa.UniqueConstraint( + "tenant_id", + "observation_id", + name="uq_risk_dispositions_tenant_observation", + ), + ) + op.create_index( + "ix_risk_dispositions_tenant_lifecycle_due", + "risk_dispositions", + ["tenant_id", "lifecycle_status", "due_at"], + unique=False, + ) + op.create_index( + "ix_risk_dispositions_assignee", + "risk_dispositions", + ["tenant_id", "assignee"], + unique=False, + ) + + op.create_table( + "risk_disposition_events", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=64), nullable=False), + sa.Column("disposition_id", sa.String(length=36), nullable=False), + sa.Column("observation_id", sa.String(length=36), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("action", sa.String(length=32), nullable=False), + sa.Column("actor_id", sa.String(length=120), nullable=False), + sa.Column("actor_name", sa.String(length=120), nullable=False), + sa.Column("request_id", sa.String(length=120), nullable=False), + sa.Column("payload_fingerprint", sa.String(length=64), nullable=False), + sa.Column("comment", sa.Text(), nullable=True), + sa.Column("before_json", sa.JSON(), nullable=False), + sa.Column("after_json", sa.JSON(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.CheckConstraint( + "action IN ('confirm', 'false_positive', 'request_supplement', " + "'start_remediation', 'resolve', 'request_waiver')", + name="ck_risk_disposition_events_action", + ), + sa.CheckConstraint( + "version > 0", + name="ck_risk_disposition_events_version", + ), + sa.ForeignKeyConstraint( + ["tenant_id", "disposition_id"], + ["risk_dispositions.tenant_id", "risk_dispositions.id"], + name="fk_risk_disposition_events_tenant_disposition", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["tenant_id", "observation_id"], + ["risk_observations.tenant_id", "risk_observations.id"], + name="fk_risk_disposition_events_tenant_observation", + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "tenant_id", + "request_id", + name="uq_risk_disposition_events_tenant_request", + ), + sa.UniqueConstraint( + "disposition_id", + "version", + name="uq_risk_disposition_events_version", + ), + ) + op.create_index( + "ix_risk_disposition_events_disposition_id", + "risk_disposition_events", + ["disposition_id"], + unique=False, + ) + op.create_index( + "ix_risk_disposition_events_tenant_observation_time", + "risk_disposition_events", + ["tenant_id", "observation_id", "created_at"], + unique=False, + ) + op.execute( + "CREATE FUNCTION reject_risk_disposition_event_mutation() " + "RETURNS trigger AS $$ " + "BEGIN " + "RAISE EXCEPTION 'risk_disposition_events is append-only'; " + "RETURN OLD; " + "END; " + "$$ LANGUAGE plpgsql" + ) + op.execute( + "CREATE TRIGGER trg_risk_disposition_events_append_only " + "BEFORE UPDATE OR DELETE ON risk_disposition_events " + "FOR EACH ROW EXECUTE FUNCTION reject_risk_disposition_event_mutation()" + ) + + +def downgrade() -> None: + _require_postgresql() + # 审计链不可静默销毁;数据归档或迁移必须由显式运维流程完成。 + _require_empty_audit_chain_for_downgrade() + op.execute("DROP TRIGGER trg_risk_disposition_events_append_only ON risk_disposition_events") + op.execute("DROP FUNCTION reject_risk_disposition_event_mutation()") + op.drop_index( + "ix_risk_disposition_events_tenant_observation_time", + table_name="risk_disposition_events", + ) + op.drop_index( + "ix_risk_disposition_events_disposition_id", + table_name="risk_disposition_events", + ) + op.drop_table("risk_disposition_events") + op.drop_index( + "ix_risk_dispositions_assignee", + table_name="risk_dispositions", + ) + op.drop_index( + "ix_risk_dispositions_tenant_lifecycle_due", + table_name="risk_dispositions", + ) + op.drop_table("risk_dispositions") + op.drop_constraint( + "uq_risk_observations_tenant_id", + "risk_observations", + type_="unique", + ) diff --git a/server/src/app/api/v1/endpoints/approval_workbench.py b/server/src/app/api/v1/endpoints/approval_workbench.py new file mode 100644 index 0000000..9c2234d --- /dev/null +++ b/server/src/app/api/v1/endpoints/approval_workbench.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.api.deps import CurrentUserContext, get_current_user, get_db +from app.schemas.approval_workbench import ApprovalWorkbenchListRead +from app.services.approval_workbench import ApprovalWorkbenchService + +router = APIRouter(prefix="/approval-workbench") +DbSession = Annotated[Session, Depends(get_db)] +CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)] + + +@router.get( + "/items", + response_model=ApprovalWorkbenchListRead, + summary="查询当前用户的例外审批优先队列", + description=( + "复用现有审批权限范围,按风险、预算、金额、等待时长和材料完整度排序;" + "AI 建议仅供人工复核,不执行自动审批。" + ), +) +def list_approval_workbench_items( + db: DbSession, + current_user: CurrentUser, + limit: Annotated[int, Query(ge=1, le=200)] = 100, +) -> ApprovalWorkbenchListRead: + return ApprovalWorkbenchService(db).list_items(current_user, limit=limit) diff --git a/server/src/app/api/v1/endpoints/reimbursement_approval_actions.py b/server/src/app/api/v1/endpoints/reimbursement_approval_actions.py new file mode 100644 index 0000000..2040d14 --- /dev/null +++ b/server/src/app/api/v1/endpoints/reimbursement_approval_actions.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from typing import Annotated, NoReturn + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.api.deps import CurrentUserContext, get_current_user, get_db +from app.schemas.common import ErrorResponse +from app.schemas.reimbursement import ( + ExpenseClaimApprovalPayload, + ExpenseClaimPaymentPayload, + ExpenseClaimRead, + ExpenseClaimReturnPayload, +) +from app.services.approval_action_protocol import ApprovalActionConflictError +from app.services.expense_claim_risk_gate import ExpenseClaimRiskBlockedError +from app.services.expense_claims import ExpenseClaimService + +router = APIRouter() +DbSession = Annotated[Session, Depends(get_db)] +CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)] + + +def _raise_action_error(error: ValueError) -> NoReturn: + if isinstance(error, ExpenseClaimRiskBlockedError): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "code": "APPROVAL_BLOCKED_BY_OPEN_HIGH_RISK", + "message": str(error), + "observations": [ + { + "id": item.observation_id, + "title": item.title, + "risk_level": item.risk_level, + "adjudication": item.adjudication, + "lifecycle_status": item.lifecycle_status, + } + for item in error.blockers + ], + }, + ) from error + if isinstance(error, ApprovalActionConflictError): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error + + +@router.post( + "/claims/{claim_id}/return", + response_model=ExpenseClaimRead, + summary="退回报销单", + description="按请求幂等键和预期状态安全退回当前节点的单据。", + responses={ + status.HTTP_404_NOT_FOUND: {"model": ErrorResponse, "description": "报销单不存在。"}, + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": "当前用户或单据状态不允许退回。", + }, + status.HTTP_409_CONFLICT: { + "model": ErrorResponse, + "description": "请求键冲突或单据状态/审批节点已经变化。", + }, + }, +) +def return_expense_claim( + claim_id: str, + payload: ExpenseClaimReturnPayload, + db: DbSession, + current_user: CurrentUser, +) -> ExpenseClaimRead: + try: + claim = ExpenseClaimService(db).return_claim( + claim_id, + current_user, + reason=payload.reason, + reason_codes=payload.reason_codes, + request_id=payload.request_id, + expected_status=payload.expected_status, + expected_approval_stage=payload.expected_approval_stage, + ) + except ValueError as error: + _raise_action_error(error) + if claim is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found") + return claim + + +@router.post( + "/claims/{claim_id}/approve", + response_model=ExpenseClaimRead, + summary="审批通过单据", + description="按请求幂等键和预期状态安全完成当前审批节点。", + responses={ + status.HTTP_404_NOT_FOUND: {"model": ErrorResponse, "description": "单据不存在。"}, + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": "当前用户或单据状态不允许审批通过。", + }, + status.HTTP_409_CONFLICT: { + "model": ErrorResponse, + "description": "请求键冲突或单据状态/审批节点已经变化。", + }, + }, +) +def approve_expense_claim( + claim_id: str, + payload: ExpenseClaimApprovalPayload, + db: DbSession, + current_user: CurrentUser, +) -> ExpenseClaimRead: + try: + claim = ExpenseClaimService(db).approve_claim( + claim_id, + current_user, + opinion=payload.opinion, + request_id=payload.request_id, + expected_status=payload.expected_status, + expected_approval_stage=payload.expected_approval_stage, + ) + except ValueError as error: + _raise_action_error(error) + if claim is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found") + return claim + + +@router.post( + "/claims/{claim_id}/pay", + response_model=ExpenseClaimRead, + summary="确认报销单已付款", + description="按请求幂等键和预期状态安全确认待付款报销单已付款。", + responses={ + status.HTTP_404_NOT_FOUND: {"model": ErrorResponse, "description": "单据不存在。"}, + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": "当前用户或单据状态不允许确认付款。", + }, + status.HTTP_409_CONFLICT: { + "model": ErrorResponse, + "description": "请求键冲突或单据状态/审批节点已经变化。", + }, + }, +) +def pay_expense_claim( + claim_id: str, + payload: ExpenseClaimPaymentPayload, + db: DbSession, + current_user: CurrentUser, +) -> ExpenseClaimRead: + try: + claim = ExpenseClaimService(db).mark_claim_paid( + claim_id, + current_user, + request_id=payload.request_id, + expected_status=payload.expected_status, + expected_approval_stage=payload.expected_approval_stage, + ) + except ValueError as error: + _raise_action_error(error) + if claim is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found") + return claim diff --git a/server/src/app/api/v1/endpoints/reimbursements.py b/server/src/app/api/v1/endpoints/reimbursements.py index 9b998be..3bef5b2 100644 --- a/server/src/app/api/v1/endpoints/reimbursements.py +++ b/server/src/app/api/v1/endpoints/reimbursements.py @@ -12,14 +12,12 @@ from app.schemas.budget import BudgetClaimAnalysisRead from app.schemas.common import ErrorResponse, PaginatedResponse from app.schemas.reimbursement import ( ExpenseClaimActionResponse, - ExpenseClaimApprovalPayload, ExpenseClaimAttachmentActionResponse, ExpenseClaimAttachmentRead, ExpenseClaimItemActionResponse, ExpenseClaimItemCreate, ExpenseClaimItemUpdate, ExpenseClaimRead, - ExpenseClaimReturnPayload, ExpenseClaimStandardAdjustmentPayload, ExpenseClaimSubmitPayload, ExpenseClaimUpdate, @@ -33,6 +31,7 @@ from app.services.expense_claims import ExpenseClaimService from app.services.reimbursement import ReimbursementService from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService +from .reimbursement_approval_actions import router as approval_action_router from .reimbursement_pre_review import ( RequestIdHeader, expense_claim_deletion_response, @@ -41,6 +40,7 @@ from .reimbursement_pre_review import ( ) router = APIRouter() +router.include_router(approval_action_router) DbSession = Annotated[Session, Depends(get_db)] CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)] @@ -198,10 +198,16 @@ def get_expense_claim_budget_analysis( claim = service.get_claim(claim_id, current_user) if claim is None: if not service.can_view_budget_analysis(current_user): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有当前审核人、该部门预算监控员或高级财务人员可以查看预算分析。") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="只有当前审核人、该部门预算监控员或高级财务人员可以查看预算分析。", + ) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found") if not service.can_view_budget_analysis(current_user, claim): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有当前审核人、该部门预算监控员或高级财务人员可以查看预算分析。") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="只有当前审核人、该部门预算监控员或高级财务人员可以查看预算分析。", + ) return BudgetService(db).analyze_claim_budget(claim) @@ -418,7 +424,9 @@ async def upload_expense_claim_item_attachment( file: Annotated[UploadFile, File(description="待上传的附件文件。")], db: DbSession, current_user: CurrentUser, - receipt_id: Annotated[str | None, Form(description="可选,来源于票据夹的持久化票据 ID。")] = None, + receipt_id: Annotated[ + str | None, Form(description="可选,来源于票据夹的持久化票据 ID。") + ] = None, ) -> ExpenseClaimAttachmentActionResponse: service = ExpenseClaimService(db) try: @@ -651,104 +659,6 @@ def submit_expense_claim( ) -@router.post( - "/claims/{claim_id}/return", - response_model=ExpenseClaimRead, - summary="退回报销单", - description="财务人员、高级财务人员或当前审批人可将可见报销单退回到待提交状态。", - responses={ - status.HTTP_404_NOT_FOUND: { - "model": ErrorResponse, - "description": "报销单不存在。", - }, - status.HTTP_400_BAD_REQUEST: { - "model": ErrorResponse, - "description": "当前用户或单据状态不允许退回。", - }, - }, -) -def return_expense_claim( - claim_id: str, - payload: ExpenseClaimReturnPayload, - db: DbSession, - current_user: CurrentUser, -) -> ExpenseClaimRead: - service = ExpenseClaimService(db) - try: - claim = service.return_claim(claim_id, current_user, reason=payload.reason, reason_codes=payload.reason_codes) - except ValueError as error: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error - - if claim is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found") - return claim - - -@router.post( - "/claims/{claim_id}/approve", - response_model=ExpenseClaimRead, - summary="审批通过单据", - description="费用申请由直属领导审批后流转到预算管理者审批,预算审核通过后生成报销草稿;报销单直属领导审批后流转到财务审批。", - responses={ - status.HTTP_404_NOT_FOUND: { - "model": ErrorResponse, - "description": "单据不存在。", - }, - status.HTTP_400_BAD_REQUEST: { - "model": ErrorResponse, - "description": "当前用户或单据状态不允许审批通过。", - }, - }, -) -def approve_expense_claim( - claim_id: str, - payload: ExpenseClaimApprovalPayload, - db: DbSession, - current_user: CurrentUser, -) -> ExpenseClaimRead: - service = ExpenseClaimService(db) - try: - claim = service.approve_claim(claim_id, current_user, opinion=payload.opinion) - except ValueError as error: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error - - if claim is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found") - return claim - - -@router.post( - "/claims/{claim_id}/pay", - response_model=ExpenseClaimRead, - summary="确认报销单已付款", - description="财务人员或高级财务人员确认待付款报销单已完成付款。", - responses={ - status.HTTP_404_NOT_FOUND: { - "model": ErrorResponse, - "description": "单据不存在。", - }, - status.HTTP_400_BAD_REQUEST: { - "model": ErrorResponse, - "description": "当前用户或单据状态不允许确认付款。", - }, - }, -) -def pay_expense_claim( - claim_id: str, - db: DbSession, - current_user: CurrentUser, -) -> ExpenseClaimRead: - service = ExpenseClaimService(db) - try: - claim = service.mark_claim_paid(claim_id, current_user) - except ValueError as error: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error - - if claim is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found") - return claim - - @router.delete( "/claims/{claim_id}", response_model=ExpenseClaimActionResponse, @@ -765,7 +675,9 @@ def pay_expense_claim( }, }, ) -def delete_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser) -> ExpenseClaimActionResponse: +def delete_expense_claim( + claim_id: str, db: DbSession, current_user: CurrentUser +) -> ExpenseClaimActionResponse: service = ExpenseClaimService(db) try: claim = service.delete_claim(claim_id, current_user) diff --git a/server/src/app/api/v1/endpoints/risk_observations.py b/server/src/app/api/v1/endpoints/risk_observations.py index 6ca66be..6d8df48 100644 --- a/server/src/app/api/v1/endpoints/risk_observations.py +++ b/server/src/app/api/v1/endpoints/risk_observations.py @@ -1,5 +1,6 @@ from __future__ import annotations +import uuid from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query, status @@ -7,6 +8,11 @@ from sqlalchemy.orm import Session from app.api.deps import CurrentUserContext, get_current_user, get_db from app.schemas.common import ErrorResponse +from app.schemas.risk_disposition import ( + RiskDispositionActionCreate, + RiskDispositionMutationRead, + RiskDispositionRead, +) from app.schemas.risk_observation import ( RiskObservationDashboardRead, RiskObservationFeedbackCreate, @@ -14,6 +20,13 @@ from app.schemas.risk_observation import ( RiskObservationListRead, RiskObservationRead, ) +from app.services.risk_dispositions import ( + RiskDispositionConflictError, + RiskDispositionPermissionError, + RiskDispositionService, + RiskDispositionVersionConflictError, +) +from app.services.risk_observation_access_policy import RiskObservationAccessPolicy from app.services.risk_observations import RiskObservationService router = APIRouter(prefix="/risk-observations") @@ -43,6 +56,7 @@ def list_risk_observations( limit: Annotated[int, Query(ge=1, le=200)] = 50, offset: Annotated[int, Query(ge=0)] = 0, ) -> RiskObservationListRead: + _require_pool_access(db, current_user) items, total = RiskObservationService(db).list_observations( tenant_id=current_user.tenant_id, claim_id=claim_id, @@ -70,6 +84,7 @@ def summarize_risk_observations( window_days: Annotated[int, Query(ge=1, le=365)] = 30, limit: Annotated[int, Query(ge=1, le=2000)] = 500, ) -> RiskObservationDashboardRead: + _require_pool_access(db, current_user) return RiskObservationService(db).summarize_dashboard( tenant_id=current_user.tenant_id, window_days=window_days, @@ -88,6 +103,8 @@ def list_claim_risk_observations( db: DbSession, current_user: CurrentUser, ) -> list[RiskObservationRead]: + if not RiskObservationAccessPolicy(db).can_read_claim_risks(claim_id, current_user): + raise _not_found() return RiskObservationService(db).list_claim_observations( claim_id, tenant_id=current_user.tenant_id, @@ -105,6 +122,7 @@ def list_execution_log_risk_observations( db: DbSession, current_user: CurrentUser, ) -> list[RiskObservationRead]: + _require_pool_access(db, current_user) return RiskObservationService(db).list_execution_log_observations( execution_log_id, tenant_id=current_user.tenant_id, @@ -128,6 +146,7 @@ def get_risk_observation( db: DbSession, current_user: CurrentUser, ) -> RiskObservationRead: + _require_pool_access(db, current_user) observation = RiskObservationService(db).get_observation( observation_key_or_id, tenant_id=current_user.tenant_id, @@ -140,6 +159,53 @@ def get_risk_observation( return observation +@router.get( + "/{observation_key_or_id}/disposition", + response_model=RiskDispositionRead, + summary="读取风险观察处置状态", + description="返回裁决结论、处置生命周期、负责人、截止时间和只追加事件。", +) +def get_risk_observation_disposition( + observation_key_or_id: str, + db: DbSession, + current_user: CurrentUser, +) -> RiskDispositionRead: + _require_pool_access(db, current_user) + observation = _get_observation_or_404(db, current_user, observation_key_or_id) + disposition = RiskDispositionService(db).get_disposition( + observation.id, + tenant_id=current_user.tenant_id, + ) + if disposition is None: + raise _not_found("Risk disposition not found") + return disposition + + +@router.post( + "/{observation_key_or_id}/disposition/actions", + response_model=RiskDispositionMutationRead, + summary="执行类型化风险处置动作", + description="使用乐观锁和请求幂等键追加风险处置事件。", +) +def execute_risk_disposition_action( + observation_key_or_id: str, + payload: RiskDispositionActionCreate, + db: DbSession, + current_user: CurrentUser, +) -> RiskDispositionMutationRead: + mutation = _execute_action( + db, + current_user, + observation_key_or_id, + payload, + ) + return RiskDispositionMutationRead( + disposition=mutation.disposition, + event=mutation.event, + replayed=mutation.replayed, + ) + + @router.post( "/{observation_key_or_id}/feedback", response_model=RiskObservationFeedbackRead, @@ -158,15 +224,103 @@ def create_risk_observation_feedback( db: DbSession, current_user: CurrentUser, ) -> RiskObservationFeedbackRead: + observation = _get_observation_or_404(db, current_user, observation_key_or_id) + if ( + payload.feedback_type not in {"confirm", "false_positive"} + or payload.action is not None + or bool(payload.payload_json) + ): + raise HTTPException( + status_code=status.HTTP_410_GONE, + detail=("自由格式反馈入口已停用;请使用 disposition/actions 类型化处置接口。"), + ) + + service = RiskDispositionService(db) + mutation = _execute_action( + db, + current_user, + observation_key_or_id, + RiskDispositionActionCreate( + action=payload.feedback_type, + expected_version=service.get_current_version( + observation.id, + tenant_id=current_user.tenant_id, + ), + request_id=f"legacy:{uuid.uuid4()}", + comment=payload.comment, + ), + ) + if mutation.legacy_feedback is None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Legacy feedback projection is unavailable.", + ) + return mutation.legacy_feedback + + +def _require_pool_access(db: Session, current_user: CurrentUserContext) -> None: + if RiskObservationAccessPolicy(db).can_read_tenant_pool(current_user): + return + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="当前用户无权访问企业风险观察池。", + ) + + +def _get_observation_or_404( + db: Session, + current_user: CurrentUserContext, + observation_key_or_id: str, +): + observation = RiskObservationService(db).get_observation( + observation_key_or_id, + tenant_id=current_user.tenant_id, + ) + if observation is None: + raise _not_found() + return observation + + +def _execute_action( + db: Session, + current_user: CurrentUserContext, + observation_key_or_id: str, + payload: RiskDispositionActionCreate, +): try: - return RiskObservationService(db).create_feedback( + return RiskDispositionService(db).execute_action( observation_key_or_id, payload, tenant_id=current_user.tenant_id, - actor=current_user.name or current_user.username, + actor_id=current_user.employee_id or current_user.username, + actor_name=current_user.name or current_user.username, + current_user=current_user, ) except LookupError: + raise _not_found() from None + except RiskDispositionVersionConflictError as error: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Risk observation not found", + status_code=status.HTTP_409_CONFLICT, + detail={ + "code": "RISK_DISPOSITION_VERSION_CONFLICT", + "current_version": error.current_version, + "message": "风险处置状态已更新,请刷新证据链后重试。", + }, ) from None + except RiskDispositionPermissionError as error: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=str(error), + ) from None + except RiskDispositionConflictError as error: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(error), + ) from None + + +def _not_found(detail: str = "Risk observation not found") -> HTTPException: + return HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=detail, + ) diff --git a/server/src/app/api/v1/router.py b/server/src/app/api/v1/router.py index 485c68c..02facfe 100644 --- a/server/src/app/api/v1/router.py +++ b/server/src/app/api/v1/router.py @@ -6,6 +6,7 @@ from app.api.v1.endpoints.agent_feedback import router as agent_feedback_router from app.api.v1.endpoints.agent_runs import router as agent_runs_router from app.api.v1.endpoints.agent_traces import router as agent_traces_router from app.api.v1.endpoints.analytics import router as analytics_router +from app.api.v1.endpoints.approval_workbench import router as approval_workbench_router from app.api.v1.endpoints.attachment_association_jobs import ( router as attachment_association_jobs_router, ) @@ -49,10 +50,14 @@ router.include_router(agent_feedback_router, tags=["agent-feedback"]) router.include_router(agent_runs_router, tags=["agent-runs"]) router.include_router(agent_traces_router, tags=["agent-traces"]) router.include_router(analytics_router, tags=["analytics"]) +router.include_router(approval_workbench_router, tags=["approval-workbench"]) router.include_router(attachment_association_jobs_router, tags=["attachment-association-jobs"]) router.include_router(audit_logs_router, tags=["audit-logs"]) router.include_router(knowledge_router, tags=["knowledge"]) -router.include_router(linked_reimbursement_draft_jobs_router, tags=["linked-reimbursement-draft-jobs"]) +router.include_router( + linked_reimbursement_draft_jobs_router, + tags=["linked-reimbursement-draft-jobs"], +) router.include_router(notification_states_router, tags=["notification-states"]) router.include_router(ocr_router, tags=["ocr"]) router.include_router(ontology_router, tags=["ontology"]) diff --git a/server/src/app/db/base.py b/server/src/app/db/base.py index e607d35..b4eeb3b 100644 --- a/server/src/app/db/base.py +++ b/server/src/app/db/base.py @@ -13,6 +13,7 @@ from app.models.ai_application_preview import AIApplicationPreviewDecision from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink from app.models.approval import ApprovalRecord +from app.models.approval_action import ApprovalActionLedger from app.models.attachment_association_job import AttachmentAssociationJob from app.models.audit_log import AuditLog from app.models.auth_session import AuthSession @@ -34,6 +35,7 @@ from app.models.hermes_report import HermesRiskReport from app.models.notification_state import NotificationState from app.models.organization import OrganizationUnit from app.models.reimbursement import ReimbursementRequest +from app.models.risk_disposition import RiskDisposition, RiskDispositionEvent from app.models.risk_observation import RiskObservation, RiskObservationFeedback from app.models.role import Role from app.models.system_model_setting import SystemModelSetting @@ -60,6 +62,7 @@ __all__ = [ "AIDecision", "AIDecisionFeedback", "ApprovalRecord", + "ApprovalActionLedger", "AttachmentAssociationJob", "AuditLog", "AuthSession", @@ -84,6 +87,8 @@ __all__ = [ "NotificationState", "OrganizationUnit", "ReimbursementRequest", + "RiskDisposition", + "RiskDispositionEvent", "RiskObservation", "RiskObservationFeedback", "Role", diff --git a/server/src/app/db/migration_preflight.py b/server/src/app/db/migration_preflight.py index ea1e294..1076ea5 100644 --- a/server/src/app/db/migration_preflight.py +++ b/server/src/app/db/migration_preflight.py @@ -130,8 +130,48 @@ MIGRATION_OWNED_TABLES_BY_REVISION: dict[str, frozenset[str]] = { "workflow_outcomes", } ), + "20260716_0010": frozenset( + { + "expense_cases", + "expense_case_links", + "business_events", + "auth_sessions", + "attachment_association_jobs", + "ai_application_preview_decisions", + "ai_decisions", + "ai_decision_feedback", + "memory_entries", + "memory_evidence_links", + "risk_observations", + "risk_observation_feedback", + "few_shot_samples", + "workflow_outcomes", + "approval_action_ledgers", + } + ), + "20260716_0011": frozenset( + { + "expense_cases", + "expense_case_links", + "business_events", + "auth_sessions", + "attachment_association_jobs", + "ai_application_preview_decisions", + "ai_decisions", + "ai_decision_feedback", + "memory_entries", + "memory_evidence_links", + "risk_observations", + "risk_observation_feedback", + "risk_dispositions", + "risk_disposition_events", + "few_shot_samples", + "workflow_outcomes", + "approval_action_ledgers", + } + ), } -if MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0009"] != MIGRATION_OWNED_TABLES: +if MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0011"] != MIGRATION_OWNED_TABLES: raise RuntimeError("latest Alembic revision must own the centralized migration table set") # 0008 之前这三张表由旧 bootstrap / 风险服务按需创建。迁移前置检查允许 @@ -198,7 +238,13 @@ def _validate_connection(connection: Connection) -> MigrationPreflightState: adoptable_tables = ( LEGACY_ADOPTABLE_HISTORICAL_CASE_TABLES - if revision not in {"20260716_0008", "20260716_0009"} + if revision + not in { + "20260716_0008", + "20260716_0009", + "20260716_0010", + "20260716_0011", + } else frozenset() ) missing_tables = expected_tables - owned_tables diff --git a/server/src/app/db/schema_ownership.py b/server/src/app/db/schema_ownership.py index 1494b88..994b687 100644 --- a/server/src/app/db/schema_ownership.py +++ b/server/src/app/db/schema_ownership.py @@ -7,6 +7,7 @@ from app.db.base import Base MIGRATION_OWNED_TABLES: frozenset[str] = frozenset( { "auth_sessions", + "approval_action_ledgers", "attachment_association_jobs", "ai_application_preview_decisions", "ai_decisions", @@ -18,6 +19,8 @@ MIGRATION_OWNED_TABLES: frozenset[str] = frozenset( "memory_evidence_links", "risk_observations", "risk_observation_feedback", + "risk_dispositions", + "risk_disposition_events", "few_shot_samples", "workflow_outcomes", } diff --git a/server/src/app/models/__init__.py b/server/src/app/models/__init__.py index 0d0fcf2..289c34e 100644 --- a/server/src/app/models/__init__.py +++ b/server/src/app/models/__init__.py @@ -11,6 +11,7 @@ from app.models.ai_application_preview import AIApplicationPreviewDecision from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink from app.models.approval import ApprovalRecord +from app.models.approval_action import ApprovalActionLedger from app.models.attachment_association_job import AttachmentAssociationJob from app.models.audit_log import AuditLog from app.models.auth_session import AuthSession @@ -32,6 +33,7 @@ from app.models.hermes_report import HermesRiskReport from app.models.notification_state import NotificationState from app.models.organization import OrganizationUnit from app.models.reimbursement import ReimbursementRequest +from app.models.risk_disposition import RiskDisposition, RiskDispositionEvent from app.models.risk_observation import RiskObservation, RiskObservationFeedback from app.models.role import Role from app.models.system_model_setting import SystemModelSetting @@ -54,6 +56,7 @@ __all__ = [ "AgentTraceEvent", "AIApplicationPreviewDecision", "ApprovalRecord", + "ApprovalActionLedger", "AttachmentAssociationJob", "AuditLog", "AuthSession", @@ -80,6 +83,8 @@ __all__ = [ "NotificationState", "OrganizationUnit", "ReimbursementRequest", + "RiskDisposition", + "RiskDispositionEvent", "RiskObservation", "RiskObservationFeedback", "Role", diff --git a/server/src/app/models/approval_action.py b/server/src/app/models/approval_action.py new file mode 100644 index 0000000..289d164 --- /dev/null +++ b/server/src/app/models/approval_action.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any + +from sqlalchemy import CheckConstraint, DateTime, Index, String, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.db.base_class import Base + + +class ApprovalActionLedger(Base): + """审批类写动作的持久化幂等账本。""" + + __tablename__ = "approval_action_ledgers" + __table_args__ = ( + CheckConstraint( + "action IN ('approve', 'return', 'pay')", + name="ck_approval_action_ledger_action", + ), + CheckConstraint( + "(completed_at IS NULL AND result_status IS NULL " + "AND result_approval_stage IS NULL) OR " + "(completed_at IS NOT NULL AND result_status IS NOT NULL " + "AND result_approval_stage IS NOT NULL)", + name="ck_approval_action_ledger_completion", + ), + UniqueConstraint( + "tenant_id", + "actor_id", + "request_id", + name="uq_approval_action_ledger_request", + ), + Index( + "ix_approval_action_ledger_claim_action", + "tenant_id", + "claim_id", + "action", + ), + ) + + id: Mapped[str] = mapped_column( + String(36), + primary_key=True, + default=lambda: str(uuid.uuid4()), + ) + tenant_id: Mapped[str] = mapped_column(String(64), nullable=False) + actor_id: Mapped[str] = mapped_column(String(120), nullable=False) + request_id: Mapped[str] = mapped_column(String(120), nullable=False) + claim_id: Mapped[str] = mapped_column(String(36), nullable=False) + action: Mapped[str] = mapped_column(String(20), nullable=False) + payload_fingerprint: Mapped[str] = mapped_column(String(80), nullable=False) + expected_status: Mapped[str] = mapped_column(String(30), nullable=False) + expected_approval_stage: Mapped[str] = mapped_column(String(50), nullable=False) + result_status: Mapped[str | None] = mapped_column(String(30), nullable=True) + result_approval_stage: Mapped[str | None] = mapped_column(String(50), nullable=True) + response_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + ) diff --git a/server/src/app/models/risk_disposition.py b/server/src/app/models/risk_disposition.py new file mode 100644 index 0000000..fdaa9fa --- /dev/null +++ b/server/src/app/models/risk_disposition.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any + +from sqlalchemy import ( + CheckConstraint, + DateTime, + ForeignKeyConstraint, + Index, + Integer, + String, + Text, + UniqueConstraint, + func, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.types import JSON + +from app.db.base_class import Base + + +def _new_id() -> str: + return str(uuid.uuid4()) + + +class RiskDisposition(Base): + """风险观察的当前处置投影;裁决结论和处置生命周期彼此独立。""" + + __tablename__ = "risk_dispositions" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "id", + name="uq_risk_dispositions_tenant_id", + ), + UniqueConstraint( + "tenant_id", + "observation_id", + name="uq_risk_dispositions_tenant_observation", + ), + CheckConstraint( + "adjudication IN ('unreviewed', 'confirmed', 'false_positive')", + name="ck_risk_dispositions_adjudication", + ), + CheckConstraint( + "lifecycle_status IN ('open', 'supplement_requested', " + "'remediation_in_progress', 'waiver_requested', 'resolved')", + name="ck_risk_dispositions_lifecycle", + ), + CheckConstraint("version >= 0", name="ck_risk_dispositions_version"), + ForeignKeyConstraint( + ["tenant_id", "observation_id"], + ["risk_observations.tenant_id", "risk_observations.id"], + ondelete="RESTRICT", + name="fk_risk_dispositions_tenant_observation", + ), + Index( + "ix_risk_dispositions_tenant_lifecycle_due", + "tenant_id", + "lifecycle_status", + "due_at", + ), + Index("ix_risk_dispositions_assignee", "tenant_id", "assignee"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id) + tenant_id: Mapped[str] = mapped_column(String(64), nullable=False) + observation_id: Mapped[str] = mapped_column(String(36), nullable=False) + adjudication: Mapped[str] = mapped_column( + String(24), + nullable=False, + default="unreviewed", + server_default="unreviewed", + ) + lifecycle_status: Mapped[str] = mapped_column( + String(32), + nullable=False, + default="open", + server_default="open", + ) + version: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=0, + server_default="0", + ) + assignee: Mapped[str | None] = mapped_column(String(120), nullable=True) + due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + resolution: Mapped[str | None] = mapped_column(Text(), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + onupdate=func.now(), + ) + + observation = relationship("RiskObservation", back_populates="disposition") + events = relationship( + "RiskDispositionEvent", + back_populates="disposition", + order_by="asc(RiskDispositionEvent.version)", + lazy="selectin", + passive_deletes=True, + ) + + +class RiskDispositionEvent(Base): + """只追加的风险处置事实;每个版本只对应一个类型化动作。""" + + __tablename__ = "risk_disposition_events" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "request_id", + name="uq_risk_disposition_events_tenant_request", + ), + UniqueConstraint( + "disposition_id", + "version", + name="uq_risk_disposition_events_version", + ), + CheckConstraint( + "action IN ('confirm', 'false_positive', 'request_supplement', " + "'start_remediation', 'resolve', 'request_waiver')", + name="ck_risk_disposition_events_action", + ), + CheckConstraint("version > 0", name="ck_risk_disposition_events_version"), + ForeignKeyConstraint( + ["tenant_id", "disposition_id"], + ["risk_dispositions.tenant_id", "risk_dispositions.id"], + ondelete="RESTRICT", + name="fk_risk_disposition_events_tenant_disposition", + ), + ForeignKeyConstraint( + ["tenant_id", "observation_id"], + ["risk_observations.tenant_id", "risk_observations.id"], + ondelete="RESTRICT", + name="fk_risk_disposition_events_tenant_observation", + ), + Index( + "ix_risk_disposition_events_tenant_observation_time", + "tenant_id", + "observation_id", + "created_at", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id) + tenant_id: Mapped[str] = mapped_column(String(64), nullable=False) + disposition_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + observation_id: Mapped[str] = mapped_column(String(36), nullable=False) + version: Mapped[int] = mapped_column(Integer, nullable=False) + action: Mapped[str] = mapped_column(String(32), nullable=False) + actor_id: Mapped[str] = mapped_column(String(120), nullable=False) + actor_name: Mapped[str] = mapped_column(String(120), nullable=False) + request_id: Mapped[str] = mapped_column(String(120), nullable=False) + payload_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False) + comment: Mapped[str | None] = mapped_column(Text(), nullable=True) + before_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict) + after_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) + + disposition = relationship("RiskDisposition", back_populates="events") diff --git a/server/src/app/models/risk_observation.py b/server/src/app/models/risk_observation.py index 81c13e7..f58441b 100644 --- a/server/src/app/models/risk_observation.py +++ b/server/src/app/models/risk_observation.py @@ -24,6 +24,11 @@ from app.db.base_class import Base class RiskObservation(Base): __tablename__ = "risk_observations" __table_args__ = ( + UniqueConstraint( + "tenant_id", + "id", + name="uq_risk_observations_tenant_id", + ), UniqueConstraint( "tenant_id", "observation_key", @@ -95,6 +100,12 @@ class RiskObservation(Base): cascade="all, delete-orphan", order_by="desc(RiskObservationFeedback.created_at)", ) + disposition = relationship( + "RiskDisposition", + back_populates="observation", + passive_deletes=True, + uselist=False, + ) @property def sampling_strategy(self) -> dict[str, Any]: diff --git a/server/src/app/schemas/approval_workbench.py b/server/src/app/schemas/approval_workbench.py new file mode 100644 index 0000000..cc487c8 --- /dev/null +++ b/server/src/app/schemas/approval_workbench.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, Field + +from app.schemas.reimbursement import ExpenseClaimRead + + +class ApprovalWorkbenchPriorityReasonRead(BaseModel): + code: str + label: str + weight: int = 0 + tone: Literal["normal", "warning", "danger"] = "normal" + + +class ApprovalWorkbenchEvidenceRead(BaseModel): + completeness: float = 0.0 + present_count: int = 0 + required_count: int = 0 + missing_labels: list[str] = Field(default_factory=list) + historical_labels: list[str] = Field(default_factory=list) + + +class ApprovalWorkbenchSuggestionRead(BaseModel): + action: Literal[ + "manual_review", + "request_supplement", + "budget_review", + "approve_candidate", + ] + label: str + reason: str + advisory_only: Literal[True] = True + + +class ApprovalWorkbenchItemRead(BaseModel): + task_key: str + claim: ExpenseClaimRead + priority_score: int = 0 + priority_tier: Literal["normal", "high", "urgent"] = "normal" + priority_reasons: list[ApprovalWorkbenchPriorityReasonRead] = Field(default_factory=list) + risk_level: Literal["low", "medium", "high", "critical"] = "low" + open_risk_count: int = 0 + budget_usage_rate: float | None = None + waiting_hours: float = 0.0 + sla_due_at: datetime | None = None + sla_overdue: bool = False + evidence: ApprovalWorkbenchEvidenceRead + suggestion: ApprovalWorkbenchSuggestionRead + + +class ApprovalWorkbenchListRead(BaseModel): + items: list[ApprovalWorkbenchItemRead] = Field(default_factory=list) + total: int = 0 + generated_at: datetime + scoring_version: str = "approval_workbench_priority.v1" diff --git a/server/src/app/schemas/reimbursement.py b/server/src/app/schemas/reimbursement.py index a371f47..1e4ab5b 100644 --- a/server/src/app/schemas/reimbursement.py +++ b/server/src/app/schemas/reimbursement.py @@ -276,15 +276,38 @@ class ExpenseApplicationPreviewActionResponse(BaseModel): result: ExpenseApplicationPreviewActionResult -class ExpenseClaimReturnPayload(BaseModel): +class ExpenseClaimActionProtocolPayload(BaseModel): + request_id: str = Field(min_length=1, max_length=120) + expected_status: str = Field(min_length=1, max_length=30) + expected_approval_stage: str = Field(min_length=1, max_length=50) + + @field_validator("request_id", "expected_status", "expected_approval_stage") + @classmethod + def validate_action_protocol_text(cls, value: str) -> str: + normalized = str(value or "").strip() + if not normalized: + raise ValueError("审批动作协议字段不能为空。") + return normalized + + @field_validator("expected_status") + @classmethod + def normalize_expected_status(cls, value: str) -> str: + return value.lower() + + +class ExpenseClaimReturnPayload(ExpenseClaimActionProtocolPayload): reason: str | None = Field(default=None, max_length=500) reason_codes: list[str] = Field(default_factory=list, max_length=10) -class ExpenseClaimApprovalPayload(BaseModel): +class ExpenseClaimApprovalPayload(ExpenseClaimActionProtocolPayload): opinion: str | None = Field(default=None, max_length=500) +class ExpenseClaimPaymentPayload(ExpenseClaimActionProtocolPayload): + pass + + class TravelReimbursementCalculatorRequest(BaseModel): days: int = Field(ge=1, le=365) location: str = Field(min_length=1, max_length=120) diff --git a/server/src/app/schemas/risk_disposition.py b/server/src/app/schemas/risk_disposition.py new file mode 100644 index 0000000..22ce8bd --- /dev/null +++ b/server/src/app/schemas/risk_disposition.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +RiskDispositionAction = Literal[ + "confirm", + "false_positive", + "request_supplement", + "start_remediation", + "resolve", + "request_waiver", +] +RiskAdjudication = Literal["unreviewed", "confirmed", "false_positive"] +RiskLifecycleStatus = Literal[ + "open", + "supplement_requested", + "remediation_in_progress", + "waiver_requested", + "resolved", +] + + +class RiskDispositionActionCreate(BaseModel): + action: RiskDispositionAction + expected_version: int = Field(ge=0) + request_id: str = Field(min_length=8, max_length=120) + comment: str | None = Field(default=None, max_length=1000) + assignee: str | None = Field(default=None, max_length=120) + due_at: datetime | None = None + resolution: str | None = Field(default=None, max_length=2000) + + @field_validator("request_id", "comment", "assignee", "resolution", mode="before") + @classmethod + def normalize_text(cls, value: Any) -> Any: + if value is None: + return None + normalized = str(value).strip() + return normalized or None + + @model_validator(mode="after") + def validate_action_fields(self) -> RiskDispositionActionCreate: + if self.action == "resolve" and not self.resolution: + raise ValueError("resolve 动作必须填写 resolution") + if self.action in {"false_positive", "request_supplement", "request_waiver"} and not ( + self.comment + ): + raise ValueError(f"{self.action} 动作必须填写 comment") + if self.action in {"confirm", "false_positive"} and any( + value is not None for value in (self.assignee, self.due_at, self.resolution) + ): + raise ValueError("裁决动作不能同时修改负责人、截止时间或解决说明") + return self + + +class RiskDispositionEventRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + tenant_id: str + disposition_id: str + observation_id: str + version: int + action: RiskDispositionAction + actor_id: str + actor_name: str + request_id: str + comment: str | None + before_json: dict[str, Any] + after_json: dict[str, Any] + created_at: datetime + + +class RiskDispositionRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + tenant_id: str + observation_id: str + adjudication: RiskAdjudication + lifecycle_status: RiskLifecycleStatus + version: int + assignee: str | None + due_at: datetime | None + resolution: str | None + created_at: datetime + updated_at: datetime + events: list[RiskDispositionEventRead] = Field(default_factory=list) + + +class RiskDispositionMutationRead(BaseModel): + disposition: RiskDispositionRead + event: RiskDispositionEventRead + replayed: bool = False diff --git a/server/src/app/schemas/risk_observation.py b/server/src/app/schemas/risk_observation.py index 5c23cf1..3562c66 100644 --- a/server/src/app/schemas/risk_observation.py +++ b/server/src/app/schemas/risk_observation.py @@ -5,6 +5,8 @@ from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator +from app.schemas.risk_disposition import RiskDispositionRead + RiskObservationStatus = Literal[ "pending_review", "confirmed", @@ -89,6 +91,7 @@ class RiskObservationRead(BaseModel): created_at: datetime updated_at: datetime feedback_items: list[RiskObservationFeedbackRead] = Field(default_factory=list) + disposition: RiskDispositionRead | None = None class RiskObservationListRead(BaseModel): diff --git a/server/src/app/services/approval_action_protocol.py b/server/src/app/services/approval_action_protocol.py new file mode 100644 index 0000000..d92565b --- /dev/null +++ b/server/src/app/services/approval_action_protocol.py @@ -0,0 +1,270 @@ +from __future__ import annotations + +import hashlib +import json +import threading +import uuid +from collections.abc import Callable, Iterator, Mapping +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import select, text +from sqlalchemy.orm import Session + +from app.api.deps import CurrentUserContext +from app.models.approval_action import ApprovalActionLedger +from app.models.financial_record import ExpenseClaim +from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin + + +class ApprovalActionConflictError(ValueError): + """请求已被占用,或动作前置状态已经过期。""" + + +@dataclass(frozen=True, slots=True) +class ApprovalActionStart: + claim: ExpenseClaim | None + ledger: ApprovalActionLedger | None + request_id: str + replayed: bool + + +class ApprovalActionProtocol: + """为 approve/return/pay 提供并发安全和事务内幂等语义。""" + + _fallback_registry_guard = threading.Lock() + _fallback_locks: dict[str, tuple[threading.RLock, int]] = {} + + def __init__(self, db: Session) -> None: + self.db = db + + @contextmanager + def serialize_request( + self, + *, + tenant_id: str, + actor_id: str, + request_id: str | None, + ) -> Iterator[str]: + normalized_request_id = self._normalize_request_id(request_id) + lock_name = f"approval-action:{tenant_id}:{actor_id}:{normalized_request_id}" + if self._dialect_name() == "postgresql": + self.db.execute( + text("SELECT pg_advisory_xact_lock(:lock_id)"), + {"lock_id": self._signed_lock_id(lock_name)}, + ) + yield normalized_request_id + return + + with self._serialize_fallback(lock_name): + yield normalized_request_id + + def begin( + self, + *, + action: str, + claim_id: str, + current_user: CurrentUserContext, + request_id: str, + expected_status: str | None, + expected_approval_stage: str | None, + payload: Mapping[str, Any], + claim_loader: Callable[[], ExpenseClaim | None], + replay_claim_loader: Callable[[], ExpenseClaim | None], + ) -> ApprovalActionStart: + tenant_id = ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id) + actor_id = self._normalize_actor_id(current_user.username) + normalized_action = self._normalize_action(action) + existing = self.db.scalar( + select(ApprovalActionLedger).where( + ApprovalActionLedger.tenant_id == tenant_id, + ApprovalActionLedger.actor_id == actor_id, + ApprovalActionLedger.request_id == request_id, + ) + ) + if existing is not None: + supplied_fingerprint = self._fingerprint( + action=normalized_action, + claim_id=claim_id, + expected_status=expected_status or existing.expected_status, + expected_approval_stage=( + expected_approval_stage or existing.expected_approval_stage + ), + payload=payload, + ) + if supplied_fingerprint != existing.payload_fingerprint: + raise ApprovalActionConflictError( + "该 request_id 已用于另一项审批动作,请生成新的 request_id 后重试。" + ) + if existing.completed_at is None: + raise ApprovalActionConflictError("该审批动作仍在处理中,请稍后重试。") + return ApprovalActionStart( + claim=replay_claim_loader(), + ledger=existing, + request_id=request_id, + replayed=True, + ) + + claim = claim_loader() + if claim is None: + return ApprovalActionStart( + claim=None, + ledger=None, + request_id=request_id, + replayed=False, + ) + + actual_tenant_id = ExpenseClaimTenantScopeMixin.resolve_claim_tenant_id( + self.db, + claim.id, + ) + if actual_tenant_id != tenant_id: + # 正常情况下租户查询范围会先拦截;这里是写入账本前的纵深校验。 + raise ApprovalActionConflictError("单据租户上下文已变化,请刷新后重试。") + + current_status = self._normalize_status(claim.status) + current_stage = self._normalize_stage(claim.approval_stage) + normalized_expected_status = self._normalize_status( + expected_status if expected_status is not None else current_status + ) + normalized_expected_stage = self._normalize_stage( + expected_approval_stage if expected_approval_stage is not None else current_stage + ) + if normalized_expected_status != current_status: + raise ApprovalActionConflictError( + f"单据状态已从 {normalized_expected_status} 变为 {current_status},请刷新后重试。" + ) + if normalized_expected_stage != current_stage: + raise ApprovalActionConflictError( + f"审批节点已从 {normalized_expected_stage} 变为 {current_stage},请刷新后重试。" + ) + + ledger = ApprovalActionLedger( + tenant_id=tenant_id, + actor_id=actor_id, + request_id=request_id, + claim_id=str(claim.id), + action=normalized_action, + payload_fingerprint=self._fingerprint( + action=normalized_action, + claim_id=claim.id, + expected_status=normalized_expected_status, + expected_approval_stage=normalized_expected_stage, + payload=payload, + ), + expected_status=normalized_expected_status, + expected_approval_stage=normalized_expected_stage, + ) + self.db.add(ledger) + self.db.flush() + return ApprovalActionStart( + claim=claim, + ledger=ledger, + request_id=request_id, + replayed=False, + ) + + def complete( + self, + ledger: ApprovalActionLedger, + claim: ExpenseClaim, + *, + response_json: Mapping[str, Any] | None = None, + ) -> None: + ledger.result_status = self._normalize_status(claim.status) + ledger.result_approval_stage = self._normalize_stage(claim.approval_stage) + ledger.response_json = dict(response_json or {}) + ledger.completed_at = datetime.now(UTC) + self.db.flush() + + def _dialect_name(self) -> str: + bind = self.db.get_bind() + return str(bind.dialect.name if bind is not None else "") + + @classmethod + @contextmanager + def _serialize_fallback(cls, name: str) -> Iterator[None]: + with cls._fallback_registry_guard: + lock, references = cls._fallback_locks.get( + name, + (threading.RLock(), 0), + ) + cls._fallback_locks[name] = (lock, references + 1) + try: + with lock: + yield + finally: + with cls._fallback_registry_guard: + current = cls._fallback_locks.get(name) + if current is None or current[0] is not lock: + return + if current[1] <= 1: + cls._fallback_locks.pop(name, None) + else: + cls._fallback_locks[name] = (lock, current[1] - 1) + + @staticmethod + def _signed_lock_id(value: str) -> int: + unsigned = int.from_bytes( + hashlib.sha256(value.encode("utf-8")).digest()[:8], + byteorder="big", + signed=False, + ) + return unsigned - (1 << 64) if unsigned >= (1 << 63) else unsigned + + @staticmethod + def _normalize_request_id(value: str | None) -> str: + normalized = str(value or "").strip() or f"internal:{uuid.uuid4()}" + if len(normalized) > 120: + raise ValueError("request_id 最长为 120 个字符。") + return normalized + + @staticmethod + def _normalize_actor_id(value: str | None) -> str: + normalized = str(value or "").strip().casefold() + if not normalized: + raise ValueError("当前用户缺少可审计的账号标识。") + return normalized + + @staticmethod + def _normalize_action(value: str) -> str: + normalized = str(value or "").strip().lower() + if normalized not in {"approve", "return", "pay"}: + raise ValueError("不支持的审批动作。") + return normalized + + @staticmethod + def _normalize_status(value: str | None) -> str: + return str(value or "").strip().lower() + + @staticmethod + def _normalize_stage(value: str | None) -> str: + return str(value or "").strip() + + @classmethod + def _fingerprint( + cls, + *, + action: str, + claim_id: str, + expected_status: str, + expected_approval_stage: str, + payload: Mapping[str, Any], + ) -> str: + canonical = { + "action": cls._normalize_action(action), + "claim_id": str(claim_id or "").strip(), + "expected_status": cls._normalize_status(expected_status), + "expected_approval_stage": cls._normalize_stage(expected_approval_stage), + "payload": dict(payload), + } + encoded = json.dumps( + canonical, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + default=str, + ).encode("utf-8") + return f"sha256:{hashlib.sha256(encoded).hexdigest()}" diff --git a/server/src/app/services/approval_workbench.py b/server/src/app/services/approval_workbench.py new file mode 100644 index 0000000..3e855a7 --- /dev/null +++ b/server/src/app/services/approval_workbench.py @@ -0,0 +1,518 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from decimal import Decimal, InvalidOperation +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.api.deps import CurrentUserContext +from app.models.financial_record import ExpenseClaim +from app.models.risk_disposition import RiskDisposition +from app.models.risk_observation import RiskObservation +from app.schemas.approval_workbench import ( + ApprovalWorkbenchEvidenceRead, + ApprovalWorkbenchItemRead, + ApprovalWorkbenchListRead, + ApprovalWorkbenchPriorityReasonRead, + ApprovalWorkbenchSuggestionRead, +) +from app.schemas.reimbursement import ExpenseClaimRead +from app.services.expense_claim_risk_flags import ( + claim_risk_flag_observation_key, + claim_risk_flag_severity, + is_open_claim_risk_flag, +) +from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin + +_SLA_HOURS = 24 +_RISK_WEIGHTS = {"critical": 38, "high": 30, "medium": 14, "low": 0} +_RISK_ORDER = {"low": 0, "medium": 1, "high": 2, "critical": 3} +_RESOLVED_STATUSES = {"resolved", "accepted", "waived", "false_positive"} + + +class ApprovalWorkbenchService: + """构建只读、可解释且不扩大审批权限的例外审批队列。""" + + def __init__(self, db: Session) -> None: + self.db = db + + def list_items( + self, + current_user: CurrentUserContext, + *, + limit: int = 100, + now: datetime | None = None, + ) -> ApprovalWorkbenchListRead: + # 延迟导入,避免 ExpenseClaimService 的 mixin 聚合产生循环依赖。 + from app.services.expense_claims import ExpenseClaimService + + generated_at = now or datetime.now(UTC) + claims = ExpenseClaimService(self.db).list_approval_claims(current_user) + observations_by_claim = self._risk_observations_by_claim( + claims, + tenant_id=current_user.tenant_id, + ) + items = [ + self.build_item( + claim, + now=generated_at, + observation_rows=observations_by_claim.get(claim.id), + ) + for claim in claims + ] + items.sort( + key=lambda item: ( + -item.priority_score, + -item.waiting_hours, + item.claim.claim_no, + ) + ) + return ApprovalWorkbenchListRead( + items=items[: max(1, min(int(limit), 200))], + total=len(items), + generated_at=generated_at, + ) + + @classmethod + def build_item( + cls, + claim: ExpenseClaim, + *, + now: datetime | None = None, + observation_rows: list[tuple[RiskObservation, RiskDisposition | None]] | None = None, + ) -> ApprovalWorkbenchItemRead: + generated_at = now or datetime.now(UTC) + submitted_at = cls._aware(claim.submitted_at or claim.created_at) + waiting_hours = max( + 0.0, + (generated_at - submitted_at).total_seconds() / 3600, + ) + sla_due_at = submitted_at + timedelta(hours=_SLA_HOURS) + risk_level, open_risk_count = cls._combined_risk_summary( + claim, + observation_rows or [], + ) + budget_usage_rate = cls._budget_usage_rate(claim.risk_flags_json) + evidence = cls._evidence_summary(claim) + reasons = cls._priority_reasons( + claim=claim, + risk_level=risk_level, + open_risk_count=open_risk_count, + budget_usage_rate=budget_usage_rate, + waiting_hours=waiting_hours, + evidence=evidence, + ) + score = min(100, sum(item.weight for item in reasons)) + tier = "urgent" if score >= 65 else "high" if score >= 40 else "normal" + return ApprovalWorkbenchItemRead( + task_key=f"{claim.id}:{claim.approval_stage or ''}:{submitted_at.isoformat()}", + claim=cls._safe_claim_read(claim), + priority_score=score, + priority_tier=tier, + priority_reasons=reasons, + risk_level=risk_level, + open_risk_count=open_risk_count, + budget_usage_rate=budget_usage_rate, + waiting_hours=round(waiting_hours, 2), + sla_due_at=sla_due_at, + sla_overdue=generated_at >= sla_due_at, + evidence=evidence, + suggestion=cls._suggestion( + risk_level=risk_level, + open_risk_count=open_risk_count, + budget_usage_rate=budget_usage_rate, + evidence=evidence, + ), + ) + + def _risk_observations_by_claim( + self, + claims: list[ExpenseClaim], + *, + tenant_id: str | None, + ) -> dict[str, list[tuple[RiskObservation, RiskDisposition | None]]]: + claim_ids = [str(claim.id) for claim in claims if str(claim.id or "").strip()] + if not claim_ids: + return {} + normalized_tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(tenant_id) + rows = self.db.execute( + select(RiskObservation, RiskDisposition) + .outerjoin( + RiskDisposition, + ( + (RiskDisposition.tenant_id == RiskObservation.tenant_id) + & (RiskDisposition.observation_id == RiskObservation.id) + ), + ) + .where( + RiskObservation.tenant_id == normalized_tenant, + RiskObservation.claim_id.in_(claim_ids), + ) + ).all() + grouped: dict[str, list[tuple[RiskObservation, RiskDisposition | None]]] = {} + for observation, disposition in rows: + grouped.setdefault(str(observation.claim_id), []).append((observation, disposition)) + return grouped + + @classmethod + def _safe_claim_read(cls, claim: ExpenseClaim) -> ExpenseClaimRead: + claim_read = ExpenseClaimRead.model_validate(claim) + claim_read.risk_flags_json = [ + cls._safe_risk_flag(flag) + for flag in list(claim_read.risk_flags_json or []) + if isinstance(flag, dict) + ] + return claim_read + + @staticmethod + def _safe_risk_flag(flag: dict[str, Any]) -> dict[str, Any]: + safe_flag = dict(flag) + if "historical_case_evidence" not in safe_flag: + return safe_flag + evidence: list[dict[str, Any]] = [] + for item in list(safe_flag.get("historical_case_evidence") or []): + if not isinstance(item, dict): + continue + label = str(item.get("label") or "").strip().lower() + if label not in {"confirmed", "false_positive"}: + continue + evidence.append( + { + "label": label, + "label_text": ( + "历史已确认,仅供复核" if label == "confirmed" else "历史误报,仅供复核" + ), + "advisory_only": True, + "stale": bool(item.get("stale")), + "summary": ( + "历史相似案例经人工复核确认风险成立。" + if label == "confirmed" + else "历史相似案例经人工复核判定为误报。" + ), + } + ) + safe_flag["historical_case_evidence"] = evidence + return safe_flag + + @classmethod + def _priority_reasons( + cls, + *, + claim: ExpenseClaim, + risk_level: str, + open_risk_count: int, + budget_usage_rate: float | None, + waiting_hours: float, + evidence: ApprovalWorkbenchEvidenceRead, + ) -> list[ApprovalWorkbenchPriorityReasonRead]: + reasons: list[ApprovalWorkbenchPriorityReasonRead] = [] + risk_weight = _RISK_WEIGHTS[risk_level] + if risk_weight: + reasons.append( + ApprovalWorkbenchPriorityReasonRead( + code="open_risk", + label=f"{risk_level.upper()} 风险 {open_risk_count} 项待复核", + weight=risk_weight, + tone="danger" if risk_level in {"high", "critical"} else "warning", + ) + ) + if waiting_hours >= _SLA_HOURS: + reasons.append( + ApprovalWorkbenchPriorityReasonRead( + code="sla_overdue", + label="已超过 24 小时审批 SLA", + weight=26, + tone="danger", + ) + ) + elif waiting_hours >= 16: + reasons.append( + ApprovalWorkbenchPriorityReasonRead( + code="sla_near_due", + label="审批 SLA 即将到期", + weight=16, + tone="warning", + ) + ) + elif waiting_hours >= 8: + reasons.append( + ApprovalWorkbenchPriorityReasonRead( + code="waiting", + label="等待时间已超过 8 小时", + weight=8, + tone="warning", + ) + ) + if budget_usage_rate is not None and budget_usage_rate >= 90: + reasons.append( + ApprovalWorkbenchPriorityReasonRead( + code="budget_pressure", + label=f"审批后预算占用约 {budget_usage_rate:.0f}%", + weight=18, + tone="danger" if budget_usage_rate >= 100 else "warning", + ) + ) + amount = cls._decimal(claim.amount) + if amount >= Decimal("50000"): + reasons.append( + ApprovalWorkbenchPriorityReasonRead( + code="large_amount", + label="大额费用需重点核对", + weight=15, + tone="warning", + ) + ) + elif amount >= Decimal("10000"): + reasons.append( + ApprovalWorkbenchPriorityReasonRead( + code="amount_attention", + label="金额超过 1 万元", + weight=8, + tone="warning", + ) + ) + if evidence.completeness < 1: + reasons.append( + ApprovalWorkbenchPriorityReasonRead( + code="evidence_gap", + label="材料仍有缺口:" + "、".join(evidence.missing_labels), + weight=12, + tone="warning", + ) + ) + if not reasons: + reasons.append( + ApprovalWorkbenchPriorityReasonRead( + code="routine", + label="常规低风险待办", + ) + ) + return reasons + + @classmethod + def _risk_summary( + cls, + raw_flags: Any, + *, + claim_id: str = "", + materialized_keys: set[str] | None = None, + ) -> tuple[str, int]: + flags = raw_flags if isinstance(raw_flags, list) else [raw_flags] + persisted_keys = materialized_keys or set() + level = "low" + count = 0 + for flag in flags: + if not isinstance(flag, dict) or not is_open_claim_risk_flag(flag): + continue + observation_key = claim_risk_flag_observation_key(flag, claim_id=claim_id) + if observation_key and observation_key in persisted_keys: + continue + candidate = claim_risk_flag_severity(flag) or "medium" + if candidate not in _RISK_ORDER: + candidate = "medium" + count += 1 + if _RISK_ORDER[candidate] > _RISK_ORDER[level]: + level = candidate + return level, count + + @classmethod + def _combined_risk_summary( + cls, + claim: ExpenseClaim, + rows: list[tuple[RiskObservation, RiskDisposition | None]], + ) -> tuple[str, int]: + persisted_level, persisted_count = cls._persisted_risk_summary(rows) + materialized_keys = { + str(observation.observation_key or "").strip() + for observation, _disposition in rows + if str(observation.observation_key or "").strip() + } + raw_level, raw_count = cls._risk_summary( + claim.risk_flags_json, + claim_id=str(claim.id or ""), + materialized_keys=materialized_keys, + ) + return ( + max((persisted_level, raw_level), key=lambda item: _RISK_ORDER[item]), + persisted_count + raw_count, + ) + + @staticmethod + def _persisted_risk_summary( + rows: list[tuple[RiskObservation, RiskDisposition | None]], + ) -> tuple[str, int]: + level = "low" + count = 0 + for observation, disposition in rows: + adjudication = ( + str(disposition.adjudication or "").strip().lower() + if disposition is not None + else str(observation.feedback_status or "").strip().lower() + ) + lifecycle = ( + str(disposition.lifecycle_status or "").strip().lower() + if disposition is not None + else "open" + ) + status = str(observation.status or "").strip().lower() + if ( + adjudication == "false_positive" + or lifecycle == "resolved" + or status in _RESOLVED_STATUSES + ): + continue + candidate = str(observation.risk_level or "medium").strip().lower() + if candidate == "danger": + candidate = "high" + if candidate not in _RISK_ORDER: + candidate = "medium" + count += 1 + if _RISK_ORDER[candidate] > _RISK_ORDER[level]: + level = candidate + return level, count + + @classmethod + def _budget_usage_rate(cls, raw_flags: Any) -> float | None: + values: list[float] = [] + flags = raw_flags if isinstance(raw_flags, list) else [raw_flags] + for flag in flags: + if not isinstance(flag, dict): + continue + root_containers = [ + flag, + flag.get("metrics"), + flag.get("budget_result"), + (flag.get("route_decision") or {}).get("budget_result") + if isinstance(flag.get("route_decision"), dict) + else None, + ] + containers = [item for item in root_containers if isinstance(item, dict)] + containers.extend( + item["metrics"] + for item in list(containers) + if isinstance(item.get("metrics"), dict) + ) + for container in containers: + for key in ( + "after_usage_rate", + "budget_usage_rate", + "usage_rate", + "utilization_rate", + ): + value = cls._float(container.get(key)) + if value is None: + continue + values.append(value * 100 if 0 < value <= 1 else value) + return round(max(values), 2) if values else None + + @classmethod + def _evidence_summary(cls, claim: ExpenseClaim) -> ApprovalWorkbenchEvidenceRead: + is_application = cls._is_application(claim) + checks = [ + ("事由", bool(str(claim.reason or "").strip())), + ("地点", bool(str(claim.location or "").strip())), + ("费用明细", bool(list(claim.items or []))), + ] + if not is_application: + checks.append( + ( + "票据", + int(claim.invoice_count or 0) > 0 + or any(str(item.invoice_id or "").strip() for item in claim.items or []), + ) + ) + missing = [label for label, present in checks if not present] + historical_labels = cls._historical_labels(claim.risk_flags_json) + present_count = len(checks) - len(missing) + return ApprovalWorkbenchEvidenceRead( + completeness=round(present_count / len(checks), 4) if checks else 1, + present_count=present_count, + required_count=len(checks), + missing_labels=missing, + historical_labels=historical_labels, + ) + + @staticmethod + def _historical_labels(raw_flags: Any) -> list[str]: + result: list[str] = [] + flags = raw_flags if isinstance(raw_flags, list) else [raw_flags] + for flag in flags: + if not isinstance(flag, dict): + continue + for item in list(flag.get("historical_case_evidence") or []): + if not isinstance(item, dict): + continue + label = str(item.get("label") or "").strip().lower() + text = ( + "历史已确认,仅供复核" + if label == "confirmed" + else "历史误报,仅供复核" + if label == "false_positive" + else "" + ) + if text and text not in result: + result.append(text) + return result + + @staticmethod + def _suggestion( + *, + risk_level: str, + open_risk_count: int, + budget_usage_rate: float | None, + evidence: ApprovalWorkbenchEvidenceRead, + ) -> ApprovalWorkbenchSuggestionRead: + if risk_level in {"high", "critical"} and open_risk_count: + return ApprovalWorkbenchSuggestionRead( + action="manual_review", + label="先核对风险再决策", + reason="存在高风险关注项,AI 不建议直接通过。", + ) + if evidence.missing_labels: + return ApprovalWorkbenchSuggestionRead( + action="request_supplement", + label="建议退回补充材料", + reason="缺少" + "、".join(evidence.missing_labels) + "。", + ) + if budget_usage_rate is not None and budget_usage_rate >= 90: + return ApprovalWorkbenchSuggestionRead( + action="budget_review", + label="重点复核预算影响", + reason="审批后预算占用已达到复核线。", + ) + return ApprovalWorkbenchSuggestionRead( + action="approve_candidate", + label="可人工确认后通过", + reason="材料相对完整且未发现高风险阻断项。", + ) + + @staticmethod + def _is_application(claim: ExpenseClaim) -> bool: + claim_no = str(claim.claim_no or "").strip().upper() + expense_type = str(claim.expense_type or "").strip().lower() + return ( + claim_no.startswith(("AP-", "APP-")) + or expense_type == "application" + or expense_type.endswith("_application") + ) + + @staticmethod + def _aware(value: datetime) -> datetime: + return value if value.tzinfo is not None else value.replace(tzinfo=UTC) + + @staticmethod + def _decimal(value: Any) -> Decimal: + try: + return Decimal(str(value or "0")) + except (InvalidOperation, ValueError): + return Decimal("0") + + @staticmethod + def _float(value: Any) -> float | None: + try: + number = float(value) + except (TypeError, ValueError): + return None + return number if number >= 0 else None diff --git a/server/src/app/services/expense_claim_action_protocol.py b/server/src/app/services/expense_claim_action_protocol.py new file mode 100644 index 0000000..4c6050e --- /dev/null +++ b/server/src/app/services/expense_claim_action_protocol.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from collections.abc import Callable, Mapping +from typing import Any + +from sqlalchemy import select + +from app.api.deps import CurrentUserContext +from app.models.approval_action import ApprovalActionLedger +from app.models.financial_record import ExpenseClaim +from app.services.approval_action_protocol import ApprovalActionProtocol +from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin + +ClaimActionExecutor = Callable[ + [ExpenseClaim, ApprovalActionLedger, str], + ExpenseClaim, +] + + +class ExpenseClaimActionProtocolMixin: + def _execute_claim_action( + self, + *, + action: str, + claim_id: str, + current_user: CurrentUserContext, + request_id: str | None, + expected_status: str | None, + expected_approval_stage: str | None, + payload: Mapping[str, Any], + executor: ClaimActionExecutor, + ) -> ExpenseClaim | None: + protocol = ApprovalActionProtocol(self.db) + tenant_id = ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id) + actor_id = str(current_user.username or "").strip().casefold() + with protocol.serialize_request( + tenant_id=tenant_id, + actor_id=actor_id, + request_id=request_id, + ) as normalized_request_id: + try: + started = protocol.begin( + action=action, + claim_id=claim_id, + current_user=current_user, + request_id=normalized_request_id, + expected_status=expected_status, + expected_approval_stage=expected_approval_stage, + payload=payload, + claim_loader=lambda: self._load_claim_for_action( + claim_id, + current_user, + ), + replay_claim_loader=lambda: self._load_claim_for_replay( + claim_id, + current_user, + ), + ) + if started.claim is None: + self.db.rollback() + return None + if started.replayed: + self.db.commit() + self.db.refresh(started.claim) + return self._access_policy.attach_approval_snapshot(started.claim) + if started.ledger is None: # pragma: no cover - defensive invariant + raise RuntimeError("审批动作账本初始化失败。") + + claim = executor( + started.claim, + started.ledger, + normalized_request_id, + ) + protocol.complete( + started.ledger, + claim, + response_json={ + "claim_id": claim.id, + "status": str(claim.status or "").strip(), + "approval_stage": str(claim.approval_stage or "").strip(), + }, + ) + self.db.commit() + self.db.refresh(claim) + return self._access_policy.attach_approval_snapshot(claim) + except Exception: + self.db.rollback() + raise + + def _load_claim_for_action( + self, + claim_id: str, + current_user: CurrentUserContext, + ) -> ExpenseClaim | None: + stmt = select(ExpenseClaim).where(ExpenseClaim.id == claim_id) + stmt = self._access_policy.apply_claim_scope( + stmt, + current_user, + include_approval_scope=True, + ) + bind = self.db.get_bind() + if bind is not None and bind.dialect.name == "postgresql": + stmt = stmt.with_for_update() + claim = self.db.scalar(stmt) + if claim is not None: + # 动作协议已经持有事务级请求锁和 Claim 行锁;这里只允许在同一 + # 事务内修正对象,不能调用会自行 commit 的读取兼容修复入口。 + self._repair_duplicate_budget_approval_stage(claim) + return claim + + def _load_claim_for_replay( + self, + claim_id: str, + current_user: CurrentUserContext, + ) -> ExpenseClaim | None: + stmt = select(ExpenseClaim).where( + ExpenseClaim.id == claim_id, + ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(current_user.tenant_id), + ) + return self.db.scalar(stmt) diff --git a/server/src/app/services/expense_claim_approval_flow.py b/server/src/app/services/expense_claim_approval_flow.py index 2c17410..c261764 100644 --- a/server/src/app/services/expense_claim_approval_flow.py +++ b/server/src/app/services/expense_claim_approval_flow.py @@ -6,8 +6,14 @@ from decimal import Decimal, InvalidOperation from typing import Any from app.api.deps import CurrentUserContext +from app.models.approval_action import ApprovalActionLedger from app.models.financial_record import ExpenseClaim from app.services.budget import BudgetService +from app.services.expense_claim_risk_gate import ExpenseClaimRiskGate +from app.services.expense_claim_risk_stage import ( + risk_business_stage_for_claim, + with_risk_business_stage, +) from app.services.expense_claim_workflow_constants import ( APPLICATION_LINK_STATUS_STAGE, BUDGET_MANAGER_APPROVAL_STAGE, @@ -18,10 +24,6 @@ from app.services.expense_claim_workflow_constants import ( PAYMENT_PENDING_STAGE, PAYMENT_PENDING_STATUS, ) -from app.services.expense_claim_risk_stage import ( - risk_business_stage_for_claim, - with_risk_business_stage, -) class ExpenseClaimApprovalFlowMixin: @@ -31,10 +33,37 @@ class ExpenseClaimApprovalFlowMixin: current_user: CurrentUserContext, *, opinion: str | None = None, + request_id: str | None = None, + expected_status: str | None = None, + expected_approval_stage: str | None = None, ): - claim = self.get_claim(claim_id, current_user) - if claim is None: - return None + normalized_opinion = str(opinion or "").strip() + return self._execute_claim_action( + action="approve", + claim_id=claim_id, + current_user=current_user, + request_id=request_id, + expected_status=expected_status, + expected_approval_stage=expected_approval_stage, + payload={"opinion": normalized_opinion}, + executor=lambda claim, ledger, normalized_request_id: self._approve_claim_once( + claim, + current_user, + opinion=normalized_opinion, + ledger=ledger, + request_id=normalized_request_id, + ), + ) + + def _approve_claim_once( + self, + claim: ExpenseClaim, + current_user: CurrentUserContext, + *, + opinion: str, + ledger: ApprovalActionLedger, + request_id: str, + ) -> ExpenseClaim: normalized_status = str(claim.status or "").strip().lower() if normalized_status != "submitted": @@ -50,8 +79,11 @@ class ExpenseClaimApprovalFlowMixin: if previous_stage == DIRECT_MANAGER_APPROVAL_STAGE: if not self._access_policy.can_approve_claim(current_user, claim): raise ValueError("只有当前直属领导审批人可以审批通过该单据。") + self._ensure_claim_has_no_blocking_risk(claim, current_user) approval_source = "manual_approval" - event_type = "expense_application_approval" if is_application_claim else "expense_claim_approval" + event_type = ( + "expense_application_approval" if is_application_claim else "expense_claim_approval" + ) label = "领导审批通过" route_decision_flag = self._build_approval_route_decision( claim, @@ -67,18 +99,29 @@ class ExpenseClaimApprovalFlowMixin: label = "领导及预算审核通过" next_status = "approved" next_stage = APPLICATION_LINK_STATUS_STAGE - default_message = "{operator} 已完成直属领导和预算管理者审核,申请流程完成并生成报销草稿。" + default_message = ( + "{operator} 已完成直属领导和预算管理者审核,申请流程完成并生成报销草稿。" + ) elif requires_budget_review: - next_budget_manager = self._access_policy.resolve_department_budget_manager(claim) + next_budget_manager = self._access_policy.resolve_department_budget_manager( + claim + ) if next_budget_manager is None: - raise ValueError("未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。") + raise ValueError( + "未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。" + ) next_status = "submitted" next_stage = BUDGET_MANAGER_APPROVAL_STAGE - default_message = "{operator} 已确认直属领导审核,因预算或风险关注项流转至预算管理者审批。" + default_message = ( + "{operator} 已确认直属领导审核,因预算或风险关注项流转至预算管理者审批。" + ) else: next_status = "approved" next_stage = APPLICATION_LINK_STATUS_STAGE - default_message = "{operator} 已确认直属领导审核,系统判断预算充足且无风险,申请流程完成并生成报销草稿。" + default_message = ( + "{operator} 已确认直属领导审核,系统判断预算充足且无风险," + "申请流程完成并生成报销草稿。" + ) else: merged_budget_approval = ( requires_budget_review @@ -88,21 +131,32 @@ class ExpenseClaimApprovalFlowMixin: label = "领导及预算审核通过" next_status = "submitted" next_stage = FINANCE_APPROVAL_STAGE - default_message = "{operator} 已完成直属领导和预算管理者审核,流转至{next_stage}。" + default_message = ( + "{operator} 已完成直属领导和预算管理者审核,流转至{next_stage}。" + ) elif requires_budget_review: - next_budget_manager = self._access_policy.resolve_department_budget_manager(claim) + next_budget_manager = self._access_policy.resolve_department_budget_manager( + claim + ) if next_budget_manager is None: - raise ValueError("未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。") + raise ValueError( + "未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。" + ) next_status = "submitted" next_stage = BUDGET_MANAGER_APPROVAL_STAGE - default_message = "{operator} 已审批通过,因预算或风险关注项流转至预算管理者审批。" + default_message = ( + "{operator} 已审批通过,因预算或风险关注项流转至预算管理者审批。" + ) else: next_status = "submitted" next_stage = FINANCE_APPROVAL_STAGE - default_message = "{operator} 已审批通过,系统判断预算充足且无风险,流转至{next_stage}。" + default_message = ( + "{operator} 已审批通过,系统判断预算充足且无风险,流转至{next_stage}。" + ) elif previous_stage == BUDGET_MANAGER_APPROVAL_STAGE: if not self._access_policy.can_approve_claim(current_user, claim): raise ValueError("只有当前预算管理者可以审批通过该单据。") + self._ensure_claim_has_no_blocking_risk(claim, current_user) approval_source = "budget_approval" event_type = ( "expense_application_budget_approval" @@ -123,6 +177,7 @@ class ExpenseClaimApprovalFlowMixin: raise ValueError("费用申请需先完成预算管理者审批。") if not self._access_policy.can_approve_claim(current_user, claim): raise ValueError("只有财务人员可以完成财务终审。") + self._ensure_claim_has_no_blocking_risk(claim, current_user) approval_source = "finance_approval" event_type = "expense_claim_finance_approval" label = "财务审核通过" @@ -146,7 +201,10 @@ class ExpenseClaimApprovalFlowMixin: and not approval_opinion ): raise ValueError("预算已超过警戒值,预算管理者需填写审批意见后才能通过。") - if previous_stage in {DIRECT_MANAGER_APPROVAL_STAGE, BUDGET_MANAGER_APPROVAL_STAGE} and not approval_opinion: + if ( + previous_stage in {DIRECT_MANAGER_APPROVAL_STAGE, BUDGET_MANAGER_APPROVAL_STAGE} + and not approval_opinion + ): approval_opinion = "同意" before_json = self._serialize_claim(claim) @@ -163,7 +221,8 @@ class ExpenseClaimApprovalFlowMixin: "approval_event_id": str(uuid.uuid4()), "severity": "info", "label": label, - "message": approval_opinion or default_message.format(operator=operator, next_stage=next_stage), + "message": approval_opinion + or default_message.format(operator=operator, next_stage=next_stage), "opinion": approval_opinion, "operator": operator, "operator_username": current_user.username, @@ -193,8 +252,10 @@ class ExpenseClaimApprovalFlowMixin: "next_approver_name": str(next_budget_manager.name or "").strip(), "next_approver_employee_id": next_budget_manager.id, "next_approver_grade": str(next_budget_manager.grade or "").strip(), - "next_approver_role_code": self._access_policy.resolve_budget_approval_role_code( - next_budget_manager, + "next_approver_role_code": ( + self._access_policy.resolve_budget_approval_role_code( + next_budget_manager, + ) ), } ) @@ -299,10 +360,6 @@ class ExpenseClaimApprovalFlowMixin: update_case_state=False, ) - self.db.commit() - self.db.refresh(claim) - self._access_policy.attach_budget_approval_snapshot(claim) - self.audit_service.log_action( actor=operator, action="expense_claim.approve", @@ -310,18 +367,55 @@ class ExpenseClaimApprovalFlowMixin: resource_id=claim.id, before_json=before_json, after_json=self._serialize_claim(claim), + request_id=request_id, + commit=False, ) return claim + def _ensure_claim_has_no_blocking_risk( + self, + claim: ExpenseClaim, + current_user: CurrentUserContext, + ) -> None: + ExpenseClaimRiskGate(self.db).ensure_approvable( + claim, + tenant_id=current_user.tenant_id, + ) + def mark_claim_paid( self, claim_id: str, current_user: CurrentUserContext, + *, + request_id: str | None = None, + expected_status: str | None = None, + expected_approval_stage: str | None = None, ): - claim = self.get_claim(claim_id, current_user) - if claim is None: - return None + return self._execute_claim_action( + action="pay", + claim_id=claim_id, + current_user=current_user, + request_id=request_id, + expected_status=expected_status, + expected_approval_stage=expected_approval_stage, + payload={}, + executor=lambda claim, ledger, normalized_request_id: self._mark_claim_paid_once( + claim, + current_user, + ledger=ledger, + request_id=normalized_request_id, + ), + ) + + def _mark_claim_paid_once( + self, + claim: ExpenseClaim, + current_user: CurrentUserContext, + *, + ledger: ApprovalActionLedger, + request_id: str, + ) -> ExpenseClaim: normalized_status = str(claim.status or "").strip().lower() if normalized_status == PAYMENT_PAID_STATUS: @@ -412,9 +506,6 @@ class ExpenseClaimApprovalFlowMixin: update_case_state=False, ) - self.db.commit() - self.db.refresh(claim) - self.audit_service.log_action( actor=operator, action="expense_claim.mark_paid", @@ -422,6 +513,8 @@ class ExpenseClaimApprovalFlowMixin: resource_id=claim.id, before_json=before_json, after_json=self._serialize_claim(claim), + request_id=request_id, + commit=False, ) return claim @@ -440,7 +533,9 @@ class ExpenseClaimApprovalFlowMixin: def _budget_approval_opinion_required(self, claim) -> bool: budget_result = BudgetService(self.db).analyze_claim_budget(claim) - metrics = budget_result.get("metrics") if isinstance(budget_result.get("metrics"), dict) else {} + metrics = ( + budget_result.get("metrics") if isinstance(budget_result.get("metrics"), dict) else {} + ) context = ( budget_result.get("budget_context") if isinstance(budget_result.get("budget_context"), dict) diff --git a/server/src/app/services/expense_claim_pagination.py b/server/src/app/services/expense_claim_pagination.py index 7aaca77..01b3792 100644 --- a/server/src/app/services/expense_claim_pagination.py +++ b/server/src/app/services/expense_claim_pagination.py @@ -30,7 +30,6 @@ class ExpenseClaimPaginationMixin: ) stmt = self._access_policy.apply_claim_scope(stmt, current_user) result = paginate_select(self.db, stmt, page=page, page_size=page_size) - self._repair_duplicate_budget_approval_stages(result.items) self._access_policy.attach_budget_approval_snapshots(result.items) return result @@ -47,7 +46,6 @@ class ExpenseClaimPaginationMixin: ) stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user) result = paginate_select(self.db, stmt, page=page, page_size=page_size) - self._repair_duplicate_budget_approval_stages(result.items) self._access_policy.attach_budget_approval_snapshots(result.items) return result diff --git a/server/src/app/services/expense_claim_return_flow.py b/server/src/app/services/expense_claim_return_flow.py new file mode 100644 index 0000000..3f9df7e --- /dev/null +++ b/server/src/app/services/expense_claim_return_flow.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import uuid +from datetime import UTC, datetime + +from app.api.deps import CurrentUserContext +from app.models.approval_action import ApprovalActionLedger +from app.models.financial_record import ExpenseClaim +from app.services.expense_claim_constants import RETURN_REASON_OPTIONS + + +class ExpenseClaimReturnFlowMixin: + def return_claim( + self, + claim_id: str, + current_user: CurrentUserContext, + *, + reason: str | None = None, + reason_codes: list[str] | None = None, + request_id: str | None = None, + expected_status: str | None = None, + expected_approval_stage: str | None = None, + ) -> ExpenseClaim | None: + normalized_reason = str(reason or "").strip() + reason_code_payload = self._normalize_return_reason_code_payload(reason_codes) + normalized_codes = [ + *reason_code_payload["reason_codes"], + *reason_code_payload["unknown_reason_codes"], + ] + return self._execute_claim_action( + action="return", + claim_id=claim_id, + current_user=current_user, + request_id=request_id, + expected_status=expected_status, + expected_approval_stage=expected_approval_stage, + payload={"reason": normalized_reason, "reason_codes": normalized_codes}, + executor=lambda claim, ledger, normalized_request_id: self._return_claim_once( + claim, + current_user, + reason=normalized_reason, + reason_codes=normalized_codes, + ledger=ledger, + request_id=normalized_request_id, + ), + ) + + def _return_claim_once( + self, + claim: ExpenseClaim, + current_user: CurrentUserContext, + *, + reason: str, + reason_codes: list[str], + ledger: ApprovalActionLedger, + request_id: str, + ) -> ExpenseClaim: + normalized_status = str(claim.status or "").strip().lower() + if normalized_status == "draft": + raise ValueError("草稿状态无需退回。") + if normalized_status == "returned": + raise ValueError("该单据已处于退回待提交状态,无需重复退回。") + if normalized_status in {"approved", "completed", "paid"}: + raise ValueError("已完成单据不允许退回。") + + if not self._access_policy.can_return_claim(current_user, claim): + raise ValueError("只有财务人员、高级财务人员或当前审批人可以退回报销单。") + + before_json = self._serialize_claim(claim) + operator = self._access_policy.resolve_current_user_display_name(current_user) + previous_status = str(claim.status or "").strip() + previous_stage = str(claim.approval_stage or "").strip() or "未标记审批环节" + previous_stage_key = self._normalize_return_stage_key(previous_stage) + is_application_claim = self._is_expense_application_claim(claim) + is_direct_manager_return = previous_stage_key == "direct_manager" + is_budget_return = previous_stage_key == "budget" + is_application_return = is_application_claim and ( + is_direct_manager_return or is_budget_return + ) + return_event_type = ( + "expense_application_return" if is_application_return else "expense_claim_return" + ) + return_label = ( + "领导退回" + if is_application_claim and is_direct_manager_return + else "预算退回" + if is_application_claim and is_budget_return + else "人工退回" + ) + reason_code_payload = self._normalize_return_reason_code_payload(reason_codes) + normalized_reason_codes = reason_code_payload["reason_codes"] + unknown_reason_codes = reason_code_payload["unknown_reason_codes"] + if is_application_return and not any( + code.startswith("application_") for code in normalized_reason_codes + ): + raise ValueError("申请单退回必须选择至少一个退单类型。") + risk_points = [RETURN_REASON_OPTIONS[code] for code in normalized_reason_codes] + existing_return_flags = self._collect_return_flags(claim.risk_flags_json) + return_count = len(existing_return_flags) + 1 + stage_return_count = ( + sum( + 1 + for flag in existing_return_flags + if ( + str(flag.get("return_stage_key") or "").strip() + or self._normalize_return_stage_key(str(flag.get("return_stage") or "").strip()) + ) + == previous_stage_key + ) + + 1 + ) + message = reason or self._build_default_return_message( + operator=operator, + risk_points=risk_points, + ) + return_flag = { + "source": "manual_return", + "event_type": return_event_type, + "return_event_id": str(uuid.uuid4()), + "severity": "medium", + "label": return_label, + "node_key": "returned", + "node_label": "退回", + "approval_node": "退回", + "message": message, + "reason": reason, + "opinion": message, + "leader_opinion": message if is_application_claim and is_direct_manager_return else "", + "budget_opinion": message if is_application_claim and is_budget_return else "", + "reason_codes": normalized_reason_codes, + "risk_points": risk_points, + "operator": operator, + "operator_username": current_user.username, + "operator_role_codes": [ + str(item).strip().lower() for item in current_user.role_codes if str(item).strip() + ], + "previous_status": previous_status, + "previous_approval_stage": previous_stage, + "return_stage": previous_stage, + "return_stage_key": previous_stage_key, + "next_status": "returned", + "next_approval_stage": "待提交", + "return_count": return_count, + "stage_return_count": stage_return_count, + "created_at": datetime.now(UTC).isoformat(), + } + if unknown_reason_codes: + return_flag["unknown_reason_codes"] = unknown_reason_codes + + budget_flags = self._release_budget_for_return( + claim, + current_user, + reason=message, + ) + claim.status = "returned" + claim.approval_stage = "待提交" + claim.submitted_at = None + claim.risk_flags_json = self._append_budget_flags( + [*list(claim.risk_flags_json or []), return_flag], + budget_flags, + business_stage=("expense_application" if is_application_claim else "reimbursement"), + ) + + self._expense_cases.record_claim_event( + claim, + event_type=("application_returned" if is_application_claim else "claim_returned"), + actor_id=current_user.username, + tenant_id=getattr(current_user, "tenant_id", None), + idempotency_key=str(return_flag.get("return_event_id") or ""), + previous_status=previous_status, + previous_approval_stage=previous_stage, + extra_payload={ + "reason": message, + "reason_codes": normalized_reason_codes, + }, + ) + self.audit_service.log_action( + actor=operator, + action="expense_claim.return", + resource_type="expense_claim", + resource_id=claim.id, + before_json=before_json, + after_json=self._serialize_claim(claim), + request_id=request_id, + commit=False, + ) + return claim diff --git a/server/src/app/services/expense_claim_risk_flags.py b/server/src/app/services/expense_claim_risk_flags.py index 8a0f7f2..5e8e41c 100644 --- a/server/src/app/services/expense_claim_risk_flags.py +++ b/server/src/app/services/expense_claim_risk_flags.py @@ -2,7 +2,6 @@ from __future__ import annotations from typing import Any - _SEVERITY_WEIGHT = { "critical": 0, "high": 1, @@ -12,6 +11,48 @@ _SEVERITY_WEIGHT = { "pass": 5, } +_BLOCKING_RISK_SEVERITIES = {"high", "critical", "danger"} +_NON_RISK_SOURCES = { + "application_detail", + "application_handoff", + "application_link", + "application_link_sync", + "application_submission", + "approval", + "approval_log", + "approval_routing", + "budget_approval", + "expense_claim_approval", + "expense_claim_finance_approval", + "finance_approval", + "manual_approval", + "manual_return", + "payment", + "reminder", + "sla_reminder", + "urge", +} +_NON_RISK_EVENTS = { + "expense_application_budget_approval", + "expense_application_reimbursement_deleted", + "expense_application_submission", + "expense_application_to_reimbursement_draft", + "expense_claim_approval", + "expense_claim_finance_approval", + "expense_claim_payment_completed", + "expense_reimbursement_application_linked", + "reminder", + "sla_reminder", + "urge", +} +_CLOSED_RISK_STATES = { + "accepted", + "false_positive", + "ignored", + "resolved", + "waived", +} + def _text(value: Any) -> str: return str(value or "").strip() @@ -22,6 +63,74 @@ def _severity_weight(flag: dict[str, Any]) -> int: return _SEVERITY_WEIGHT.get(severity, 9) +def claim_risk_flag_severity(flag: dict[str, Any]) -> str: + severity = _text( + flag.get("severity") + or flag.get("risk_level") + or flag.get("riskLevel") + or flag.get("tone") + or flag.get("level") + ).lower() + return "high" if severity == "danger" else severity + + +def is_open_claim_risk_flag(flag: dict[str, Any]) -> bool: + """识别仍需处理的业务风险,排除审批、付款等流程轨迹。""" + + source = _text(flag.get("source")).lower() + event_type = _text(flag.get("event_type") or flag.get("eventType")).lower() + if source in _NON_RISK_SOURCES or event_type in _NON_RISK_EVENTS: + return False + if _text(flag.get("actionability")).lower() == "system_trace": + return False + states = { + _text(flag.get("resolution_status") or flag.get("resolutionStatus")).lower(), + _text(flag.get("status")).lower(), + _text(flag.get("feedback_status") or flag.get("feedbackStatus")).lower(), + _text(flag.get("adjudication")).lower(), + } + if states & _CLOSED_RISK_STATES or bool(flag.get("resolved")): + return False + severity = claim_risk_flag_severity(flag) + return bool( + severity in {"medium", *_BLOCKING_RISK_SEVERITIES} + or flag.get("triggered") is True + or _text(flag.get("disposition")).lower() in {"fix", "review"} + ) + + +def is_blocking_claim_risk_flag(flag: dict[str, Any]) -> bool: + if not ( + is_open_claim_risk_flag(flag) + and claim_risk_flag_severity(flag) in _BLOCKING_RISK_SEVERITIES + ): + return False + actionability = _text(flag.get("actionability")).lower() + disposition = _text(flag.get("disposition")).lower() + return actionability not in {"advisory_only", "route_review"} and disposition != "review" + + +def claim_risk_flag_observation_key( + flag: dict[str, Any], + *, + claim_id: str, +) -> str: + explicit_key = _text(flag.get("observation_key") or flag.get("observationKey")) + if explicit_key: + return explicit_key + source = _text(flag.get("source")).lower() + hit_source = _text(flag.get("hit_source") or flag.get("hitSource")).lower() + rule_code = _text(flag.get("rule_code") or flag.get("ruleCode")) + signal = _text(flag.get("risk_signal") or flag.get("riskSignal")) + if source in {"platform_risk", "platform_risk_rule", "rule_center"} or hit_source == ( + "rule_center" + ): + suffix = rule_code or signal + if suffix: + return f"risk:{claim_id}:platform:{suffix}" + return "" + + def _list_values(value: Any) -> list[Any]: if isinstance(value, list): return value @@ -138,7 +247,9 @@ def dedupe_claim_risk_flags(flags: list[Any] | None) -> list[Any]: continue other_weight = _severity_weight(other) - if other_weight < current_weight or (other_weight == current_weight and other_index < index): + if other_weight < current_weight or ( + other_weight == current_weight and other_index < index + ): is_shadowed = True break diff --git a/server/src/app/services/expense_claim_risk_gate.py b/server/src/app/services/expense_claim_risk_gate.py new file mode 100644 index 0000000..09df4b4 --- /dev/null +++ b/server/src/app/services/expense_claim_risk_gate.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.financial_record import ExpenseClaim +from app.models.risk_disposition import RiskDisposition +from app.models.risk_observation import RiskObservation +from app.services.expense_claim_risk_flags import ( + claim_risk_flag_observation_key, + claim_risk_flag_severity, + is_blocking_claim_risk_flag, +) +from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin + +_BLOCKING_LEVELS = {"high", "critical", "danger"} +_LEGACY_CLOSED_STATUSES = {"false_positive", "ignored", "resolved"} + + +@dataclass(frozen=True, slots=True) +class BlockingRiskObservation: + observation_id: str + title: str + risk_level: str + adjudication: str + lifecycle_status: str + + +class ExpenseClaimRiskBlockedError(ValueError): + def __init__(self, blockers: list[BlockingRiskObservation]) -> None: + self.blockers = blockers + titles = "、".join(item.title for item in blockers[:3]) + suffix = f"等 {len(blockers)} 项" if len(blockers) > 3 else "" + super().__init__( + "存在尚未完成处置的高风险观察,暂不能审批通过:" + f"{titles}{suffix}。请先在风险证据链中确认并关闭风险,或标记为误报。" + ) + + +class ExpenseClaimRiskGate: + """把持久化风险处置状态变成审批前的强制业务门禁。""" + + def __init__(self, db: Session) -> None: + self.db = db + + def find_blockers( + self, + claim: ExpenseClaim, + *, + tenant_id: str | None, + ) -> list[BlockingRiskObservation]: + normalized_tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(tenant_id) + rows = self.db.execute( + select(RiskObservation, RiskDisposition) + .outerjoin( + RiskDisposition, + ( + (RiskDisposition.tenant_id == RiskObservation.tenant_id) + & (RiskDisposition.observation_id == RiskObservation.id) + ), + ) + .where( + RiskObservation.tenant_id == normalized_tenant, + RiskObservation.claim_id == claim.id, + ) + .order_by( + RiskObservation.risk_score.desc(), + RiskObservation.created_at.desc(), + ) + ).all() + + blockers: list[BlockingRiskObservation] = [] + materialized_keys: set[str] = set() + for observation, disposition in rows: + materialized_keys.add(str(observation.observation_key or "").strip()) + level = str(observation.risk_level or "").strip().lower() + if level not in _BLOCKING_LEVELS: + continue + if self._is_closed(observation, disposition): + continue + blockers.append( + BlockingRiskObservation( + observation_id=observation.id, + title=str(observation.title or observation.risk_signal or "高风险").strip(), + risk_level=level, + adjudication=( + str(disposition.adjudication or "unreviewed").strip().lower() + if disposition is not None + else str(observation.feedback_status or "unreviewed").strip().lower() + ), + lifecycle_status=( + str(disposition.lifecycle_status or "open").strip().lower() + if disposition is not None + else "open" + ), + ) + ) + for index, flag in enumerate(list(claim.risk_flags_json or [])): + if not isinstance(flag, dict) or not is_blocking_claim_risk_flag(flag): + continue + observation_key = claim_risk_flag_observation_key(flag, claim_id=claim.id) + if observation_key and observation_key in materialized_keys: + # 完整物化后的处置投影优先;仅对没有 Observation 的高风险兜底。 + continue + blockers.append( + BlockingRiskObservation( + observation_id=observation_key or f"raw:{claim.id}:{index}", + title=str( + flag.get("label") + or flag.get("title") + or flag.get("message") + or "未物化高风险" + ).strip(), + risk_level=claim_risk_flag_severity(flag), + adjudication=str(flag.get("adjudication") or "unreviewed").strip().lower(), + lifecycle_status=str( + flag.get("lifecycle_status") or flag.get("lifecycleStatus") or "open" + ) + .strip() + .lower(), + ) + ) + return blockers + + def ensure_approvable( + self, + claim: ExpenseClaim, + *, + tenant_id: str | None, + ) -> None: + blockers = self.find_blockers(claim, tenant_id=tenant_id) + if blockers: + raise ExpenseClaimRiskBlockedError(blockers) + + @staticmethod + def _is_closed( + observation: RiskObservation, + disposition: RiskDisposition | None, + ) -> bool: + if disposition is not None: + adjudication = str(disposition.adjudication or "").strip().lower() + lifecycle = str(disposition.lifecycle_status or "").strip().lower() + return adjudication == "false_positive" or lifecycle == "resolved" + status = str(observation.status or "").strip().lower() + feedback_status = str(observation.feedback_status or "").strip().lower() + return status in _LEGACY_CLOSED_STATUSES or feedback_status == "false_positive" diff --git a/server/src/app/services/expense_claim_risk_review.py b/server/src/app/services/expense_claim_risk_review.py index 067effc..58e83c3 100644 --- a/server/src/app/services/expense_claim_risk_review.py +++ b/server/src/app/services/expense_claim_risk_review.py @@ -15,7 +15,10 @@ from app.services.expense_claim_constants import ( from app.services.expense_claim_item_sync import ExpenseClaimItemSyncMixin from app.services.expense_claim_platform_risk import ExpenseClaimPlatformRiskMixin from app.services.expense_claim_policy_review import ExpenseClaimPolicyReviewMixin -from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags +from app.services.expense_claim_risk_flags import ( + dedupe_claim_risk_flags, + is_blocking_claim_risk_flag, +) from app.services.expense_claim_risk_stage import with_risk_business_stage from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin from app.services.risk_observations import RiskObservationService @@ -98,11 +101,15 @@ class ExpenseClaimRiskReviewMixin( claim, platform_risk_flags, ) - except Exception: + except Exception as error: logger.exception( "Failed to persist platform risk observations for claim_id=%s", claim.id, ) + if any(is_blocking_claim_risk_flag(flag) for flag in platform_risk_flags): + raise RuntimeError( + "高风险观察持久化失败,已停止提交;请稍后重试或联系管理员。" + ) from error review_flags = [with_risk_business_stage(flag, "reimbursement") for flag in review_flags] final_risk_flags = dedupe_claim_risk_flags([*preserved_flags, *review_flags]) @@ -111,9 +118,7 @@ class ExpenseClaimRiskReviewMixin( "status": "submitted", "approval_stage": "直属领导审批", "risk_flags": final_risk_flags, - "rule_set_fingerprint": str( - platform_risk_review.get("rule_set_fingerprint") or "" - ), + "rule_set_fingerprint": str(platform_risk_review.get("rule_set_fingerprint") or ""), "message": ( f"报销单 {claim.claim_no} 已完成自动检测," f"现已提交给直属领导 {manager_name or '审批人'} 审批。" @@ -159,4 +164,3 @@ class ExpenseClaimRiskReviewMixin( ) recent_claims = list(self.db.scalars(stmt).all()) return sum(1 for item in recent_claims if list(item.risk_flags_json or [])) - diff --git a/server/src/app/services/expense_claim_workflow_repair.py b/server/src/app/services/expense_claim_workflow_repair.py index 8f9342c..ed4d10f 100644 --- a/server/src/app/services/expense_claim_workflow_repair.py +++ b/server/src/app/services/expense_claim_workflow_repair.py @@ -4,7 +4,10 @@ from datetime import UTC, datetime from typing import Any from app.models.financial_record import ExpenseClaim -from app.services.expense_claim_risk_stage import risk_business_stage_for_claim, with_risk_business_stage +from app.services.expense_claim_risk_stage import ( + risk_business_stage_for_claim, + with_risk_business_stage, +) from app.services.expense_claim_workflow_constants import ( BUDGET_MANAGER_APPROVAL_STAGE, DIRECT_MANAGER_APPROVAL_STAGE, @@ -13,18 +16,17 @@ from app.services.expense_claim_workflow_constants import ( class ExpenseClaimWorkflowRepairMixin: - def _repair_duplicate_budget_approval_stages(self, claims: list[ExpenseClaim]) -> None: - repaired_claims = [ + def _repair_duplicate_budget_approval_stages( + self, + claims: list[ExpenseClaim], + ) -> list[ExpenseClaim]: + """只修改当前事务中的对象,不在底层辅助方法里提交事务。""" + + return [ claim for claim in claims if claim is not None and self._repair_duplicate_budget_approval_stage(claim) ] - if not repaired_claims: - return - - self.db.commit() - for claim in repaired_claims: - self.db.refresh(claim) def _repair_duplicate_budget_approval_stage(self, claim: ExpenseClaim) -> bool: if self._is_expense_application_claim(claim): @@ -54,7 +56,8 @@ class ExpenseClaimWorkflowRepairMixin: if isinstance(flag, dict) and str(flag.get("source") or "").strip() == "manual_approval" and str(flag.get("event_type") or "").strip() == "expense_claim_approval" - and str(flag.get("previous_approval_stage") or "").strip() == DIRECT_MANAGER_APPROVAL_STAGE + and str(flag.get("previous_approval_stage") or "").strip() + == DIRECT_MANAGER_APPROVAL_STAGE and str(flag.get("next_approval_stage") or "").strip() == BUDGET_MANAGER_APPROVAL_STAGE ] for flag in reversed(flags): @@ -74,11 +77,14 @@ class ExpenseClaimWorkflowRepairMixin: return any( isinstance(flag, dict) and str(flag.get("source") or "").strip() == "approval_flow_repair" - and str(flag.get("event_type") or "").strip() == "duplicate_budget_approval_stage_repaired" + and str(flag.get("event_type") or "").strip() + == "duplicate_budget_approval_stage_repaired" for flag in list(claim.risk_flags_json or []) ) - def _build_duplicate_budget_stage_repair_flag(self, approval_event: dict[str, Any]) -> dict[str, Any]: + def _build_duplicate_budget_stage_repair_flag( + self, approval_event: dict[str, Any] + ) -> dict[str, Any]: return with_risk_business_stage( { "source": "approval_flow_repair", diff --git a/server/src/app/services/expense_claims.py b/server/src/app/services/expense_claims.py index f8f4e08..f78ced4 100644 --- a/server/src/app/services/expense_claims.py +++ b/server/src/app/services/expense_claims.py @@ -1,6 +1,5 @@ from __future__ import annotations -import uuid from collections.abc import Callable from datetime import UTC, datetime from decimal import Decimal @@ -25,6 +24,7 @@ from app.services.budget_types import BudgetControlError from app.services.document_numbering import is_application_claim_no from app.services.expense_cases import ExpenseCaseService from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy +from app.services.expense_claim_action_protocol import ExpenseClaimActionProtocolMixin from app.services.expense_claim_application_handoff import ExpenseClaimApplicationHandoffMixin from app.services.expense_claim_approval_flow import ExpenseClaimApprovalFlowMixin from app.services.expense_claim_approval_routing import ExpenseClaimApprovalRoutingMixin @@ -34,7 +34,6 @@ from app.services.expense_claim_attachment_operations import ExpenseClaimAttachm from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin -from app.services.expense_claim_constants import RETURN_REASON_OPTIONS from app.services.expense_claim_document_item_builder import ExpenseClaimDocumentItemBuilderMixin from app.services.expense_claim_document_parsing import ExpenseClaimDocumentParsingMixin from app.services.expense_claim_draft_flow import ExpenseClaimDraftFlowMixin @@ -51,6 +50,7 @@ from app.services.expense_claim_pre_review_decision import ( pre_review_public_payload, ) from app.services.expense_claim_read_model import ExpenseClaimReadModelMixin +from app.services.expense_claim_return_flow import ExpenseClaimReturnFlowMixin from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin @@ -87,7 +87,10 @@ class ExpenseClaimItemActionMixin: if payload.item_date is not None: item.item_date = payload.item_date if payload.item_type is not None: - item.item_type = self._normalize_optional_text(payload.item_type, fallback=item.item_type) or item.item_type + item.item_type = ( + self._normalize_optional_text(payload.item_type, fallback=item.item_type) + or item.item_type + ) if payload.item_reason is not None: item.item_reason = ( self._normalize_optional_text(payload.item_reason, allow_empty=True) or "" @@ -97,7 +100,9 @@ class ExpenseClaimItemActionMixin: self._normalize_optional_text(payload.item_location, allow_empty=True) or "" ) if payload.item_note is not None: - item.item_note = self._normalize_optional_text(payload.item_note, allow_empty=True) or "" + item.item_note = ( + self._normalize_optional_text(payload.item_note, allow_empty=True) or "" + ) if payload.item_amount is not None: amount = payload.item_amount.quantize(Decimal("0.01")) if amount < Decimal("0.00"): @@ -203,7 +208,9 @@ class ExpenseClaimItemActionMixin: self._ensure_draft_claim(claim) before_json = self._serialize_claim(claim) - item_label = str(item.item_reason or "").strip() or self._resolve_expense_type_label(item.item_type) + item_label = str(item.item_reason or "").strip() or self._resolve_expense_type_label( + item.item_type + ) self._attachment_storage.delete_item_files(item) claim.items = [entry for entry in claim.items if entry.id != item.id] @@ -266,8 +273,7 @@ class ExpenseClaimItemActionMixin: raise RuntimeError("无法生成提交前预审结果。") client_review_provided = bool( - str(pre_review_id or "").strip() - or str(pre_review_input_fingerprint or "").strip() + str(pre_review_id or "").strip() or str(pre_review_input_fingerprint or "").strip() ) client_review_matches = pre_review_identity_matches( pre_review_flag, @@ -423,7 +429,9 @@ class ExpenseClaimItemActionMixin: if not self._access_policy.has_claim_delete_access(current_user): self._ensure_draft_claim(claim) if not self._access_policy.is_claim_owned_by_current_user(claim, current_user): - raise ValueError("只有系统管理员或草稿、待补充、退回待提交阶段的申请人本人可以删除单据。") + raise ValueError( + "只有系统管理员或草稿、待补充、退回待提交阶段的申请人本人可以删除单据。" + ) before_json = self._serialize_claim(claim) resource_id = claim.id @@ -464,154 +472,31 @@ class ExpenseClaimItemActionMixin: self.db.execute(delete(RiskObservation).where(RiskObservation.claim_id == claim_id)) self.db.execute(delete(HermesRiskReport).where(HermesRiskReport.claim_id == claim_id)) - def return_claim( - self, - claim_id: str, - current_user: CurrentUserContext, - *, - reason: str | None = None, - reason_codes: list[str] | None = None, - ) -> ExpenseClaim | None: - claim = self.get_claim(claim_id, current_user) - if claim is None: - return None - normalized_status = str(claim.status or "").strip().lower() - if normalized_status == "draft": - raise ValueError("草稿状态无需退回。") - if normalized_status == "returned": - raise ValueError("该单据已处于退回待提交状态,无需重复退回。") - if normalized_status in {"approved", "completed", "paid"}: - raise ValueError("已完成单据不允许退回。") - - if not self._access_policy.can_return_claim(current_user, claim): - raise ValueError("只有财务人员、高级财务人员或当前审批人可以退回报销单。") - - before_json = self._serialize_claim(claim) - operator = self._access_policy.resolve_current_user_display_name(current_user) - previous_status = str(claim.status or "").strip() - previous_stage = str(claim.approval_stage or "").strip() or "未标记审批环节" - previous_stage_key = self._normalize_return_stage_key(previous_stage) - is_application_claim = self._is_expense_application_claim(claim) - is_direct_manager_return = previous_stage_key == "direct_manager" - is_budget_return = previous_stage_key == "budget" - is_application_return = is_application_claim and (is_direct_manager_return or is_budget_return) - return_event_type = ( - "expense_application_return" - if is_application_return - else "expense_claim_return" - ) - return_label = ( - "领导退回" - if is_application_claim and is_direct_manager_return - else "预算退回" - if is_application_claim and is_budget_return - else "人工退回" - ) - return_reason = str(reason or "").strip() - reason_code_payload = self._normalize_return_reason_code_payload(reason_codes) - normalized_reason_codes = reason_code_payload["reason_codes"] - unknown_reason_codes = reason_code_payload["unknown_reason_codes"] - if is_application_return and not any( - code.startswith("application_") for code in normalized_reason_codes - ): - raise ValueError("申请单退回必须选择至少一个退单类型。") - risk_points = [RETURN_REASON_OPTIONS[code] for code in normalized_reason_codes] - existing_return_flags = self._collect_return_flags(claim.risk_flags_json) - return_count = len(existing_return_flags) + 1 - stage_return_count = ( - sum( - 1 - for flag in existing_return_flags - if ( - str(flag.get("return_stage_key") or "").strip() - or self._normalize_return_stage_key(str(flag.get("return_stage") or "").strip()) - ) - == previous_stage_key - ) - + 1 - ) - message = return_reason or self._build_default_return_message(operator=operator, risk_points=risk_points) - return_flag = { - "source": "manual_return", - "event_type": return_event_type, - "return_event_id": str(uuid.uuid4()), - "severity": "medium", - "label": return_label, - "node_key": "returned", - "node_label": "退回", - "approval_node": "退回", - "message": message, - "reason": return_reason, - "opinion": message, - "leader_opinion": message if is_application_claim and is_direct_manager_return else "", - "budget_opinion": message if is_application_claim and is_budget_return else "", - "reason_codes": normalized_reason_codes, - "risk_points": risk_points, - "operator": operator, - "operator_username": current_user.username, - "operator_role_codes": [ - str(item).strip().lower() - for item in current_user.role_codes - if str(item).strip() - ], - "previous_status": previous_status, - "previous_approval_stage": previous_stage, - "return_stage": previous_stage, - "return_stage_key": previous_stage_key, - "next_status": "returned", - "next_approval_stage": "待提交", - "return_count": return_count, - "stage_return_count": stage_return_count, - "created_at": datetime.now(UTC).isoformat(), - } - if unknown_reason_codes: - return_flag["unknown_reason_codes"] = unknown_reason_codes - - budget_flags = self._release_budget_for_return( - claim, - current_user, - reason=message, - ) - claim.status = "returned" - claim.approval_stage = "待提交" - claim.submitted_at = None - claim.risk_flags_json = self._append_budget_flags( - [*list(claim.risk_flags_json or []), return_flag], - budget_flags, - business_stage="expense_application" if is_application_claim else "reimbursement", - ) - - self._expense_cases.record_claim_event( - claim, - event_type=("application_returned" if is_application_claim else "claim_returned"), - actor_id=current_user.username, - tenant_id=getattr(current_user, "tenant_id", None), - idempotency_key=str(return_flag.get("return_event_id") or ""), - previous_status=previous_status, - previous_approval_stage=previous_stage, - extra_payload={ - "reason": message, - "reason_codes": normalized_reason_codes, - }, - ) - - self.db.commit() - self.db.refresh(claim) - - self.audit_service.log_action( - actor=operator, - action="expense_claim.return", - resource_type="expense_claim", - resource_id=claim.id, - before_json=before_json, - after_json=self._serialize_claim(claim), - ) - - return claim - - -class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemActionMixin, ExpenseClaimPaginationMixin, ExpenseClaimApprovalFlowMixin, ExpenseClaimApprovalRoutingMixin, ExpenseClaimApplicationHandoffMixin, ExpenseClaimPreReviewMixin, ExpenseClaimBudgetFlowMixin, ExpenseClaimAttachmentOperationsMixin, ExpenseClaimReviewPreviewMixin, ExpenseClaimDraftFlowMixin, ExpenseClaimDraftPersistenceMixin, ExpenseClaimDocumentItemBuilderMixin, ExpenseClaimDocumentParsingMixin, ExpenseClaimOntologyResolverMixin, ExpenseClaimAttachmentDocumentMixin, ExpenseClaimAttachmentAnalysisMixin, ExpenseClaimReadModelMixin, ExpenseClaimRiskReviewMixin, ExpenseClaimWorkflowRepairMixin): +class ExpenseClaimService( + ExpenseClaimActionProtocolMixin, + ExpenseClaimReturnFlowMixin, + ExpenseClaimStandardAdjustmentMixin, + ExpenseClaimItemActionMixin, + ExpenseClaimPaginationMixin, + ExpenseClaimApprovalFlowMixin, + ExpenseClaimApprovalRoutingMixin, + ExpenseClaimApplicationHandoffMixin, + ExpenseClaimPreReviewMixin, + ExpenseClaimBudgetFlowMixin, + ExpenseClaimAttachmentOperationsMixin, + ExpenseClaimReviewPreviewMixin, + ExpenseClaimDraftFlowMixin, + ExpenseClaimDraftPersistenceMixin, + ExpenseClaimDocumentItemBuilderMixin, + ExpenseClaimDocumentParsingMixin, + ExpenseClaimOntologyResolverMixin, + ExpenseClaimAttachmentDocumentMixin, + ExpenseClaimAttachmentAnalysisMixin, + ExpenseClaimReadModelMixin, + ExpenseClaimRiskReviewMixin, + ExpenseClaimWorkflowRepairMixin, +): def __init__(self, db: Session) -> None: self.db = db self.audit_service = AuditLogService(db) @@ -624,11 +509,15 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA def _is_expense_application_claim(claim: ExpenseClaim) -> bool: claim_no = str(getattr(claim, "claim_no", "") or "").strip().upper() expense_type = str(getattr(claim, "expense_type", "") or "").strip().lower() - document_type = str( - getattr(claim, "document_type_code", "") - or getattr(claim, "document_type", "") - or "" - ).strip().lower() + document_type = ( + str( + getattr(claim, "document_type_code", "") + or getattr(claim, "document_type", "") + or "" + ) + .strip() + .lower() + ) return ( is_application_claim_no(claim_no) or expense_type == "application" @@ -667,7 +556,6 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA ) stmt = self._access_policy.apply_claim_scope(stmt, current_user) claims = list(self.db.scalars(stmt).all()) - self._repair_duplicate_budget_approval_stages(claims) return self._access_policy.attach_budget_approval_snapshots(claims) def list_approval_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]: @@ -683,7 +571,6 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA ) stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user) claims = list(self.db.scalars(stmt).all()) - self._repair_duplicate_budget_approval_stages(claims) return self._access_policy.attach_budget_approval_snapshots(claims) def list_archived_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]: @@ -695,7 +582,11 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit), selectinload(ExpenseClaim.employee).selectinload(Employee.roles), ) - .order_by(ExpenseClaim.updated_at.desc(), ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc()) + .order_by( + ExpenseClaim.updated_at.desc(), + ExpenseClaim.submitted_at.desc(), + ExpenseClaim.created_at.desc(), + ) ) stmt = self._access_policy.apply_archived_claim_scope(stmt, current_user) return list(self.db.scalars(stmt).all()) @@ -711,13 +602,15 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA ) .where(ExpenseClaim.id == claim_id) ) - stmt = self._access_policy.apply_claim_scope(stmt, current_user, include_approval_scope=True) + stmt = self._access_policy.apply_claim_scope( + stmt, current_user, include_approval_scope=True + ) claim = self.db.scalar(stmt) - if claim is not None: - self._repair_duplicate_budget_approval_stages([claim]) return self._access_policy.attach_approval_snapshot(claim) - def can_view_budget_analysis(self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None) -> bool: + def can_view_budget_analysis( + self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None + ) -> bool: if claim is None: return self._access_policy.is_budget_manager_user(current_user) if current_user.is_admin: @@ -725,10 +618,9 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA role_codes = self._access_policy.normalize_role_codes(current_user) if "executive" in role_codes: return True - if ( - self._access_policy.has_privileged_claim_access(current_user) - and not self._access_policy.is_claim_owned_by_current_user(claim, current_user) - ): + if self._access_policy.has_privileged_claim_access( + current_user + ) and not self._access_policy.is_claim_owned_by_current_user(claim, current_user): return True if self._access_policy.can_approve_claim(current_user, claim): return True @@ -751,7 +643,9 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA before_json = self._serialize_claim(claim) if payload.reason is not None: - claim.reason = self._normalize_optional_text(payload.reason, allow_empty=True) or "待补充" + claim.reason = ( + self._normalize_optional_text(payload.reason, allow_empty=True) or "待补充" + ) if not self._is_expense_application_claim(claim): self._refresh_claim_pre_review_flags(claim, is_application_claim=False) diff --git a/server/src/app/services/hermes_risk_scanner.py b/server/src/app/services/hermes_risk_scanner.py index d416d61..0c263a4 100644 --- a/server/src/app/services/hermes_risk_scanner.py +++ b/server/src/app/services/hermes_risk_scanner.py @@ -40,63 +40,72 @@ class HermesRiskScannerService: observation_service = RiskObservationService(self.db) observation_count = 0 + scanned_claim_count = 0 graph_node_count = 0 graph_edge_count = 0 - for tenant_id, tenant_claims in self._group_claims_by_tenant(claims).items(): + now = datetime.now(timezone.utc) + grouped_claims = self._group_claims_by_tenant(claims) + for tenant_id in sorted(grouped_claims): + tenant_claims = sorted(grouped_claims[tenant_id], key=lambda item: str(item.id)) + snapshot_versions = {claim.id: claim.updated_at for claim in tenant_claims} result = evaluate_financial_risk_graph( RiskGraphEvaluationContext( - claims=[ - RiskGraphClaimSnapshot.from_orm(claim) - for claim in tenant_claims - ], + claims=[RiskGraphClaimSnapshot.from_orm(claim) for claim in tenant_claims], target_claim_ids={claim.id for claim in tenant_claims}, history_stats=observation_service.build_history_stats( tenant_id=tenant_id, - expense_types={ - str(claim.expense_type or "") for claim in tenant_claims - }, + expense_types={str(claim.expense_type or "") for claim in tenant_claims}, ), ) ) - claims_by_id = {claim.id: claim for claim in tenant_claims} - observation_count += len(result.observations) graph_node_count += len(result.nodes) graph_edge_count += len(result.edges) - + observations_by_claim = {} for observation in result.observations: - claim = claims_by_id.get(observation.claim_id) - if claim is None: - continue - observation_service.upsert_observation( - observation, - tenant_id=tenant_id, - run_id=run_id, - execution_log_id=log_id, - ) - claim.hermes_risk_flag = True - claim.risk_flags_json = self._append_algorithm_flag( - claim, - observation.as_dict(), - ) + observations_by_claim.setdefault(observation.claim_id, []).append(observation) - if log_id: - self.db.add( - HermesRiskReport( - claim_id=observation.claim_id, - execution_log_id=log_id, - risk_level=observation.risk_level, - risk_type=observation.risk_signal, - risk_description=observation.description, - related_claim_ids=[ - observation.claim_id, - *observation.similar_case_claim_ids, - ], - ) + for snapshot_claim in tenant_claims: + claim = observation_service.lock_claim_for_risk_write( + snapshot_claim.id, + tenant_id=tenant_id, + refresh=True, + ) + if claim is None or not self._is_scan_eligible(claim): + continue + if claim.updated_at != snapshot_versions.get(claim.id): + # 计算期间单据已变化,旧快照不再写回;下一轮重新扫描。 + continue + for observation in observations_by_claim.get(claim.id, []): + observation_service.upsert_observation( + observation, + tenant_id=tenant_id, + run_id=run_id, + execution_log_id=log_id, + claim_lock_acquired=True, + ) + observation_count += 1 + claim.hermes_risk_flag = True + claim.risk_flags_json = self._append_algorithm_flag( + claim, + observation.as_dict(), ) - now = datetime.now(timezone.utc) - for claim in claims: - claim.hermes_scanned_at = now + if log_id: + self.db.add( + HermesRiskReport( + claim_id=observation.claim_id, + execution_log_id=log_id, + risk_level=observation.risk_level, + risk_type=observation.risk_signal, + risk_description=observation.description, + related_claim_ids=[ + observation.claim_id, + *observation.similar_case_claim_ids, + ], + ) + ) + claim.hermes_scanned_at = now + scanned_claim_count += 1 self.db.commit() logger.info( @@ -104,7 +113,7 @@ class HermesRiskScannerService: observation_count, ) return { - "scanned_claim_count": len(claims), + "scanned_claim_count": scanned_claim_count, "risk_observation_count": observation_count, "graph_node_count": graph_node_count, "graph_edge_count": graph_edge_count, @@ -134,11 +143,16 @@ class HermesRiskScannerService: ExpenseClaim.hermes_risk_flag.is_(False), ), ) + .order_by(ExpenseClaim.id) .limit(50) ) return list(self.db.scalars(stmt).all()) + @staticmethod + def _is_scan_eligible(claim: ExpenseClaim) -> bool: + return str(claim.status or "").strip().lower() in {"draft", "submitted", "review"} + @staticmethod def _append_algorithm_flag(claim: ExpenseClaim, observation: dict) -> list: existing = list(claim.risk_flags_json or []) @@ -155,8 +169,7 @@ class HermesRiskScannerService: "reimbursement", ) if any( - isinstance(item, dict) - and item.get("observation_key") == flag["observation_key"] + isinstance(item, dict) and item.get("observation_key") == flag["observation_key"] for item in existing ): return existing diff --git a/server/src/app/services/risk_dispositions.py b/server/src/app/services/risk_dispositions.py new file mode 100644 index 0000000..1be9b24 --- /dev/null +++ b/server/src/app/services/risk_dispositions.py @@ -0,0 +1,430 @@ +from __future__ import annotations + +import hashlib +import json +import os +import uuid +from dataclasses import dataclass +from datetime import UTC, datetime + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.api.deps import CurrentUserContext +from app.core.logging import get_logger +from app.models.financial_record import ExpenseClaim +from app.models.risk_disposition import RiskDisposition, RiskDispositionEvent +from app.models.risk_observation import RiskObservation, RiskObservationFeedback +from app.schemas.risk_disposition import RiskDispositionActionCreate +from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin +from app.services.risk_observation_access_policy import RiskObservationAccessPolicy + +logger = get_logger("app.services.risk_dispositions") + + +class RiskDispositionConflictError(RuntimeError): + pass + + +class RiskDispositionVersionConflictError(RiskDispositionConflictError): + def __init__(self, current_version: int) -> None: + self.current_version = current_version + super().__init__( + f"Risk disposition version conflict; current version is {current_version}." + ) + + +class RiskDispositionIdempotencyConflictError(RiskDispositionConflictError): + pass + + +class RiskDispositionPermissionError(RuntimeError): + pass + + +@dataclass(slots=True) +class RiskDispositionMutation: + disposition: RiskDisposition + event: RiskDispositionEvent + replayed: bool + legacy_feedback: RiskObservationFeedback | None = None + + +class RiskDispositionService: + def __init__(self, db: Session) -> None: + self.db = db + + def get_disposition( + self, + observation_id: str, + *, + tenant_id: str, + ) -> RiskDisposition | None: + return self.db.scalar( + select(RiskDisposition).where( + RiskDisposition.tenant_id == _tenant(tenant_id), + RiskDisposition.observation_id == observation_id, + ) + ) + + def get_current_version(self, observation_id: str, *, tenant_id: str) -> int: + disposition = self.get_disposition(observation_id, tenant_id=tenant_id) + return disposition.version if disposition is not None else 0 + + def execute_action( + self, + observation_key_or_id: str, + payload: RiskDispositionActionCreate, + *, + tenant_id: str, + actor_id: str, + actor_name: str, + current_user: CurrentUserContext | None = None, + ) -> RiskDispositionMutation: + normalized_tenant = _tenant(tenant_id) + normalized_actor_id = _text(actor_id) or "anonymous" + observation_locator = self.db.execute( + select(RiskObservation.id, RiskObservation.claim_id).where( + RiskObservation.tenant_id == normalized_tenant, + ( + (RiskObservation.id == observation_key_or_id) + | (RiskObservation.observation_key == observation_key_or_id) + ), + ) + ).one_or_none() + if observation_locator is None: + raise LookupError("Risk observation not found.") + observation_id, located_claim_id = observation_locator + fingerprint = _payload_fingerprint( + payload, + observation_id=observation_id, + actor_id=normalized_actor_id, + ) + replay = self._find_replay( + tenant_id=normalized_tenant, + request_id=payload.request_id, + fingerprint=fingerprint, + ) + if replay is not None: + return replay + + try: + locked_claim = self._lock_claim( + str(located_claim_id or "").strip(), + tenant_id=normalized_tenant, + ) + observation = self.db.scalar( + select(RiskObservation) + .where( + RiskObservation.tenant_id == normalized_tenant, + RiskObservation.id == observation_id, + ) + .with_for_update() + ) + if observation is None: + raise LookupError("Risk observation not found.") + if str(observation.claim_id or "").strip() != str(located_claim_id or "").strip(): + raise RiskDispositionConflictError( + "Risk observation claim changed concurrently; reload and retry." + ) + if current_user is not None and not RiskObservationAccessPolicy( + self.db + ).can_manage_locked_disposition( + observation, + current_user, + locked_claim=locked_claim, + ): + raise RiskDispositionPermissionError("当前用户已不再是该单据的有效审批人。") + + disposition = self.db.scalar( + select(RiskDisposition) + .where( + RiskDisposition.tenant_id == normalized_tenant, + RiskDisposition.observation_id == observation.id, + ) + .with_for_update() + ) + current_version = disposition.version if disposition is not None else 0 + if current_version != payload.expected_version: + raise RiskDispositionVersionConflictError(current_version) + + if disposition is None: + disposition = RiskDisposition( + id=str(uuid.uuid4()), + tenant_id=normalized_tenant, + observation_id=observation.id, + adjudication=_initial_adjudication(observation), + lifecycle_status=_initial_lifecycle_status(observation), + version=0, + ) + self.db.add(disposition) + + _validate_transition(disposition, payload) + before = _state(disposition) + _apply_action(disposition, observation, payload) + disposition.version = current_version + 1 + disposition.updated_at = datetime.now(UTC) + event = RiskDispositionEvent( + id=str(uuid.uuid4()), + tenant_id=normalized_tenant, + disposition_id=disposition.id, + observation_id=observation.id, + version=disposition.version, + action=payload.action, + actor_id=normalized_actor_id, + actor_name=_text(actor_name) or _text(actor_id) or "anonymous", + request_id=payload.request_id, + payload_fingerprint=fingerprint, + comment=payload.comment, + before_json=before, + after_json=_state(disposition), + ) + self.db.add(event) + legacy_feedback = self._append_safe_feedback( + observation, + event, + payload, + actor_name=actor_name, + ) + self.db.commit() + self.db.refresh(disposition) + self.db.refresh(event) + if legacy_feedback is not None: + self.db.refresh(legacy_feedback) + self._ingest_feedback_sample(observation, legacy_feedback) + return RiskDispositionMutation( + disposition=disposition, + event=event, + replayed=False, + legacy_feedback=legacy_feedback, + ) + except (LookupError, RiskDispositionConflictError, RiskDispositionPermissionError): + self.db.rollback() + raise + except IntegrityError as error: + self.db.rollback() + replay = self._find_replay( + tenant_id=normalized_tenant, + request_id=payload.request_id, + fingerprint=fingerprint, + ) + if replay is not None: + return replay + raise RiskDispositionConflictError( + "Risk disposition was changed concurrently; reload and retry." + ) from error + + def _lock_claim( + self, + claim_id: str, + *, + tenant_id: str, + ) -> ExpenseClaim | None: + if not claim_id: + return None + statement = select(ExpenseClaim).where( + ExpenseClaim.id == claim_id, + ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(tenant_id), + ) + bind = self.db.get_bind() + if bind is not None and bind.dialect.name == "postgresql": + statement = statement.with_for_update() + return self.db.scalar(statement.execution_options(populate_existing=True)) + + def _find_replay( + self, + *, + tenant_id: str, + request_id: str, + fingerprint: str, + ) -> RiskDispositionMutation | None: + event = self.db.scalar( + select(RiskDispositionEvent).where( + RiskDispositionEvent.tenant_id == tenant_id, + RiskDispositionEvent.request_id == request_id, + ) + ) + if event is None: + return None + if event.payload_fingerprint != fingerprint: + raise RiskDispositionIdempotencyConflictError("request_id 已被不同的风险处置内容使用。") + disposition = self.db.get(RiskDisposition, event.disposition_id) + if disposition is None: + raise RiskDispositionConflictError("Risk disposition replay target is missing.") + feedback = self.db.scalar( + select(RiskObservationFeedback).where( + RiskObservationFeedback.observation_id == event.observation_id, + RiskObservationFeedback.action == f"disposition:{event.id}", + ) + ) + return RiskDispositionMutation( + disposition=disposition, + event=event, + replayed=True, + legacy_feedback=feedback, + ) + + def _append_safe_feedback( + self, + observation: RiskObservation, + event: RiskDispositionEvent, + payload: RiskDispositionActionCreate, + *, + actor_name: str, + ) -> RiskObservationFeedback | None: + if payload.action not in {"confirm", "false_positive"}: + return None + feedback = RiskObservationFeedback( + observation_id=observation.id, + feedback_type=payload.action, + action=f"disposition:{event.id}", + actor=_text(actor_name) or "anonymous", + comment=payload.comment, + payload_json={ + "decision": payload.action, + "source": "typed_risk_disposition", + }, + ) + self.db.add(feedback) + return feedback + + def _ingest_feedback_sample( + self, + observation: RiskObservation, + feedback: RiskObservationFeedback, + ) -> None: + enabled = os.environ.get("FEW_SHOT_INJECTION_ENABLED", "true") + if enabled.strip().lower() in {"0", "false", "no"}: + return + try: + from app.services.few_shot_ingestion import FewShotIngestionService + + FewShotIngestionService(self.db).ingest_observation_feedback( + observation, + feedback, + ) + except Exception: + logger.exception( + "few-shot ingestion failed for disposition event %s", + feedback.action, + ) + + +def _apply_action( + disposition: RiskDisposition, + observation: RiskObservation, + payload: RiskDispositionActionCreate, +) -> None: + if payload.action == "confirm": + disposition.adjudication = "confirmed" + observation.status = "confirmed" + observation.feedback_status = "confirmed" + return + if payload.action == "false_positive": + disposition.adjudication = "false_positive" + observation.status = "false_positive" + observation.feedback_status = "false_positive" + return + + lifecycle_by_action = { + "request_supplement": "supplement_requested", + "start_remediation": "remediation_in_progress", + "request_waiver": "waiver_requested", + "resolve": "resolved", + } + disposition.lifecycle_status = lifecycle_by_action[payload.action] + if payload.assignee is not None: + disposition.assignee = payload.assignee + if payload.due_at is not None: + disposition.due_at = payload.due_at + if payload.action != "resolve": + disposition.resolution = None + else: + disposition.resolution = payload.resolution + observation.status = "resolved" + + +def _validate_transition( + disposition: RiskDisposition, + payload: RiskDispositionActionCreate, +) -> None: + if disposition.lifecycle_status == "resolved": + raise RiskDispositionConflictError("已解决的风险不能再次变更裁决或处置生命周期。") + if payload.action == "confirm": + if disposition.adjudication == "confirmed": + raise RiskDispositionConflictError("该风险已经确认成立,请勿重复提交。") + return + if payload.action == "false_positive": + if disposition.adjudication == "false_positive": + raise RiskDispositionConflictError("该风险已经标记为误报,请勿重复提交。") + return + target_lifecycle = { + "request_supplement": "supplement_requested", + "start_remediation": "remediation_in_progress", + "request_waiver": "waiver_requested", + "resolve": "resolved", + }[payload.action] + if disposition.lifecycle_status == target_lifecycle: + raise RiskDispositionConflictError("该风险已处于目标处置状态,请勿重复提交。") + if payload.action == "request_supplement": + return + if disposition.adjudication != "confirmed": + raise RiskDispositionConflictError("风险必须先确认成立,才能启动整改、申请豁免或标记解决。") + + +def _state(disposition: RiskDisposition) -> dict[str, object]: + return { + "adjudication": disposition.adjudication, + "lifecycle_status": disposition.lifecycle_status, + "assignee": disposition.assignee, + "due_at": disposition.due_at.isoformat() if disposition.due_at else None, + "resolution": disposition.resolution, + "version": disposition.version, + } + + +def _initial_adjudication(observation: RiskObservation) -> str: + values = { + _text(observation.feedback_status).lower(), + _text(observation.status).lower(), + } + if "false_positive" in values: + return "false_positive" + if "confirmed" in values: + return "confirmed" + return "unreviewed" + + +def _initial_lifecycle_status(observation: RiskObservation) -> str: + return "resolved" if _text(observation.status).lower() == "resolved" else "open" + + +def _payload_fingerprint( + payload: RiskDispositionActionCreate, + *, + observation_id: str, + actor_id: str, +) -> str: + canonical_payload = payload.model_dump( + mode="json", + exclude={"request_id"}, + exclude_none=False, + ) + canonical_payload["observation_id"] = observation_id + canonical_payload["actor_id"] = actor_id + canonical = json.dumps( + canonical_payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def _tenant(value: str) -> str: + return _text(value) or "default" + + +def _text(value: object) -> str: + return str(value or "").strip() diff --git a/server/src/app/services/risk_observation_access_policy.py b/server/src/app/services/risk_observation_access_policy.py new file mode 100644 index 0000000..29830f9 --- /dev/null +++ b/server/src/app/services/risk_observation_access_policy.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.api.deps import CurrentUserContext +from app.models.financial_record import ExpenseClaim +from app.models.risk_observation import RiskObservation +from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy + +RISK_POOL_ROLE_CODES = { + "budget_monitor", + "executive", + "finance", +} + + +class RiskObservationAccessPolicy: + """集中定义风险池、单据风险和处置动作的访问边界。""" + + def __init__(self, db: Session) -> None: + self.db = db + self.claim_policy = ExpenseClaimAccessPolicy(db) + + def can_read_tenant_pool(self, current_user: CurrentUserContext) -> bool: + if current_user.is_admin: + return True + return bool(self.claim_policy.normalize_role_codes(current_user) & RISK_POOL_ROLE_CODES) + + def find_visible_claim( + self, + claim_id: str, + current_user: CurrentUserContext, + ) -> ExpenseClaim | None: + statement = select(ExpenseClaim).where(ExpenseClaim.id == claim_id) + statement = self.claim_policy.apply_claim_scope( + statement, + current_user, + include_approval_scope=True, + ) + return self.db.scalar(statement) + + def can_read_claim_risks( + self, + claim_id: str, + current_user: CurrentUserContext, + ) -> bool: + claim = self.find_visible_claim(claim_id, current_user) + if claim is None: + return False + if current_user.is_admin: + return True + # 完整证据链含图谱、相似案例与人工反馈,仅当前审批人可读取。 + return self.claim_policy.can_approve_claim(current_user, claim) + + def can_manage_disposition( + self, + observation: RiskObservation, + current_user: CurrentUserContext, + ) -> bool: + claim_id = str(observation.claim_id or "").strip() + claim = self.find_visible_claim(claim_id, current_user) if claim_id else None + return self.can_manage_locked_disposition( + observation, + current_user, + locked_claim=claim, + ) + + def can_manage_locked_disposition( + self, + observation: RiskObservation, + current_user: CurrentUserContext, + *, + locked_claim: ExpenseClaim | None, + ) -> bool: + """在共享 Claim 行锁内按最新审批节点复核处置权限。""" + + if current_user.is_admin: + return True + claim_id = str(observation.claim_id or "").strip() + if not claim_id or locked_claim is None or str(locked_claim.id) != claim_id: + # 无关联单据的租户级风险缺少可复用的业务权限边界,仅 admin 可处置。 + return False + return self.claim_policy.can_approve_claim(current_user, locked_claim) diff --git a/server/src/app/services/risk_observations.py b/server/src/app/services/risk_observations.py index 01e7a64..8a7efa4 100644 --- a/server/src/app/services/risk_observations.py +++ b/server/src/app/services/risk_observations.py @@ -19,6 +19,7 @@ from app.schemas.risk_observation import ( RiskObservationFeedbackCreate, ) from app.services.expense_claim_risk_stage import normalize_risk_business_stage +from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin logger = get_logger("app.services.risk_observations") @@ -66,6 +67,7 @@ class RiskObservationService: tenant_id: str | None = None, run_id: str | None = None, execution_log_id: str | None = None, + claim_lock_acquired: bool = False, ) -> RiskObservation: self.ensure_storage_ready() payload = ( @@ -80,6 +82,12 @@ class RiskObservationService: tenant_id=tenant_id or _optional_text(payload.get("tenant_id")), claim_id=_optional_text(payload.get("claim_id")), ) + claim_id = _optional_text(payload.get("claim_id")) + if claim_id and not claim_lock_acquired: + self.lock_claim_for_risk_write( + claim_id, + tenant_id=normalized_tenant_id, + ) item = self.db.scalar( select(RiskObservation).where( @@ -126,6 +134,27 @@ class RiskObservationService: self.db.flush() return item + def lock_claim_for_risk_write( + self, + claim_id: str, + *, + tenant_id: str | None, + refresh: bool = False, + ) -> ExpenseClaim | None: + """所有 claim-linked 风险写入共享同一 Claim 行锁。""" + + normalized_tenant = _normalize_tenant_id(tenant_id) + statement = select(ExpenseClaim).where( + ExpenseClaim.id == claim_id, + ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(normalized_tenant), + ) + bind = self.db.get_bind() + if bind is not None and bind.dialect.name == "postgresql": + statement = statement.with_for_update() + if refresh: + statement = statement.execution_options(populate_existing=True) + return self.db.scalar(statement) + def upsert_platform_risk_flags( self, claim: ExpenseClaim, @@ -154,9 +183,7 @@ class RiskObservationService: score = SEVERITY_SCORE.get(severity, SEVERITY_SCORE["medium"]) rule_code = _text(flag.get("rule_code")) business_stage = normalize_risk_business_stage(flag.get("business_stage")) - observation_key = ( - f"risk:{claim.id}:platform:{rule_code or signal}" - ) + observation_key = f"risk:{claim.id}:platform:{rule_code or signal}" observations.append( self.upsert_observation( { @@ -176,9 +203,7 @@ class RiskObservationService: "control_stage": business_stage, "control_mode": "risk_observation", "automation_mode": ( - "semi_auto_review" - if severity in HIGH_LEVELS - else "manual_review" + "semi_auto_review" if severity in HIGH_LEVELS else "manual_review" ), "source": "rule_center", "algorithm_version": _text(flag.get("rule_version")) or "v1.0.0", @@ -357,6 +382,24 @@ class RiskObservationService: observation_key_or_id, tenant_id=tenant_id, ) + if observation is None: + raise LookupError("Risk observation not found.") + normalized_tenant = _normalize_tenant_id(tenant_id) + if observation.claim_id: + self.lock_claim_for_risk_write( + observation.claim_id, + tenant_id=normalized_tenant, + ) + observation_statement = select(RiskObservation).where( + RiskObservation.tenant_id == normalized_tenant, + RiskObservation.id == observation.id, + ) + bind = self.db.get_bind() + if bind is not None and bind.dialect.name == "postgresql": + observation_statement = observation_statement.with_for_update() + observation = self.db.scalar( + observation_statement.execution_options(populate_existing=True) + ) if observation is None: raise LookupError("Risk observation not found.") @@ -505,12 +548,16 @@ class RiskObservationService: ) -> str: explicit_tenant_id = str(tenant_id or "").strip() normalized_claim_id = str(claim_id or "").strip() - linked_tenant_id = self.db.scalar( - select(ExpenseCaseLink.tenant_id).where( - ExpenseCaseLink.resource_type == "expense_claim", - ExpenseCaseLink.resource_id == normalized_claim_id, + linked_tenant_id = ( + self.db.scalar( + select(ExpenseCaseLink.tenant_id).where( + ExpenseCaseLink.resource_type == "expense_claim", + ExpenseCaseLink.resource_id == normalized_claim_id, + ) ) - ) if normalized_claim_id else None + if normalized_claim_id + else None + ) claim_tenant_id = _normalize_tenant_id(linked_tenant_id) if explicit_tenant_id: normalized_tenant_id = _normalize_tenant_id(explicit_tenant_id) diff --git a/server/tests/test_alembic_migrations.py b/server/tests/test_alembic_migrations.py index ef6bb61..4a90cd0 100644 --- a/server/tests/test_alembic_migrations.py +++ b/server/tests/test_alembic_migrations.py @@ -28,7 +28,7 @@ from app.models.risk_observation import RiskObservation MIGRATION_TEST_DATABASE_URL = os.getenv("MIGRATION_TEST_DATABASE_URL", "").strip() LEGACY_PROBE_TABLE = "legacy_migration_probe_records" -HEAD_REVISION = "20260716_0009" +HEAD_REVISION = "20260716_0011" SERVER_DIR = Path(__file__).resolve().parents[1] ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini" @@ -357,12 +357,150 @@ def _assert_head_schema(engine: Engine) -> None: "uq_risk_observations_tenant_key", ("tenant_id", "observation_key"), ) + _assert_unique_constraint( + engine, + "risk_observations", + "uq_risk_observations_tenant_id", + ("tenant_id", "id"), + ) _assert_unique_constraint( engine, "few_shot_samples", "uq_few_shot_samples_tenant_key", ("tenant_id", "sample_key"), ) + _assert_unique_constraint( + engine, + "approval_action_ledgers", + "uq_approval_action_ledger_request", + ("tenant_id", "actor_id", "request_id"), + ) + _assert_indexes( + engine, + "approval_action_ledgers", + { + "ix_approval_action_ledger_claim_action": ( + "tenant_id", + "claim_id", + "action", + ) + }, + ) + _assert_check_constraint( + engine, + "approval_action_ledgers", + "ck_approval_action_ledger_action", + ) + _assert_check_constraint( + engine, + "approval_action_ledgers", + "ck_approval_action_ledger_completion", + ) + _assert_unique_constraint( + engine, + "risk_dispositions", + "uq_risk_dispositions_tenant_observation", + ("tenant_id", "observation_id"), + ) + _assert_unique_constraint( + engine, + "risk_dispositions", + "uq_risk_dispositions_tenant_id", + ("tenant_id", "id"), + ) + _assert_composite_foreign_key( + engine, + "risk_dispositions", + ("tenant_id", "observation_id"), + "risk_observations", + ) + _assert_indexes( + engine, + "risk_dispositions", + { + "ix_risk_dispositions_tenant_lifecycle_due": ( + "tenant_id", + "lifecycle_status", + "due_at", + ), + "ix_risk_dispositions_assignee": ("tenant_id", "assignee"), + }, + ) + _assert_check_constraint( + engine, + "risk_dispositions", + "ck_risk_dispositions_adjudication", + ) + _assert_check_constraint( + engine, + "risk_dispositions", + "ck_risk_dispositions_lifecycle", + ) + _assert_check_constraint( + engine, + "risk_dispositions", + "ck_risk_dispositions_version", + ) + _assert_unique_constraint( + engine, + "risk_disposition_events", + "uq_risk_disposition_events_tenant_request", + ("tenant_id", "request_id"), + ) + _assert_unique_constraint( + engine, + "risk_disposition_events", + "uq_risk_disposition_events_version", + ("disposition_id", "version"), + ) + _assert_indexes( + engine, + "risk_disposition_events", + { + "ix_risk_disposition_events_disposition_id": ("disposition_id",), + "ix_risk_disposition_events_tenant_observation_time": ( + "tenant_id", + "observation_id", + "created_at", + ), + }, + ) + _assert_check_constraint( + engine, + "risk_disposition_events", + "ck_risk_disposition_events_action", + ) + _assert_check_constraint( + engine, + "risk_disposition_events", + "ck_risk_disposition_events_version", + ) + _assert_composite_foreign_key( + engine, + "risk_disposition_events", + ("tenant_id", "disposition_id"), + "risk_dispositions", + ) + _assert_composite_foreign_key( + engine, + "risk_disposition_events", + ("tenant_id", "observation_id"), + "risk_observations", + ) + with engine.connect() as connection: + append_only_trigger_count = int( + connection.scalar( + text( + "SELECT COUNT(*) FROM pg_trigger trigger " + "JOIN pg_class relation ON relation.oid = trigger.tgrelid " + "WHERE relation.relname = 'risk_disposition_events' " + "AND trigger.tgname = 'trg_risk_disposition_events_append_only' " + "AND NOT trigger.tgisinternal" + ) + ) + or 0 + ) + assert append_only_trigger_count == 1 _assert_check_constraint( engine, "memory_entries", @@ -972,12 +1110,15 @@ def _create_hierarchical_memory_downgrade_probe(engine: Engine) -> None: """ ) ) - assert connection.scalar( - text( - "SELECT COUNT(*) FROM memory_entries " - "WHERE id = 'hierarchical-memory-downgrade-probe'" + assert ( + connection.scalar( + text( + "SELECT COUNT(*) FROM memory_entries " + "WHERE id = 'hierarchical-memory-downgrade-probe'" + ) ) - ) == 1 + == 1 + ) def _create_duplicate_active_organization_memory_probe(engine: Engine) -> None: @@ -1056,10 +1197,7 @@ def _create_enriched_few_shot_downgrade_probe(engine: Engine) -> None: def _delete_enriched_few_shot_downgrade_probe(engine: Engine) -> None: with engine.begin() as connection: connection.execute( - text( - "DELETE FROM few_shot_samples " - "WHERE id = 'enriched-few-shot-downgrade-probe'" - ) + text("DELETE FROM few_shot_samples WHERE id = 'enriched-few-shot-downgrade-probe'") ) @@ -1116,12 +1254,8 @@ def _create_historical_case_downgrade_probe(engine: Engine) -> None: def _assert_historical_case_downgrade_probe(engine: Engine) -> None: inspector = inspect(engine) - risk_columns = { - str(item["name"]) for item in inspector.get_columns("risk_observations") - } - sample_columns = { - str(item["name"]) for item in inspector.get_columns("few_shot_samples") - } + risk_columns = {str(item["name"]) for item in inspector.get_columns("risk_observations")} + sample_columns = {str(item["name"]) for item in inspector.get_columns("few_shot_samples")} assert "tenant_id" not in risk_columns assert {"tenant_id", "policy_ref", "rule_version"}.isdisjoint(sample_columns) assert not any( @@ -1131,24 +1265,32 @@ def _assert_historical_case_downgrade_probe(engine: Engine) -> None: for item in inspector.get_foreign_keys("risk_observations") ) with engine.connect() as connection: - assert connection.scalar( - text( - "SELECT COUNT(*) FROM risk_observations " - "WHERE id = 'historical-downgrade-observation'" + assert ( + connection.scalar( + text( + "SELECT COUNT(*) FROM risk_observations " + "WHERE id = 'historical-downgrade-observation'" + ) ) - ) == 1 - assert connection.scalar( - text( - "SELECT COUNT(*) FROM risk_observation_feedback " - "WHERE id = 'historical-downgrade-feedback'" + == 1 + ) + assert ( + connection.scalar( + text( + "SELECT COUNT(*) FROM risk_observation_feedback " + "WHERE id = 'historical-downgrade-feedback'" + ) ) - ) == 1 - assert connection.scalar( - text( - "SELECT COUNT(*) FROM few_shot_samples " - "WHERE id = 'historical-downgrade-sample'" + == 1 + ) + assert ( + connection.scalar( + text( + "SELECT COUNT(*) FROM few_shot_samples WHERE id = 'historical-downgrade-sample'" + ) ) - ) == 1 + == 1 + ) def _assert_legacy_sentinel(engine: Engine) -> None: @@ -1179,6 +1321,10 @@ def _assert_base_schema(engine: Engine) -> None: ("20260716_0008_tenant_safe_historical_cases.py", "downgrade"), ("20260716_0009_organization_memory_idempotency.py", "upgrade"), ("20260716_0009_organization_memory_idempotency.py", "downgrade"), + ("20260716_0010_approval_action_protocol.py", "upgrade"), + ("20260716_0010_approval_action_protocol.py", "downgrade"), + ("20260716_0011_risk_disposition.py", "upgrade"), + ("20260716_0011_risk_disposition.py", "downgrade"), ], ) def test_postgresql_only_migrations_reject_other_dialects_before_mutation( diff --git a/server/tests/test_approval_risk_concurrency_postgres.py b/server/tests/test_approval_risk_concurrency_postgres.py new file mode 100644 index 0000000..2e51a65 --- /dev/null +++ b/server/tests/test_approval_risk_concurrency_postgres.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import os +import threading +import time +import uuid +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime +from decimal import Decimal + +import pytest +from sqlalchemy import create_engine, func, select +from sqlalchemy.engine import make_url +from sqlalchemy.orm import Session, sessionmaker + +from app.api.deps import CurrentUserContext +from app.db.base import Base +from app.models.approval_action import ApprovalActionLedger +from app.models.employee import Employee +from app.models.financial_record import ExpenseClaim +from app.models.risk_disposition import RiskDisposition +from app.models.risk_observation import RiskObservation +from app.schemas.risk_disposition import RiskDispositionActionCreate +from app.services.expense_claim_risk_gate import ExpenseClaimRiskBlockedError +from app.services.expense_claims import ExpenseClaimService +from app.services.risk_dispositions import RiskDispositionService + +DATABASE_URL = os.environ.get("MIGRATION_TEST_DATABASE_URL", "").strip() + + +def test_disposition_reopen_and_approval_share_claim_lock( + monkeypatch: pytest.MonkeyPatch, +) -> None: + database_url = _require_disposable_database_url() + monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false") + engine = create_engine(database_url, pool_pre_ping=True) + Base.metadata.create_all(bind=engine) + factory = sessionmaker(bind=engine, expire_on_commit=False) + suffix = uuid.uuid4().hex[:12] + claim_id = f"claim-risk-lock-{suffix}" + observation_id = f"risk-lock-{suffix}" + disposition_id = f"disposition-lock-{suffix}" + manager_email = f"manager-{suffix}@example.com" + manager_user = CurrentUserContext( + username=manager_email, + name="并发审批经理", + role_codes=["manager"], + is_admin=False, + ) + with factory() as db: + _seed_locked_risk_case( + db, + claim_id=claim_id, + observation_id=observation_id, + disposition_id=disposition_id, + manager_email=manager_email, + suffix=suffix, + ) + + claim_locked = threading.Event() + release_disposition = threading.Event() + approval_started = threading.Event() + from app.services import risk_dispositions as risk_disposition_module + + original_apply_action = risk_disposition_module._apply_action + + def pause_after_claim_lock(*args, **kwargs): + claim_locked.set() + if not release_disposition.wait(timeout=5): + raise TimeoutError("test did not release risk disposition") + return original_apply_action(*args, **kwargs) + + monkeypatch.setattr(risk_disposition_module, "_apply_action", pause_after_claim_lock) + + def reopen_risk() -> str: + with factory() as db: + result = RiskDispositionService(db).execute_action( + observation_id, + RiskDispositionActionCreate( + action="confirm", + expected_version=1, + request_id=f"request-risk-reopen-{suffix}", + comment="复核后确认风险成立", + ), + tenant_id="default", + actor_id=manager_email, + actor_name="并发审批经理", + current_user=manager_user, + ) + return result.disposition.adjudication + + def approve_claim() -> str: + approval_started.set() + with factory() as db: + try: + ExpenseClaimService(db).approve_claim( + claim_id, + manager_user, + opinion="同意", + request_id=f"request-approve-after-risk-{suffix}", + expected_status="submitted", + expected_approval_stage="直属领导审批", + ) + except ExpenseClaimRiskBlockedError: + return "blocked" + return "approved" + + try: + with ThreadPoolExecutor(max_workers=2) as pool: + risk_future = pool.submit(reopen_risk) + assert claim_locked.wait(timeout=5) + approval_future = pool.submit(approve_claim) + assert approval_started.wait(timeout=5) + time.sleep(0.2) + assert not approval_future.done() + release_disposition.set() + assert risk_future.result(timeout=5) == "confirmed" + assert approval_future.result(timeout=5) == "blocked" + + with factory() as db: + claim = db.get(ExpenseClaim, claim_id) + disposition = db.get(RiskDisposition, disposition_id) + assert claim is not None and claim.approval_stage == "直属领导审批" + assert disposition is not None and disposition.adjudication == "confirmed" + assert ( + db.scalar( + select(func.count()) + .select_from(ApprovalActionLedger) + .where(ApprovalActionLedger.claim_id == claim_id) + ) + == 0 + ) + finally: + release_disposition.set() + engine.dispose() + + +def _seed_locked_risk_case( + db: Session, + *, + claim_id: str, + observation_id: str, + disposition_id: str, + manager_email: str, + suffix: str, +) -> None: + manager = Employee( + id=f"manager-risk-lock-{suffix}", + employee_no=f"M-RISK-LOCK-{suffix}", + name="并发审批经理", + email=manager_email, + ) + employee = Employee( + id=f"employee-risk-lock-{suffix}", + employee_no=f"E-RISK-LOCK-{suffix}", + name="并发风险员工", + email=f"employee-{suffix}@example.com", + manager=manager, + ) + now = datetime.now(UTC) + claim = ExpenseClaim( + id=claim_id, + claim_no=f"EXP-RISK-LOCK-{suffix}", + employee=employee, + employee_name=employee.name, + department_name="风控部", + expense_type="travel", + reason="客户拜访", + location="上海", + amount=Decimal("1200.00"), + currency="CNY", + invoice_count=1, + occurred_at=now, + submitted_at=now, + status="submitted", + approval_stage="直属领导审批", + risk_flags_json=[], + ) + observation = RiskObservation( + id=observation_id, + tenant_id="default", + observation_key=f"risk:claim-lock:{suffix}", + subject_type="expense_claim", + subject_key=f"claim:{claim_id}", + subject_label=claim.claim_no, + claim_id=claim_id, + claim_no=claim.claim_no, + risk_type="duplicate_invoice", + risk_signal="duplicate_invoice", + title="重复票据风险", + description="此前被标记为误报,现重新确认。", + risk_score=92, + risk_level="high", + confidence_score=0.95, + control_stage="reimbursement", + control_mode="risk_observation", + automation_mode="semi_auto_review", + source="financial_risk_graph", + algorithm_version="financial_risk_graph.v1", + status="false_positive", + feedback_status="false_positive", + ) + disposition = RiskDisposition( + id=disposition_id, + tenant_id="default", + observation_id=observation_id, + adjudication="false_positive", + lifecycle_status="open", + version=1, + ) + db.add_all([manager, employee, claim, observation, disposition]) + db.commit() + + +def _require_disposable_database_url() -> str: + if not DATABASE_URL: + pytest.skip("仅在显式配置 MIGRATION_TEST_DATABASE_URL 时运行 PostgreSQL 并发测试") + parsed = make_url(DATABASE_URL) + host = str(parsed.host or "").replace("_", "-").lower() + database = str(parsed.database or "").replace("_", "-").lower() + if not host.startswith(("migration-probe", "disposable-probe")): + raise RuntimeError("并发测试数据库主机必须使用 disposable 前缀") + if not database.startswith(("migration-probe", "disposable-probe")): + raise RuntimeError("并发测试数据库名必须使用 disposable 前缀") + return DATABASE_URL diff --git a/server/tests/test_approval_workbench.py b/server/tests/test_approval_workbench.py new file mode 100644 index 0000000..1a241b5 --- /dev/null +++ b/server/tests/test_approval_workbench.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +from datetime import UTC, date, datetime, timedelta +from decimal import Decimal + +from app.models.financial_record import ExpenseClaim, ExpenseClaimItem +from app.models.risk_disposition import RiskDisposition +from app.models.risk_observation import RiskObservation +from app.services.approval_workbench import ApprovalWorkbenchService + + +def _claim( + *, + claim_no: str = "RE-WORKBENCH-1", + amount: str = "888.00", + submitted_at: datetime, + risk_flags: list[dict] | None = None, + invoice_count: int = 1, +) -> ExpenseClaim: + claim = ExpenseClaim( + id=f"claim-{claim_no.lower()}", + claim_no=claim_no, + employee_id=None, + employee_name="张三", + department_id=None, + department_name="市场部", + project_code="PRJ-WORKBENCH", + expense_type="travel", + reason="客户现场差旅", + location="上海", + amount=Decimal(amount), + currency="CNY", + invoice_count=invoice_count, + occurred_at=submitted_at, + submitted_at=submitted_at, + status="submitted", + approval_stage="直属领导审批", + risk_flags_json=list(risk_flags or []), + created_at=submitted_at, + updated_at=submitted_at, + ) + claim.items = [ + ExpenseClaimItem( + id=f"item-{claim_no.lower()}", + claim_id=claim.id, + item_date=date(2026, 7, 15), + item_type="hotel", + item_reason="住宿", + item_location="上海", + item_note="", + item_amount=Decimal(amount), + invoice_id="INV-WORKBENCH" if invoice_count else None, + created_at=submitted_at, + updated_at=submitted_at, + ) + ] + return claim + + +def test_priority_queue_explains_risk_budget_sla_amount_and_history() -> None: + now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC) + claim = _claim( + amount="60000.00", + submitted_at=now - timedelta(hours=26), + risk_flags=[ + { + "source": "ai_pre_review", + "severity": "high", + "disposition": "review", + "resolution_status": "unresolved", + "route_decision": {"budget_result": {"metrics": {"after_usage_rate": "96.5"}}}, + "historical_case_evidence": [{"label": "confirmed", "sample_id": "must-not-leak"}], + } + ], + ) + + item = ApprovalWorkbenchService.build_item(claim, now=now) + + assert item.priority_score >= 85 + assert item.priority_tier == "urgent" + assert item.risk_level == "high" + assert item.sla_overdue is True + assert item.budget_usage_rate == 96.5 + assert item.suggestion.action == "manual_review" + assert item.evidence.historical_labels == ["历史已确认,仅供复核"] + assert "must-not-leak" not in repr(item.model_dump()) + assert {reason.code for reason in item.priority_reasons} >= { + "open_risk", + "sla_overdue", + "budget_pressure", + "large_amount", + } + + +def test_application_evidence_does_not_require_invoice_and_resolved_risk_is_ignored() -> None: + now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC) + claim = _claim( + claim_no="AP-WORKBENCH-1", + submitted_at=now - timedelta(hours=1), + invoice_count=0, + risk_flags=[ + { + "source": "ai_pre_review", + "severity": "critical", + "resolution_status": "resolved", + } + ], + ) + + item = ApprovalWorkbenchService.build_item(claim, now=now) + + assert item.evidence.completeness == 1 + assert item.evidence.missing_labels == [] + assert item.risk_level == "low" + assert item.open_risk_count == 0 + assert item.suggestion.action == "approve_candidate" + assert item.priority_score == 0 + + +def test_persisted_disposition_is_authoritative_over_stale_claim_risk_flags() -> None: + now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC) + claim = _claim( + submitted_at=now - timedelta(hours=1), + risk_flags=[ + { + "severity": "critical", + "triggered": True, + "observation_key": "risk:workbench:resolved", + } + ], + ) + observation = RiskObservation( + id="risk-workbench-resolved", + tenant_id="default", + observation_key="risk:workbench:resolved", + subject_type="expense_claim", + subject_key=f"claim:{claim.id}", + subject_label=claim.claim_no, + claim_id=claim.id, + claim_no=claim.claim_no, + risk_type="duplicate_invoice", + risk_signal="duplicate_invoice", + title="重复票据风险", + description="已复核完成。", + risk_score=95, + risk_level="critical", + confidence_score=0.96, + control_stage="reimbursement", + control_mode="risk_observation", + automation_mode="semi_auto_review", + source="financial_risk_graph", + algorithm_version="v1", + status="resolved", + feedback_status="confirmed", + ) + disposition = RiskDisposition( + tenant_id="default", + observation_id=observation.id, + adjudication="confirmed", + lifecycle_status="resolved", + version=2, + ) + + item = ApprovalWorkbenchService.build_item( + claim, + now=now, + observation_rows=[(observation, disposition)], + ) + + assert item.risk_level == "low" + assert item.open_risk_count == 0 + assert item.suggestion.action == "approve_candidate" + + +def test_persisted_low_risk_does_not_hide_unmaterialized_raw_high_risk() -> None: + now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC) + claim = _claim( + submitted_at=now - timedelta(hours=1), + risk_flags=[ + { + "source": "attachment_analysis", + "severity": "high", + "label": "票据金额异常", + "triggered": True, + } + ], + ) + observation = RiskObservation( + id="risk-workbench-low", + tenant_id="default", + observation_key="risk:workbench:low", + subject_type="expense_claim", + subject_key=f"claim:{claim.id}", + subject_label=claim.claim_no, + claim_id=claim.id, + claim_no=claim.claim_no, + risk_type="minor_notice", + risk_signal="minor_notice", + title="普通提醒", + description="普通提醒。", + risk_score=30, + risk_level="low", + confidence_score=0.8, + control_stage="reimbursement", + control_mode="risk_observation", + automation_mode="manual_review", + source="financial_risk_graph", + algorithm_version="v1", + status="pending_review", + feedback_status="unreviewed", + ) + + item = ApprovalWorkbenchService.build_item( + claim, + now=now, + observation_rows=[(observation, None)], + ) + + assert item.risk_level == "high" + assert item.open_risk_count == 2 + assert item.suggestion.action == "manual_review" diff --git a/server/tests/test_expense_claim_action_protocol.py b/server/tests/test_expense_claim_action_protocol.py new file mode 100644 index 0000000..a6ec7c2 --- /dev/null +++ b/server/tests/test_expense_claim_action_protocol.py @@ -0,0 +1,322 @@ +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime +from decimal import Decimal + +import pytest +from sqlalchemy import create_engine, func, select +from sqlalchemy.orm import Session, sessionmaker + +from app.api.deps import CurrentUserContext +from app.db.base import Base +from app.models.approval_action import ApprovalActionLedger +from app.models.audit_log import AuditLog +from app.models.employee import Employee +from app.models.expense_case import BusinessEvent +from app.models.financial_record import ExpenseClaim +from app.services.approval_action_protocol import ( + ApprovalActionConflictError, + ApprovalActionProtocol, +) +from app.services.expense_claims import ExpenseClaimService + + +def _manager_user() -> CurrentUserContext: + return CurrentUserContext( + username="manager-action@example.com", + name="李经理", + role_codes=["manager"], + is_admin=False, + ) + + +def _finance_user() -> CurrentUserContext: + return CurrentUserContext( + username="finance-action@example.com", + name="王财务", + role_codes=["finance"], + is_admin=False, + ) + + +def _seed_claim( + db: Session, + *, + claim_id: str = "claim-action-1", + manager_email: str = "manager-action@example.com", +) -> ExpenseClaim: + manager = Employee( + id=f"manager-{claim_id}", + employee_no=f"M-{claim_id}", + name="李经理", + email=manager_email, + ) + employee = Employee( + id=f"employee-{claim_id}", + employee_no=f"E-{claim_id}", + name="张三", + email=f"employee-{claim_id}@example.com", + manager=manager, + ) + claim = ExpenseClaim( + id=claim_id, + claim_no=f"EXP-{claim_id}", + employee=employee, + employee_name="张三", + department_name="市场部", + expense_type="transport", + reason="客户拜访", + location="上海", + amount=Decimal("88.00"), + currency="CNY", + invoice_count=1, + occurred_at=datetime(2026, 7, 16, tzinfo=UTC), + submitted_at=datetime(2026, 7, 16, tzinfo=UTC), + status="submitted", + approval_stage="直属领导审批", + risk_flags_json=[], + ) + db.add(claim) + db.commit() + return claim + + +@pytest.fixture +def session_factory() -> sessionmaker[Session]: + engine = create_engine("sqlite+pysqlite:///:memory:") + Base.metadata.create_all(engine) + factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) + try: + yield factory + finally: + engine.dispose() + + +def test_approve_replay_persists_one_ledger_event_and_audit( + session_factory: sessionmaker[Session], +) -> None: + with session_factory() as db: + claim = _seed_claim(db) + service = ExpenseClaimService(db) + first = service.approve_claim( + claim.id, + _manager_user(), + opinion="同意", + request_id="approve-retry-1", + expected_status="submitted", + expected_approval_stage="直属领导审批", + ) + replay = service.approve_claim( + claim.id, + _manager_user(), + opinion="同意", + request_id="approve-retry-1", + expected_status="submitted", + expected_approval_stage="直属领导审批", + ) + + assert first is not None and replay is not None + assert replay.approval_stage == "财务审批" + assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 1 + assert db.scalar(select(func.count()).select_from(BusinessEvent)) == 1 + assert ( + db.scalar( + select(func.count()) + .select_from(AuditLog) + .where(AuditLog.request_id == "approve-retry-1") + ) + == 1 + ) + + +def test_request_id_payload_mismatch_and_stale_preconditions_return_conflict( + session_factory: sessionmaker[Session], +) -> None: + with session_factory() as db: + claim = _seed_claim(db, claim_id="claim-action-conflict") + service = ExpenseClaimService(db) + with pytest.raises(ApprovalActionConflictError, match="单据状态已从"): + service.approve_claim( + claim.id, + _manager_user(), + opinion="同意", + request_id="approve-stale-1", + expected_status="draft", + expected_approval_stage="直属领导审批", + ) + service.approve_claim( + claim.id, + _manager_user(), + opinion="同意", + request_id="approve-conflict-1", + expected_status="submitted", + expected_approval_stage="直属领导审批", + ) + + with pytest.raises(ApprovalActionConflictError, match="已用于另一项"): + service.approve_claim( + claim.id, + _manager_user(), + opinion="改为有条件通过", + request_id="approve-conflict-1", + expected_status="submitted", + expected_approval_stage="直属领导审批", + ) + + assert ( + db.scalar( + select(func.count()) + .select_from(ApprovalActionLedger) + .where(ApprovalActionLedger.request_id == "approve-stale-1") + ) + == 0 + ) + + +def test_action_failure_rolls_back_ledger_claim_event_and_audit( + session_factory: sessionmaker[Session], + monkeypatch: pytest.MonkeyPatch, +) -> None: + with session_factory() as db: + claim = _seed_claim(db, claim_id="claim-action-rollback") + service = ExpenseClaimService(db) + + def fail_completion(*args, **kwargs): + raise RuntimeError("ledger completion failed") + + monkeypatch.setattr(ApprovalActionProtocol, "complete", fail_completion) + with pytest.raises(RuntimeError, match="ledger completion failed"): + service.approve_claim( + claim.id, + _manager_user(), + opinion="同意", + request_id="approve-rollback-1", + expected_status="submitted", + expected_approval_stage="直属领导审批", + ) + + db.expire_all() + persisted = db.get(ExpenseClaim, claim.id) + assert persisted is not None + assert persisted.status == "submitted" + assert persisted.approval_stage == "直属领导审批" + assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 0 + assert db.scalar(select(func.count()).select_from(BusinessEvent)) == 0 + assert db.scalar(select(func.count()).select_from(AuditLog)) == 0 + + +def test_legacy_stage_repair_cannot_commit_inside_action_protocol( + session_factory: sessionmaker[Session], +) -> None: + with session_factory() as db: + claim = _seed_claim(db, claim_id="claim-action-stage-repair") + claim.approval_stage = "预算管理者审批" + claim.risk_flags_json = [ + { + "source": "manual_approval", + "event_type": "expense_claim_approval", + "previous_approval_stage": "直属领导审批", + "next_approval_stage": "预算管理者审批", + "operator": "李经理", + "next_approver_name": "李经理", + } + ] + db.commit() + admin_user = CurrentUserContext( + username="admin-action@example.com", + name="审批管理员", + role_codes=["admin"], + is_admin=True, + ) + + with pytest.raises(ApprovalActionConflictError, match="审批节点已从"): + ExpenseClaimService(db).approve_claim( + claim.id, + admin_user, + opinion="同意", + request_id="approve-stage-repair-1", + expected_status="submitted", + expected_approval_stage="预算管理者审批", + ) + + db.expire_all() + persisted = db.get(ExpenseClaim, claim.id) + assert persisted is not None + assert persisted.approval_stage == "预算管理者审批" + assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 0 + + +def test_return_and_pay_actions_use_the_same_protocol( + session_factory: sessionmaker[Session], +) -> None: + with session_factory() as db: + returned_claim = _seed_claim(db, claim_id="claim-action-return") + paid_claim = _seed_claim( + db, + claim_id="claim-action-pay", + manager_email="manager-pay-action@example.com", + ) + paid_claim.status = "pending_payment" + paid_claim.approval_stage = "待付款" + db.commit() + + returned = ExpenseClaimService(db).return_claim( + returned_claim.id, + _manager_user(), + reason="请补充材料", + request_id="return-action-1", + expected_status="submitted", + expected_approval_stage="直属领导审批", + ) + paid = ExpenseClaimService(db).mark_claim_paid( + paid_claim.id, + _finance_user(), + request_id="pay-action-1", + expected_status="pending_payment", + expected_approval_stage="待付款", + ) + + assert returned is not None and returned.status == "returned" + assert paid is not None and paid.status == "paid" + ledgers = list( + db.scalars(select(ApprovalActionLedger).order_by(ApprovalActionLedger.action)).all() + ) + assert [(item.action, item.result_status) for item in ledgers] == [ + ("pay", "paid"), + ("return", "returned"), + ] + + +def test_concurrent_identical_request_executes_once(tmp_path) -> None: + engine = create_engine( + f"sqlite+pysqlite:///{tmp_path / 'approval-action.db'}", + connect_args={"check_same_thread": False}, + ) + Base.metadata.create_all(engine) + factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) + with factory() as db: + _seed_claim(db, claim_id="claim-action-concurrent") + + def approve() -> str: + with factory() as db: + result = ExpenseClaimService(db).approve_claim( + "claim-action-concurrent", + _manager_user(), + opinion="同意", + request_id="approve-concurrent-1", + expected_status="submitted", + expected_approval_stage="直属领导审批", + ) + assert result is not None + return str(result.approval_stage) + + try: + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(lambda _: approve(), range(2))) + assert results == ["财务审批", "财务审批"] + with factory() as db: + assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 1 + assert db.scalar(select(func.count()).select_from(BusinessEvent)) == 1 + finally: + engine.dispose() diff --git a/server/tests/test_expense_claim_risk_gate.py b/server/tests/test_expense_claim_risk_gate.py new file mode 100644 index 0000000..f3504f2 --- /dev/null +++ b/server/tests/test_expense_claim_risk_gate.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from decimal import Decimal + +import pytest +from sqlalchemy import create_engine, func, select +from sqlalchemy.orm import Session + +from app.api.deps import CurrentUserContext +from app.db.base import Base +from app.models.approval_action import ApprovalActionLedger +from app.models.employee import Employee +from app.models.financial_record import ExpenseClaim +from app.models.risk_disposition import RiskDisposition +from app.models.risk_observation import RiskObservation +from app.services.expense_claim_risk_gate import ( + ExpenseClaimRiskBlockedError, + ExpenseClaimRiskGate, +) +from app.services.expense_claims import ExpenseClaimService + + +def test_high_risk_requires_false_positive_or_resolved_disposition() -> None: + with _session() as db: + claim = _claim() + observation = _observation(claim) + db.add_all([claim, observation]) + db.commit() + gate = ExpenseClaimRiskGate(db) + + with pytest.raises(ExpenseClaimRiskBlockedError): + gate.ensure_approvable(claim, tenant_id="default") + + disposition = RiskDisposition( + tenant_id="default", + observation_id=observation.id, + adjudication="false_positive", + lifecycle_status="open", + ) + db.add(disposition) + db.commit() + gate.ensure_approvable(claim, tenant_id="default") + + disposition.adjudication = "confirmed" + db.commit() + with pytest.raises(ExpenseClaimRiskBlockedError): + gate.ensure_approvable(claim, tenant_id="default") + + disposition.lifecycle_status = "resolved" + db.commit() + gate.ensure_approvable(claim, tenant_id="default") + + +def test_medium_and_foreign_tenant_risks_do_not_block_claim() -> None: + with _session() as db: + claim = _claim(claim_id="claim-risk-nonblocking") + medium = _observation(claim, observation_id="risk-medium", risk_level="medium") + foreign = _observation( + claim, + observation_id="risk-foreign", + risk_level="critical", + tenant_id="tenant-b", + ) + db.add_all([claim, medium, foreign]) + db.commit() + + ExpenseClaimRiskGate(db).ensure_approvable(claim, tenant_id="default") + + +def test_unmaterialized_raw_high_risk_blocks_approval() -> None: + with _session() as db: + claim = _claim(claim_id="claim-risk-raw-only") + claim.risk_flags_json = [ + { + "source": "attachment_analysis", + "severity": "high", + "label": "票据金额异常", + "message": "票据金额与申报金额不一致。", + "triggered": True, + } + ] + db.add(claim) + db.commit() + + with pytest.raises(ExpenseClaimRiskBlockedError) as captured: + ExpenseClaimRiskGate(db).ensure_approvable(claim, tenant_id="default") + + assert captured.value.blockers[0].observation_id.startswith("raw:") + assert captured.value.blockers[0].risk_level == "high" + + +def test_persisted_observation_does_not_hide_another_raw_high_risk() -> None: + with _session() as db: + claim = _claim(claim_id="claim-risk-partial-materialization") + claim.risk_flags_json = [ + { + "source": "attachment_analysis", + "severity": "critical", + "label": "另一条未物化风险", + "triggered": True, + } + ] + low_observation = _observation( + claim, + observation_id="risk-low-materialized", + risk_level="low", + ) + db.add_all([claim, low_observation]) + db.commit() + + with pytest.raises(ExpenseClaimRiskBlockedError) as captured: + ExpenseClaimRiskGate(db).ensure_approvable(claim, tenant_id="default") + + assert [item.risk_level for item in captured.value.blockers] == ["critical"] + + +def test_blocked_approval_rolls_back_action_ledger_and_claim_mutation() -> None: + with _session() as db: + manager = Employee( + id="manager-risk-gate", + employee_no="M-RISK-GATE", + name="风险经理", + email="risk-gate-manager@example.com", + ) + employee = Employee( + id="employee-risk-gate", + employee_no="E-RISK-GATE", + name="风险员工", + email="risk-gate-employee@example.com", + manager=manager, + ) + claim = _claim(employee=employee, claim_id="claim-risk-blocked-approval") + db.add_all([manager, employee, claim, _observation(claim)]) + db.commit() + + with pytest.raises(ExpenseClaimRiskBlockedError): + ExpenseClaimService(db).approve_claim( + claim.id, + CurrentUserContext( + username=manager.email, + name=manager.name, + role_codes=["manager"], + is_admin=False, + ), + opinion="同意", + request_id="risk-blocked-approval-001", + expected_status="submitted", + expected_approval_stage="直属领导审批", + ) + + db.expire_all() + persisted = db.get(ExpenseClaim, claim.id) + assert persisted is not None + assert persisted.status == "submitted" + assert persisted.approval_stage == "直属领导审批" + assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 0 + + +def _session() -> Session: + engine = create_engine("sqlite+pysqlite:///:memory:") + Base.metadata.create_all(bind=engine) + return Session(engine) + + +def _claim( + *, + claim_id: str = "claim-risk-gate", + employee: Employee | None = None, +) -> ExpenseClaim: + now = datetime(2026, 7, 16, tzinfo=UTC) + return ExpenseClaim( + id=claim_id, + claim_no=f"EXP-{claim_id}", + employee=employee, + employee_name=employee.name if employee else "风险员工", + department_name="风控部", + expense_type="travel", + reason="客户拜访", + location="上海", + amount=Decimal("1200"), + currency="CNY", + invoice_count=1, + occurred_at=now, + submitted_at=now, + status="submitted", + approval_stage="直属领导审批", + risk_flags_json=[], + ) + + +def _observation( + claim: ExpenseClaim, + *, + observation_id: str = "risk-gate-observation", + risk_level: str = "high", + tenant_id: str = "default", +) -> RiskObservation: + return RiskObservation( + id=f"{observation_id}-{claim.id}", + tenant_id=tenant_id, + observation_key=f"risk:{tenant_id}:{observation_id}:{claim.id}", + subject_type="expense_claim", + subject_key=f"claim:{claim.id}", + subject_label=claim.claim_no, + claim_id=claim.id, + claim_no=claim.claim_no, + risk_type="duplicate_invoice", + risk_signal="duplicate_invoice", + title="重复票据风险", + description="同一票据可能重复报销。", + risk_score=90, + risk_level=risk_level, + confidence_score=0.95, + control_stage="reimbursement", + control_mode="risk_observation", + automation_mode="semi_auto_review", + source="financial_risk_graph", + algorithm_version="financial_risk_graph.v1", + status="pending_review", + feedback_status="unreviewed", + ) diff --git a/server/tests/test_expense_claim_service.py b/server/tests/test_expense_claim_service.py index d203ddb..414b808 100644 --- a/server/tests/test_expense_claim_service.py +++ b/server/tests/test_expense_claim_service.py @@ -38,7 +38,6 @@ from app.services.expense_claim_workflow_constants import ( APPROVAL_DONE_STAGE, BUDGET_MANAGER_APPROVAL_STAGE, DIRECT_MANAGER_APPROVAL_STAGE, - FINANCE_APPROVAL_STAGE, ) from app.services.expense_claims import ExpenseClaimService from app.services.ocr import OcrService @@ -486,11 +485,13 @@ def test_upsert_draft_from_ontology_persists_linked_application_context() -> Non ) db.add(employee) db.flush() - db.add(build_application_claim( - id="application-linked-1", - claim_no="AP-202605-001", - employee=employee, - )) + db.add( + build_application_claim( + id="application-linked-1", + claim_no="AP-202605-001", + employee=employee, + ) + ) db.commit() ontology = SemanticOntologyService(db).parse( OntologyParseRequest( @@ -554,11 +555,13 @@ def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_ite ) db.add(employee) db.flush() - db.add(build_application_claim( - id="application-linked-no-receipt", - claim_no="AP-202606-001", - employee=employee, - )) + db.add( + build_application_claim( + id="application-linked-no-receipt", + claim_no="AP-202606-001", + employee=employee, + ) + ) db.commit() ontology = SemanticOntologyService(db).parse( OntologyParseRequest( @@ -623,7 +626,10 @@ def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_ite ) assert link_flag["application_claim_no"] == "AP-202606-001" assert link_flag["application_detail"]["application_time"] == "2026-02-20 至 2026-02-23" - assert link_flag["application_detail"]["application_business_time"] == "2026-02-20 至 2026-02-23" + assert ( + link_flag["application_detail"]["application_business_time"] + == "2026-02-20 至 2026-02-23" + ) assert link_flag["application_detail"]["application_date"] == "2026-06-02T00:58:00Z" assert link_flag["application_detail"]["application_amount"] == "3000" assert link_flag["application_detail"]["application_days"] == "4 天" @@ -649,11 +655,13 @@ def test_upsert_linked_application_draft_clears_existing_placeholder_item() -> N ) db.add(employee) db.flush() - db.add(build_application_claim( - id="application-linked-existing-placeholder", - claim_no="AP-202606-002", - employee=employee, - )) + db.add( + build_application_claim( + id="application-linked-existing-placeholder", + claim_no="AP-202606-002", + employee=employee, + ) + ) existing_claim = ExpenseClaim( claim_no="RE-202606020001-PLACEHOLDER", employee_id=employee.id, @@ -738,12 +746,14 @@ def test_upsert_linked_application_requires_approved_application() -> None: employee = Employee(employee_no="E5108", name="Linked Employee", email=user_id) db.add(employee) db.flush() - db.add(build_application_claim( - id="application-returned-blocked", - claim_no="AP-202606-STATUS", - employee=employee, - status="returned", - )) + db.add( + build_application_claim( + id="application-returned-blocked", + claim_no="AP-202606-STATUS", + employee=employee, + status="returned", + ) + ) db.commit() ontology = SemanticOntologyService(db).parse( @@ -785,11 +795,13 @@ def test_upsert_linked_application_rejects_duplicate_reimbursement_draft() -> No employee = Employee(employee_no="E5109", name="Linked Employee", email=user_id) db.add(employee) db.flush() - db.add(build_application_claim( - id="application-duplicate-blocked", - claim_no="AP-202606-DUP", - employee=employee, - )) + db.add( + build_application_claim( + id="application-duplicate-blocked", + claim_no="AP-202606-DUP", + employee=employee, + ) + ) existing_claim = ExpenseClaim( claim_no="RE-202606-DUP-DRAFT", employee_id=employee.id, @@ -995,11 +1007,7 @@ def test_unsaved_conversation_expires_after_retention_but_saved_conversation_sta def test_resolve_expense_type_maps_office_supplies_review_value_to_office() -> None: expense_type = ExpenseClaimService._resolve_expense_type( [], - context_json={ - "review_form_values": { - "expense_type": "办公用品" - } - }, + context_json={"review_form_values": {"expense_type": "办公用品"}}, ) assert expense_type == "office" @@ -1008,11 +1016,7 @@ def test_resolve_expense_type_maps_office_supplies_review_value_to_office() -> N def test_resolve_expense_type_maps_riding_fare_review_value_to_transport() -> None: expense_type = ExpenseClaimService._resolve_expense_type( [], - context_json={ - "review_form_values": { - "expense_type": "乘车费用" - } - }, + context_json={"review_form_values": {"expense_type": "乘车费用"}}, ) assert expense_type == "transport" @@ -1340,7 +1344,9 @@ def test_upsert_draft_from_ontology_supports_link_or_create_for_multi_documents( "text": "停车费 合计 18 元", "document_type": "parking_toll_receipt", "scene_code": "transport", - "document_fields": [{"key": "total_amount", "label": "合计金额", "value": "18"}], + "document_fields": [ + {"key": "total_amount", "label": "合计金额", "value": "18"} + ], }, ], } @@ -2044,6 +2050,7 @@ def test_update_claim_item_reanalyzes_existing_attachment(monkeypatch, tmp_path) assert refreshed_meta["requirement_check"]["matches"] is False assert any("附件类型要求" in point for point in refreshed_meta["analysis"]["points"]) + def test_upload_attachment_refreshes_claim_pre_review(monkeypatch, tmp_path) -> None: current_user = CurrentUserContext( username="emp-1", @@ -2518,15 +2525,13 @@ def test_upload_attachment_runs_rule_center_city_risk_from_origin_destination_fi flags = payload["claim_risk_flags"] assert any( - isinstance(flag, dict) - and flag.get("rule_code") == "risk.travel.high.city_mismatch" + isinstance(flag, dict) and flag.get("rule_code") == "risk.travel.high.city_mismatch" for flag in flags ) city_flag = next( flag for flag in flags - if isinstance(flag, dict) - and flag.get("rule_code") == "risk.travel.high.city_mismatch" + if isinstance(flag, dict) and flag.get("rule_code") == "risk.travel.high.city_mismatch" ) assert city_flag.get("item_ids") == [claim.items[0].id] @@ -2604,8 +2609,7 @@ def test_upload_attachment_uses_linked_application_business_time_for_date_risk( flags = payload["claim_risk_flags"] assert any( - isinstance(flag, dict) - and flag.get("rule_code") == "risk.travel.high.date_outside_trip" + isinstance(flag, dict) and flag.get("rule_code") == "risk.travel.high.date_outside_trip" for flag in flags ) @@ -2686,8 +2690,13 @@ def test_upload_hotel_attachment_audits_date_like_amount(monkeypatch, tmp_path) ) assert uploaded_meta is not None assert uploaded_meta["analysis"]["severity"] == "medium" - assert any("费用核算" in point and "828.00 元" in point for point in uploaded_meta["analysis"]["points"]) - assert not any("2026.00 元与报销金额" in point for point in uploaded_meta["analysis"]["points"]) + assert any( + "费用核算" in point and "828.00 元" in point + for point in uploaded_meta["analysis"]["points"] + ) + assert not any( + "2026.00 元与报销金额" in point for point in uploaded_meta["analysis"]["points"] + ) def test_upload_hotel_attachment_flags_amount_over_travel_policy(monkeypatch, tmp_path) -> None: @@ -2889,7 +2898,9 @@ def test_upload_hotel_attachment_does_not_add_generic_auto_review_summary( ) -def test_delete_claim_item_attachment_removes_attachment_analysis_risk(monkeypatch, tmp_path) -> None: +def test_delete_claim_item_attachment_removes_attachment_analysis_risk( + monkeypatch, tmp_path +) -> None: current_user = CurrentUserContext( username="emp-hotel-risk@example.com", name="张三", @@ -2964,7 +2975,8 @@ def test_delete_claim_item_attachment_removes_attachment_analysis_risk(monkeypat assert upload_payload is not None assert any( - isinstance(flag, dict) and str(flag.get("source") or "").strip() == "attachment_analysis" + isinstance(flag, dict) + and str(flag.get("source") or "").strip() == "attachment_analysis" for flag in upload_payload["claim_risk_flags"] ) @@ -2977,7 +2989,8 @@ def test_delete_claim_item_attachment_removes_attachment_analysis_risk(monkeypat assert delete_payload is not None assert delete_payload["invoice_id"] is None assert not any( - isinstance(flag, dict) and str(flag.get("source") or "").strip() == "attachment_analysis" + isinstance(flag, dict) + and str(flag.get("source") or "").strip() == "attachment_analysis" for flag in delete_payload["claim_risk_flags"] ) assert not any( @@ -2990,7 +3003,8 @@ def test_delete_claim_item_attachment_removes_attachment_analysis_risk(monkeypat assert claim.invoice_count == 0 assert claim.items[0].invoice_id is None assert not any( - isinstance(flag, dict) and str(flag.get("source") or "").strip() == "attachment_analysis" + isinstance(flag, dict) + and str(flag.get("source") or "").strip() == "attachment_analysis" for flag in list(claim.risk_flags_json or []) ) @@ -3278,7 +3292,9 @@ def test_applicant_can_delete_own_editable_draft_claim(monkeypatch, tmp_path) -> assert db.get(ExpenseClaim, claim_id) is None -def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(monkeypatch, tmp_path) -> None: +def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory( + monkeypatch, tmp_path +) -> None: current_user = CurrentUserContext( username="emp-1", name="张三", @@ -3316,7 +3332,9 @@ def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(mon assert filename == "legacy-ticket.pdf" -def test_attachment_pdf_preview_falls_back_to_source_when_render_fonts_missing(monkeypatch, tmp_path) -> None: +def test_attachment_pdf_preview_falls_back_to_source_when_render_fonts_missing( + monkeypatch, tmp_path +) -> None: current_user = CurrentUserContext( username="emp-1", name="张三", @@ -3359,9 +3377,13 @@ def test_attachment_pdf_preview_falls_back_to_source_when_render_fonts_missing(m def fake_render_pdf_first_page(*, pdf_path, preview_path, timeout_seconds): raise RuntimeError("Missing language pack for 'Adobe-GB1' mapping") - monkeypatch.setattr(DocumentPreviewAssets, "render_pdf_first_page", fake_render_pdf_first_page) + monkeypatch.setattr( + DocumentPreviewAssets, "render_pdf_first_page", fake_render_pdf_first_page + ) - resolved_path, media_type, filename = ExpenseClaimService(db).get_claim_item_attachment_preview_content( + resolved_path, media_type, filename = ExpenseClaimService( + db + ).get_claim_item_attachment_preview_content( claim_id=claim.id, item_id=claim.items[0].id, current_user=current_user, @@ -3412,6 +3434,7 @@ def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None: assert submitted.approval_stage == "直属领导审批" assert submitted.submitted_at is not None + def test_submit_claim_refreshes_legacy_pre_review_without_fingerprint(monkeypatch) -> None: current_user = CurrentUserContext( username="emp-submit@example.com", @@ -3470,13 +3493,10 @@ def test_submit_claim_refreshes_legacy_pre_review_without_fingerprint(monkeypatc assert submitted.status == "submitted" assert review_calls == 1 assert not any( - flag.get("label") == "upload-time-warning" - for flag in submitted.risk_flags_json + flag.get("label") == "upload-time-warning" for flag in submitted.risk_flags_json ) pre_review_flag = next( - flag - for flag in submitted.risk_flags_json - if flag.get("source") == "ai_pre_review" + flag for flag in submitted.risk_flags_json if flag.get("source") == "ai_pre_review" ) assert pre_review_flag["review_id"] assert pre_review_flag["input_fingerprint"].startswith("sha256:") @@ -3823,8 +3843,7 @@ def test_submit_claim_blocks_high_risk_attachment_until_submitter_fixes_it( assert blocked.submitted_at is None assert error_info.value.review["decision"] == "needs_fix" assert any( - finding["severity"] == "high" - and finding["disposition"] == "fix" + finding["severity"] == "high" and finding["disposition"] == "fix" for finding in error_info.value.review["findings"] ) @@ -4000,10 +4019,7 @@ def test_submit_claim_blocks_travel_route_mismatch_until_submitter_explains_it( if "多城市" in finding["message"] or "终点" in finding["message"] ] assert route_findings - assert any( - "travel-item-2" in finding["item_ids"] - for finding in route_findings - ) + assert any("travel-item-2" in finding["item_ids"] for finding in route_findings) def test_submit_claim_allows_round_trip_ticket_origin_inferred_from_route( @@ -4297,8 +4313,7 @@ def test_submit_claim_blocks_hotel_amount_over_policy_until_standard_adjustment( assert blocked.status == "draft" assert error_info.value.review["decision"] == "needs_fix" assert any( - finding.get("remediation", {}).get("alternative_action") - == "accept_standard_limit" + finding.get("remediation", {}).get("alternative_action") == "accept_standard_limit" for finding in error_info.value.review["findings"] ) assert any( @@ -5360,12 +5375,15 @@ def test_admin_delete_linked_reimbursement_resets_application_link_status() -> N sync_flag = next( flag for flag in application_claim.risk_flags_json - if isinstance(flag, dict) and flag.get("event_type") == "expense_application_reimbursement_deleted" + if isinstance(flag, dict) + and flag.get("event_type") == "expense_application_reimbursement_deleted" ) assert sync_flag["source"] == "application_link_sync" assert sync_flag["severity"] == "info" assert sync_flag["actionability"] == "system_trace" - assert sync_flag["deleted_reimbursement_claim_id"] == "reimbursement-delete-linked-application" + assert ( + sync_flag["deleted_reimbursement_claim_id"] == "reimbursement-delete-linked-application" + ) assert sync_flag["deleted_reimbursement_claim_no"] == "RDELETE01" assert sync_flag["next_approval_stage"] == APPLICATION_LINK_STATUS_STAGE @@ -5414,7 +5432,9 @@ def test_direct_manager_can_return_subordinate_claim_to_pending_submission() -> db.commit() claim_id = claim.id - returned = ExpenseClaimService(db).return_claim(claim_id, current_user, reason="请补充行程说明") + returned = ExpenseClaimService(db).return_claim( + claim_id, current_user, reason="请补充行程说明" + ) assert returned is not None assert returned.status == "returned" @@ -5603,6 +5623,7 @@ def test_direct_manager_budget_monitor_routes_reimbursement_directly_to_finance( { "source": "submission_review", "severity": "high", + "actionability": "route_review", "label": "报销风险复核", "message": "多城市行程和住宿超标需要预算管理者二次确认。", } @@ -5621,8 +5642,7 @@ def test_direct_manager_budget_monitor_routes_reimbursement_directly_to_finance( assert approved.status == "submitted" assert approved.approval_stage == "财务审批" assert not any( - isinstance(flag, dict) - and flag.get("next_approval_stage") == "预算管理者审批" + isinstance(flag, dict) and flag.get("next_approval_stage") == "预算管理者审批" for flag in approved.risk_flags_json ) assert any( @@ -5635,12 +5655,13 @@ def test_direct_manager_budget_monitor_routes_reimbursement_directly_to_finance( and flag.get("next_status") == "submitted" and flag.get("next_approval_stage") == "财务审批" and flag.get("budget_approval_merged") is True - and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver" + and flag.get("budget_approval_merged_reason") + == "direct_manager_is_department_budget_approver" for flag in approved.risk_flags_json ) -def test_duplicate_budget_stage_from_legacy_reimbursement_is_repaired_on_read() -> None: +def test_legacy_duplicate_budget_stage_is_not_mutated_by_read() -> None: admin_user = CurrentUserContext( username="admin", name="admin", @@ -5706,20 +5727,25 @@ def test_duplicate_budget_stage_from_legacy_reimbursement_is_repaired_on_read() db.add(claim) db.commit() - repaired = ExpenseClaimService(db).get_claim(claim.id, admin_user) + result = ExpenseClaimService(db).get_claim(claim.id, admin_user) - assert repaired is not None - assert repaired.approval_stage == FINANCE_APPROVAL_STAGE - assert any( + assert result is not None + assert result.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE + assert not any( isinstance(flag, dict) and flag.get("source") == "approval_flow_repair" and flag.get("event_type") == "duplicate_budget_approval_stage_repaired" - and flag.get("next_approval_stage") == FINANCE_APPROVAL_STAGE - for flag in repaired.risk_flags_json + for flag in result.risk_flags_json ) + db.expire_all() + persisted = db.get(ExpenseClaim, claim.id) + assert persisted is not None + assert persisted.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE -def test_application_submit_skips_ai_review_and_receipt_requirements(monkeypatch: pytest.MonkeyPatch) -> None: +def test_application_submit_skips_ai_review_and_receipt_requirements( + monkeypatch: pytest.MonkeyPatch, +) -> None: current_user = CurrentUserContext( username="application-owner@example.com", name="张三", @@ -5970,7 +5996,9 @@ def test_application_submit_skips_budget_for_non_demo_subject() -> None: ) -def test_direct_manager_can_route_application_claim_to_budget_approval_then_budget_manager_creates_draft() -> None: +def test_direct_manager_can_route_application_claim_to_budget_approval_then_budget_manager_creates_draft() -> ( + None +): manager_user = CurrentUserContext( username="manager-application-approve@example.com", name="李经理", @@ -6053,9 +6081,10 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg { "source": "submission_review", "severity": "high", + "actionability": "route_review", "label": "申请风险复核", "message": "申请金额和行程安排需要预算管理者二次确认。", - } + }, ], ) db.add(claim) @@ -6120,13 +6149,18 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg and flag.get("source") == "application_handoff" and flag.get("event_type") == "expense_application_to_reimbursement_draft" and flag.get("application_claim_no") == "APP-20260525-APPROVE" - and flag.get("application_detail", {}).get("application_content") == "差旅费用申请 / 上海" - and flag.get("application_detail", {}).get("application_reason") == "支撑国网服务器上线部署" + and flag.get("application_detail", {}).get("application_content") + == "差旅费用申请 / 上海" + and flag.get("application_detail", {}).get("application_reason") + == "支撑国网服务器上线部署" and flag.get("application_detail", {}).get("application_days") == "3 天" and flag.get("application_detail", {}).get("application_transport_mode") == "高铁" - and flag.get("application_detail", {}).get("application_lodging_daily_cap") == "600元/天" - and flag.get("application_detail", {}).get("application_subsidy_daily_cap") == "120元/天" - and flag.get("application_detail", {}).get("application_transport_policy") == "按真实票据复核" + and flag.get("application_detail", {}).get("application_lodging_daily_cap") + == "600元/天" + and flag.get("application_detail", {}).get("application_subsidy_daily_cap") + == "120元/天" + and flag.get("application_detail", {}).get("application_transport_policy") + == "按真实票据复核" and flag.get("application_detail", {}).get("application_policy_estimate") == "交通按真实票据 + 住宿 1,800元 + 补贴 360元" and flag.get("application_detail", {}).get("application_rule_name") == "差旅标准规则" @@ -6214,6 +6248,7 @@ def test_application_routes_to_department_p8_executive_with_approver_name() -> N { "source": "submission_review", "severity": "high", + "actionability": "route_review", "label": "Route risk", "message": "Application requires budget confirmation.", } @@ -6306,6 +6341,7 @@ def test_direct_manager_cannot_route_application_to_missing_budget_approver() -> { "source": "submission_review", "severity": "high", + "actionability": "route_review", "label": "Route risk", "message": "Application requires budget confirmation.", } @@ -6328,7 +6364,9 @@ def test_direct_manager_cannot_route_application_to_missing_budget_approver() -> assert reimbursement_claim_query(db).count() == 0 -def test_direct_manager_p8_executive_completes_application_without_duplicate_budget_approval() -> None: +def test_direct_manager_p8_executive_completes_application_without_duplicate_budget_approval() -> ( + None +): manager_user = CurrentUserContext( username="manager-executive-merged@example.com", name="P8 Manager", @@ -6381,6 +6419,7 @@ def test_direct_manager_p8_executive_completes_application_without_duplicate_bud { "source": "submission_review", "severity": "high", + "actionability": "route_review", "label": "Route risk", "message": "Application requires budget confirmation.", } @@ -6411,12 +6450,15 @@ def test_direct_manager_p8_executive_completes_application_without_duplicate_bud and flag.get("next_status") == "approved" and flag.get("next_approval_stage") == APPLICATION_LINK_STATUS_STAGE and flag.get("budget_approval_merged") is True - and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver" + and flag.get("budget_approval_merged_reason") + == "direct_manager_is_department_budget_approver" for flag in approved.risk_flags_json ) -def test_direct_manager_budget_monitor_completes_application_claim_without_duplicate_budget_approval() -> None: +def test_direct_manager_budget_monitor_completes_application_claim_without_duplicate_budget_approval() -> ( + None +): manager_user = CurrentUserContext( username="manager-budget-monitor-application@example.com", name="李预算经理", @@ -6469,6 +6511,7 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli { "source": "submission_review", "severity": "high", + "actionability": "route_review", "label": "申请风险复核", "message": "申请金额和行程安排需要预算管理者二次确认。", } @@ -6489,8 +6532,7 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli assert approved.approval_stage == "关联单据状态" assert reimbursement_claim_query(db).count() == 1 assert not any( - isinstance(flag, dict) - and flag.get("next_approval_stage") == "预算管理者审批" + isinstance(flag, dict) and flag.get("next_approval_stage") == "预算管理者审批" for flag in approved.risk_flags_json ) assert any( @@ -6503,7 +6545,8 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli and flag.get("next_status") == "approved" and flag.get("next_approval_stage") == "关联单据状态" and flag.get("budget_approval_merged") is True - and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver" + and flag.get("budget_approval_merged_reason") + == "direct_manager_is_department_budget_approver" for flag in approved.risk_flags_json ) generated_draft = reimbursement_claim_query(db).one() @@ -6692,6 +6735,7 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf { "source": "platform_risk", "severity": "high", + "actionability": "route_review", "label": "申请风险复核", "message": "申请金额和行程安排需要预算管理者二次确认。", } @@ -6719,10 +6763,11 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf assert reservation.source_type == "claim" assert reservation.source_id == generated_draft.id assert reservation.source_no == generated_draft.claim_no - assert any(item.transaction_type == "transfer" for item in db.query(BudgetTransaction).all()) assert any( - isinstance(flag, dict) - and flag.get("event_type") == "budget_reservation_transferred" + item.transaction_type == "transfer" for item in db.query(BudgetTransaction).all() + ) + assert any( + isinstance(flag, dict) and flag.get("event_type") == "budget_reservation_transferred" for flag in generated_draft.risk_flags_json ) @@ -6916,7 +6961,12 @@ def test_finance_approve_reimbursement_consumes_budget_reservation() -> None: db.refresh(reservation) assert reservation.source_status == "consumed" assert reservation.consumed_amount == Decimal("12000.00") - assert db.query(BudgetTransaction).filter(BudgetTransaction.transaction_type == "consume").count() == 1 + assert ( + db.query(BudgetTransaction) + .filter(BudgetTransaction.transaction_type == "consume") + .count() + == 1 + ) assert any( isinstance(flag, dict) and flag.get("source") == "budget_control" @@ -7290,7 +7340,10 @@ def test_return_claim_records_each_return_event_with_stage_reason_and_counts() - assert return_events[0]["stage_return_count"] == 1 assert return_events[0]["return_stage"] == "直属领导审批" assert return_events[0]["reason_codes"] == ["invoice_mismatch", "business_explanation"] - assert return_events[0]["risk_points"] == ["票据类型/金额与明细不一致", "业务事由/地点/人员信息不完整"] + assert return_events[0]["risk_points"] == [ + "票据类型/金额与明细不一致", + "业务事由/地点/人员信息不完整", + ] assert return_events[0]["reason"] == "发票金额与明细金额不一致,请重新核对。" assert return_events[0]["operator_role_codes"] == ["manager"] assert return_events[1]["return_count"] == 2 @@ -7624,14 +7677,16 @@ def test_list_approval_claims_allows_budget_monitor_to_view_budget_stage_applica email="budget-list-market@example.com", organization_unit=market_department, ) - db.add_all([ - delivery_department, - market_department, - budget_manager, - p8_without_budget_employee, - employee, - market_employee, - ]) + db.add_all( + [ + delivery_department, + market_department, + budget_manager, + p8_without_budget_employee, + employee, + market_employee, + ] + ) db.flush() db.add_all( [ @@ -7702,5 +7757,7 @@ def test_list_approval_claims_allows_budget_monitor_to_view_budget_stage_applica assert getattr(claims[0], "budget_approver_name", "") == "赵预算" assert getattr(claims[0], "budget_approver_grade", "") == "P8" assert getattr(claims[0], "budget_approver_role_code", "") == "budget_monitor" - claims_without_budget_role = ExpenseClaimService(db).list_approval_claims(p8_without_budget_role) + claims_without_budget_role = ExpenseClaimService(db).list_approval_claims( + p8_without_budget_role + ) assert [claim.claim_no for claim in claims_without_budget_role] == [] diff --git a/server/tests/test_migration_preflight.py b/server/tests/test_migration_preflight.py index f9b8b13..932004d 100644 --- a/server/tests/test_migration_preflight.py +++ b/server/tests/test_migration_preflight.py @@ -137,6 +137,14 @@ def test_known_revision_requires_and_accepts_its_exact_owned_table_set( "20260716_0009", MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0009"] - {"memory_entries"}, ), + ( + "20260716_0010", + MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0010"] - {"approval_action_ledgers"}, + ), + ( + "20260716_0011", + MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0011"] - {"risk_disposition_events"}, + ), ], ) def test_known_revision_with_missing_or_unexpected_owned_tables_is_rejected( diff --git a/server/tests/test_reimbursement_endpoints.py b/server/tests/test_reimbursement_endpoints.py index 6a4f5f3..32d4d62 100644 --- a/server/tests/test_reimbursement_endpoints.py +++ b/server/tests/test_reimbursement_endpoints.py @@ -9,7 +9,7 @@ from decimal import Decimal import pytest from auth_helpers import install_legacy_header_auth_override from fastapi.testclient import TestClient -from sqlalchemy import create_engine, select +from sqlalchemy import create_engine, func, select from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool @@ -17,6 +17,7 @@ from app.api.deps import get_db from app.db.base import Base from app.main import create_app from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome +from app.models.approval_action import ApprovalActionLedger from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction from app.models.employee import Employee from app.models.expense_case import BusinessEvent, ExpenseCaseLink @@ -213,12 +214,15 @@ def test_claim_submit_returns_structured_pre_review_conflict() -> None: assert claim is not None assert claim.status == "draft" assert claim.submitted_at is None - assert db.scalar( - select(BusinessEvent).where( - BusinessEvent.aggregate_id == claim.id, - BusinessEvent.event_type == "claim_submitted", + assert ( + db.scalar( + select(BusinessEvent).where( + BusinessEvent.aggregate_id == claim.id, + BusinessEvent.event_type == "claim_submitted", + ) ) - ) is None + is None + ) def test_claim_submit_returns_changed_conflict_when_dynamic_review_changes( @@ -402,7 +406,9 @@ def test_claim_read_attaches_finance_approver_name_for_finance_stage() -> None: db.commit() headers = {"x-auth-username": "qianqi@example.com"} - response = client.get("/api/v1/reimbursements/claims/claim-finance-stage-reader", headers=headers) + response = client.get( + "/api/v1/reimbursements/claims/claim-finance-stage-reader", headers=headers + ) assert response.status_code == 200 payload = response.json() assert payload["finance_owner_name"] == "Wang Finance Group" @@ -518,7 +524,9 @@ def test_claim_item_attachment_upload_preview_and_delete(monkeypatch, tmp_path) meta_payload = meta_response.json() assert meta_payload["media_type"] == "image/png" assert meta_payload["preview_kind"] == "image" - assert meta_payload["preview_url"].endswith(f"/reimbursements/claims/{claim_id}/items/{item_id}/attachment/preview") + assert meta_payload["preview_url"].endswith( + f"/reimbursements/claims/{claim_id}/items/{item_id}/attachment/preview" + ) assert meta_payload["analysis"]["headline"] assert meta_payload["document_info"]["fields"][0]["label"] == "金额" @@ -550,7 +558,9 @@ def test_claim_item_attachment_upload_preview_and_delete(monkeypatch, tmp_path) assert deleted_meta_response.status_code == 404 -def test_claim_item_attachment_upload_flags_purpose_and_amount_mismatch(monkeypatch, tmp_path) -> None: +def test_claim_item_attachment_upload_flags_purpose_and_amount_mismatch( + monkeypatch, tmp_path +) -> None: def fake_recognize( self, files: list[tuple[str, bytes, str | None]], @@ -596,7 +606,9 @@ def test_claim_item_attachment_upload_flags_purpose_and_amount_mismatch(monkeypa assert upload_response.json()["attachment"]["requirement_check"]["matches"] is False -def test_claim_item_attachment_upload_flags_non_invoice_image_as_high_risk(monkeypatch, tmp_path) -> None: +def test_claim_item_attachment_upload_flags_non_invoice_image_as_high_risk( + monkeypatch, tmp_path +) -> None: def fake_recognize( self, files: list[tuple[str, bytes, str | None]], @@ -679,14 +691,32 @@ def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review() db.add_all([manager, employee, claim]) db.commit() + action_headers = { + "X-Auth-Username": "manager-approve-api@example.com", + "X-Auth-Name": "manager-approve-api@example.com", + "X-Auth-Role-Codes": "manager", + } + stale_response = client.post( + "/api/v1/reimbursements/claims/claim-approve-1/approve", + json={ + "opinion": "情况属实,同意报销。", + "request_id": "approve-api-stale-1", + "expected_status": "draft", + "expected_approval_stage": "直属领导审批", + }, + headers=action_headers, + ) + assert stale_response.status_code == 409 + response = client.post( "/api/v1/reimbursements/claims/claim-approve-1/approve", - json={"opinion": "情况属实,同意报销。"}, - headers={ - "X-Auth-Username": "manager-approve-api@example.com", - "X-Auth-Name": "manager-approve-api@example.com", - "X-Auth-Role-Codes": "manager", + json={ + "opinion": "情况属实,同意报销。", + "request_id": "approve-api-claim-1", + "expected_status": "submitted", + "expected_approval_stage": "直属领导审批", }, + headers=action_headers, ) assert response.status_code == 200 @@ -701,13 +731,136 @@ def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review() for item in payload["risk_flags_json"] ) approval_events = [ - item - for item in payload["risk_flags_json"] - if item["source"] == "manual_approval" + item for item in payload["risk_flags_json"] if item["source"] == "manual_approval" ] assert approval_events[0]["operator"] == "李经理" assert "manager-approve-api@example.com" not in approval_events[0]["message"] + replay_response = client.post( + "/api/v1/reimbursements/claims/claim-approve-1/approve", + json={ + "opinion": "情况属实,同意报销。", + "request_id": "approve-api-claim-1", + "expected_status": "submitted", + "expected_approval_stage": "直属领导审批", + }, + headers=action_headers, + ) + assert replay_response.status_code == 200 + assert replay_response.json()["approval_stage"] == "财务审批" + + changed_payload_response = client.post( + "/api/v1/reimbursements/claims/claim-approve-1/approve", + json={ + "opinion": "改为有条件通过", + "request_id": "approve-api-claim-1", + "expected_status": "submitted", + "expected_approval_stage": "直属领导审批", + }, + headers=action_headers, + ) + assert changed_payload_response.status_code == 409 + with session_factory() as db: + ledgers = list(db.scalars(select(ApprovalActionLedger)).all()) + assert len(ledgers) == 1 + assert ledgers[0].completed_at is not None + + +def test_approve_claim_endpoint_blocks_open_high_risk_with_machine_readable_detail() -> None: + client, session_factory = build_client() + with session_factory() as db: + manager = Employee( + id="manager-risk-block-api", + employee_no="M-RISK-BLOCK-API", + name="风险经理", + email="manager-risk-block-api@example.com", + ) + employee = Employee( + id="employee-risk-block-api", + employee_no="E-RISK-BLOCK-API", + name="风险员工", + email="employee-risk-block-api@example.com", + manager=manager, + ) + claim = ExpenseClaim( + id="claim-risk-block-api", + claim_no="EXP-RISK-BLOCK-API", + employee=employee, + employee_name=employee.name, + department_name="风控部", + expense_type="travel", + reason="客户拜访", + location="上海", + amount=Decimal("1200.00"), + currency="CNY", + invoice_count=1, + occurred_at=datetime(2026, 7, 16, tzinfo=UTC), + submitted_at=datetime(2026, 7, 16, tzinfo=UTC), + status="submitted", + approval_stage="直属领导审批", + risk_flags_json=[], + ) + observation = RiskObservation( + id="risk-block-api-observation", + tenant_id="default", + observation_key="risk:claim-risk-block-api:duplicate", + subject_type="expense_claim", + subject_key="claim:claim-risk-block-api", + subject_label=claim.claim_no, + claim_id=claim.id, + claim_no=claim.claim_no, + risk_type="duplicate_invoice", + risk_signal="duplicate_invoice", + title="重复票据风险", + description="同一票据可能重复报销。", + risk_score=92, + risk_level="high", + confidence_score=0.95, + control_stage="reimbursement", + control_mode="risk_observation", + automation_mode="semi_auto_review", + source="financial_risk_graph", + algorithm_version="financial_risk_graph.v1", + status="pending_review", + feedback_status="unreviewed", + ) + db.add_all([manager, employee, claim, observation]) + db.commit() + manager_email = manager.email + + response = client.post( + "/api/v1/reimbursements/claims/claim-risk-block-api/approve", + headers={ + "X-Auth-Username": manager_email, + "X-Auth-Name": "Risk Manager", + "X-Auth-Role-Codes": "manager", + }, + json={ + "opinion": "同意", + "request_id": "approve-risk-block-api-001", + "expected_status": "submitted", + "expected_approval_stage": "直属领导审批", + }, + ) + + assert response.status_code == 409 + detail = response.json()["detail"] + assert detail["code"] == "APPROVAL_BLOCKED_BY_OPEN_HIGH_RISK" + assert detail["observations"] == [ + { + "id": "risk-block-api-observation", + "title": "重复票据风险", + "risk_level": "high", + "adjudication": "unreviewed", + "lifecycle_status": "open", + } + ] + with session_factory() as db: + persisted = db.get(ExpenseClaim, "claim-risk-block-api") + assert persisted is not None + assert persisted.approval_stage == "直属领导审批" + assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 0 + def test_approve_application_endpoint_routes_direct_manager_review_to_budget_review() -> None: client, session_factory = build_client() @@ -766,10 +919,11 @@ def test_approve_application_endpoint_routes_direct_manager_review_to_budget_rev status="submitted", approval_stage="直属领导审批", risk_flags_json=[ - { - "source": "submission_review", - "severity": "high", - "label": "申请风险复核", + { + "source": "submission_review", + "severity": "high", + "actionability": "route_review", + "label": "申请风险复核", "message": "申请金额和行程安排需要预算管理者二次确认。", } ], @@ -779,7 +933,12 @@ def test_approve_application_endpoint_routes_direct_manager_review_to_budget_rev response = client.post( "/api/v1/reimbursements/claims/claim-application-approve-1/approve", - json={"opinion": "业务必要,同意申请。"}, + json={ + "opinion": "业务必要,同意申请。", + "request_id": "approve-api-application-1", + "expected_status": "submitted", + "expected_approval_stage": "直属领导审批", + }, headers={ "X-Auth-Username": "manager-application-approve-api@example.com", "X-Auth-Name": "manager-application-approve-api@example.com", @@ -853,7 +1012,9 @@ def test_claim_item_pdf_attachment_preview_returns_generated_image(monkeypatch, assert upload_response.status_code == 200 meta_payload = upload_response.json()["attachment"] assert meta_payload["preview_kind"] == "image" - assert meta_payload["preview_url"].endswith(f"/reimbursements/claims/{claim_id}/items/{item_id}/attachment/preview") + assert meta_payload["preview_url"].endswith( + f"/reimbursements/claims/{claim_id}/items/{item_id}/attachment/preview" + ) meta_path = next(tmp_path.rglob("invoice.pdf.meta.json")) stored_meta = json.loads(meta_path.read_text(encoding="utf-8")) assert stored_meta["preview_rendered_with"] == DocumentPreviewAssets.PDF_RENDERER_ID @@ -1015,7 +1176,9 @@ def test_claim_delete_allows_applicant_to_delete_own_draft(monkeypatch, tmp_path assert db.get(ExpenseClaim, claim_id) is None -def test_claim_delete_allows_legacy_superadmin_without_is_admin_header(monkeypatch, tmp_path) -> None: +def test_claim_delete_allows_legacy_superadmin_without_is_admin_header( + monkeypatch, tmp_path +) -> None: monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path) client, session_factory = build_client() @@ -1157,8 +1320,7 @@ def test_application_preview_action_submits_without_orchestrator_run(monkeypatch assert outcome.outcome_type == "application_submitted" assert outcome.business_event_id == event.id assert any( - isinstance(flag, dict) - and flag.get("event_type") == "expense_application_submission" + isinstance(flag, dict) and flag.get("event_type") == "expense_application_submission" for flag in list(claim.risk_flags_json or []) ) @@ -1212,7 +1374,9 @@ def test_application_direct_submit_rolls_back_budget_when_case_event_fails( assert list(db.scalars(select(WorkflowOutcome)).all()) == [] -def test_application_preview_action_saves_draft_with_detail_reference(monkeypatch, tmp_path) -> None: +def test_application_preview_action_saves_draft_with_detail_reference( + monkeypatch, tmp_path +) -> None: monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path) client, session_factory = build_client() @@ -1288,19 +1452,16 @@ def test_application_preview_action_saves_draft_with_detail_reference(monkeypatc assert claim.approval_stage == "待提交" assert claim.submitted_at is None assert claim.employee_name == "张三" - assert db.scalar( - select(BudgetReservation).where(BudgetReservation.source_id == claim.id) - ) is None - event = db.scalar( - select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id) + assert ( + db.scalar(select(BudgetReservation).where(BudgetReservation.source_id == claim.id)) + is None ) + event = db.scalar(select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id)) assert event is not None assert event.event_type == "claim_draft_created" assert event.payload_json["previous_status"] == "" assert event.payload_json["next_status"] == "draft" - link = db.scalar( - select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id) - ) + link = db.scalar(select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id)) assert link is not None assert link.relation_type == "application" @@ -1372,9 +1533,10 @@ def test_application_preview_action_rejects_forged_identity_when_editing_other_c assert persisted.reason == "其他员工原申请" assert persisted.status == "returned" assert persisted.approval_stage == "退回补充" - assert db.scalar( - select(BusinessEvent).where(BusinessEvent.aggregate_id == persisted.id) - ) is None + assert ( + db.scalar(select(BusinessEvent).where(BusinessEvent.aggregate_id == persisted.id)) + is None + ) def test_application_preview_action_reuses_created_draft_for_identical_retry() -> None: @@ -1420,17 +1582,13 @@ def test_application_preview_action_reuses_created_draft_for_identical_retry() - with session_factory() as db: application_claims = list( db.scalars( - select(ExpenseClaim).where( - ExpenseClaim.expense_type == "travel_application" - ) + select(ExpenseClaim).where(ExpenseClaim.expense_type == "travel_application") ).all() ) assert len(application_claims) == 1 events = list( db.scalars( - select(BusinessEvent).where( - BusinessEvent.aggregate_id == application_claims[0].id - ) + select(BusinessEvent).where(BusinessEvent.aggregate_id == application_claims[0].id) ).all() ) assert len(events) == 1 diff --git a/server/tests/test_risk_dispositions.py b/server/tests/test_risk_dispositions.py new file mode 100644 index 0000000..eef6c33 --- /dev/null +++ b/server/tests/test_risk_dispositions.py @@ -0,0 +1,661 @@ +from __future__ import annotations + +from collections.abc import Generator +from datetime import UTC, datetime, timedelta +from decimal import Decimal + +import pytest +from auth_helpers import install_legacy_header_auth_override +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, func, select +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.api.deps import CurrentUserContext, get_db +from app.api.v1.endpoints.risk_observations import router as risk_observations_router +from app.db.base import Base +from app.models.employee import Employee +from app.models.financial_record import ExpenseClaim +from app.models.risk_disposition import RiskDispositionEvent +from app.models.risk_observation import RiskObservationFeedback +from app.schemas.risk_disposition import RiskDispositionActionCreate +from app.services.risk_dispositions import ( + RiskDispositionConflictError, + RiskDispositionIdempotencyConflictError, + RiskDispositionPermissionError, + RiskDispositionService, + RiskDispositionVersionConflictError, +) +from app.services.risk_observation_access_policy import RiskObservationAccessPolicy +from app.services.risk_observations import RiskObservationService + + +def test_risk_disposition_separates_adjudication_and_lifecycle_with_audit_events( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false") + with _build_session() as db: + observation = RiskObservationService(db).upsert_observation( + _observation_payload("risk:typed:duplicate") + ) + db.commit() + service = RiskDispositionService(db) + + confirmed = service.execute_action( + observation.id, + _action("confirm", version=0, request_id="request-confirm-001"), + tenant_id="default", + actor_id="finance-1", + actor_name="财务甲", + ) + assert confirmed.disposition.adjudication == "confirmed" + assert confirmed.disposition.lifecycle_status == "open" + assert confirmed.disposition.version == 1 + assert confirmed.event.before_json["adjudication"] == "unreviewed" + assert confirmed.event.after_json["adjudication"] == "confirmed" + + supplemented = service.execute_action( + observation.id, + RiskDispositionActionCreate( + action="request_supplement", + expected_version=1, + request_id="request-supplement-001", + assignee="员工甲", + due_at=datetime.now(UTC) + timedelta(days=2), + comment="请补齐行程单", + ), + tenant_id="default", + actor_id="finance-1", + actor_name="财务甲", + ) + assert supplemented.disposition.adjudication == "confirmed" + assert supplemented.disposition.lifecycle_status == "supplement_requested" + assert supplemented.disposition.assignee == "员工甲" + assert supplemented.disposition.version == 2 + + resolved = service.execute_action( + observation.id, + RiskDispositionActionCreate( + action="resolve", + expected_version=2, + request_id="request-resolve-001", + resolution="补充材料已核验,风险关闭。", + ), + tenant_id="default", + actor_id="finance-1", + actor_name="财务甲", + ) + assert resolved.disposition.adjudication == "confirmed" + assert resolved.disposition.lifecycle_status == "resolved" + assert resolved.disposition.resolution == "补充材料已核验,风险关闭。" + assert resolved.disposition.version == 3 + assert db.scalar(select(func.count()).select_from(RiskDispositionEvent)) == 3 + assert db.scalar(select(func.count()).select_from(RiskObservationFeedback)) == 1 + + with pytest.raises(RiskDispositionConflictError, match="已解决"): + service.execute_action( + observation.id, + _action( + "start_remediation", + version=3, + request_id="request-remediation-after-resolve", + ), + tenant_id="default", + actor_id="finance-1", + actor_name="财务甲", + ) + with pytest.raises(RiskDispositionConflictError, match="已解决"): + service.execute_action( + observation.id, + _action( + "false_positive", + version=3, + request_id="request-readjudicate-after-resolve", + ), + tenant_id="default", + actor_id="finance-1", + actor_name="财务甲", + ) + + +def test_risk_disposition_requires_confirmation_before_remediation_or_resolution( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false") + with _build_session() as db: + observation = RiskObservationService(db).upsert_observation( + _observation_payload("risk:typed:transition-guard") + ) + db.commit() + service = RiskDispositionService(db) + + with pytest.raises(RiskDispositionConflictError, match="必须先确认成立"): + service.execute_action( + observation.id, + RiskDispositionActionCreate( + action="resolve", + expected_version=0, + request_id="request-resolve-unreviewed", + resolution="不能跳过裁决直接关闭。", + ), + tenant_id="default", + actor_id="finance-1", + actor_name="财务甲", + ) + + supplemented = service.execute_action( + observation.id, + RiskDispositionActionCreate( + action="request_supplement", + expected_version=0, + request_id="request-supplement-unreviewed", + comment="先补充材料再裁决。", + ), + tenant_id="default", + actor_id="finance-1", + actor_name="财务甲", + ) + assert supplemented.disposition.adjudication == "unreviewed" + assert supplemented.disposition.lifecycle_status == "supplement_requested" + + +def test_risk_disposition_imports_legacy_confirmation_before_first_typed_action( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false") + with _build_session() as db: + observation = RiskObservationService(db).upsert_observation( + _observation_payload("risk:typed:legacy-confirmed") + ) + observation.status = "confirmed" + observation.feedback_status = "confirmed" + db.commit() + + resolved = RiskDispositionService(db).execute_action( + observation.id, + RiskDispositionActionCreate( + action="resolve", + expected_version=0, + request_id="request-resolve-legacy-confirmed", + resolution="历史确认风险已完成整改。", + ), + tenant_id="default", + actor_id="finance-1", + actor_name="财务甲", + ) + + assert resolved.event.before_json["adjudication"] == "confirmed" + assert resolved.disposition.adjudication == "confirmed" + assert resolved.disposition.lifecycle_status == "resolved" + + +def test_risk_disposition_idempotency_and_optimistic_version_are_enforced( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false") + with _build_session() as db: + observation = RiskObservationService(db).upsert_observation( + _observation_payload("risk:typed:idempotency") + ) + second_observation = RiskObservationService(db).upsert_observation( + { + **_observation_payload("risk:typed:idempotency:second"), + "claim_id": None, + "claim_no": "", + "subject_key": "standalone:second", + } + ) + db.commit() + service = RiskDispositionService(db) + payload = _action("confirm", version=0, request_id="request-idempotent-001") + + first = service.execute_action( + observation.id, + payload, + tenant_id="default", + actor_id="finance-1", + actor_name="财务甲", + ) + replay = service.execute_action( + observation.id, + payload, + tenant_id="default", + actor_id="finance-1", + actor_name="财务甲", + ) + + assert replay.replayed is True + assert replay.event.id == first.event.id + assert db.scalar(select(func.count()).select_from(RiskDispositionEvent)) == 1 + + with pytest.raises(RiskDispositionIdempotencyConflictError): + service.execute_action( + observation.id, + _action( + "false_positive", + version=0, + request_id="request-idempotent-001", + ), + tenant_id="default", + actor_id="finance-1", + actor_name="财务甲", + ) + + with pytest.raises(RiskDispositionIdempotencyConflictError): + service.execute_action( + second_observation.id, + payload, + tenant_id="default", + actor_id="finance-1", + actor_name="财务甲", + ) + + with pytest.raises(RiskDispositionIdempotencyConflictError): + service.execute_action( + observation.id, + payload, + tenant_id="default", + actor_id="finance-2", + actor_name="财务乙", + ) + + with pytest.raises(RiskDispositionVersionConflictError) as error: + service.execute_action( + observation.id, + _action("request_waiver", version=0, request_id="request-stale-001"), + tenant_id="default", + actor_id="finance-1", + actor_name="财务甲", + ) + assert error.value.current_version == 1 + + +def test_risk_observation_api_enforces_pool_claim_and_typed_action_permissions( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false") + client, session_factory = _build_client() + with session_factory() as db: + db.add(_employee()) + db.add(_claim()) + db.flush() + observation = RiskObservationService(db).upsert_observation( + _observation_payload("risk:api:duplicate") + ) + standalone_observation = RiskObservationService(db).upsert_observation( + { + **_observation_payload("risk:api:standalone"), + "claim_id": None, + "claim_no": "", + "subject_key": "default:standalone", + } + ) + foreign_observation = RiskObservationService(db).upsert_observation( + { + **_observation_payload("risk:api:foreign"), + "claim_id": None, + "claim_no": "", + "subject_key": "tenant-b:standalone", + }, + tenant_id="tenant-b", + ) + observation_id = observation.id + standalone_observation_id = standalone_observation.id + foreign_observation_id = foreign_observation.id + db.commit() + + employee_headers = { + "X-Auth-Username": "risk.employee@example.com", + "X-Auth-Name": "Risk Employee", + "X-Auth-Employee-No": "E-RISK", + } + finance_headers = { + "X-Auth-Username": "finance@example.com", + "X-Auth-Name": "Finance Reviewer", + "X-Auth-Role-Codes": "finance", + } + + assert client.get("/api/v1/risk-observations", headers=employee_headers).status_code == 403 + assert ( + client.get( + f"/api/v1/risk-observations/{observation_id}", + headers=employee_headers, + ).status_code + == 403 + ) + assert ( + client.get( + "/api/v1/risk-observations/claim/claim-risk-1", + headers=employee_headers, + ).status_code + == 404 + ) + assert ( + client.post( + f"/api/v1/risk-observations/{observation_id}/feedback", + headers=employee_headers, + json={"feedback_type": "confirm"}, + ).status_code + == 403 + ) + + first = client.post( + f"/api/v1/risk-observations/{observation_id}/disposition/actions", + headers=finance_headers, + json={ + "action": "confirm", + "expected_version": 0, + "request_id": "api-confirm-request-001", + "comment": "人工复核确认", + }, + ) + replay = client.post( + f"/api/v1/risk-observations/{observation_id}/disposition/actions", + headers=finance_headers, + json={ + "action": "confirm", + "expected_version": 0, + "request_id": "api-confirm-request-001", + "comment": "人工复核确认", + }, + ) + stale = client.post( + f"/api/v1/risk-observations/{observation_id}/disposition/actions", + headers=finance_headers, + json={ + "action": "request_waiver", + "expected_version": 0, + "request_id": "api-stale-request-001", + "comment": "申请风险豁免复核", + }, + ) + changed_replay = client.post( + f"/api/v1/risk-observations/{observation_id}/disposition/actions", + headers=finance_headers, + json={ + "action": "false_positive", + "expected_version": 0, + "request_id": "api-confirm-request-001", + "comment": "经核验属于误报", + }, + ) + unsafe_legacy = client.post( + f"/api/v1/risk-observations/{observation_id}/feedback", + headers=finance_headers, + json={"feedback_type": "comment", "payload_json": {"arbitrary": True}}, + ) + foreign_action = client.post( + f"/api/v1/risk-observations/{foreign_observation_id}/disposition/actions", + headers=finance_headers, + json={ + "action": "confirm", + "expected_version": 0, + "request_id": "api-cross-tenant-001", + }, + ) + standalone_action = client.post( + f"/api/v1/risk-observations/{standalone_observation_id}/disposition/actions", + headers=finance_headers, + json={ + "action": "confirm", + "expected_version": 0, + "request_id": "api-standalone-finance-001", + }, + ) + + assert first.status_code == 200 + assert first.json()["disposition"]["adjudication"] == "confirmed" + assert first.json()["disposition"]["lifecycle_status"] == "open" + assert replay.status_code == 200 + assert replay.json()["replayed"] is True + assert replay.json()["event"]["id"] == first.json()["event"]["id"] + assert stale.status_code == 409 + assert stale.json()["detail"]["code"] == "RISK_DISPOSITION_VERSION_CONFLICT" + assert stale.json()["detail"]["message"] == "风险处置状态已更新,请刷新证据链后重试。" + assert changed_replay.status_code == 409 + assert unsafe_legacy.status_code == 410 + assert foreign_action.status_code == 404 + assert standalone_action.status_code == 403 + + detail = client.get( + f"/api/v1/risk-observations/{observation_id}", + headers=finance_headers, + ) + assert detail.status_code == 200 + assert detail.json()["disposition"]["version"] == 1 + assert len(detail.json()["disposition"]["events"]) == 1 + + +def test_current_claim_approver_can_manage_disposition_without_pool_access() -> None: + with _build_session() as db: + manager = Employee( + id="manager-risk", + employee_no="M-RISK", + name="风险主管", + email="risk.manager@example.com", + position="部门经理", + grade="P8", + ) + employee = _employee() + employee.manager_id = manager.id + claim = _claim() + claim.approval_stage = "直属领导审批" + db.add_all([manager, employee, claim]) + db.flush() + observation = RiskObservationService(db).upsert_observation( + _observation_payload("risk:approver:duplicate") + ) + db.commit() + current_user = CurrentUserContext( + username="risk.manager@example.com", + name="风险主管", + role_codes=["approver"], + is_admin=False, + employee_no="M-RISK", + ) + policy = RiskObservationAccessPolicy(db) + + assert policy.can_read_tenant_pool(current_user) is False + assert policy.can_read_claim_risks(claim.id, current_user) is True + assert policy.can_manage_disposition(observation, current_user) is True + + unrelated_manager = CurrentUserContext( + username="unrelated.manager@example.com", + name="其他经理", + role_codes=["manager"], + is_admin=False, + ) + finance_outside_stage = CurrentUserContext( + username="finance@example.com", + name="财务甲", + role_codes=["finance"], + is_admin=False, + ) + assert policy.can_read_tenant_pool(unrelated_manager) is False + assert policy.can_read_claim_risks(claim.id, unrelated_manager) is False + assert policy.can_manage_disposition(observation, unrelated_manager) is False + assert policy.can_manage_disposition(observation, finance_outside_stage) is False + + +def test_disposition_rechecks_current_approver_after_claim_stage_changes() -> None: + with _build_session() as db: + manager = Employee( + id="manager-risk-stage-change", + employee_no="M-RISK-STAGE", + name="原审批主管", + email="risk.stage.manager@example.com", + ) + employee = _employee() + employee.id = "emp-risk-stage-change" + employee.employee_no = "E-RISK-STAGE" + employee.email = "risk.stage.employee@example.com" + employee.manager_id = manager.id + claim = _claim() + claim.id = "claim-risk-stage-change" + claim.claim_no = "BX-RISK-STAGE" + claim.employee_id = employee.id + claim.approval_stage = "直属领导审批" + db.add_all([manager, employee, claim]) + db.flush() + observation = RiskObservationService(db).upsert_observation( + { + **_observation_payload("risk:approver:stage-change"), + "claim_id": claim.id, + "claim_no": claim.claim_no, + "subject_key": f"claim:{claim.id}", + } + ) + db.commit() + current_user = CurrentUserContext( + username=manager.email, + name=manager.name, + role_codes=["manager"], + is_admin=False, + employee_no=manager.employee_no, + ) + + claim.approval_stage = "财务审批" + db.commit() + + with pytest.raises(RiskDispositionPermissionError, match="不再是"): + RiskDispositionService(db).execute_action( + observation.id, + _action("confirm", version=0, request_id="request-stage-changed-001"), + tenant_id="default", + actor_id=manager.id, + actor_name=manager.name, + current_user=current_user, + ) + assert db.scalar(select(func.count()).select_from(RiskDispositionEvent)) == 0 + + +@pytest.mark.parametrize( + "action", + ["false_positive", "request_supplement", "request_waiver"], +) +def test_evidence_sensitive_actions_require_server_side_comment(action: str) -> None: + with pytest.raises(ValueError, match="必须填写 comment"): + RiskDispositionActionCreate( + action=action, + expected_version=0, + request_id=f"request-comment-{action}", + ) + + +def _action( + action: str, + *, + version: int, + request_id: str, +) -> RiskDispositionActionCreate: + return RiskDispositionActionCreate( + action=action, + expected_version=version, + request_id=request_id, + comment=( + "风险处置说明" + if action in {"false_positive", "request_supplement", "request_waiver"} + else None + ), + ) + + +def _build_session() -> Session: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) + return factory() + + +def _build_client() -> tuple[TestClient, sessionmaker[Session]]: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) + app = FastAPI() + app.include_router(risk_observations_router, prefix="/api/v1") + install_legacy_header_auth_override(app) + + def override_db() -> Generator[Session, None, None]: + db = factory() + try: + yield db + finally: + db.close() + + app.dependency_overrides[get_db] = override_db + return TestClient(app), factory + + +def _employee() -> Employee: + return Employee( + id="emp-risk", + employee_no="E-RISK", + name="风险员工", + email="risk.employee@example.com", + position="高级专员", + grade="P6", + ) + + +def _claim() -> ExpenseClaim: + now = datetime(2026, 7, 16, tzinfo=UTC) + return ExpenseClaim( + id="claim-risk-1", + claim_no="BX-RISK-001", + employee_id="emp-risk", + employee_name="风险员工", + department_id="dept-risk", + department_name="风控部", + expense_type="travel", + reason="客户拜访", + location="上海", + amount=Decimal("1200"), + currency="CNY", + invoice_count=1, + occurred_at=now, + submitted_at=now, + status="submitted", + approval_stage="财务审批", + risk_flags_json=[], + ) + + +def _observation_payload(observation_key: str) -> dict[str, object]: + return { + "observation_key": observation_key, + "subject_type": "expense_claim", + "subject_key": "claim:claim-risk-1", + "subject_label": "BX-RISK-001", + "claim_id": "claim-risk-1", + "claim_no": "BX-RISK-001", + "risk_type": "duplicate_invoice", + "risk_signal": "duplicate_invoice", + "title": "重复票据风险", + "description": "同一票据可能重复报销。", + "risk_score": 86, + "risk_level": "high", + "confidence_score": 0.91, + "control_stage": "reimbursement", + "control_mode": "risk_observation", + "automation_mode": "semi_auto_review", + "source": "financial_risk_graph", + "algorithm_version": "financial_risk_graph.v1", + "contribution_scores": {}, + "baseline": {}, + "evidence": [], + "graph_node_keys": [], + "graph_edge_keys": [], + "policy_refs": [], + "similar_case_claim_ids": [], + "ontology_json": {}, + "decision_trace": {}, + } diff --git a/server/tests/test_risk_observations_service.py b/server/tests/test_risk_observations_service.py index 8a75b57..6976876 100644 --- a/server/tests/test_risk_observations_service.py +++ b/server/tests/test_risk_observations_service.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Generator -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from decimal import Decimal from types import SimpleNamespace @@ -22,6 +22,7 @@ from app.models.expense_case import ExpenseCase, ExpenseCaseLink from app.models.financial_record import ExpenseClaim from app.models.risk_observation import RiskObservation from app.schemas.risk_observation import RiskObservationFeedbackCreate +from app.services.expense_claims import ExpenseClaimService from app.services.hermes_risk_scanner import HermesRiskScannerService from app.services.risk_observations import RiskObservationService @@ -129,6 +130,47 @@ def test_platform_rule_flags_are_persisted_as_risk_observations() -> None: assert persisted.contribution_scores_json == {"S_rule": 100} +def test_high_platform_risk_persistence_failure_is_fail_closed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with _build_session() as db: + claim = _claim_orm("c-platform-fail-closed", "BX-PLATFORM-FAIL-CLOSED") + db.add(claim) + db.flush() + service = ExpenseClaimService(db) + monkeypatch.setattr( + service, + "evaluate_platform_risk_rules", + lambda _claim, **_kwargs: { + "flags": [ + { + "source": "platform_risk", + "hit_source": "rule_center", + "rule_type": "risk", + "rule_code": "risk.invoice.blocking", + "severity": "high", + "action": "block", + "label": "高风险票据", + "message": "票据需要人工核验。", + } + ], + "rule_set_fingerprint": "rules-v1", + }, + ) + + def fail_persistence(*_args, **_kwargs): + raise RuntimeError("database unavailable") + + monkeypatch.setattr( + RiskObservationService, + "upsert_platform_risk_flags", + fail_persistence, + ) + + with pytest.raises(RuntimeError, match="高风险观察持久化失败"): + service._run_ai_submission_review(claim) + + def test_risk_observation_storage_ready_is_cached_per_bind(monkeypatch: pytest.MonkeyPatch) -> None: with _build_session() as db: RiskObservationService._storage_ready_cache.clear() @@ -189,9 +231,9 @@ def test_risk_observation_endpoints_return_list_detail_dashboard_and_feedback() assert updated_detail_response.json()["feedback_items"][0]["feedback_type"] == "false_positive" with session_factory() as db: - observation = db.query(RiskObservation).filter_by( - observation_key="risk:c1:duplicate_invoice" - ).one() + observation = ( + db.query(RiskObservation).filter_by(observation_key="risk:c1:duplicate_invoice").one() + ) assert observation.status == "false_positive" assert observation.feedback_status == "false_positive" @@ -223,11 +265,15 @@ def test_risk_observation_endpoints_enforce_tenant_scope_and_authenticated_actor tenant_a_headers = { "X-Auth-Username": "auditor-a", "X-Auth-Name": "Tenant A Auditor", + "X-Auth-Role-Codes": "finance", + "X-Auth-Is-Admin": "true", "X-Auth-Tenant-Id": "tenant-a", } tenant_b_headers = { "X-Auth-Username": "auditor-b", "X-Auth-Name": "Tenant B Auditor", + "X-Auth-Role-Codes": "finance", + "X-Auth-Is-Admin": "true", "X-Auth-Tenant-Id": "tenant-b", } @@ -269,8 +315,8 @@ def test_risk_observation_endpoints_enforce_tenant_scope_and_authenticated_actor assert detail_response.status_code == 200 assert detail_response.json()["tenant_id"] == "tenant-a" assert foreign_detail_response.status_code == 404 - assert claim_response.status_code == 200 - assert [item["tenant_id"] for item in claim_response.json()] == ["tenant-a"] + # 单据风险入口必须先通过单据自身可见范围;不存在的历史 claim 不再旁路读取。 + assert claim_response.status_code == 404 assert execution_log_response.status_code == 200 assert [item["tenant_id"] for item in execution_log_response.json()] == ["tenant-a"] assert dashboard_response.status_code == 200 @@ -359,9 +405,12 @@ def test_risk_observation_rejects_explicit_tenant_mismatching_claim_link() -> No tenant_id="tenant-b", ) - assert db.query(RiskObservation).filter_by( - observation_key="risk:tenant-boundary" - ).one_or_none() is None + assert ( + db.query(RiskObservation) + .filter_by(observation_key="risk:tenant-boundary") + .one_or_none() + is None + ) def test_hermes_global_scan_builds_graphs_inside_each_tenant( @@ -427,6 +476,37 @@ def test_hermes_global_scan_builds_graphs_inside_each_tenant( assert summary["scanned_claim_count"] == 2 +def test_risk_scan_discards_snapshot_after_claim_changes_during_evaluation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with _build_session() as db: + claim = _claim_orm("claim-scan-stale", "BX-SCAN-STALE") + db.add(claim) + db.commit() + original_updated_at = claim.updated_at + + def fake_evaluate(_context): + claim.status = "pending_payment" + claim.approval_stage = "待付款" + claim.updated_at = original_updated_at + timedelta(seconds=1) + db.flush() + return SimpleNamespace(observations=[], nodes=[], edges=[]) + + scanner = HermesRiskScannerService(db) + monkeypatch.setattr(scanner, "_fetch_unscanned_claims", lambda: [claim]) + monkeypatch.setattr( + "app.services.hermes_risk_scanner.evaluate_financial_risk_graph", + fake_evaluate, + ) + + summary = scanner.scan_global_risks() + + db.refresh(claim) + assert summary["scanned_claim_count"] == 0 + assert claim.status == "pending_payment" + assert claim.hermes_scanned_at is None + + def test_risk_observation_feedback_pool_fields_and_replay_set_contract() -> None: with _build_session() as db: service = RiskObservationService(db) diff --git a/server/tests/test_schema_ownership.py b/server/tests/test_schema_ownership.py index 0229791..a9bf2e3 100644 --- a/server/tests/test_schema_ownership.py +++ b/server/tests/test_schema_ownership.py @@ -19,6 +19,7 @@ def test_create_legacy_schema_never_creates_migration_owned_tables() -> None: "ai_application_preview_decisions", "ai_decision_feedback", "ai_decisions", + "approval_action_ledgers", "auth_sessions", "attachment_association_jobs", "business_events", @@ -28,6 +29,8 @@ def test_create_legacy_schema_never_creates_migration_owned_tables() -> None: "memory_evidence_links", "risk_observations", "risk_observation_feedback", + "risk_disposition_events", + "risk_dispositions", "few_shot_samples", "workflow_outcomes", } diff --git a/web/src/assets/styles/components/risk-observation-evidence-card.css b/web/src/assets/styles/components/risk-observation-evidence-card.css index c3a129a..5837dcc 100644 --- a/web/src/assets/styles/components/risk-observation-evidence-card.css +++ b/web/src/assets/styles/components/risk-observation-evidence-card.css @@ -195,6 +195,142 @@ line-height: 1.55; } +.risk-disposition-panel { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid rgba(var(--theme-primary-rgb), .18); + border-radius: 6px; + background: linear-gradient(135deg, #fff, var(--theme-primary-soft)); +} + +.risk-disposition-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.risk-disposition-head > div:first-child { + display: grid; + gap: 3px; +} + +.risk-disposition-head span { + color: #64748b; + font-size: 12px; + font-weight: 850; +} + +.risk-disposition-head strong { + color: #0f172a; + font-size: 13px; + font-weight: 900; + line-height: 1.45; +} + +.risk-disposition-badges, +.risk-disposition-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + +.risk-disposition-badges em { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0 8px; + border-radius: 4px; + background: #e2e8f0; + color: #475569; + font-size: 11px; + font-style: normal; + font-weight: 850; +} + +.risk-disposition-badges em.confirmed, +.risk-disposition-badges em.remediation_in_progress, +.risk-disposition-badges em.waiver_requested { + background: var(--warning-soft); + color: var(--warning-active); +} + +.risk-disposition-badges em.false_positive, +.risk-disposition-badges em.resolved { + background: var(--success-soft); + color: var(--success); +} + +.risk-disposition-panel textarea { + width: 100%; + min-height: 68px; + resize: vertical; + padding: 9px 10px; + border: 1px solid #dbe5ef; + border-radius: 4px; + background: #fff; + color: #0f172a; + font: inherit; + font-size: 12px; + line-height: 1.55; +} + +.risk-disposition-panel textarea:focus { + border-color: var(--theme-primary); + box-shadow: 0 0 0 3px var(--theme-focus-ring); + outline: 0; +} + +.risk-disposition-actions button { + min-height: 32px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 10px; + border: 1px solid #dbe5ef; + border-radius: 4px; + background: #fff; + color: #334155; + font-size: 12px; + font-weight: 850; +} + +.risk-disposition-actions button.danger { + border-color: var(--danger-line); + color: var(--danger); +} + +.risk-disposition-actions button.warning { + border-color: var(--warning-line); + color: var(--warning-active); +} + +.risk-disposition-actions button.safe { + border-color: rgba(34, 197, 94, .28); + color: var(--success); +} + +.risk-disposition-actions button:disabled { + cursor: not-allowed; + opacity: .66; +} + +.risk-disposition-resolution, +.risk-disposition-complete, +.risk-disposition-error { + margin: 0; + color: #475569; + font-size: 12px; + font-weight: 700; + line-height: 1.55; +} + +.risk-disposition-error { + color: var(--danger); +} + .risk-evidence-meta { display: flex; flex-wrap: wrap; diff --git a/web/src/assets/styles/views/approval-center-view.css b/web/src/assets/styles/views/approval-center-view.css index f50a61d..209b78d 100644 --- a/web/src/assets/styles/views/approval-center-view.css +++ b/web/src/assets/styles/views/approval-center-view.css @@ -181,7 +181,7 @@ font-weight: 760; } -table { width: 100%; min-width: 1180px; border-collapse: collapse; table-layout: fixed; } +table { width: 100%; min-width: 1520px; border-collapse: collapse; table-layout: fixed; } th, td { padding: 15px 12px; @@ -232,6 +232,60 @@ tbody tr:last-child td { border-bottom: 0; } .risk-tag.medium { background: var(--warning-soft); color: var(--warning-active); } .risk-tag.high { background: var(--danger-soft); color: var(--danger); } +.priority-tag, +.evidence-meta, +.ai-suggestion { + min-height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + max-width: 100%; + padding: 0 9px; + border: 1px solid transparent; + border-radius: 6px; + font-size: 12px; + font-weight: 780; + white-space: nowrap; +} + +.priority-tag { + min-width: 66px; + background: #f1f5f9; + color: #475569; + font-variant-numeric: tabular-nums; +} + +.priority-tag.high { + border-color: var(--warning-line); + background: var(--warning-soft); + color: var(--warning-active); +} + +.priority-tag.urgent { + border-color: var(--danger-line); + background: var(--danger-soft); + color: var(--danger); +} + +.evidence-meta.safe { + background: var(--success-soft); + color: var(--success); +} + +.evidence-meta.warning { + border-color: var(--warning-line); + background: var(--warning-soft); + color: var(--warning-active); +} + +.ai-suggestion { + overflow: hidden; + border-color: rgba(var(--theme-primary-rgb), .18); + background: var(--theme-primary-soft); + color: var(--theme-primary-active); + text-overflow: ellipsis; +} + .sla { font-size: 13px; font-weight: 850; } .sla.safe { color: var(--success); } .sla.warning { color: var(--warning-active); } @@ -1473,4 +1527,3 @@ tbody tr:last-child td { border-bottom: 0; } .opinion-wrap { padding: 12px 14px; } - diff --git a/web/src/components/travel/RiskObservationEvidenceCard.vue b/web/src/components/travel/RiskObservationEvidenceCard.vue index f057b77..66e6cff 100644 --- a/web/src/components/travel/RiskObservationEvidenceCard.vue +++ b/web/src/components/travel/RiskObservationEvidenceCard.vue @@ -58,6 +58,50 @@ +
+
+
+ 风险处置 + {{ dispositionStateLabel }} +
+
+ + {{ formatAdjudication(currentDisposition.adjudication) }} + + + {{ formatLifecycleStatus(currentDisposition.lifecycleStatus) }} + + v{{ currentDisposition.version }} +
+
+

+ {{ currentDisposition.resolution }} +

+ +

{{ dispositionCompleteMessage }}

+

{{ dispositionError }}

+
+
贡献分 @@ -141,7 +185,11 @@ diff --git a/web/src/services/approvalWorkbench.js b/web/src/services/approvalWorkbench.js new file mode 100644 index 0000000..b58f779 --- /dev/null +++ b/web/src/services/approvalWorkbench.js @@ -0,0 +1,66 @@ +import { apiRequest } from './api.js' + +function toNumber(value, fallback = 0) { + const number = Number(value) + return Number.isFinite(number) ? number : fallback +} + +function toArray(value) { + return Array.isArray(value) ? value : [] +} + +export function normalizeApprovalWorkbenchItem(item = {}) { + const evidence = item.evidence && typeof item.evidence === 'object' ? item.evidence : {} + const suggestion = item.suggestion && typeof item.suggestion === 'object' ? item.suggestion : {} + return { + taskKey: String(item.task_key || item.taskKey || '').trim(), + claim: item.claim && typeof item.claim === 'object' ? item.claim : {}, + priorityScore: Math.max(0, Math.min(toNumber(item.priority_score ?? item.priorityScore), 100)), + priorityTier: String(item.priority_tier || item.priorityTier || 'normal').trim(), + priorityReasons: toArray(item.priority_reasons || item.priorityReasons).map((reason) => ({ + code: String(reason?.code || '').trim(), + label: String(reason?.label || '').trim(), + weight: toNumber(reason?.weight), + tone: String(reason?.tone || 'normal').trim() + })).filter((reason) => reason.label), + riskLevel: String(item.risk_level || item.riskLevel || 'low').trim(), + openRiskCount: Math.max(0, toNumber(item.open_risk_count ?? item.openRiskCount)), + budgetUsageRate: item.budget_usage_rate == null && item.budgetUsageRate == null + ? null + : toNumber(item.budget_usage_rate ?? item.budgetUsageRate), + waitingHours: Math.max(0, toNumber(item.waiting_hours ?? item.waitingHours)), + slaDueAt: String(item.sla_due_at || item.slaDueAt || '').trim(), + slaOverdue: Boolean(item.sla_overdue ?? item.slaOverdue), + evidence: { + completeness: Math.max(0, Math.min(toNumber(evidence.completeness), 1)), + presentCount: Math.max(0, toNumber(evidence.present_count ?? evidence.presentCount)), + requiredCount: Math.max(0, toNumber(evidence.required_count ?? evidence.requiredCount)), + missingLabels: toArray(evidence.missing_labels || evidence.missingLabels) + .map((value) => String(value || '').trim()) + .filter(Boolean), + historicalLabels: toArray(evidence.historical_labels || evidence.historicalLabels) + .map((value) => String(value || '').trim()) + .filter(Boolean) + }, + suggestion: { + action: String(suggestion.action || 'manual_review').trim(), + label: String(suggestion.label || '人工复核').trim(), + reason: String(suggestion.reason || '').trim(), + advisoryOnly: suggestion.advisory_only !== false && suggestion.advisoryOnly !== false + } + } +} + +export async function fetchApprovalWorkbenchItems(options = {}) { + const limit = Math.max(1, Math.min(toNumber(options.limit || 100), 200)) + const payload = await apiRequest(`/approval-workbench/items?limit=${limit}`, { + timeoutMs: toNumber(options.timeoutMs || 5000), + timeoutMessage: '审批优先队列加载超时,请稍后重试。' + }) + return { + items: toArray(payload?.items).map(normalizeApprovalWorkbenchItem), + total: Math.max(0, toNumber(payload?.total)), + generatedAt: String(payload?.generated_at || payload?.generatedAt || '').trim(), + scoringVersion: String(payload?.scoring_version || payload?.scoringVersion || '').trim() + } +} diff --git a/web/src/services/reimbursements.js b/web/src/services/reimbursements.js index bdd6b0f..6e6d51c 100644 --- a/web/src/services/reimbursements.js +++ b/web/src/services/reimbursements.js @@ -231,6 +231,15 @@ function createPreReviewRequestId() { return `pre-review:${Date.now()}:${Math.random().toString(16).slice(2)}` } +export function createExpenseClaimActionRequestId(action, claimId) { + const normalizedAction = String(action || 'action').trim() || 'action' + const normalizedClaimId = String(claimId || 'claim').trim() || 'claim' + if (typeof globalThis.crypto?.randomUUID === 'function') { + return `${normalizedAction}:${normalizedClaimId}:${globalThis.crypto.randomUUID()}` + } + return `${normalizedAction}:${normalizedClaimId}:${Date.now()}:${Math.random().toString(16).slice(2)}` +} + export function preReviewExpenseClaim(claimId, requestId = '') { const normalizedRequestId = String(requestId || '').trim() || createPreReviewRequestId() return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/pre-review`, { @@ -261,10 +270,10 @@ export function approveExpenseClaim(claimId, payload = {}) { }) } -export function payExpenseClaim(claimId) { +export function payExpenseClaim(claimId, payload = {}) { return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/pay`, { method: 'POST', - body: JSON.stringify({}) + body: JSON.stringify(payload) }) } diff --git a/web/src/services/riskObservations.js b/web/src/services/riskObservations.js index ad27b99..062a5a7 100644 --- a/web/src/services/riskObservations.js +++ b/web/src/services/riskObservations.js @@ -13,6 +13,41 @@ function toObject(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {} } +export function createRiskDispositionRequestId(action, observationId) { + const normalizedAction = String(action || 'action').trim() || 'action' + const normalizedObservationId = String(observationId || 'risk').trim() || 'risk' + if (typeof globalThis.crypto?.randomUUID === 'function') { + return `risk:${normalizedAction}:${normalizedObservationId}:${globalThis.crypto.randomUUID()}` + } + return `risk:${normalizedAction}:${normalizedObservationId}:${Date.now()}:${Math.random().toString(16).slice(2)}` +} + +export function normalizeRiskDisposition(item = {}) { + if (!item || typeof item !== 'object' || !String(item.id || '').trim()) { + return null + } + return { + id: String(item.id || '').trim(), + observationId: String(item.observation_id || item.observationId || '').trim(), + adjudication: String(item.adjudication || 'unreviewed').trim(), + lifecycleStatus: String(item.lifecycle_status || item.lifecycleStatus || 'open').trim(), + version: Math.max(0, toNumber(item.version)), + assignee: String(item.assignee || '').trim(), + dueAt: String(item.due_at || item.dueAt || '').trim(), + resolution: String(item.resolution || '').trim(), + createdAt: String(item.created_at || item.createdAt || '').trim(), + updatedAt: String(item.updated_at || item.updatedAt || '').trim(), + events: toArray(item.events).map((event) => ({ + id: String(event?.id || '').trim(), + action: String(event?.action || '').trim(), + version: Math.max(0, toNumber(event?.version)), + actorName: String(event?.actor_name || event?.actorName || '').trim(), + comment: String(event?.comment || '').trim(), + createdAt: String(event?.created_at || event?.createdAt || '').trim() + })) + } +} + export function normalizeRiskObservation(item = {}) { return { id: String(item.id || '').trim(), @@ -33,16 +68,29 @@ export function normalizeRiskObservation(item = {}) { algorithmVersion: String(item.algorithm_version || item.algorithmVersion || '').trim(), status: String(item.status || '').trim(), feedbackStatus: String(item.feedback_status || item.feedbackStatus || '').trim(), - contributionScores: toObject(item.contribution_scores || item.contributionScores), - baseline: toObject(item.baseline), - evidence: toArray(item.evidence), - graphNodeKeys: toArray(item.graph_node_keys || item.graphNodeKeys), - graphEdgeKeys: toArray(item.graph_edge_keys || item.graphEdgeKeys), - policyRefs: toArray(item.policy_refs || item.policyRefs), - similarCaseClaimIds: toArray(item.similar_case_claim_ids || item.similarCaseClaimIds), + contributionScores: toObject( + item.contribution_scores_json || item.contribution_scores || item.contributionScores + ), + baseline: toObject(item.baseline_json || item.baseline), + evidence: toArray(item.evidence_json || item.evidence), + graphNodeKeys: toArray( + item.graph_node_keys_json || item.graph_node_keys || item.graphNodeKeys + ), + graphEdgeKeys: toArray( + item.graph_edge_keys_json || item.graph_edge_keys || item.graphEdgeKeys + ), + policyRefs: toArray(item.policy_refs_json || item.policy_refs || item.policyRefs), + similarCaseClaimIds: toArray( + item.similar_case_claim_ids_json + || item.similar_case_claim_ids + || item.similarCaseClaimIds + ), ontology: toObject(item.ontology_json || item.ontologyJson), - decisionTrace: toObject(item.decision_trace || item.decisionTrace), + decisionTrace: toObject( + item.decision_trace_json || item.decision_trace || item.decisionTrace + ), feedbackItems: toArray(item.feedback_items || item.feedbackItems), + disposition: normalizeRiskDisposition(item.disposition), createdAt: String(item.created_at || item.createdAt || '').trim(), updatedAt: String(item.updated_at || item.updatedAt || '').trim() } @@ -145,3 +193,42 @@ export async function fetchRunRiskObservations(runId, options = {}) { return toArray(payload.items || payload).map(normalizeRiskObservation) } + +export async function executeRiskDispositionAction(observationId, payload = {}) { + const normalizedObservationId = String(observationId || '').trim() + const action = String(payload.action || '').trim() + if (!normalizedObservationId || !action) { + throw new Error('风险处置缺少观察标识或动作。') + } + const requestId = String(payload.requestId || payload.request_id || '').trim() + || createRiskDispositionRequestId(action, normalizedObservationId) + const body = { + action, + expected_version: Math.max(0, toNumber(payload.expectedVersion ?? payload.expected_version)), + request_id: requestId + } + const comment = String(payload.comment || '').trim() + const resolution = String(payload.resolution || '').trim() + const assignee = String(payload.assignee || '').trim() + const dueAt = String(payload.dueAt || payload.due_at || '').trim() + if (comment) body.comment = comment + if (resolution) body.resolution = resolution + if (assignee) body.assignee = assignee + if (dueAt) body.due_at = dueAt + + const result = await apiRequest( + `/risk-observations/${encodeURIComponent(normalizedObservationId)}/disposition/actions`, + { + method: 'POST', + body: JSON.stringify(body), + timeoutMs: toNumber(payload.timeoutMs || 5000), + timeoutMessage: '风险处置提交超时,请刷新状态后重试。' + } + ) + return { + disposition: normalizeRiskDisposition(result?.disposition), + event: result?.event && typeof result.event === 'object' ? result.event : {}, + replayed: Boolean(result?.replayed), + requestId + } +} diff --git a/web/src/views/ApprovalCenterView.vue b/web/src/views/ApprovalCenterView.vue index 6604f65..caf084b 100644 --- a/web/src/views/ApprovalCenterView.vue +++ b/web/src/views/ApprovalCenterView.vue @@ -48,18 +48,21 @@