From 242d68c36f03f9c43d350b9e1551b844000b4bfd Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Thu, 16 Jul 2026 16:52:12 +0800 Subject: [PATCH] feat(approval): add task workflow and waiver decisions --- .../CONCEPT.md | 10 +- .../TODO.md | 10 +- ...al-task-state-and-workspace-regressions.md | 8 + .../bugs/risk-waiver-projection-and-audit.md | 8 + .../versions/20260716_0013_approval_tasks.py | 400 +++++++++ .../20260716_0014_risk_waiver_decision.py | 248 ++++++ server/scripts/backfill_approval_tasks.py | 361 +++++++++ .../app/api/v1/endpoints/approval_tasks.py | 394 +++++++++ .../reimbursement_approval_actions.py | 10 + .../app/api/v1/endpoints/risk_observations.py | 58 +- server/src/app/api/v1/router.py | 2 + server/src/app/db/base.py | 3 + server/src/app/db/migration_preflight.py | 50 +- server/src/app/db/schema_ownership.py | 2 + server/src/app/main.py | 14 +- server/src/app/models/__init__.py | 3 + server/src/app/models/approval_task.py | 361 +++++++++ server/src/app/models/risk_disposition.py | 67 +- server/src/app/schemas/approval_task.py | 279 +++++++ server/src/app/schemas/reimbursement.py | 18 +- server/src/app/schemas/risk_disposition.py | 66 +- server/src/app/schemas/risk_observation.py | 4 +- .../services/approval_task_access_policy.py | 266 ++++++ .../src/app/services/approval_task_actions.py | 762 ++++++++++++++++++ .../app/services/approval_task_backfill.py | 761 +++++++++++++++++ .../src/app/services/approval_task_batch.py | 207 +++++ .../app/services/approval_task_lifecycle.py | 499 ++++++++++++ .../app/services/approval_task_projection.py | 566 +++++++++++++ .../approval_task_projection_refresh.py | 117 +++ .../app/services/approval_task_protocol.py | 223 +++++ .../src/app/services/approval_task_query.py | 278 +++++++ .../app/services/approval_task_scheduler.py | 76 ++ server/src/app/services/approval_task_sla.py | 147 ++++ .../services/expense_claim_action_protocol.py | 69 +- .../services/expense_claim_approval_flow.py | 23 +- .../app/services/expense_claim_return_flow.py | 18 +- .../app/services/expense_claim_risk_gate.py | 12 + server/src/app/services/expense_claims.py | 12 + .../risk_disposition_read_projection.py | 214 +++++ server/src/app/services/risk_dispositions.py | 196 ++++- server/src/app/services/risk_observations.py | 17 +- .../services/risk_waiver_decision_policy.py | 154 ++++ server/tests/test_alembic_migrations.py | 292 ++++++- server/tests/test_approval_task_actions.py | 436 ++++++++++ server/tests/test_approval_task_backfill.py | 409 ++++++++++ ...test_approval_task_concurrency_postgres.py | 237 ++++++ server/tests/test_approval_task_projection.py | 485 +++++++++++ .../test_approval_task_query_and_batch.py | 600 ++++++++++++++ server/tests/test_expense_claim_risk_gate.py | 44 +- server/tests/test_migration_preflight.py | 8 + .../test_risk_disposition_read_projection.py | 616 ++++++++++++++ server/tests/test_risk_dispositions.py | 351 +++++++- server/tests/test_schema_ownership.py | 2 + .../risk-observation-evidence-card.css | 36 +- .../components/risk-waiver-action-dialog.css | 231 ++++++ .../styles/components/risk-waiver-record.css | 171 ++++ .../approval/ApprovalAssignmentDialog.vue | 409 ++++++++++ .../approval/ApprovalBatchResultPanel.vue | 193 +++++ .../approval/ApprovalParticipantsDialog.vue | 497 ++++++++++++ .../components/approval/ApprovalTaskQueue.vue | 696 ++++++++++++++++ .../approval/ApprovalTaskWorkspace.vue | 390 +++++++++ web/src/components/layout/TopBar.vue | 25 - web/src/components/layout/topBarKpis.js | 7 - .../components/shared/ReturnReasonDialog.vue | 12 + .../travel/RiskObservationEvidenceCard.vue | 312 +++++-- .../travel/RiskWaiverActionDialog.vue | 264 ++++++ .../components/travel/RiskWaiverRecord.vue | 161 ++++ web/src/composables/useApprovalTaskQueue.js | 339 ++++++++ .../useDocumentCenterArchiveRows.js | 43 + web/src/services/approvalTasks.js | 535 ++++++++++++ web/src/services/riskObservations.js | 109 +++ web/src/utils/documentCenterViewModel.js | 7 +- web/src/views/AppShellRouteView.vue | 2 +- web/src/views/DocumentsCenterView.vue | 145 ++-- .../views/scripts/approvalTaskQueueState.js | 191 +++++ .../scripts/approvalTaskQueueViewModel.js | 181 +++++ web/src/views/scripts/approvalTaskRetry.js | 27 + .../views/scripts/approvalTaskRouteState.js | 59 ++ .../approval-task-components-compile.test.mjs | 48 ++ .../approval-task-queue-composable.test.mjs | 201 +++++ web/tests/approval-task-queue-state.test.mjs | 127 +++ .../approval-task-queue-view-model.test.mjs | 89 ++ .../approval-task-retry-and-route.test.mjs | 67 ++ web/tests/approval-task-workspace.test.mjs | 44 + web/tests/approval-tasks-service.test.mjs | 233 ++++++ .../documents-center-status-filter.test.mjs | 45 +- .../reimbursement-list-preview-fetch.test.mjs | 14 +- web/tests/risk-disposition-ui.test.mjs | 9 +- web/tests/risk-waiver-flow.test.mjs | 215 +++++ 89 files changed, 16313 insertions(+), 294 deletions(-) create mode 100644 document/development/2026-07-16/dev-logs/bugs/approval-task-state-and-workspace-regressions.md create mode 100644 document/development/2026-07-16/dev-logs/bugs/risk-waiver-projection-and-audit.md create mode 100644 server/alembic/versions/20260716_0013_approval_tasks.py create mode 100644 server/alembic/versions/20260716_0014_risk_waiver_decision.py create mode 100644 server/scripts/backfill_approval_tasks.py create mode 100644 server/src/app/api/v1/endpoints/approval_tasks.py create mode 100644 server/src/app/models/approval_task.py create mode 100644 server/src/app/schemas/approval_task.py create mode 100644 server/src/app/services/approval_task_access_policy.py create mode 100644 server/src/app/services/approval_task_actions.py create mode 100644 server/src/app/services/approval_task_backfill.py create mode 100644 server/src/app/services/approval_task_batch.py create mode 100644 server/src/app/services/approval_task_lifecycle.py create mode 100644 server/src/app/services/approval_task_projection.py create mode 100644 server/src/app/services/approval_task_projection_refresh.py create mode 100644 server/src/app/services/approval_task_protocol.py create mode 100644 server/src/app/services/approval_task_query.py create mode 100644 server/src/app/services/approval_task_scheduler.py create mode 100644 server/src/app/services/approval_task_sla.py create mode 100644 server/src/app/services/risk_disposition_read_projection.py create mode 100644 server/src/app/services/risk_waiver_decision_policy.py create mode 100644 server/tests/test_approval_task_actions.py create mode 100644 server/tests/test_approval_task_backfill.py create mode 100644 server/tests/test_approval_task_concurrency_postgres.py create mode 100644 server/tests/test_approval_task_projection.py create mode 100644 server/tests/test_approval_task_query_and_batch.py create mode 100644 server/tests/test_risk_disposition_read_projection.py create mode 100644 web/src/assets/styles/components/risk-waiver-action-dialog.css create mode 100644 web/src/assets/styles/components/risk-waiver-record.css create mode 100644 web/src/components/approval/ApprovalAssignmentDialog.vue create mode 100644 web/src/components/approval/ApprovalBatchResultPanel.vue create mode 100644 web/src/components/approval/ApprovalParticipantsDialog.vue create mode 100644 web/src/components/approval/ApprovalTaskQueue.vue create mode 100644 web/src/components/approval/ApprovalTaskWorkspace.vue create mode 100644 web/src/components/travel/RiskWaiverActionDialog.vue create mode 100644 web/src/components/travel/RiskWaiverRecord.vue create mode 100644 web/src/composables/useApprovalTaskQueue.js create mode 100644 web/src/composables/useDocumentCenterArchiveRows.js create mode 100644 web/src/services/approvalTasks.js create mode 100644 web/src/views/scripts/approvalTaskQueueState.js create mode 100644 web/src/views/scripts/approvalTaskQueueViewModel.js create mode 100644 web/src/views/scripts/approvalTaskRetry.js create mode 100644 web/src/views/scripts/approvalTaskRouteState.js create mode 100644 web/tests/approval-task-components-compile.test.mjs create mode 100644 web/tests/approval-task-queue-composable.test.mjs create mode 100644 web/tests/approval-task-queue-state.test.mjs create mode 100644 web/tests/approval-task-queue-view-model.test.mjs create mode 100644 web/tests/approval-task-retry-and-route.test.mjs create mode 100644 web/tests/approval-task-workspace.test.mjs create mode 100644 web/tests/approval-tasks-service.test.mjs create mode 100644 web/tests/risk-waiver-flow.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 02dfaee..ccce517 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,14 +133,14 @@ - 以风险、金额、预算影响、等待时长和证据完整度排序。 - 支持批量处理低风险事项,行内保留真实按钮和键盘可访问入口。 - 高风险审批展示模型版本、规则版本、政策依据、证据和人工覆盖原因。 -- 当前首个可用切片由 `/api/v1/approval-workbench/items` 返回统一投影,优先级由风险、SLA、预算、金额和证据完整度共同计算,并返回每个分项、排序原因和仅供参考的 AI 建议;前端展示状态与服务端业务状态分离,避免乐观并发前置条件被中文标签覆盖。 +- 正式审批任务切片由 `/api/v1/approval-tasks` 返回租户安全的数据库分页投影,优先级由风险、SLA、金额和证据完整度共同计算;任务保存节点进入时间、处理人、乐观版本、批量资格和权威动作集合。旧 `/approval-workbench/items` 仍可承担 advisory-only 摘要,但不再作为“待我审核”的业务动作事实源。 - 批准、退回和付款均使用动作协议:客户端在确认时冻结 `request_id`、预期状态和预期审批节点;服务端以租户 + 操作人 + 请求 ID 唯一账本、请求指纹、PostgreSQL advisory lock、Claim 行锁和同事务业务事件确保相同请求可安全重放,不同内容或陈旧快照以 409 拒绝。 - 高风险门禁同时读取持久化风险观察和尚未落表的原始风险标记;未处置的严重/高危可行动风险在路由、预算和状态修改前阻断,原始重大风险持久化失败时 fail-closed。仅明确标记为 `route_review` 的路由型风险进入对应审批节点而不冒充已解决。 - 门禁只消费与当前单据业务阶段一致的显式风险:申请阶段不会被明确标记为 `reimbursement` 的风险阻断,反之亦然;缺少阶段的未知重大风险仍保守处理。`route_review` 是显式路由语义,审批路由可以继续但风险仍保留在证据和后续复核中。 -- 风险处置采用判定与生命周期双状态:支持确认风险、误报、补件、开始整改、申请豁免和完成处置;每个动作绑定请求指纹、预期版本、操作人和只追加事件。完整证据只对管理员或当前审批人开放,风险池对财务/管理/预算角色开放,处置写入由管理员或锁内重新确认的当前审批人完成。 +- 风险处置采用判定与生命周期双状态:支持确认风险、误报、补件、开始整改、申请豁免、财务/管理层批准或拒绝豁免,以及完成处置;每个动作绑定请求指纹、预期版本、操作人和只追加事件。申请人不得自批/自拒,仅在职 finance/executive 且非申请人可决定,过期申请 fail-closed。完整证据只对管理员或当前审批人开放,风险池对财务/管理/预算角色开放。 - 并发锁顺序固定为 Claim → RiskObservation → RiskDisposition,审批动作、人工处置和 Hermes 扫描共用 Claim 锁。扫描在锁外计算、锁内校验状态与更新时间,过期快照丢弃并等待下一轮,避免旧图结果覆盖刚完成的人工处置。 - 所有单据动作首次完整 API 响应写入动作账本;风险处置首次完整响应在 append-only 事件 INSERT 前原子写入。相同请求重放只返回首次版本,风险旧事件无快照时也只从目标版本及以前的审计链重建,不读取当前 Claim、当前处置投影或后续事件。 -- 尚未完成边界:工作台当前仍为服务内排序后截断;SLA 仍基于申请提交时间而不是当前节点进入时间;批量审批、委托/转交、加签/会签和豁免批准/拒绝将在本阶段后续切片完成。 +- 审批任务支持低风险批量通过、委托/撤销委托、永久转交、顺序加签、并行会签和手动/调度 SLA 升级;SLA 以当前节点进入时间为基准。前端“待我审核”完全消费服务端分页、筛选、`available_actions` 与版本,筛选和页码可从详情返回恢复。尚未完成的主要边界是统一必要性/政策证据摘要、真实企微/钉钉/邮件触达及外部结果回写。 #### CFO 价值看板 @@ -548,6 +548,10 @@ docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv \ ## 本轮实现记录 +- 2026-07-16(审批任务正式化):新增 `approval_tasks` 与 append-only `approval_task_events`,报销提交、批准、退回和下一节点推进统一生成/完成任务。支持数据库分页筛选、可解释优先级、当前节点 SLA、委托/撤销、转交、顺序加签、并行会签、手动/调度升级、独立事务批量部分成功、乐观版本和不可变幂等响应;历史回填默认 dry-run。 +- 2026-07-16(风险豁免决定):豁免申请新增范围、条件与到期时间,只有在职 finance/executive 且非申请人可批准或拒绝;过期 fail-closed。风险变更在同一事务刷新开放审批任务的风险、证据、批量资格和优先级,不重置 SLA 窗口;前端展示完整追加审计链并在到期时刷新权威投影。 +- 2026-07-16(审批工作台接入):单据中心“审核单”切换到 `ApprovalTaskWorkspace`,移除旧 Claim 预览列表和静态“批量通过 23 条”提示。工作台提供真实批准、退回、委托、转交、加签、会签、升级和批量动作;未筛选 pending 摘要与队列筛选结果分离,详情返回恢复任务筛选/页码,相同负载安全重试、编辑负载自动换新幂等键。 +- 2026-07-16(阶段验证):容器内相关后端 `122 passed, 4 skipped`、前端审批/单据中心/风险专项 `60 passed`、Ruff 与 Vite 构建通过;一次性 PostgreSQL 17 空库迁移循环 `1 passed`、审批任务和风险并发 `3 passed`。完整前端套件未新增真实回归,仍有 31 项既有陈旧断言/质量债待后续硬化阶段清理。 - 2026-07-16(审批动作协议):批准、退回和付款统一接入租户化幂等账本、请求指纹、乐观状态/节点前置条件、advisory lock 与 Claim 行锁;完全相同的网络重试不重复扣减预算、写审批、生成事件或付款,不同内容和陈旧状态稳定返回 409。前端确认流在用户确认时冻结请求和前置条件,并保持服务端状态与展示标签分离。 - 2026-07-16(风险门禁与处置):高危/严重可行动风险在任何审批副作用前阻断;类型化风险处置覆盖确认、误报、补件、整改、豁免申请和完成处置,具备乐观版本、权限守卫和只追加审计。门禁合并持久观察与未匹配原始风险,重大观察落库失败 fail-closed;审批、处置和 Hermes 扫描通过 Claim 公共锁协调,扫描过期快照不会覆盖新事实。 - 2026-07-16(审批工作台):审批中心接入可解释优先级投影,展示风险、预算、金额、等待时长、证据完整度和 advisory-only AI 建议;风险证据卡读取真实持久字段、显示生命周期和明确动作,但不提供绕过风险门禁的直接审批入口。 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 99a4e4f..fe74daa 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 @@ -177,8 +177,10 @@ 证据:`20260716_0012` 为 append-only 风险事件增加 `response_json`;新事件在 INSERT 前原子写入完整响应,重放只返回目标事件版本及以前的事件。0012 前无快照事件从 `after_json` 与 `version <= target` 的审计链安全重建,不读取当前处置投影。 - [x] [CONCEPT: 审批例外工作台] 建立高风险门禁与类型化风险处置生命周期。 证据:严重/高危可行动风险在任何审批副作用前阻断;确认风险、误报、补件、整改、豁免申请和完成处置具有乐观版本、请求指纹、租户权限、只追加事件和 Claim 共用锁。未持久化的原始高风险仍保守阻断,重大风险持久化失败 fail-closed。 -- [ ] [CONCEPT: 审批例外工作台] 补齐批量审批、委托、转交、加签、会签和超时升级交互。 - 当前进度:单笔批准/退回/付款协议、原响应快照重放、优先级工作台、风险证据和处置状态已完成;批量动作、委托/转交、加签/会签、当前节点进入时间 SLA、豁免批准/拒绝及真正的数据库分页尚未实现,不以现有单笔基础冒充完成。 +- [x] [CONCEPT: 审批例外工作台] 补齐批量审批、委托、转交、加签、会签和超时升级交互。 + 证据:`approval_tasks`/`approval_task_events`、`/api/v1/approval-tasks`、`ApprovalTaskWorkspace.vue` 与 `20260716_0013`;支持数据库分页筛选、节点进入时间 SLA、委托/撤销、永久转交、顺序加签、并行会签、手动/调度升级和逐项独立事务批量结果。服务端动作集合与版本为唯一权限事实,筛选/页码可从详情返回恢复。 +- [x] [CONCEPT: 审批例外工作台] 完成风险豁免申请、批准、拒绝、到期和审计展示闭环。 + 证据:`20260716_0014`、`risk_waiver_decision_policy.py`、风险读投影与 `RiskWaiverActionDialog.vue`/`RiskWaiverRecord.vue`;申请人不能决定自己的申请,仅在职 finance/executive 可决定,过期 fail-closed,全部事件保留版本、人员、请求 ID、原因和关键前后快照。 - [ ] [CONCEPT: 审批例外工作台] 接入真实企微/钉钉/邮件触达和处理结果回写。 - [ ] [CONCEPT: AI 记忆与自动化设置] 新增“我的 AI 记忆”,支持来源解释、修改、忘记和关闭个性化。 - [x] [CONCEPT: AI 记忆与自动化设置] 在申请核对表展示常用出行方式的记忆来源、证据数量和“忘记此偏好”,并在保存/提交后区分候选记录与已应用回执。 @@ -226,8 +228,8 @@ - [ ] [CONCEPT: 测试方案] 为 Alembic baseline、升级、旧数据迁移和回滚边界补充 Postgres 集成测试。 - [x] [CONCEPT: 测试方案] 为当前 migration-owned schema 切片补充一次性 PostgreSQL 集成测试和危险 URL 防误连门禁。 证据:`test_alembic_migrations.py` 默认无显式 URL 时跳过,主机和库名必须带 disposable 标记;当前 0012 Head 在 tmpfs PostgreSQL 17 中通过完整迁移验证,覆盖空库升级、重复升级、审批动作账本、风险处置复合租户约束、只追加事件触发器、不可变响应快照及有数据降级拒绝、组织 active 脏数据升级前拒绝、版本化 few-shot 无损降级拒绝、外键级联、base 降级、legacy 哨兵保留、漂移拒绝和再次升级;临时容器自动清理,持久化开发库未被修改。完整 legacy baseline 仍保留在上一条未完成项中。 -- [x] [CONCEPT: 测试方案] 验证审批动作幂等、风险门禁、风险处置权限和共同锁顺序。 - 证据:容器内审批、费用 Case、路由、租户、风险和严格重放组合 182 项,迁移/所有权 54 项、前端审批/风险 73 项通过;一次性 PostgreSQL 17 上迁移与真实并发共 17 项通过,证明风险重新打开持有 Claim 锁时审批等待,随后按最新风险阻断且不留下动作账本或审批副作用。 +- [x] [CONCEPT: 测试方案] 验证审批动作幂等、任务编排、风险门禁、豁免决定权限和共同锁顺序。 + 证据:容器内本阶段后端回归 `122 passed, 4 skipped`、前端审批/单据中心/风险专项 `60 passed`、Ruff 与生产构建通过;一次性 PostgreSQL 17 空库迁移循环 `1 passed`、审批任务和风险并发 `3 passed`,证明同 request ID 并发只生成一个事件、陈旧版本只有一个胜者、风险重新打开与审批竞争时按 Claim 公共锁读取最新事实。 - [ ] [CONCEPT: 测试方案] 为连接器幂等、重试、回执、失败恢复、重复付款和对账补充测试。 - [ ] [CONCEPT: 测试方案] 跑通申请 → 票据 → 报销 → 预审 → 审批 → 付款 → 入账 → 归档端到端。 当前进度:申请批准 → 自动报销草稿 → 票据归集 → 预审 → 报销提交已在同一 Case 中跑通;付款回执、ERP 入账和对账仍未接入。 diff --git a/document/development/2026-07-16/dev-logs/bugs/approval-task-state-and-workspace-regressions.md b/document/development/2026-07-16/dev-logs/bugs/approval-task-state-and-workspace-regressions.md new file mode 100644 index 0000000..611f5ec --- /dev/null +++ b/document/development/2026-07-16/dev-logs/bugs/approval-task-state-and-workspace-regressions.md @@ -0,0 +1,8 @@ +## 修复记录 + +- 2026-07-16 16:50:44 CST:修复 SQLAlchemy Session 关闭 autoflush 后的两处审批任务状态错误。根任务完成后先显式 flush 再生成下一节点,避免仍被查询为开放任务并误标 superseded;SLA 扫描每次升级后 flush,避免同一事务重复扫描生成相同版本事件。 +- 2026-07-16 16:50:44 CST:修复审批队列前后端 SLA 与权限动作契约不一致。前端统一使用 `due_soon`,服务端正式支持 `escalated` 并拒绝未知筛选;批准/退回仍要求 `can_act`,委托撤销、管理员转交和 SLA 升级则严格按服务端 `available_actions` 展示,不再被前端错误隐藏。 +- 2026-07-16 16:50:44 CST:修复任务筛选总数污染全局待办数、批量审批后摘要不刷新、队列错误与动作错误相互覆盖,以及详情返回丢失任务筛选/页码的问题。工作台独立查询未筛选 pending 摘要,批量成功聚合回流单据更新,审核路由保存专属风险/SLA/关键词/页码状态,旧响应由请求代次丢弃。 +- 2026-07-16 16:50:44 CST:修复审批动作网络失败后允许编辑负载却继续复用旧幂等键的问题。单项、委托/转交、加签/会签和批量审批均对请求负载生成稳定指纹;完全相同的重试复用 request ID,理由、候选人、参与人或批量意见变化时自动生成新 ID,避免同键不同内容冲突被误解为操作失败。 +- 2026-07-16 16:50:44 CST:验证全部在 `local-x-financial-linux` 容器完成。审批/风险/迁移相关后端 `122 passed, 4 skipped`,变更 Python 文件 Ruff 通过;审批、单据中心和风险前端专项 `60 passed`,Vite 生产构建通过;一次性 PostgreSQL 17 空库迁移循环 `1 passed`,审批任务与风险锁并发 `3 passed`,探针数据库均已销毁。 +- 2026-07-16 16:50:44 CST:执行 `git fetch --all --prune`、工作区状态和上下游提交差异检查;上游无新增提交,本地相对 `origin/main` ahead 16,最近两个检查点为 `28b834ed` 不可变审批响应重放和 `4940ebc4` 安全风险处置。本次继续排除 7 个用户维护的财务规则工作簿及历史未跟踪开发文档。 diff --git a/document/development/2026-07-16/dev-logs/bugs/risk-waiver-projection-and-audit.md b/document/development/2026-07-16/dev-logs/bugs/risk-waiver-projection-and-audit.md new file mode 100644 index 0000000..19e669b --- /dev/null +++ b/document/development/2026-07-16/dev-logs/bugs/risk-waiver-projection-and-audit.md @@ -0,0 +1,8 @@ +## 修复记录 + +- 2026-07-16 16:50:44 CST:修复首次风险尚无 disposition 时前端丢弃 Observation 顶层 `available_actions/read_only_reason`、导致“确认风险”等首次处置入口永久隐藏的问题。前端只消费服务端权威投影,缺少 disposition 时构造 version 0 展示模型,不进行角色猜测或本地越权推导。 +- 2026-07-16 16:50:44 CST:修复页面持续打开跨过豁免到期点后仍显示“豁免有效”的问题。证据卡按 `waiver_expires_at` 注册到期刷新,切换单据和卸载时清理定时器;服务端过期门禁继续 fail-closed,前端不再长期展示过期授权。 +- 2026-07-16 16:50:44 CST:修复风险豁免界面只展示当前快照、拒绝后重新申请会让上一轮审计信息消失的问题。服务保留 actor、request ID、before/after JSON,记录组件展示申请、批准、拒绝的追加事件、版本、人员、时间、原因、申请关键字段和请求 ID。 +- 2026-07-16 16:50:44 CST:风险豁免申请、批准、拒绝和到期后的任务投影刷新与风险处置同事务执行,保持 Claim → Observation → Disposition → Task 固定锁顺序;刷新风险、证据、批量资格和优先级时保留 SLA 时间窗与显式升级原因。 +- 2026-07-16 16:50:44 CST:容器内风险处置、门禁与审批投影定向 `30 passed`,扩展回归 `39 passed, 3 skipped`,前端风险专项 `12 passed`,生产构建通过;一次性 PostgreSQL 并发验证包含风险重新打开与审批竞争,未发现绕过或脏写。 +- 2026-07-16 16:50:44 CST:Git 拉取检查确认上游无新增提交、本地 ahead 16;本记录基于当前未提交修复,未纳入用户维护的财务规则工作簿和历史开发日志。 diff --git a/server/alembic/versions/20260716_0013_approval_tasks.py b/server/alembic/versions/20260716_0013_approval_tasks.py new file mode 100644 index 0000000..4fba7e3 --- /dev/null +++ b/server/alembic/versions/20260716_0013_approval_tasks.py @@ -0,0 +1,400 @@ +"""add tenant-safe approval task work queue and append-only events + +Revision ID: 20260716_0013 +Revises: 20260716_0012 +Create Date: 2026-07-16 18:20:00 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "20260716_0013" +down_revision: str | None = "20260716_0012" +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_0013 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() + task_count = int(bind.scalar(sa.text("SELECT COUNT(*) FROM approval_tasks")) or 0) + event_count = int(bind.scalar(sa.text("SELECT COUNT(*) FROM approval_task_events")) or 0) + if task_count or event_count: + raise RuntimeError( + "cannot downgrade approval tasks: audit chain is not empty " + f"({task_count} tasks, {event_count} events)" + ) + + +def upgrade() -> None: + _require_postgresql() + op.create_table( + "approval_tasks", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=64), nullable=False), + # expense_claims 仍由 legacy bootstrap 管理,故这里只保存软引用。 + sa.Column("claim_id", sa.String(length=36), nullable=False), + sa.Column("expense_case_id", sa.String(length=36), nullable=True), + sa.Column("node_instance_id", sa.String(length=120), nullable=False), + sa.Column("node_entry_key", sa.String(length=160), nullable=False), + sa.Column("parent_task_id", sa.String(length=36), nullable=True), + sa.Column("task_kind", sa.String(length=24), nullable=False), + sa.Column("node_key", sa.String(length=32), nullable=False), + sa.Column("node_label", sa.String(length=120), nullable=False), + sa.Column("node_sequence", sa.Integer(), nullable=False), + sa.Column("sequence_order", sa.Integer(), nullable=False, server_default="0"), + sa.Column("coordination_mode", sa.String(length=32), nullable=False), + sa.Column("owner_kind", sa.String(length=20), nullable=False), + sa.Column("owner_key", sa.String(length=120), nullable=False), + sa.Column("owner_employee_id", sa.String(length=36), nullable=True), + sa.Column("owner_name", sa.String(length=120), nullable=False), + sa.Column("assignee_kind", sa.String(length=20), nullable=False), + sa.Column("assignee_key", sa.String(length=120), nullable=False), + sa.Column("assignee_employee_id", sa.String(length=36), nullable=True), + sa.Column("assignee_name", sa.String(length=120), nullable=False), + sa.Column("delegated_by", sa.String(length=120), nullable=True), + sa.Column("delegation_expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "status", + sa.String(length=24), + nullable=False, + server_default="waiting", + ), + sa.Column("decision", sa.String(length=20), nullable=True), + sa.Column("opinion", sa.Text(), nullable=True), + sa.Column("version", sa.Integer(), nullable=False, server_default="1"), + sa.Column("claim_status_snapshot", sa.String(length=30), nullable=False), + sa.Column("claim_stage_snapshot", sa.String(length=50), nullable=False), + sa.Column("entered_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("entered_at_source", sa.String(length=24), nullable=False), + sa.Column("activated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("sla_hours_snapshot", sa.Integer(), nullable=False), + sa.Column("due_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("escalation_level", sa.Integer(), nullable=False, server_default="0"), + sa.Column("escalated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("next_escalation_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("priority_score", sa.Integer(), nullable=False, server_default="0"), + sa.Column( + "priority_tier", + sa.String(length=16), + nullable=False, + server_default="normal", + ), + sa.Column( + "priority_reasons_json", + sa.JSON(), + nullable=False, + server_default=sa.text("'[]'::json"), + ), + sa.Column( + "risk_level", + sa.String(length=16), + nullable=False, + server_default="low", + ), + sa.Column("open_risk_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column( + "evidence_completeness", + sa.Numeric(precision=5, scale=4), + nullable=False, + server_default="0", + ), + sa.Column( + "batch_eligible", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + sa.Column( + "batch_block_reasons_json", + sa.JSON(), + nullable=False, + server_default=sa.text("'[]'::json"), + ), + sa.Column( + "projection_updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + 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( + "task_kind IN ('root', 'add_sign', 'countersign')", + name="ck_approval_tasks_task_kind", + ), + sa.CheckConstraint( + "node_key IN ('direct_manager', 'budget_manager', 'finance')", + name="ck_approval_tasks_node_key", + ), + sa.CheckConstraint( + "coordination_mode IN ('single', 'sequential_before_owner', 'parallel_all')", + name="ck_approval_tasks_coordination_mode", + ), + sa.CheckConstraint( + "owner_kind IN ('employee', 'role')", + name="ck_approval_tasks_owner_kind", + ), + sa.CheckConstraint( + "assignee_kind IN ('employee', 'role')", + name="ck_approval_tasks_assignee_kind", + ), + sa.CheckConstraint( + "status IN ('waiting', 'pending', 'completed', 'returned', " + "'cancelled', 'superseded')", + name="ck_approval_tasks_status", + ), + sa.CheckConstraint( + "decision IS NULL OR decision IN ('approved', 'returned')", + name="ck_approval_tasks_decision", + ), + sa.CheckConstraint( + "entered_at_source IN ('workflow_event', 'submitted_at', 'updated_at', 'backfill')", + name="ck_approval_tasks_entered_at_source", + ), + sa.CheckConstraint( + "priority_tier IN ('normal', 'high', 'urgent')", + name="ck_approval_tasks_priority_tier", + ), + sa.CheckConstraint( + "risk_level IN ('low', 'medium', 'high', 'critical')", + name="ck_approval_tasks_risk_level", + ), + sa.CheckConstraint( + "(task_kind = 'root' AND parent_task_id IS NULL AND sequence_order = 0) OR " + "(task_kind IN ('add_sign', 'countersign') " + "AND parent_task_id IS NOT NULL AND sequence_order > 0)", + name="ck_approval_tasks_parent_shape", + ), + sa.CheckConstraint( + "(status = 'completed' AND decision = 'approved' " + "AND completed_at IS NOT NULL AND cancelled_at IS NULL) OR " + "(status = 'returned' AND decision = 'returned' " + "AND completed_at IS NOT NULL AND cancelled_at IS NULL) OR " + "(status IN ('cancelled', 'superseded') AND decision IS NULL " + "AND completed_at IS NULL AND cancelled_at IS NOT NULL) OR " + "(status IN ('waiting', 'pending') AND decision IS NULL " + "AND completed_at IS NULL AND cancelled_at IS NULL)", + name="ck_approval_tasks_lifecycle", + ), + sa.CheckConstraint( + "status NOT IN ('pending', 'completed', 'returned') OR activated_at IS NOT NULL", + name="ck_approval_tasks_activation", + ), + sa.CheckConstraint( + "delegation_expires_at IS NULL OR delegated_by IS NOT NULL", + name="ck_approval_tasks_delegation", + ), + sa.CheckConstraint("version >= 1", name="ck_approval_tasks_version"), + sa.CheckConstraint("node_sequence >= 1", name="ck_approval_tasks_node_sequence"), + sa.CheckConstraint("sla_hours_snapshot > 0", name="ck_approval_tasks_sla_hours"), + sa.CheckConstraint("due_at >= entered_at", name="ck_approval_tasks_due_at"), + sa.CheckConstraint("escalation_level >= 0", name="ck_approval_tasks_escalation"), + sa.CheckConstraint( + "priority_score >= 0 AND priority_score <= 100", + name="ck_approval_tasks_priority_score", + ), + sa.CheckConstraint( + "open_risk_count >= 0", + name="ck_approval_tasks_open_risk_count", + ), + sa.CheckConstraint( + "evidence_completeness >= 0 AND evidence_completeness <= 1", + name="ck_approval_tasks_evidence_completeness", + ), + sa.ForeignKeyConstraint( + ["tenant_id", "parent_task_id"], + ["approval_tasks.tenant_id", "approval_tasks.id"], + name="fk_approval_tasks_tenant_parent", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["tenant_id", "expense_case_id"], + ["expense_cases.tenant_id", "expense_cases.id"], + name="fk_approval_tasks_tenant_expense_case", + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("tenant_id", "id", name="uq_approval_tasks_tenant_id"), + sa.UniqueConstraint( + "tenant_id", + "node_entry_key", + name="uq_approval_tasks_tenant_node_entry", + ), + sa.UniqueConstraint( + "tenant_id", + "node_instance_id", + "assignee_kind", + "assignee_key", + name="uq_approval_tasks_node_participant", + ), + ) + op.create_index( + "uq_approval_tasks_open_root_per_claim", + "approval_tasks", + ["tenant_id", "claim_id"], + unique=True, + postgresql_where=sa.text( + "task_kind = 'root' AND status IN ('waiting', 'pending')" + ), + ) + op.create_index( + "ix_approval_tasks_personal_inbox", + "approval_tasks", + ["tenant_id", "assignee_kind", "assignee_key", "status", "due_at"], + unique=False, + ) + op.create_index( + "ix_approval_tasks_tenant_queue", + "approval_tasks", + ["tenant_id", "status", "priority_score", "due_at"], + unique=False, + ) + op.create_index( + "ix_approval_tasks_tenant_claim", + "approval_tasks", + ["tenant_id", "claim_id", "node_sequence"], + unique=False, + ) + op.create_index( + "ix_approval_tasks_tenant_node", + "approval_tasks", + ["tenant_id", "node_instance_id", "sequence_order"], + unique=False, + ) + + op.create_table( + "approval_task_events", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=64), nullable=False), + sa.Column("task_id", sa.String(length=36), nullable=False), + sa.Column("node_instance_id", sa.String(length=120), nullable=False), + sa.Column("event_type", sa.String(length=50), nullable=False), + sa.Column("actor_id", sa.String(length=120), nullable=False), + sa.Column("actor_name", sa.String(length=120), nullable=False), + sa.Column("actor_type", sa.String(length=20), nullable=False), + sa.Column("request_id", sa.String(length=120), nullable=False), + sa.Column("expected_task_version", sa.Integer(), nullable=False), + sa.Column("result_task_version", sa.Integer(), nullable=False), + sa.Column("payload_fingerprint", sa.String(length=80), nullable=False), + sa.Column("payload_json", sa.JSON(), nullable=False), + sa.Column("before_json", sa.JSON(), nullable=False), + sa.Column("after_json", sa.JSON(), nullable=False), + sa.Column("response_json", sa.JSON(), nullable=False), + sa.Column("approval_action_ledger_id", sa.String(length=36), nullable=True), + sa.Column("business_event_id", sa.String(length=36), nullable=True), + sa.Column("correlation_id", sa.String(length=64), nullable=True), + sa.Column("causation_id", sa.String(length=64), nullable=True), + sa.Column( + "occurred_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.CheckConstraint( + "length(trim(event_type)) > 0", + name="ck_approval_task_events_type", + ), + sa.CheckConstraint( + "actor_type IN ('user', 'system', 'agent', 'service')", + name="ck_approval_task_events_actor_type", + ), + sa.CheckConstraint( + "expected_task_version >= 0 AND result_task_version >= 1 " + "AND result_task_version >= expected_task_version", + name="ck_approval_task_events_version", + ), + sa.ForeignKeyConstraint( + ["tenant_id", "task_id"], + ["approval_tasks.tenant_id", "approval_tasks.id"], + name="fk_approval_task_events_tenant_task", + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "tenant_id", + "actor_id", + "request_id", + name="uq_approval_task_events_actor_request", + ), + sa.UniqueConstraint( + "tenant_id", + "task_id", + "result_task_version", + name="uq_approval_task_events_task_version", + ), + ) + op.create_index( + "ix_approval_task_events_tenant_task_time", + "approval_task_events", + ["tenant_id", "task_id", "occurred_at"], + unique=False, + ) + op.create_index( + "ix_approval_task_events_tenant_node_time", + "approval_task_events", + ["tenant_id", "node_instance_id", "occurred_at"], + unique=False, + ) + op.execute( + "CREATE FUNCTION reject_approval_task_event_mutation() " + "RETURNS trigger AS $$ " + "BEGIN " + "RAISE EXCEPTION 'approval_task_events is append-only'; " + "RETURN OLD; " + "END; " + "$$ LANGUAGE plpgsql" + ) + op.execute( + "CREATE TRIGGER trg_approval_task_events_append_only " + "BEFORE UPDATE OR DELETE ON approval_task_events " + "FOR EACH ROW EXECUTE FUNCTION reject_approval_task_event_mutation()" + ) + + +def downgrade() -> None: + _require_postgresql() + # 任务投影与事件共同组成审批审计链,降级前必须显式迁移或清空数据。 + _require_empty_audit_chain_for_downgrade() + op.execute("DROP TRIGGER trg_approval_task_events_append_only ON approval_task_events") + op.execute("DROP FUNCTION reject_approval_task_event_mutation()") + op.drop_index( + "ix_approval_task_events_tenant_node_time", + table_name="approval_task_events", + ) + op.drop_index( + "ix_approval_task_events_tenant_task_time", + table_name="approval_task_events", + ) + op.drop_table("approval_task_events") + op.drop_index("ix_approval_tasks_tenant_node", table_name="approval_tasks") + op.drop_index("ix_approval_tasks_tenant_claim", table_name="approval_tasks") + op.drop_index("ix_approval_tasks_tenant_queue", table_name="approval_tasks") + op.drop_index("ix_approval_tasks_personal_inbox", table_name="approval_tasks") + op.drop_index("uq_approval_tasks_open_root_per_claim", table_name="approval_tasks") + op.drop_table("approval_tasks") diff --git a/server/alembic/versions/20260716_0014_risk_waiver_decision.py b/server/alembic/versions/20260716_0014_risk_waiver_decision.py new file mode 100644 index 0000000..862a2d2 --- /dev/null +++ b/server/alembic/versions/20260716_0014_risk_waiver_decision.py @@ -0,0 +1,248 @@ +"""add risk waiver decision metadata and separation of duties state + +Revision ID: 20260716_0014 +Revises: 20260716_0013 +Create Date: 2026-07-16 19:10:00 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "20260716_0014" +down_revision: str | None = "20260716_0013" +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_0014 only supports PostgreSQL; " + f"refusing to mutate {dialect_name} without transactional constraint DDL" + ) + + +def _require_no_waiver_audit_data_for_downgrade() -> None: + bind = op.get_bind() + disposition_count = int( + bind.scalar( + sa.text( + "SELECT COUNT(*) FROM risk_dispositions " + "WHERE waiver_requester_id IS NOT NULL " + "OR waiver_decision IS NOT NULL " + "OR lifecycle_status IN ('waived', 'waiver_rejected')" + ) + ) + or 0 + ) + event_count = int( + bind.scalar( + sa.text( + "SELECT COUNT(*) FROM risk_disposition_events " + "WHERE action IN ('approve_waiver', 'reject_waiver')" + ) + ) + or 0 + ) + if disposition_count or event_count: + raise RuntimeError( + "cannot downgrade risk waiver decisions: immutable waiver audit data exists " + f"({disposition_count} dispositions, {event_count} decision events)" + ) + + +def upgrade() -> None: + _require_postgresql() + op.add_column( + "risk_dispositions", + sa.Column("waiver_requester_id", sa.String(length=120), nullable=True), + ) + op.add_column( + "risk_dispositions", + sa.Column("waiver_requester_name", sa.String(length=120), nullable=True), + ) + op.add_column( + "risk_dispositions", + sa.Column("waiver_requested_at", sa.DateTime(timezone=True), nullable=True), + ) + op.add_column( + "risk_dispositions", + sa.Column("waiver_reason", sa.Text(), nullable=True), + ) + op.add_column( + "risk_dispositions", + sa.Column("waiver_scope", sa.Text(), nullable=True), + ) + op.add_column( + "risk_dispositions", + sa.Column("waiver_expires_at", sa.DateTime(timezone=True), nullable=True), + ) + op.add_column( + "risk_dispositions", + sa.Column( + "waiver_conditions_json", + sa.JSON(), + nullable=False, + server_default=sa.text("'[]'::json"), + ), + ) + op.add_column( + "risk_dispositions", + sa.Column("waiver_decision", sa.String(length=20), nullable=True), + ) + op.add_column( + "risk_dispositions", + sa.Column("waiver_decider_id", sa.String(length=120), nullable=True), + ) + op.add_column( + "risk_dispositions", + sa.Column("waiver_decider_name", sa.String(length=120), nullable=True), + ) + op.add_column( + "risk_dispositions", + sa.Column("waiver_decided_at", sa.DateTime(timezone=True), nullable=True), + ) + op.add_column( + "risk_dispositions", + sa.Column("waiver_decision_reason", sa.Text(), nullable=True), + ) + + op.drop_constraint( + "ck_risk_dispositions_lifecycle", + "risk_dispositions", + type_="check", + ) + op.create_check_constraint( + "ck_risk_dispositions_lifecycle", + "risk_dispositions", + "lifecycle_status IN ('open', 'supplement_requested', " + "'remediation_in_progress', 'waiver_requested', 'waived', " + "'waiver_rejected', 'resolved')", + ) + op.create_check_constraint( + "ck_risk_dispositions_waiver_request", + "risk_dispositions", + "(waiver_requester_id IS NULL AND waiver_requester_name IS NULL " + "AND waiver_requested_at IS NULL AND waiver_reason IS NULL " + "AND waiver_scope IS NULL AND waiver_expires_at IS NULL) OR " + "(waiver_requester_id IS NOT NULL " + "AND length(trim(waiver_requester_id)) > 0 " + "AND waiver_requester_name IS NOT NULL " + "AND length(trim(waiver_requester_name)) > 0 " + "AND waiver_requested_at IS NOT NULL " + "AND waiver_reason IS NOT NULL AND length(trim(waiver_reason)) > 0 " + "AND waiver_scope IS NOT NULL AND length(trim(waiver_scope)) > 0 " + "AND waiver_expires_at IS NOT NULL " + "AND waiver_expires_at > waiver_requested_at)", + ) + op.create_check_constraint( + "ck_risk_dispositions_waiver_decision", + "risk_dispositions", + "(waiver_decision IS NULL AND waiver_decider_id IS NULL " + "AND waiver_decider_name IS NULL AND waiver_decided_at IS NULL " + "AND waiver_decision_reason IS NULL) OR " + "(waiver_decision IN ('approved', 'rejected') " + "AND waiver_requester_id IS NOT NULL " + "AND waiver_decider_id IS NOT NULL " + "AND length(trim(waiver_decider_id)) > 0 " + "AND waiver_decider_name IS NOT NULL " + "AND length(trim(waiver_decider_name)) > 0 " + "AND waiver_decided_at IS NOT NULL " + "AND waiver_decision_reason IS NOT NULL " + "AND length(trim(waiver_decision_reason)) > 0)", + ) + op.create_check_constraint( + "ck_risk_dispositions_waiver_lifecycle", + "risk_dispositions", + "(lifecycle_status != 'waived' OR waiver_decision = 'approved') AND " + "(lifecycle_status != 'waiver_rejected' OR waiver_decision = 'rejected')", + ) + op.create_index( + "ix_risk_dispositions_tenant_waiver_expiry", + "risk_dispositions", + ["tenant_id", "lifecycle_status", "waiver_expires_at"], + unique=False, + ) + + op.drop_constraint( + "ck_risk_disposition_events_action", + "risk_disposition_events", + type_="check", + ) + op.create_check_constraint( + "ck_risk_disposition_events_action", + "risk_disposition_events", + "action IN ('confirm', 'false_positive', 'request_supplement', " + "'start_remediation', 'resolve', 'request_waiver', " + "'approve_waiver', 'reject_waiver')", + ) + + +def downgrade() -> None: + _require_postgresql() + # 申请与决定元数据已经进入不可变事件快照时,禁止静默丢弃审计链。 + _require_no_waiver_audit_data_for_downgrade() + op.drop_constraint( + "ck_risk_disposition_events_action", + "risk_disposition_events", + type_="check", + ) + op.create_check_constraint( + "ck_risk_disposition_events_action", + "risk_disposition_events", + "action IN ('confirm', 'false_positive', 'request_supplement', " + "'start_remediation', 'resolve', 'request_waiver')", + ) + + op.drop_index( + "ix_risk_dispositions_tenant_waiver_expiry", + table_name="risk_dispositions", + ) + op.drop_constraint( + "ck_risk_dispositions_waiver_lifecycle", + "risk_dispositions", + type_="check", + ) + op.drop_constraint( + "ck_risk_dispositions_waiver_decision", + "risk_dispositions", + type_="check", + ) + op.drop_constraint( + "ck_risk_dispositions_waiver_request", + "risk_dispositions", + type_="check", + ) + op.drop_constraint( + "ck_risk_dispositions_lifecycle", + "risk_dispositions", + type_="check", + ) + op.create_check_constraint( + "ck_risk_dispositions_lifecycle", + "risk_dispositions", + "lifecycle_status IN ('open', 'supplement_requested', " + "'remediation_in_progress', 'waiver_requested', 'resolved')", + ) + + for column_name in ( + "waiver_decision_reason", + "waiver_decided_at", + "waiver_decider_name", + "waiver_decider_id", + "waiver_decision", + "waiver_conditions_json", + "waiver_expires_at", + "waiver_scope", + "waiver_reason", + "waiver_requested_at", + "waiver_requester_name", + "waiver_requester_id", + ): + op.drop_column("risk_dispositions", column_name) diff --git a/server/scripts/backfill_approval_tasks.py b/server/scripts/backfill_approval_tasks.py new file mode 100644 index 0000000..bb7a967 --- /dev/null +++ b/server/scripts/backfill_approval_tasks.py @@ -0,0 +1,361 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import sys +import uuid +from dataclasses import asdict +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from sqlalchemy import create_engine, text +from sqlalchemy.engine import Connection +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session +from sqlalchemy.pool import NullPool + +SERVER_DIR = Path(__file__).resolve().parents[1] +SRC_DIR = SERVER_DIR / "src" +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) + +from app.db.maintenance_database_target import ( # noqa: E402 + MaintenanceDatabaseTargetError, + validate_maintenance_database_target, +) +from app.db.migration_preflight import ( # noqa: E402 + MigrationPreflightError, + validate_migration_state, +) +from app.services.approval_task_backfill import ( # noqa: E402 + DEFAULT_BACKFILL_BATCH_SIZE, + MAX_BACKFILL_BATCH_SIZE, + ApprovalTaskBackfillCursor, + ApprovalTaskBackfillService, +) + +REQUIRED_ALEMBIC_REVISION = "20260716_0014" +EXIT_SAFETY = 3 +EXIT_LOCKED = 4 +EXIT_RUNTIME = 6 + + +class BackfillCommandError(RuntimeError): + def __init__(self, message: str, *, code: str, exit_code: int) -> None: + super().__init__(message) + self.code = code + self.exit_code = exit_code + + +def parse_timestamp(value: str) -> datetime: + normalized = str(value or "").strip() + if normalized.endswith("Z"): + normalized = f"{normalized[:-1]}+00:00" + try: + parsed = datetime.fromisoformat(normalized) + except ValueError as exc: + raise argparse.ArgumentTypeError("必须是带时区的 ISO 8601 时间") from exc + if parsed.tzinfo is None or parsed.utcoffset() is None: + raise argparse.ArgumentTypeError("时间必须显式包含时区") + return parsed.astimezone(UTC) + + +def positive_int(value: str) -> int: + try: + parsed = int(value) + except ValueError as exc: + raise argparse.ArgumentTypeError("必须是正整数") from exc + if parsed < 1: + raise argparse.ArgumentTypeError("必须是正整数") + return parsed + + +def batch_size(value: str) -> int: + parsed = positive_int(value) + if parsed > MAX_BACKFILL_BATCH_SIZE: + raise argparse.ArgumentTypeError(f"不能超过 {MAX_BACKFILL_BATCH_SIZE}") + return parsed + + +def non_empty_text(value: str) -> str: + normalized = str(value or "").strip() + if not normalized: + raise argparse.ArgumentTypeError("不能为空") + return normalized + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="为历史待审批费用单生成审批任务;默认只读预览。", + ) + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--dry-run", action="store_true", help="只读预览(默认)。") + mode.add_argument("--apply", action="store_true", help="显式写入审批任务和审计事件。") + parser.add_argument("--tenant-id", required=True, type=non_empty_text) + parser.add_argument("--created-before", required=True, type=parse_timestamp) + parser.add_argument("--batch-size", type=batch_size, default=DEFAULT_BACKFILL_BATCH_SIZE) + parser.add_argument("--max-claims", type=positive_int) + parser.add_argument("--sample-limit", type=positive_int, default=20) + parser.add_argument("--expected-host", required=True) + parser.add_argument("--expected-database", required=True) + parser.add_argument( + "--confirm-target", + help="apply 时必须精确等于解析后的 host:port/database。", + ) + parser.add_argument("--allow-non-disposable-target", action="store_true") + return parser + + +def _cursor_payload(cursor: ApprovalTaskBackfillCursor | None) -> dict[str, str] | None: + if cursor is None: + return None + return { + "created_at": _isoformat(cursor.created_at), + "claim_id": cursor.claim_id, + } + + +def _item_payload(item: Any) -> dict[str, Any]: + payload = asdict(item) + payload["disposition"] = item.disposition.value + if item.entered_at is not None: + payload["entered_at"] = _isoformat(item.entered_at) + return payload + + +def _summary(args: argparse.Namespace, *, target: Any, revision: str) -> dict[str, Any]: + return { + "mode": "apply" if args.apply else "dry-run", + "database": { + "target": target.exact_target, + "url": target.sanitized_url, + "revision": revision, + }, + "tenant_id": args.tenant_id, + "created_before": _isoformat(args.created_before), + "inspected": 0, + "eligible": 0, + "existing": 0, + "skipped": 0, + "created": 0, + "batches": 0, + "limited": False, + "last_cursor": None, + "samples": [], + } + + +def _page_size(configured: int, remaining: int | None) -> int: + return configured if remaining is None else min(configured, remaining) + + +def _preview(session: Session, args: argparse.Namespace, summary: dict[str, Any]) -> None: + service = ApprovalTaskBackfillService( + session, + tenant_id=args.tenant_id, + created_before=args.created_before, + ) + cursor = None + remaining = args.max_claims + last_has_more = False + while remaining is None or remaining > 0: + page = service.preview( + batch_size=_page_size(args.batch_size, remaining), + after=cursor, + ) + if not page.items: + break + summary["batches"] += 1 + summary["inspected"] += page.inspected + summary["eligible"] += page.eligible + summary["existing"] += page.existing + summary["skipped"] += page.skipped + available = max(0, args.sample_limit - len(summary["samples"])) + summary["samples"].extend(_item_payload(item) for item in page.items[:available]) + cursor = page.next_cursor + summary["last_cursor"] = _cursor_payload(cursor) + last_has_more = page.has_more + if remaining is not None: + remaining -= page.inspected + if not page.has_more: + break + summary["limited"] = bool(remaining == 0 and last_has_more) + + +def _acquire_lock(connection: Connection, tenant_id: str) -> str: + lock_name = f"approval-task-backfill:{tenant_id}" + acquired = connection.scalar( + text("SELECT pg_try_advisory_lock(hashtextextended(:name, 0))"), + {"name": lock_name}, + ) + connection.commit() + if not acquired: + raise BackfillCommandError( + "同一租户已有审批任务回填正在运行。", + code="advisory_lock_unavailable", + exit_code=EXIT_LOCKED, + ) + return lock_name + + +def _release_lock(connection: Connection, lock_name: str) -> None: + if connection.in_transaction(): + connection.rollback() + connection.execute( + text("SELECT pg_advisory_unlock(hashtextextended(:name, 0))"), + {"name": lock_name}, + ) + connection.commit() + + +def _apply(connection: Connection, args: argparse.Namespace, summary: dict[str, Any]) -> None: + lock_name = _acquire_lock(connection, args.tenant_id) + run_id = f"approval-task-backfill-{uuid.uuid4().hex}" + backfilled_at = datetime.now(UTC) + summary["run_id"] = run_id + summary["backfilled_at"] = _isoformat(backfilled_at) + cursor = None + remaining = args.max_claims + last_has_more = False + try: + with Session(bind=connection, autoflush=False, expire_on_commit=False) as session: + service = ApprovalTaskBackfillService( + session, + tenant_id=args.tenant_id, + created_before=args.created_before, + ) + while remaining is None or remaining > 0: + session.execute(text("SET LOCAL lock_timeout = '5s'")) + result = service.apply_batch( + run_id=run_id, + batch_size=_page_size(args.batch_size, remaining), + after=cursor, + backfilled_at=backfilled_at, + ) + if not result.items: + session.rollback() + break + session.commit() + summary["batches"] += 1 + summary["inspected"] += result.inspected + summary["created"] += result.created + summary["existing"] += result.existing + summary["skipped"] += result.skipped + available = max(0, args.sample_limit - len(summary["samples"])) + summary["samples"].extend(_item_payload(item) for item in result.items[:available]) + cursor = result.next_cursor + summary["last_cursor"] = _cursor_payload(cursor) + last_has_more = result.has_more + if remaining is not None: + remaining -= result.inspected + if not result.has_more: + break + summary["limited"] = bool(remaining == 0 and last_has_more) + finally: + _release_lock(connection, lock_name) + + +def run(args: argparse.Namespace) -> dict[str, Any]: + if args.apply and not str(args.confirm_target or "").strip(): + raise BackfillCommandError( + "--apply 必须提供 --confirm-target 精确确认数据库目标。", + code="confirm_target_required", + exit_code=EXIT_SAFETY, + ) + database_url = os.environ.get("DATABASE_URL", "") + target = validate_maintenance_database_target( + database_url, + expected_host=args.expected_host, + expected_database=args.expected_database, + apply=args.apply, + allow_non_disposable=args.allow_non_disposable_target, + confirm_target=args.confirm_target, + ) + engine = create_engine(database_url, pool_pre_ping=True, poolclass=NullPool) + try: + with engine.connect() as connection: + database = str(connection.scalar(text("SELECT current_database()")) or "") + if database != target.database: + raise BackfillCommandError( + "连接后的数据库名与 DATABASE_URL 不一致。", + code="connected_database_mismatch", + exit_code=EXIT_SAFETY, + ) + state = validate_migration_state(connection) + if state.revision != REQUIRED_ALEMBIC_REVISION: + raise BackfillCommandError( + f"数据库迁移版本必须为 {REQUIRED_ALEMBIC_REVISION}," + f"实际为 {state.revision or 'unversioned/base'}。", + code="migration_revision_mismatch", + exit_code=EXIT_SAFETY, + ) + connection.rollback() + summary = _summary(args, target=target, revision=state.revision) + with Session(bind=connection, autoflush=False) as session: + _preview(session, args, summary) + session.rollback() + if not args.apply: + summary["would_create"] = summary["eligible"] + return summary + + preview = dict(summary) + summary.update( + inspected=0, + existing=0, + skipped=0, + created=0, + batches=0, + limited=False, + last_cursor=None, + samples=[], + preview=preview, + ) + _apply(connection, args, summary) + return summary + finally: + engine.dispose() + + +def _isoformat(value: datetime) -> str: + normalized = value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) + return normalized.isoformat().replace("+00:00", "Z") + + +def _error_payload(exc: Exception, *, code: str) -> dict[str, str]: + return {"status": "error", "code": code, "message": str(exc)} + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + payload = run(args) + except BackfillCommandError as exc: + print(json.dumps(_error_payload(exc, code=exc.code), ensure_ascii=False), file=sys.stderr) + return exc.exit_code + except MaintenanceDatabaseTargetError as exc: + print(json.dumps(_error_payload(exc, code=exc.code), ensure_ascii=False), file=sys.stderr) + return EXIT_SAFETY + except MigrationPreflightError as exc: + print( + json.dumps( + _error_payload(exc, code="migration_preflight_failed"), + ensure_ascii=False, + ), + file=sys.stderr, + ) + return EXIT_SAFETY + except (OSError, SQLAlchemyError) as exc: + print( + json.dumps(_error_payload(exc, code="database_runtime_error"), ensure_ascii=False), + file=sys.stderr, + ) + return EXIT_RUNTIME + print(json.dumps(payload, ensure_ascii=False, indent=2, default=str)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/server/src/app/api/v1/endpoints/approval_tasks.py b/server/src/app/api/v1/endpoints/approval_tasks.py new file mode 100644 index 0000000..7b1b6f9 --- /dev/null +++ b/server/src/app/api/v1/endpoints/approval_tasks.py @@ -0,0 +1,394 @@ +from __future__ import annotations + +from typing import Annotated, NoReturn + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.api.deps import CurrentUserContext, get_current_user, get_db +from app.models.approval_task import ApprovalTask, ApprovalTaskEvent +from app.schemas.approval_task import ( + ApprovalTaskActionBase, + ApprovalTaskAssignmentAction, + ApprovalTaskBatchApproveCreate, + ApprovalTaskBatchApproveRead, + ApprovalTaskCandidateRead, + ApprovalTaskDecisionAction, + ApprovalTaskDecisionRead, + ApprovalTaskListRead, + ApprovalTaskMutationRead, + ApprovalTaskParticipantsAction, + ApprovalTaskQueueItemRead, + ApprovalTaskReturnAction, +) +from app.schemas.reimbursement import ExpenseClaimRead +from app.services.approval_action_protocol import ApprovalActionConflictError +from app.services.approval_task_access_policy import ApprovalTaskPermissionError +from app.services.approval_task_actions import ApprovalTaskActionService +from app.services.approval_task_batch import ApprovalTaskBatchService +from app.services.approval_task_protocol import ApprovalTaskConflictError +from app.services.approval_task_query import ApprovalTaskQueryService +from app.services.expense_claim_risk_gate import ExpenseClaimRiskBlockedError +from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin +from app.services.expense_claims import ExpenseClaimService + +router = APIRouter(prefix="/approval-tasks") +DbSession = Annotated[Session, Depends(get_db)] +CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)] + + +@router.get("", response_model=ApprovalTaskListRead, summary="查询个人审批任务队列") +def list_approval_tasks( + db: DbSession, + current_user: CurrentUser, + page: Annotated[int, Query(ge=1)] = 1, + page_size: Annotated[int, Query(ge=1, le=200)] = 20, + task_status: Annotated[str, Query(alias="status")] = "pending", + risk_level: str | None = None, + sla_state: str | None = None, + node_key: str | None = None, + task_kind: str | None = None, + batch_eligible: bool | None = None, + assignee: Annotated[str | None, Query(max_length=100)] = None, + sort: str = "priority_desc", + keyword: Annotated[str | None, Query(max_length=100)] = None, +) -> ApprovalTaskListRead: + try: + return ApprovalTaskQueryService(db).list_tasks( + current_user, + page=page, + page_size=page_size, + status=task_status, + risk_level=risk_level, + sla_state=sla_state, + node_key=node_key, + task_kind=task_kind, + batch_eligible=batch_eligible, + assignee=assignee, + sort=sort, + keyword=keyword, + ) + except ValueError as error: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error + + +@router.get( + "/{task_id}", + response_model=ApprovalTaskQueueItemRead, + summary="查询审批任务详情", +) +def get_approval_task( + task_id: str, + db: DbSession, + current_user: CurrentUser, +) -> ApprovalTaskQueueItemRead: + item = ApprovalTaskQueryService(db).get_task(task_id, current_user) + if item is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found") + return item + + +@router.get( + "/{task_id}/candidates", + response_model=list[ApprovalTaskCandidateRead], + summary="查询当前节点合格处理人", +) +def list_approval_task_candidates( + task_id: str, + db: DbSession, + current_user: CurrentUser, + action: str | None = None, + keyword: Annotated[str | None, Query(max_length=100)] = None, + limit: Annotated[int, Query(ge=1, le=100)] = 50, +) -> list[ApprovalTaskCandidateRead]: + try: + return ApprovalTaskActionService(db).candidates( + task_id, + current_user, + action=action, + keyword=keyword, + limit=limit, + ) + except (LookupError, ApprovalTaskPermissionError) as error: + _raise_task_error(error) + + +@router.post( + "/{task_id}/delegate", + response_model=ApprovalTaskMutationRead, + summary="委托审批任务", +) +def delegate_approval_task( + task_id: str, + payload: ApprovalTaskAssignmentAction, + db: DbSession, + current_user: CurrentUser, +) -> ApprovalTaskMutationRead: + return _assignment_action(task_id, payload, db, current_user, action="delegate") + + +@router.post( + "/{task_id}/delegation/revoke", + response_model=ApprovalTaskMutationRead, + summary="撤销审批委托", +) +def revoke_approval_task_delegation( + task_id: str, + payload: ApprovalTaskActionBase, + db: DbSession, + current_user: CurrentUser, +) -> ApprovalTaskMutationRead: + try: + return ApprovalTaskActionService(db).revoke_delegation(task_id, current_user, payload) + except (LookupError, ValueError) as error: + _raise_task_error(error) + + +@router.post( + "/{task_id}/transfer", + response_model=ApprovalTaskMutationRead, + summary="永久转交审批任务", +) +def transfer_approval_task( + task_id: str, + payload: ApprovalTaskAssignmentAction, + db: DbSession, + current_user: CurrentUser, +) -> ApprovalTaskMutationRead: + return _assignment_action(task_id, payload, db, current_user, action="transfer") + + +@router.post( + "/{task_id}/add-sign", + response_model=ApprovalTaskMutationRead, + summary="发起顺序加签", +) +def add_sign_approval_task( + task_id: str, + payload: ApprovalTaskParticipantsAction, + db: DbSession, + current_user: CurrentUser, +) -> ApprovalTaskMutationRead: + return _participants_action(task_id, payload, db, current_user, action="add_sign") + + +@router.post( + "/{task_id}/countersign", + response_model=ApprovalTaskMutationRead, + summary="发起并行会签", +) +def countersign_approval_task( + task_id: str, + payload: ApprovalTaskParticipantsAction, + db: DbSession, + current_user: CurrentUser, +) -> ApprovalTaskMutationRead: + return _participants_action(task_id, payload, db, current_user, action="countersign") + + +@router.post( + "/{task_id}/escalate", + response_model=ApprovalTaskMutationRead, + summary="升级超时审批任务", +) +def escalate_approval_task( + task_id: str, + payload: ApprovalTaskActionBase, + db: DbSession, + current_user: CurrentUser, +) -> ApprovalTaskMutationRead: + try: + return ApprovalTaskActionService(db).escalate(task_id, current_user, payload) + except (LookupError, ValueError) as error: + _raise_task_error(error) + + +@router.post( + "/{task_id}/approve", + response_model=ApprovalTaskDecisionRead, + summary="通过审批任务", +) +def approve_approval_task( + task_id: str, + payload: ApprovalTaskDecisionAction, + db: DbSession, + current_user: CurrentUser, +) -> ApprovalTaskDecisionRead: + try: + task = _task_or_404(db, task_id, current_user) + if task.task_kind == "root": + claim = ExpenseClaimService(db).approve_claim( + task.claim_id, + current_user, + opinion=payload.opinion or payload.reason, + request_id=payload.request_id, + expected_status=task.claim_status_snapshot, + expected_approval_stage=task.claim_stage_snapshot, + task_id=task.id, + expected_task_version=payload.expected_task_version, + ) + if claim is None: + raise LookupError("Claim not found.") + mutation = _mutation_for_request(db, task, payload.request_id) + if isinstance(claim, ExpenseClaimRead): + mutation = mutation.model_copy(update={"replayed": True}) + claim_read = ( + claim + if isinstance(claim, ExpenseClaimRead) + else ExpenseClaimRead.model_validate(claim) + ) + else: + mutation = ApprovalTaskActionService(db).approve_child( + task.id, + current_user, + payload, + ) + queue_item = ApprovalTaskQueryService(db).get_task(task.id, current_user) + if queue_item is None: + raise LookupError("Task not found.") + claim_read = queue_item.claim + return ApprovalTaskDecisionRead(mutation=mutation, claim=claim_read) + except (LookupError, ValueError) as error: + _raise_task_error(error) + + +@router.post( + "/{task_id}/return", + response_model=ApprovalTaskDecisionRead, + summary="退回审批任务关联单据", +) +def return_approval_task( + task_id: str, + payload: ApprovalTaskReturnAction, + db: DbSession, + current_user: CurrentUser, +) -> ApprovalTaskDecisionRead: + try: + task = _task_or_404(db, task_id, current_user) + claim = ExpenseClaimService(db).return_claim( + task.claim_id, + current_user, + reason=payload.reason, + reason_codes=payload.reason_codes, + request_id=payload.request_id, + expected_status=task.claim_status_snapshot, + expected_approval_stage=task.claim_stage_snapshot, + task_id=task.id, + expected_task_version=payload.expected_task_version, + ) + if claim is None: + raise LookupError("Claim not found.") + mutation = _mutation_for_request(db, task, payload.request_id) + if isinstance(claim, ExpenseClaimRead): + mutation = mutation.model_copy(update={"replayed": True}) + claim_read = ( + claim + if isinstance(claim, ExpenseClaimRead) + else ExpenseClaimRead.model_validate(claim) + ) + return ApprovalTaskDecisionRead(mutation=mutation, claim=claim_read) + except (LookupError, ValueError) as error: + _raise_task_error(error) + + +@router.post( + "/batch-approve", + response_model=ApprovalTaskBatchApproveRead, + summary="安全批量审批任务", +) +def batch_approve_tasks( + payload: ApprovalTaskBatchApproveCreate, + current_user: CurrentUser, +) -> ApprovalTaskBatchApproveRead: + return ApprovalTaskBatchService().approve(payload, current_user) + + +def _assignment_action( + task_id: str, + payload: ApprovalTaskAssignmentAction, + db: Session, + current_user: CurrentUserContext, + *, + action: str, +) -> ApprovalTaskMutationRead: + try: + return ApprovalTaskActionService(db).assign( + task_id, + current_user, + action=action, + payload=payload, + ) + except (LookupError, ValueError) as error: + _raise_task_error(error) + + +def _participants_action( + task_id: str, + payload: ApprovalTaskParticipantsAction, + db: Session, + current_user: CurrentUserContext, + *, + action: str, +) -> ApprovalTaskMutationRead: + try: + return ApprovalTaskActionService(db).add_participants( + task_id, + current_user, + action=action, + payload=payload, + ) + except (LookupError, ValueError) as error: + _raise_task_error(error) + + +def _task_or_404( + db: Session, + task_id: str, + current_user: CurrentUserContext, +) -> ApprovalTask: + tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id) + task = db.scalar( + select(ApprovalTask).where( + ApprovalTask.tenant_id == tenant, + ApprovalTask.id == str(task_id).strip(), + ) + ) + if task is None: + raise LookupError("Task not found.") + return task + + +def _mutation_for_request( + db: Session, + task: ApprovalTask, + request_id: str, +) -> ApprovalTaskMutationRead: + event = db.scalar( + select(ApprovalTaskEvent).where( + ApprovalTaskEvent.tenant_id == task.tenant_id, + ApprovalTaskEvent.task_id == task.id, + ApprovalTaskEvent.request_id == request_id, + ) + ) + if event is None: + raise ApprovalTaskConflictError("审批任务审计事件缺失。") + try: + return ApprovalTaskMutationRead.model_validate(event.response_json) + except ValueError as error: + raise ApprovalTaskConflictError("审批任务审计响应快照无效。") from error + + +def _raise_task_error(error: Exception) -> NoReturn: + if isinstance(error, LookupError): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error + if isinstance(error, ApprovalTaskPermissionError): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(error)) from error + if isinstance(error, ExpenseClaimRiskBlockedError): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={"code": "APPROVAL_BLOCKED_BY_OPEN_HIGH_RISK", "message": str(error)}, + ) from error + if isinstance(error, (ApprovalActionConflictError, ApprovalTaskConflictError)): + 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 diff --git a/server/src/app/api/v1/endpoints/reimbursement_approval_actions.py b/server/src/app/api/v1/endpoints/reimbursement_approval_actions.py index 2040d14..721e3e2 100644 --- a/server/src/app/api/v1/endpoints/reimbursement_approval_actions.py +++ b/server/src/app/api/v1/endpoints/reimbursement_approval_actions.py @@ -14,6 +14,8 @@ from app.schemas.reimbursement import ( ExpenseClaimReturnPayload, ) from app.services.approval_action_protocol import ApprovalActionConflictError +from app.services.approval_task_access_policy import ApprovalTaskPermissionError +from app.services.approval_task_protocol import ApprovalTaskConflictError from app.services.expense_claim_risk_gate import ExpenseClaimRiskBlockedError from app.services.expense_claims import ExpenseClaimService @@ -43,6 +45,10 @@ def _raise_action_error(error: ValueError) -> NoReturn: ) from error if isinstance(error, ApprovalActionConflictError): raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error + if isinstance(error, ApprovalTaskPermissionError): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(error)) from error + if isinstance(error, ApprovalTaskConflictError): + 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 @@ -78,6 +84,8 @@ def return_expense_claim( request_id=payload.request_id, expected_status=payload.expected_status, expected_approval_stage=payload.expected_approval_stage, + task_id=payload.task_id, + expected_task_version=payload.expected_task_version, ) except ValueError as error: _raise_action_error(error) @@ -117,6 +125,8 @@ def approve_expense_claim( request_id=payload.request_id, expected_status=payload.expected_status, expected_approval_stage=payload.expected_approval_stage, + task_id=payload.task_id, + expected_task_version=payload.expected_task_version, ) except ValueError as error: _raise_action_error(error) diff --git a/server/src/app/api/v1/endpoints/risk_observations.py b/server/src/app/api/v1/endpoints/risk_observations.py index d148696..49fc4a0 100644 --- a/server/src/app/api/v1/endpoints/risk_observations.py +++ b/server/src/app/api/v1/endpoints/risk_observations.py @@ -20,11 +20,13 @@ from app.schemas.risk_observation import ( RiskObservationListRead, RiskObservationRead, ) +from app.services.risk_disposition_read_projection import RiskDispositionReadProjection from app.services.risk_dispositions import ( RiskDispositionConflictError, RiskDispositionPermissionError, RiskDispositionService, RiskDispositionVersionConflictError, + RiskWaiverDecisionPermissionError, ) from app.services.risk_observation_access_policy import RiskObservationAccessPolicy from app.services.risk_observations import RiskObservationService @@ -69,7 +71,16 @@ def list_risk_observations( limit=limit, offset=offset, ) - return RiskObservationListRead(items=items, total=total, limit=limit, offset=offset) + projected_items = RiskDispositionReadProjection(db).observation_list_read( + items, + current_user, + ) + return RiskObservationListRead( + items=projected_items, + total=total, + limit=limit, + offset=offset, + ) @router.get( @@ -105,10 +116,14 @@ def list_claim_risk_observations( ) -> list[RiskObservationRead]: if not RiskObservationAccessPolicy(db).can_read_claim_risks(claim_id, current_user): raise _not_found() - return RiskObservationService(db).list_claim_observations( + observations = RiskObservationService(db).list_claim_observations( claim_id, tenant_id=current_user.tenant_id, ) + return RiskDispositionReadProjection(db).observation_list_read( + observations, + current_user, + ) @router.get( @@ -123,10 +138,14 @@ def list_execution_log_risk_observations( current_user: CurrentUser, ) -> list[RiskObservationRead]: _require_pool_access(db, current_user) - return RiskObservationService(db).list_execution_log_observations( + observations = RiskObservationService(db).list_execution_log_observations( execution_log_id, tenant_id=current_user.tenant_id, ) + return RiskDispositionReadProjection(db).observation_list_read( + observations, + current_user, + ) @router.get( @@ -156,7 +175,10 @@ def get_risk_observation( status_code=status.HTTP_404_NOT_FOUND, detail="Risk observation not found", ) - return observation + return RiskDispositionReadProjection(db).observation_read( + observation, + current_user, + ) @router.get( @@ -178,7 +200,11 @@ def get_risk_observation_disposition( ) if disposition is None: raise _not_found("Risk disposition not found") - return disposition + return RiskDispositionReadProjection(db).disposition_read( + disposition, + observation, + current_user, + ) @router.post( @@ -199,7 +225,19 @@ def execute_risk_disposition_action( observation_key_or_id, payload, ) - return mutation.response + observation = _get_observation_or_404( + db, + current_user, + observation_key_or_id, + ) + projected_disposition = RiskDispositionReadProjection(db).disposition_snapshot_read( + mutation.response.disposition, + observation, + current_user, + ) + return mutation.response.model_copy( + update={"disposition": projected_disposition}, + ) @router.post( @@ -303,6 +341,14 @@ def _execute_action( "message": "风险处置状态已更新,请刷新证据链后重试。", }, ) from None + except RiskWaiverDecisionPermissionError as error: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "code": "RISK_WAIVER_DECISION_FORBIDDEN", + "message": str(error), + }, + ) from None except RiskDispositionPermissionError as error: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, diff --git a/server/src/app/api/v1/router.py b/server/src/app/api/v1/router.py index 02facfe..96c1e06 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_tasks import router as approval_tasks_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, @@ -51,6 +52,7 @@ 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(approval_tasks_router, tags=["approval-tasks"]) 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"]) diff --git a/server/src/app/db/base.py b/server/src/app/db/base.py index b4eeb3b..316de32 100644 --- a/server/src/app/db/base.py +++ b/server/src/app/db/base.py @@ -14,6 +14,7 @@ from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutco from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink from app.models.approval import ApprovalRecord from app.models.approval_action import ApprovalActionLedger +from app.models.approval_task import ApprovalTask, ApprovalTaskEvent from app.models.attachment_association_job import AttachmentAssociationJob from app.models.audit_log import AuditLog from app.models.auth_session import AuthSession @@ -63,6 +64,8 @@ __all__ = [ "AIDecisionFeedback", "ApprovalRecord", "ApprovalActionLedger", + "ApprovalTask", + "ApprovalTaskEvent", "AttachmentAssociationJob", "AuditLog", "AuthSession", diff --git a/server/src/app/db/migration_preflight.py b/server/src/app/db/migration_preflight.py index d47addb..77b2c7d 100644 --- a/server/src/app/db/migration_preflight.py +++ b/server/src/app/db/migration_preflight.py @@ -191,8 +191,54 @@ MIGRATION_OWNED_TABLES_BY_REVISION: dict[str, frozenset[str]] = { "approval_action_ledgers", } ), + "20260716_0013": 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", + "approval_tasks", + "approval_task_events", + } + ), + "20260716_0014": 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", + "approval_tasks", + "approval_task_events", + } + ), } -if MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0012"] != MIGRATION_OWNED_TABLES: +if MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0014"] != MIGRATION_OWNED_TABLES: raise RuntimeError("latest Alembic revision must own the centralized migration table set") # 0008 之前这三张表由旧 bootstrap / 风险服务按需创建。迁移前置检查允许 @@ -266,6 +312,8 @@ def _validate_connection(connection: Connection) -> MigrationPreflightState: "20260716_0010", "20260716_0011", "20260716_0012", + "20260716_0013", + "20260716_0014", } else frozenset() ) diff --git a/server/src/app/db/schema_ownership.py b/server/src/app/db/schema_ownership.py index 994b687..9765015 100644 --- a/server/src/app/db/schema_ownership.py +++ b/server/src/app/db/schema_ownership.py @@ -8,6 +8,8 @@ MIGRATION_OWNED_TABLES: frozenset[str] = frozenset( { "auth_sessions", "approval_action_ledgers", + "approval_task_events", + "approval_tasks", "attachment_association_jobs", "ai_application_preview_decisions", "ai_decisions", diff --git a/server/src/app/main.py b/server/src/app/main.py index 89cb975..25f568e 100644 --- a/server/src/app/main.py +++ b/server/src/app/main.py @@ -1,9 +1,9 @@ from __future__ import annotations +import threading from collections.abc import AsyncIterator from contextlib import asynccontextmanager from logging import Logger -import threading from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -16,9 +16,9 @@ from app.db.session import get_session_factory from app.middleware.logging import AccessLogMiddleware from app.schemas.common import RootStatusRead from app.services.agent_foundation import prepare_agent_foundation +from app.services.approval_task_scheduler import approval_task_scheduler from app.services.digital_employee_reminder_scheduler import digital_employee_reminder_scheduler -from app.services.employee import prepare_employee_directory -from app.services.employee import EmployeeService +from app.services.employee import EmployeeService, prepare_employee_directory from app.services.employee_profile_scheduler import employee_profile_scheduler from app.services.finance_dashboard_scheduler import finance_dashboard_scheduler from app.services.finance_report_scheduler import finance_report_scheduler @@ -34,7 +34,11 @@ from app.services.user_session_metrics import UserSessionMetricService def _effective_server_workers(settings: object) -> int: server_workers = getattr(settings, "server_workers", None) web_concurrency = getattr(settings, "web_concurrency", None) - workers = web_concurrency if int(server_workers or 1) <= 1 and web_concurrency else server_workers + workers = ( + web_concurrency + if int(server_workers or 1) <= 1 and web_concurrency + else server_workers + ) try: return max(1, int(workers or 1)) except (TypeError, ValueError): @@ -100,6 +104,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: schedulers_started = _should_start_background_schedulers(settings) if schedulers_started: knowledge_index_scheduler.start() + approval_task_scheduler.start() finance_dashboard_scheduler.start() employee_profile_scheduler.start() digital_employee_reminder_scheduler.start() @@ -118,6 +123,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: ) yield if schedulers_started: + approval_task_scheduler.shutdown() finance_report_scheduler.shutdown() digital_employee_reminder_scheduler.shutdown() employee_profile_scheduler.shutdown() diff --git a/server/src/app/models/__init__.py b/server/src/app/models/__init__.py index 289c34e..b5822bd 100644 --- a/server/src/app/models/__init__.py +++ b/server/src/app/models/__init__.py @@ -12,6 +12,7 @@ from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutco from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink from app.models.approval import ApprovalRecord from app.models.approval_action import ApprovalActionLedger +from app.models.approval_task import ApprovalTask, ApprovalTaskEvent from app.models.attachment_association_job import AttachmentAssociationJob from app.models.audit_log import AuditLog from app.models.auth_session import AuthSession @@ -57,6 +58,8 @@ __all__ = [ "AIApplicationPreviewDecision", "ApprovalRecord", "ApprovalActionLedger", + "ApprovalTask", + "ApprovalTaskEvent", "AttachmentAssociationJob", "AuditLog", "AuthSession", diff --git a/server/src/app/models/approval_task.py b/server/src/app/models/approval_task.py new file mode 100644 index 0000000..19b9b24 --- /dev/null +++ b/server/src/app/models/approval_task.py @@ -0,0 +1,361 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from decimal import Decimal +from typing import Any + +from sqlalchemy import ( + Boolean, + CheckConstraint, + DateTime, + ForeignKeyConstraint, + Index, + Integer, + Numeric, + String, + Text, + UniqueConstraint, + func, + text, +) +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 ApprovalTask(Base): + """审批节点的个人工作项投影,不承载 BPMN 流程定义。""" + + __tablename__ = "approval_tasks" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "id", + name="uq_approval_tasks_tenant_id", + ), + UniqueConstraint( + "tenant_id", + "node_entry_key", + name="uq_approval_tasks_tenant_node_entry", + ), + UniqueConstraint( + "tenant_id", + "node_instance_id", + "assignee_kind", + "assignee_key", + name="uq_approval_tasks_node_participant", + ), + ForeignKeyConstraint( + ["tenant_id", "parent_task_id"], + ["approval_tasks.tenant_id", "approval_tasks.id"], + ondelete="RESTRICT", + name="fk_approval_tasks_tenant_parent", + ), + ForeignKeyConstraint( + ["tenant_id", "expense_case_id"], + ["expense_cases.tenant_id", "expense_cases.id"], + ondelete="RESTRICT", + name="fk_approval_tasks_tenant_expense_case", + ), + CheckConstraint( + "task_kind IN ('root', 'add_sign', 'countersign')", + name="ck_approval_tasks_task_kind", + ), + CheckConstraint( + "node_key IN ('direct_manager', 'budget_manager', 'finance')", + name="ck_approval_tasks_node_key", + ), + CheckConstraint( + "coordination_mode IN ('single', 'sequential_before_owner', 'parallel_all')", + name="ck_approval_tasks_coordination_mode", + ), + CheckConstraint( + "owner_kind IN ('employee', 'role')", + name="ck_approval_tasks_owner_kind", + ), + CheckConstraint( + "assignee_kind IN ('employee', 'role')", + name="ck_approval_tasks_assignee_kind", + ), + CheckConstraint( + "status IN ('waiting', 'pending', 'completed', 'returned', " + "'cancelled', 'superseded')", + name="ck_approval_tasks_status", + ), + CheckConstraint( + "decision IS NULL OR decision IN ('approved', 'returned')", + name="ck_approval_tasks_decision", + ), + CheckConstraint( + "entered_at_source IN ('workflow_event', 'submitted_at', 'updated_at', 'backfill')", + name="ck_approval_tasks_entered_at_source", + ), + CheckConstraint( + "priority_tier IN ('normal', 'high', 'urgent')", + name="ck_approval_tasks_priority_tier", + ), + CheckConstraint( + "risk_level IN ('low', 'medium', 'high', 'critical')", + name="ck_approval_tasks_risk_level", + ), + CheckConstraint( + "(task_kind = 'root' AND parent_task_id IS NULL AND sequence_order = 0) OR " + "(task_kind IN ('add_sign', 'countersign') " + "AND parent_task_id IS NOT NULL AND sequence_order > 0)", + name="ck_approval_tasks_parent_shape", + ), + CheckConstraint( + "(status = 'completed' AND decision = 'approved' " + "AND completed_at IS NOT NULL AND cancelled_at IS NULL) OR " + "(status = 'returned' AND decision = 'returned' " + "AND completed_at IS NOT NULL AND cancelled_at IS NULL) OR " + "(status IN ('cancelled', 'superseded') AND decision IS NULL " + "AND completed_at IS NULL AND cancelled_at IS NOT NULL) OR " + "(status IN ('waiting', 'pending') AND decision IS NULL " + "AND completed_at IS NULL AND cancelled_at IS NULL)", + name="ck_approval_tasks_lifecycle", + ), + CheckConstraint( + "status NOT IN ('pending', 'completed', 'returned') OR activated_at IS NOT NULL", + name="ck_approval_tasks_activation", + ), + CheckConstraint( + "delegation_expires_at IS NULL OR delegated_by IS NOT NULL", + name="ck_approval_tasks_delegation", + ), + CheckConstraint("version >= 1", name="ck_approval_tasks_version"), + CheckConstraint("node_sequence >= 1", name="ck_approval_tasks_node_sequence"), + CheckConstraint("sla_hours_snapshot > 0", name="ck_approval_tasks_sla_hours"), + CheckConstraint("due_at >= entered_at", name="ck_approval_tasks_due_at"), + CheckConstraint("escalation_level >= 0", name="ck_approval_tasks_escalation"), + CheckConstraint( + "priority_score >= 0 AND priority_score <= 100", + name="ck_approval_tasks_priority_score", + ), + CheckConstraint( + "open_risk_count >= 0", + name="ck_approval_tasks_open_risk_count", + ), + CheckConstraint( + "evidence_completeness >= 0 AND evidence_completeness <= 1", + name="ck_approval_tasks_evidence_completeness", + ), + Index( + "uq_approval_tasks_open_root_per_claim", + "tenant_id", + "claim_id", + unique=True, + postgresql_where=text( + "task_kind = 'root' AND status IN ('waiting', 'pending')" + ), + ).ddl_if(dialect="postgresql"), + Index( + "ix_approval_tasks_personal_inbox", + "tenant_id", + "assignee_kind", + "assignee_key", + "status", + "due_at", + ), + Index( + "ix_approval_tasks_tenant_queue", + "tenant_id", + "status", + "priority_score", + "due_at", + ), + Index( + "ix_approval_tasks_tenant_claim", + "tenant_id", + "claim_id", + "node_sequence", + ), + Index( + "ix_approval_tasks_tenant_node", + "tenant_id", + "node_instance_id", + "sequence_order", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id) + tenant_id: Mapped[str] = mapped_column(String(64), nullable=False) + claim_id: Mapped[str] = mapped_column(String(36), nullable=False) + expense_case_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + node_instance_id: Mapped[str] = mapped_column(String(120), nullable=False) + node_entry_key: Mapped[str] = mapped_column(String(160), nullable=False) + parent_task_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + task_kind: Mapped[str] = mapped_column(String(24), nullable=False) + node_key: Mapped[str] = mapped_column(String(32), nullable=False) + node_label: Mapped[str] = mapped_column(String(120), nullable=False) + node_sequence: Mapped[int] = mapped_column(Integer, nullable=False) + sequence_order: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, server_default="0" + ) + coordination_mode: Mapped[str] = mapped_column(String(32), nullable=False) + owner_kind: Mapped[str] = mapped_column(String(20), nullable=False) + owner_key: Mapped[str] = mapped_column(String(120), nullable=False) + owner_employee_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + owner_name: Mapped[str] = mapped_column(String(120), nullable=False) + assignee_kind: Mapped[str] = mapped_column(String(20), nullable=False) + assignee_key: Mapped[str] = mapped_column(String(120), nullable=False) + assignee_employee_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + assignee_name: Mapped[str] = mapped_column(String(120), nullable=False) + delegated_by: Mapped[str | None] = mapped_column(String(120), nullable=True) + delegation_expires_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + status: Mapped[str] = mapped_column( + String(24), nullable=False, default="waiting", server_default="waiting" + ) + decision: Mapped[str | None] = mapped_column(String(20), nullable=True) + opinion: Mapped[str | None] = mapped_column(Text(), nullable=True) + version: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1") + claim_status_snapshot: Mapped[str] = mapped_column(String(30), nullable=False) + claim_stage_snapshot: Mapped[str] = mapped_column(String(50), nullable=False) + entered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + entered_at_source: Mapped[str] = mapped_column(String(24), nullable=False) + activated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + sla_hours_snapshot: Mapped[int] = mapped_column(Integer, nullable=False) + due_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + escalation_level: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, server_default="0" + ) + escalated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + next_escalation_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + priority_score: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, server_default="0" + ) + priority_tier: Mapped[str] = mapped_column( + String(16), nullable=False, default="normal", server_default="normal" + ) + priority_reasons_json: Mapped[list[dict[str, Any]]] = mapped_column( + JSON, nullable=False, default=list + ) + risk_level: Mapped[str] = mapped_column( + String(16), nullable=False, default="low", server_default="low" + ) + open_risk_count: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, server_default="0" + ) + evidence_completeness: Mapped[Decimal] = mapped_column( + Numeric(5, 4), nullable=False, default=Decimal("0"), server_default="0" + ) + batch_eligible: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default="false" + ) + batch_block_reasons_json: Mapped[list[str]] = mapped_column( + JSON, nullable=False, default=list + ) + projection_updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + 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(), + ) + + claim = relationship( + "ExpenseClaim", + primaryjoin="foreign(ApprovalTask.claim_id) == ExpenseClaim.id", + foreign_keys=[claim_id], + viewonly=True, + ) + events = relationship( + "ApprovalTaskEvent", + back_populates="task", + order_by="asc(ApprovalTaskEvent.occurred_at)", + passive_deletes=True, + ) + + +class ApprovalTaskEvent(Base): + """审批任务的不可变事实记录;所有写动作都以事件响应支持幂等重放。""" + + __tablename__ = "approval_task_events" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "actor_id", + "request_id", + name="uq_approval_task_events_actor_request", + ), + UniqueConstraint( + "tenant_id", + "task_id", + "result_task_version", + name="uq_approval_task_events_task_version", + ), + ForeignKeyConstraint( + ["tenant_id", "task_id"], + ["approval_tasks.tenant_id", "approval_tasks.id"], + ondelete="RESTRICT", + name="fk_approval_task_events_tenant_task", + ), + CheckConstraint( + "length(trim(event_type)) > 0", + name="ck_approval_task_events_type", + ), + CheckConstraint( + "actor_type IN ('user', 'system', 'agent', 'service')", + name="ck_approval_task_events_actor_type", + ), + CheckConstraint( + "expected_task_version >= 0 AND result_task_version >= 1 " + "AND result_task_version >= expected_task_version", + name="ck_approval_task_events_version", + ), + Index( + "ix_approval_task_events_tenant_task_time", + "tenant_id", + "task_id", + "occurred_at", + ), + Index( + "ix_approval_task_events_tenant_node_time", + "tenant_id", + "node_instance_id", + "occurred_at", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id) + tenant_id: Mapped[str] = mapped_column(String(64), nullable=False) + task_id: Mapped[str] = mapped_column(String(36), nullable=False) + node_instance_id: Mapped[str] = mapped_column(String(120), nullable=False) + event_type: Mapped[str] = mapped_column(String(50), nullable=False) + actor_id: Mapped[str] = mapped_column(String(120), nullable=False) + actor_name: Mapped[str] = mapped_column(String(120), nullable=False) + actor_type: Mapped[str] = mapped_column(String(20), nullable=False) + request_id: Mapped[str] = mapped_column(String(120), nullable=False) + expected_task_version: Mapped[int] = mapped_column(Integer, nullable=False) + result_task_version: Mapped[int] = mapped_column(Integer, nullable=False) + payload_fingerprint: Mapped[str] = mapped_column(String(80), nullable=False) + payload_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict) + 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) + response_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict) + approval_action_ledger_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + business_event_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + correlation_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + causation_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + occurred_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + + task = relationship("ApprovalTask", back_populates="events") diff --git a/server/src/app/models/risk_disposition.py b/server/src/app/models/risk_disposition.py index b00207d..a5c6027 100644 --- a/server/src/app/models/risk_disposition.py +++ b/server/src/app/models/risk_disposition.py @@ -46,10 +46,46 @@ class RiskDisposition(Base): ), CheckConstraint( "lifecycle_status IN ('open', 'supplement_requested', " - "'remediation_in_progress', 'waiver_requested', 'resolved')", + "'remediation_in_progress', 'waiver_requested', 'waived', " + "'waiver_rejected', 'resolved')", name="ck_risk_dispositions_lifecycle", ), CheckConstraint("version >= 0", name="ck_risk_dispositions_version"), + CheckConstraint( + "(waiver_requester_id IS NULL AND waiver_requester_name IS NULL " + "AND waiver_requested_at IS NULL AND waiver_reason IS NULL " + "AND waiver_scope IS NULL AND waiver_expires_at IS NULL) OR " + "(waiver_requester_id IS NOT NULL " + "AND length(trim(waiver_requester_id)) > 0 " + "AND waiver_requester_name IS NOT NULL " + "AND length(trim(waiver_requester_name)) > 0 " + "AND waiver_requested_at IS NOT NULL " + "AND waiver_reason IS NOT NULL AND length(trim(waiver_reason)) > 0 " + "AND waiver_scope IS NOT NULL AND length(trim(waiver_scope)) > 0 " + "AND waiver_expires_at IS NOT NULL " + "AND waiver_expires_at > waiver_requested_at)", + name="ck_risk_dispositions_waiver_request", + ), + CheckConstraint( + "(waiver_decision IS NULL AND waiver_decider_id IS NULL " + "AND waiver_decider_name IS NULL AND waiver_decided_at IS NULL " + "AND waiver_decision_reason IS NULL) OR " + "(waiver_decision IN ('approved', 'rejected') " + "AND waiver_requester_id IS NOT NULL " + "AND waiver_decider_id IS NOT NULL " + "AND length(trim(waiver_decider_id)) > 0 " + "AND waiver_decider_name IS NOT NULL " + "AND length(trim(waiver_decider_name)) > 0 " + "AND waiver_decided_at IS NOT NULL " + "AND waiver_decision_reason IS NOT NULL " + "AND length(trim(waiver_decision_reason)) > 0)", + name="ck_risk_dispositions_waiver_decision", + ), + CheckConstraint( + "(lifecycle_status != 'waived' OR waiver_decision = 'approved') AND " + "(lifecycle_status != 'waiver_rejected' OR waiver_decision = 'rejected')", + name="ck_risk_dispositions_waiver_lifecycle", + ), ForeignKeyConstraint( ["tenant_id", "observation_id"], ["risk_observations.tenant_id", "risk_observations.id"], @@ -63,6 +99,12 @@ class RiskDisposition(Base): "due_at", ), Index("ix_risk_dispositions_assignee", "tenant_id", "assignee"), + Index( + "ix_risk_dispositions_tenant_waiver_expiry", + "tenant_id", + "lifecycle_status", + "waiver_expires_at", + ), ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id) @@ -89,6 +131,26 @@ class RiskDisposition(Base): 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) + waiver_requester_id: Mapped[str | None] = mapped_column(String(120), nullable=True) + waiver_requester_name: Mapped[str | None] = mapped_column(String(120), nullable=True) + waiver_requested_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + waiver_reason: Mapped[str | None] = mapped_column(Text(), nullable=True) + waiver_scope: Mapped[str | None] = mapped_column(Text(), nullable=True) + waiver_expires_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + waiver_conditions_json: Mapped[list[str]] = mapped_column( + JSON, nullable=False, default=list + ) + waiver_decision: Mapped[str | None] = mapped_column(String(20), nullable=True) + waiver_decider_id: Mapped[str | None] = mapped_column(String(120), nullable=True) + waiver_decider_name: Mapped[str | None] = mapped_column(String(120), nullable=True) + waiver_decided_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + waiver_decision_reason: Mapped[str | None] = mapped_column(Text(), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, @@ -128,7 +190,8 @@ class RiskDispositionEvent(Base): ), CheckConstraint( "action IN ('confirm', 'false_positive', 'request_supplement', " - "'start_remediation', 'resolve', 'request_waiver')", + "'start_remediation', 'resolve', 'request_waiver', " + "'approve_waiver', 'reject_waiver')", name="ck_risk_disposition_events_action", ), CheckConstraint("version > 0", name="ck_risk_disposition_events_version"), diff --git a/server/src/app/schemas/approval_task.py b/server/src/app/schemas/approval_task.py new file mode 100644 index 0000000..cf6b0a9 --- /dev/null +++ b/server/src/app/schemas/approval_task.py @@ -0,0 +1,279 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from app.schemas.reimbursement import ExpenseClaimRead + +ApprovalTaskKind = Literal["root", "add_sign", "countersign"] +ApprovalNodeKey = Literal["direct_manager", "budget_manager", "finance"] +ApprovalTaskStatus = Literal[ + "waiting", + "pending", + "completed", + "returned", + "cancelled", + "superseded", +] +ApprovalTaskDecision = Literal["approved", "returned"] +ApprovalTaskAction = Literal[ + "delegate", + "delegation_revoke", + "transfer", + "add_sign", + "countersign", + "approve", + "return", + "sla_escalate", +] + + +class ApprovalTaskEventRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + tenant_id: str + task_id: str + node_instance_id: str + event_type: str + actor_id: str + actor_name: str + actor_type: str + request_id: str + expected_task_version: int + result_task_version: int + payload_json: dict[str, Any] = Field(default_factory=dict) + before_json: dict[str, Any] = Field(default_factory=dict) + after_json: dict[str, Any] = Field(default_factory=dict) + approval_action_ledger_id: str | None = None + business_event_id: str | None = None + correlation_id: str | None = None + causation_id: str | None = None + occurred_at: datetime + + +class ApprovalTaskRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + tenant_id: str + claim_id: str + expense_case_id: str | None = None + node_instance_id: str + node_entry_key: str + parent_task_id: str | None = None + task_kind: ApprovalTaskKind + node_key: ApprovalNodeKey + node_label: str + node_sequence: int + sequence_order: int + coordination_mode: Literal["single", "sequential_before_owner", "parallel_all"] + owner_kind: Literal["employee", "role"] + owner_key: str + owner_employee_id: str | None = None + owner_name: str + assignee_kind: Literal["employee", "role"] + assignee_key: str + assignee_employee_id: str | None = None + assignee_name: str + delegated_by: str | None = None + delegation_expires_at: datetime | None = None + status: ApprovalTaskStatus + decision: ApprovalTaskDecision | None = None + opinion: str | None = None + version: int + claim_status_snapshot: str + claim_stage_snapshot: str + entered_at: datetime + entered_at_source: Literal["workflow_event", "submitted_at", "updated_at", "backfill"] + activated_at: datetime | None = None + sla_hours_snapshot: int + due_at: datetime + completed_at: datetime | None = None + cancelled_at: datetime | None = None + escalation_level: int + escalated_at: datetime | None = None + next_escalation_at: datetime | None = None + priority_score: int + priority_tier: Literal["normal", "high", "urgent"] + priority_reasons_json: list[dict[str, Any]] = Field(default_factory=list) + risk_level: Literal["low", "medium", "high", "critical"] + open_risk_count: int + evidence_completeness: float + batch_eligible: bool + batch_block_reasons_json: list[str] = Field(default_factory=list) + projection_updated_at: datetime + created_at: datetime + updated_at: datetime + can_act: bool = False + available_actions: list[str] = Field(default_factory=list) + read_only_reason: str = "" + + +class ApprovalTaskMutationRead(BaseModel): + task: ApprovalTaskRead + event: ApprovalTaskEventRead + related_tasks: list[ApprovalTaskRead] = Field(default_factory=list) + replayed: bool = False + + +class ApprovalTaskQueueItemRead(BaseModel): + task: ApprovalTaskRead + claim: ExpenseClaimRead + + +class ApprovalTaskListRead(BaseModel): + items: list[ApprovalTaskQueueItemRead] = Field(default_factory=list) + total: int = 0 + page: int = 1 + page_size: int = 20 + total_pages: int = 0 + generated_at: datetime + + +class ApprovalTaskDecisionRead(BaseModel): + mutation: ApprovalTaskMutationRead + claim: ExpenseClaimRead + + +class ApprovalTaskActionBase(BaseModel): + request_id: str = Field(min_length=8, max_length=120) + expected_task_version: int = Field(ge=1) + reason: str = Field(min_length=2, max_length=500) + + @field_validator("request_id", "reason", mode="before") + @classmethod + def normalize_text(cls, value: Any) -> str: + return str(value or "").strip() + + +class ApprovalTaskAssignmentAction(ApprovalTaskActionBase): + target_employee_id: str = Field(min_length=1, max_length=36) + expires_at: datetime | None = None + + @field_validator("target_employee_id", mode="before") + @classmethod + def normalize_employee_id(cls, value: Any) -> str: + return str(value or "").strip() + + +class ApprovalTaskParticipantsAction(ApprovalTaskActionBase): + participant_employee_ids: list[str] = Field(min_length=1, max_length=10) + + @field_validator("participant_employee_ids", mode="before") + @classmethod + def normalize_participants(cls, value: Any) -> list[str]: + if not isinstance(value, list): + return [] + normalized = [str(item or "").strip() for item in value] + return [item for item in normalized if item] + + @model_validator(mode="after") + def validate_unique_participants(self) -> ApprovalTaskParticipantsAction: + if len(set(self.participant_employee_ids)) != len(self.participant_employee_ids): + raise ValueError("加签或会签参与人不能重复。") + return self + + +class ApprovalTaskDecisionAction(ApprovalTaskActionBase): + opinion: str | None = Field(default=None, max_length=500) + + @field_validator("opinion", mode="before") + @classmethod + def normalize_opinion(cls, value: Any) -> str | None: + normalized = str(value or "").strip() + return normalized or None + + +class ApprovalTaskReturnAction(ApprovalTaskActionBase): + reason_codes: list[str] = Field(default_factory=list, max_length=10) + + @field_validator("reason_codes", mode="before") + @classmethod + def normalize_reason_codes(cls, value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return list( + dict.fromkeys( + item + for item in (str(entry or "").strip() for entry in value) + if item + ) + ) + + +class ApprovalTaskBatchApproveItem(BaseModel): + task_id: str = Field(min_length=1, max_length=36) + expected_task_version: int = Field(ge=1) + expected_status: str = Field(min_length=1, max_length=30) + expected_approval_stage: str = Field(min_length=1, max_length=50) + opinion: str | None = Field(default=None, max_length=500) + + @field_validator( + "task_id", + "expected_status", + "expected_approval_stage", + "opinion", + mode="before", + ) + @classmethod + def normalize_batch_text(cls, value: Any) -> Any: + if value is None: + return None + return str(value).strip() + + +class ApprovalTaskBatchApproveCreate(BaseModel): + batch_request_id: str = Field(min_length=8, max_length=80) + items: list[ApprovalTaskBatchApproveItem] = Field(min_length=1, max_length=20) + + @field_validator("batch_request_id", mode="before") + @classmethod + def normalize_batch_request_id(cls, value: Any) -> str: + return str(value or "").strip() + + @model_validator(mode="after") + def validate_unique_tasks(self) -> ApprovalTaskBatchApproveCreate: + task_ids = [item.task_id for item in self.items] + if len(set(task_ids)) != len(task_ids): + raise ValueError("批量审批不能包含重复任务。") + return self + + +class ApprovalTaskBatchItemRead(BaseModel): + task_id: str + claim_id: str | None = None + status: Literal[ + "succeeded", + "replayed", + "conflict", + "blocked", + "forbidden", + "failed", + ] + code: str + message: str + claim: dict[str, Any] | None = None + + +class ApprovalTaskBatchApproveRead(BaseModel): + batch_request_id: str + status: Literal["succeeded", "partial", "failed"] + succeeded_count: int = 0 + replayed_count: int = 0 + conflict_count: int = 0 + blocked_count: int = 0 + forbidden_count: int = 0 + failed_count: int = 0 + items: list[ApprovalTaskBatchItemRead] = Field(default_factory=list) + + +class ApprovalTaskCandidateRead(BaseModel): + employee_id: str + employee_no: str + name: str + email: str + qualified: bool + reason: str = "" diff --git a/server/src/app/schemas/reimbursement.py b/server/src/app/schemas/reimbursement.py index 1e4ab5b..696f77a 100644 --- a/server/src/app/schemas/reimbursement.py +++ b/server/src/app/schemas/reimbursement.py @@ -4,7 +4,7 @@ from datetime import date, datetime from decimal import Decimal from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from app.services.expense_claim_budget_risk_flags import dedupe_budget_risk_flags @@ -298,10 +298,26 @@ class ExpenseClaimActionProtocolPayload(BaseModel): class ExpenseClaimReturnPayload(ExpenseClaimActionProtocolPayload): reason: str | None = Field(default=None, max_length=500) reason_codes: list[str] = Field(default_factory=list, max_length=10) + task_id: str | None = Field(default=None, min_length=1, max_length=36) + expected_task_version: int | None = Field(default=None, ge=1) + + @model_validator(mode="after") + def validate_task_precondition(self) -> ExpenseClaimReturnPayload: + if (self.task_id is None) != (self.expected_task_version is None): + raise ValueError("task_id 与 expected_task_version 必须同时提供。") + return self class ExpenseClaimApprovalPayload(ExpenseClaimActionProtocolPayload): opinion: str | None = Field(default=None, max_length=500) + task_id: str | None = Field(default=None, min_length=1, max_length=36) + expected_task_version: int | None = Field(default=None, ge=1) + + @model_validator(mode="after") + def validate_task_precondition(self) -> ExpenseClaimApprovalPayload: + if (self.task_id is None) != (self.expected_task_version is None): + raise ValueError("task_id 与 expected_task_version 必须同时提供。") + return self class ExpenseClaimPaymentPayload(ExpenseClaimActionProtocolPayload): diff --git a/server/src/app/schemas/risk_disposition.py b/server/src/app/schemas/risk_disposition.py index 22ce8bd..ead1472 100644 --- a/server/src/app/schemas/risk_disposition.py +++ b/server/src/app/schemas/risk_disposition.py @@ -12,6 +12,8 @@ RiskDispositionAction = Literal[ "start_remediation", "resolve", "request_waiver", + "approve_waiver", + "reject_waiver", ] RiskAdjudication = Literal["unreviewed", "confirmed", "false_positive"] RiskLifecycleStatus = Literal[ @@ -19,6 +21,8 @@ RiskLifecycleStatus = Literal[ "supplement_requested", "remediation_in_progress", "waiver_requested", + "waived", + "waiver_rejected", "resolved", ] @@ -31,8 +35,20 @@ class RiskDispositionActionCreate(BaseModel): assignee: str | None = Field(default=None, max_length=120) due_at: datetime | None = None resolution: str | None = Field(default=None, max_length=2000) + waiver_reason: str | None = Field(default=None, max_length=2000) + waiver_scope: str | None = Field(default=None, max_length=1000) + waiver_expires_at: datetime | None = None + waiver_conditions: list[str] = Field(default_factory=list, max_length=20) - @field_validator("request_id", "comment", "assignee", "resolution", mode="before") + @field_validator( + "request_id", + "comment", + "assignee", + "resolution", + "waiver_reason", + "waiver_scope", + mode="before", + ) @classmethod def normalize_text(cls, value: Any) -> Any: if value is None: @@ -40,14 +56,42 @@ class RiskDispositionActionCreate(BaseModel): normalized = str(value).strip() return normalized or None + @field_validator("waiver_conditions", mode="before") + @classmethod + def normalize_waiver_conditions(cls, value: Any) -> list[str]: + if not isinstance(value, list): + return [] + normalized = [str(item or "").strip() for item in value] + if any(len(item) > 500 for item in normalized): + raise ValueError("单条豁免条件不能超过 500 个字符") + return list(dict.fromkeys(item for item in normalized if item)) + @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 - ): + if self.action in {"false_positive", "request_supplement"} and not self.comment: raise ValueError(f"{self.action} 动作必须填写 comment") + if self.action == "request_waiver" and not all( + (self.waiver_reason, self.waiver_scope, self.waiver_expires_at) + ): + raise ValueError("request_waiver 必须填写原因、范围和有效期") + if self.action in {"approve_waiver", "reject_waiver"} and not self.comment: + raise ValueError(f"{self.action} 动作必须填写 comment") + if self.action in {"approve_waiver", "reject_waiver"} and any( + value is not None for value in (self.assignee, self.due_at, self.resolution) + ): + raise ValueError("豁免决定动作不能同时修改负责人、截止时间或解决说明") + waiver_fields_present = any( + ( + self.waiver_reason, + self.waiver_scope, + self.waiver_expires_at, + self.waiver_conditions, + ) + ) + if self.action != "request_waiver" and waiver_fields_present: + raise ValueError("只有 request_waiver 可以填写豁免申请字段") if self.action in {"confirm", "false_positive"} and any( value is not None for value in (self.assignee, self.due_at, self.resolution) ): @@ -85,6 +129,20 @@ class RiskDispositionRead(BaseModel): assignee: str | None due_at: datetime | None resolution: str | None + waiver_requester_id: str | None = None + waiver_requester_name: str | None = None + waiver_requested_at: datetime | None = None + waiver_reason: str | None = None + waiver_scope: str | None = None + waiver_expires_at: datetime | None = None + waiver_conditions_json: list[str] = Field(default_factory=list) + waiver_decision: Literal["approved", "rejected"] | None = None + waiver_decider_id: str | None = None + waiver_decider_name: str | None = None + waiver_decided_at: datetime | None = None + waiver_decision_reason: str | None = None + available_actions: list[RiskDispositionAction] = Field(default_factory=list) + read_only_reason: str = "" created_at: datetime updated_at: datetime events: list[RiskDispositionEventRead] = Field(default_factory=list) diff --git a/server/src/app/schemas/risk_observation.py b/server/src/app/schemas/risk_observation.py index 3562c66..9c5366c 100644 --- a/server/src/app/schemas/risk_observation.py +++ b/server/src/app/schemas/risk_observation.py @@ -5,7 +5,7 @@ from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator -from app.schemas.risk_disposition import RiskDispositionRead +from app.schemas.risk_disposition import RiskDispositionAction, RiskDispositionRead RiskObservationStatus = Literal[ "pending_review", @@ -92,6 +92,8 @@ class RiskObservationRead(BaseModel): updated_at: datetime feedback_items: list[RiskObservationFeedbackRead] = Field(default_factory=list) disposition: RiskDispositionRead | None = None + available_actions: list[RiskDispositionAction] = Field(default_factory=list) + read_only_reason: str = "" class RiskObservationListRead(BaseModel): diff --git a/server/src/app/services/approval_task_access_policy.py b/server/src/app/services/approval_task_access_policy.py new file mode 100644 index 0000000..a0fea1a --- /dev/null +++ b/server/src/app/services/approval_task_access_policy.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime + +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload + +from app.api.deps import CurrentUserContext +from app.models.approval_task import ApprovalTask +from app.models.employee import Employee +from app.models.financial_record import ExpenseClaim +from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy +from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin + +_OPEN_STATUSES = {"pending", "waiting"} +_ACTIVE_EMPLOYMENT_STATUSES = {"", "active", "employed", "在职", "正常"} + + +class ApprovalTaskPermissionError(ValueError): + pass + + +@dataclass(frozen=True, slots=True) +class ApprovalTaskPermission: + can_read: bool + can_act: bool + available_actions: tuple[str, ...] + read_only_reason: str = "" + + +class ApprovalTaskAccessPolicy: + """让列表可见性、任务管理和最终动作共用同一授权判断。""" + + def __init__(self, db: Session) -> None: + self.db = db + self.claim_policy = ExpenseClaimAccessPolicy(db) + + def evaluate( + self, + task: ApprovalTask, + current_user: CurrentUserContext, + *, + claim: ExpenseClaim | None = None, + ) -> ApprovalTaskPermission: + if task.tenant_id != ExpenseClaimTenantScopeMixin.normalize_tenant_id( + current_user.tenant_id + ): + return ApprovalTaskPermission(False, False, (), "任务不在当前租户范围内。") + + assigned = self._matches_assignment(task, current_user, owner=False) + owner = self._matches_assignment(task, current_user, owner=True) + admin = bool(current_user.is_admin) + can_read = admin or assigned or owner + if not can_read: + return ApprovalTaskPermission(False, False, (), "当前用户不在该任务参与范围内。") + + available: list[str] = [] + read_only_reason = "" + can_act = False + if task.status == "waiting": + read_only_reason = "当前任务正在等待加签或会签完成。" + elif task.status != "pending": + read_only_reason = "该任务已经处理,当前仅可查看审计记录。" + elif assigned: + if claim is not None and self.claim_policy.is_claim_owned_by_current_user( + claim, + current_user, + ): + read_only_reason = "申请人不能审批自己的费用单。" + else: + can_act = True + available.append("approve") + available.append("return") + if task.task_kind == "root": + available.extend(("delegate", "transfer", "add_sign", "countersign")) + elif owner and task.delegated_by: + available.append("delegation_revoke") + read_only_reason = "任务已委托,原责任人只能撤销委托。" + elif admin: + available.extend(("transfer", "sla_escalate")) + read_only_reason = "管理员可转交或升级任务,但不能凭查看权限直接审批。" + else: + read_only_reason = "当前用户不是任务的有效处理人。" + + if admin and "sla_escalate" not in available and task.status in _OPEN_STATUSES: + available.append("sla_escalate") + return ApprovalTaskPermission( + can_read=True, + can_act=can_act, + available_actions=tuple(dict.fromkeys(available)), + read_only_reason=read_only_reason, + ) + + def require_read( + self, + task: ApprovalTask, + current_user: CurrentUserContext, + *, + claim: ExpenseClaim | None = None, + ) -> ApprovalTaskPermission: + permission = self.evaluate(task, current_user, claim=claim) + if not permission.can_read: + raise LookupError("Approval task not found.") + return permission + + def require_action( + self, + task: ApprovalTask, + current_user: CurrentUserContext, + *, + action: str, + claim: ExpenseClaim, + ) -> ApprovalTaskPermission: + permission = self.require_read(task, current_user, claim=claim) + if action not in permission.available_actions: + raise ApprovalTaskPermissionError( + permission.read_only_reason or "当前用户无权执行该审批任务动作。" + ) + if action in {"approve", "return"} and not permission.can_act: + raise ApprovalTaskPermissionError( + permission.read_only_reason or "当前用户不是任务处理人。" + ) + return permission + + def resolve_and_validate_target( + self, + *, + task: ApprovalTask, + claim: ExpenseClaim, + target_employee_id: str, + ) -> Employee: + target = self.db.scalar( + select(Employee) + .options( + selectinload(Employee.roles), + selectinload(Employee.organization_unit), + ) + .where(Employee.id == str(target_employee_id or "").strip()) + ) + if target is None: + raise ValueError("目标审批人不存在。") + employment_status = str(target.employment_status or "").strip().lower() + if employment_status not in _ACTIVE_EMPLOYMENT_STATUSES: + raise ValueError("目标审批人不是在职员工。") + if str(claim.employee_id or "").strip() == target.id: + raise ValueError("申请人不能成为自己的审批人。") + if not self._target_qualified_for_node(target, task=task, claim=claim): + raise ValueError("目标员工不具备当前审批节点的处理资格。") + return target + + def candidate_employees( + self, + *, + task: ApprovalTask, + claim: ExpenseClaim, + ) -> list[tuple[Employee, bool, str]]: + employees = list( + self.db.scalars( + select(Employee) + .options( + selectinload(Employee.roles), + selectinload(Employee.organization_unit), + ) + .order_by(Employee.name.asc(), Employee.employee_no.asc()) + ).all() + ) + rows: list[tuple[Employee, bool, str]] = [] + for employee in employees: + if str(claim.employee_id or "").strip() == employee.id: + rows.append((employee, False, "申请人不能审批自己的费用单。")) + continue + status = str(employee.employment_status or "").strip().lower() + if status not in _ACTIVE_EMPLOYMENT_STATUSES: + rows.append((employee, False, "员工当前不在职。")) + continue + qualified = self._target_qualified_for_node(employee, task=task, claim=claim) + rows.append((employee, qualified, "" if qualified else "不具备当前节点资格。")) + return rows + + def actor_id(self, current_user: CurrentUserContext) -> str: + employee = self.claim_policy.resolve_current_employee(current_user) + return str( + (employee.id if employee is not None else "") + or current_user.employee_id + or current_user.username + ).strip() + + def _matches_assignment( + self, + task: ApprovalTask, + current_user: CurrentUserContext, + *, + owner: bool, + ) -> bool: + if ( + not owner + and task.delegated_by + and task.delegation_expires_at is not None + and _as_utc(task.delegation_expires_at) <= datetime.now(UTC) + ): + return False + kind = str(task.owner_kind if owner else task.assignee_kind).strip().lower() + key = str(task.owner_key if owner else task.assignee_key).strip().casefold() + employee_id = str( + (task.owner_employee_id if owner else task.assignee_employee_id) or "" + ).strip() + if kind == "role": + return key in self.claim_policy.normalize_role_codes(current_user) + + current_employee = self.claim_policy.resolve_current_employee(current_user) + identities = { + str(current_user.username or "").strip().casefold(), + str(current_user.employee_id or "").strip().casefold(), + str(current_user.employee_no or "").strip().casefold(), + } + if current_employee is not None: + identities.update( + { + str(current_employee.id or "").strip().casefold(), + str(current_employee.name or "").strip().casefold(), + str(current_employee.employee_no or "").strip().casefold(), + str(current_employee.email or "").strip().casefold(), + } + ) + identities.discard("") + return key in identities or bool(employee_id and employee_id.casefold() in identities) + + def _target_qualified_for_node( + self, + target: Employee, + *, + task: ApprovalTask, + claim: ExpenseClaim, + ) -> bool: + role_codes = { + str(role.role_code or "").strip().lower() + for role in list(target.roles or []) + if str(role.role_code or "").strip() + } + if task.node_key == "finance": + return "finance" in role_codes + if task.node_key == "budget_manager": + same_department = bool( + claim.department_id + and target.organization_unit_id + and str(claim.department_id) == str(target.organization_unit_id) + ) or ( + bool(claim.department_name and target.organization_unit) + and str(claim.department_name).strip().casefold() + == str(target.organization_unit.name or "").strip().casefold() + ) + grade = str(target.grade or "").strip().upper() + grade_level = int(grade[1:]) if grade.startswith("P") and grade[1:].isdigit() else 0 + return same_department and grade_level >= 8 and bool( + role_codes & {"budget_monitor", "executive"} + ) + if claim.employee is not None and claim.employee.manager_id == target.id: + return True + return bool(role_codes & {"manager", "approver", "executive"}) + + +def _as_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) diff --git a/server/src/app/services/approval_task_actions.py b/server/src/app/services/approval_task_actions.py new file mode 100644 index 0000000..c815579 --- /dev/null +++ b/server/src/app/services/approval_task_actions.py @@ -0,0 +1,762 @@ +from __future__ import annotations + +import hashlib +import uuid +from datetime import UTC, datetime, timedelta + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.api.deps import CurrentUserContext +from app.models.approval_task import ApprovalTask +from app.models.financial_record import ExpenseClaim +from app.schemas.approval_task import ( + ApprovalTaskActionBase, + ApprovalTaskAssignmentAction, + ApprovalTaskCandidateRead, + ApprovalTaskDecisionAction, + ApprovalTaskMutationRead, + ApprovalTaskParticipantsAction, +) +from app.services.approval_task_access_policy import ( + ApprovalTaskAccessPolicy, + ApprovalTaskPermissionError, +) +from app.services.approval_task_protocol import ( + ApprovalTaskConflictError, + ApprovalTaskEventProtocol, + ApprovalTaskVersionConflictError, + payload_fingerprint, + task_state, +) +from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin + + +class ApprovalTaskActionService: + """审批任务辅助动作;不改变费用单状态机。""" + + def __init__(self, db: Session) -> None: + self.db = db + self.access = ApprovalTaskAccessPolicy(db) + self.events = ApprovalTaskEventProtocol(db) + + def candidates( + self, + task_id: str, + current_user: CurrentUserContext, + *, + action: str | None = None, + keyword: str | None = None, + limit: int = 50, + ) -> list[ApprovalTaskCandidateRead]: + task, claim = self._read_context(task_id, current_user) + permission = self.access.require_read(task, current_user, claim=claim) + normalized_action = str(action or "").strip() + if normalized_action and normalized_action not in permission.available_actions: + raise ApprovalTaskPermissionError( + permission.read_only_reason or "当前用户无权为该任务选择处理人。" + ) + normalized_keyword = str(keyword or "").strip().casefold() + rows = [ + ApprovalTaskCandidateRead( + employee_id=employee.id, + employee_no=str(employee.employee_no or ""), + name=str(employee.name or ""), + email=str(employee.email or ""), + qualified=qualified, + reason=reason, + ) + for employee, qualified, reason in self.access.candidate_employees( + task=task, + claim=claim, + ) + ] + if normalized_keyword: + rows = [ + row + for row in rows + if normalized_keyword + in " ".join((row.name, row.employee_no, row.email)).casefold() + ] + return rows[: min(max(int(limit), 1), 100)] + + def assign( + self, + task_id: str, + current_user: CurrentUserContext, + *, + action: str, + payload: ApprovalTaskAssignmentAction, + ) -> ApprovalTaskMutationRead: + if action not in {"delegate", "transfer"}: + raise ValueError("不支持的任务分配动作。") + event_type = "task_delegated" if action == "delegate" else "task_transferred" + actor_id = self.access.actor_id(current_user) + event_payload = payload.model_dump(mode="json", exclude={"request_id"}) + fingerprint = payload_fingerprint( + task_id=task_id, + actor_id=actor_id, + event_type=event_type, + payload=event_payload, + ) + replay = self.events.find_replay( + tenant_id=self._tenant(current_user), + actor_id=actor_id, + request_id=payload.request_id, + fingerprint=fingerprint, + ) + if replay is not None: + return replay + try: + task, claim = self._lock_context(task_id, current_user) + replay = self.events.find_replay( + tenant_id=self._tenant(current_user), + actor_id=actor_id, + request_id=payload.request_id, + fingerprint=fingerprint, + ) + if replay is not None: + self.db.commit() + return replay + self._ensure_expected_version(task, payload.expected_task_version) + permission = self.access.require_action( + task, + current_user, + action=action, + claim=claim, + ) + target = self.access.resolve_and_validate_target( + task=task, + claim=claim, + target_employee_id=payload.target_employee_id, + ) + if target.id == task.assignee_employee_id: + raise ApprovalTaskConflictError("目标员工已经是当前任务处理人。") + if action == "delegate" and task.delegated_by: + raise ApprovalTaskConflictError("当前任务已有委托,请先撤销委托或执行转交。") + if action == "delegate" and payload.expires_at is not None: + if _as_utc(payload.expires_at) <= datetime.now(UTC): + raise ValueError("委托失效时间必须晚于当前时间。") + if action == "transfer" and payload.expires_at is not None: + raise ValueError("永久转交不能设置委托失效时间。") + + before = task_state(task) + expected = task.version + task.assignee_kind = "employee" + task.assignee_key = target.id + task.assignee_employee_id = target.id + task.assignee_name = str(target.name or target.employee_no) + if action == "delegate": + task.delegated_by = actor_id + task.delegation_expires_at = payload.expires_at + else: + task.owner_kind = "employee" + task.owner_key = target.id + task.owner_employee_id = target.id + task.owner_name = str(target.name or target.employee_no) + task.delegated_by = None + task.delegation_expires_at = None + task.version += 1 + task.updated_at = datetime.now(UTC) + permission = self.access.evaluate(task, current_user, claim=claim) + _, response = self.events.append_event( + task=task, + event_type=event_type, + actor_id=actor_id, + actor_name=str(current_user.name or current_user.username), + actor_type="user", + request_id=payload.request_id, + expected_task_version=expected, + payload=event_payload, + before_json=before, + permission=permission, + ) + self.db.commit() + return response + except IntegrityError as error: + self.db.rollback() + replay = self.events.find_replay( + tenant_id=self._tenant(current_user), + actor_id=actor_id, + request_id=payload.request_id, + fingerprint=fingerprint, + ) + if replay is not None: + return replay + raise ApprovalTaskConflictError("审批任务已被并发修改,请刷新后重试。") from error + except Exception: + self.db.rollback() + raise + + def revoke_delegation( + self, + task_id: str, + current_user: CurrentUserContext, + payload: ApprovalTaskActionBase, + ) -> ApprovalTaskMutationRead: + return self._simple_mutation( + task_id, + current_user, + action="delegation_revoke", + payload=payload, + mutate=self._apply_delegation_revoke, + ) + + def escalate( + self, + task_id: str, + current_user: CurrentUserContext, + payload: ApprovalTaskActionBase, + ) -> ApprovalTaskMutationRead: + return self._simple_mutation( + task_id, + current_user, + action="sla_escalate", + payload=payload, + mutate=self._apply_escalation, + ) + + def add_participants( + self, + task_id: str, + current_user: CurrentUserContext, + *, + action: str, + payload: ApprovalTaskParticipantsAction, + ) -> ApprovalTaskMutationRead: + if action not in {"add_sign", "countersign"}: + raise ValueError("不支持的参与人动作。") + event_type = "add_sign_started" if action == "add_sign" else "countersign_started" + actor_id = self.access.actor_id(current_user) + event_payload = payload.model_dump(mode="json", exclude={"request_id"}) + fingerprint = payload_fingerprint( + task_id=task_id, + actor_id=actor_id, + event_type=event_type, + payload=event_payload, + ) + replay = self.events.find_replay( + tenant_id=self._tenant(current_user), + actor_id=actor_id, + request_id=payload.request_id, + fingerprint=fingerprint, + ) + if replay is not None: + return replay + try: + root, claim = self._lock_context(task_id, current_user) + replay = self.events.find_replay( + tenant_id=self._tenant(current_user), + actor_id=actor_id, + request_id=payload.request_id, + fingerprint=fingerprint, + ) + if replay is not None: + self.db.commit() + return replay + self._ensure_expected_version(root, payload.expected_task_version) + self.access.require_action(root, current_user, action=action, claim=claim) + if root.task_kind != "root" or root.status != "pending": + raise ApprovalTaskConflictError("只有待处理的根任务可以发起加签或会签。") + existing_children = self.db.scalar( + select(ApprovalTask.id).where( + ApprovalTask.tenant_id == root.tenant_id, + ApprovalTask.parent_task_id == root.id, + ApprovalTask.status.in_(("waiting", "pending")), + ) + ) + if existing_children is not None: + raise ApprovalTaskConflictError("当前节点已有未完成的加签或会签任务。") + participants = [ + self.access.resolve_and_validate_target( + task=root, + claim=claim, + target_employee_id=employee_id, + ) + for employee_id in payload.participant_employee_ids + ] + occupied = { + str(root.owner_employee_id or ""), + str(root.assignee_employee_id or ""), + } + if any(employee.id in occupied for employee in participants): + raise ValueError("加签或会签参与人不能与当前责任人重复。") + + now = datetime.now(UTC) + before = task_state(root) + expected = root.version + root.status = "waiting" + root.coordination_mode = ( + "sequential_before_owner" if action == "add_sign" else "parallel_all" + ) + root.version += 1 + root.updated_at = now + children = [ + self._new_child( + root, + claim=claim, + employee=employee, + task_kind=action, + sequence_order=index, + active=(action == "countersign" or index == 1), + now=now, + ) + for index, employee in enumerate(participants, start=1) + ] + self.db.add_all(children) + self.db.flush() + for index, child in enumerate(children, start=1): + self.events.append_event( + task=child, + event_type="node_entered", + actor_id="system", + actor_name="审批任务引擎", + actor_type="system", + request_id=_derived_request(payload.request_id, f"child:{index}"), + expected_task_version=0, + payload={ + "parent_task_id": root.id, + "task_kind": action, + "sequence_order": index, + }, + before_json={}, + occurred_at=now, + ) + event, response = self.events.append_event( + task=root, + event_type=event_type, + actor_id=actor_id, + actor_name=str(current_user.name or current_user.username), + actor_type="user", + request_id=payload.request_id, + expected_task_version=expected, + payload=event_payload, + before_json=before, + related_tasks=children, + ) + event.response_json = response.model_dump(mode="json") + self.db.commit() + return response + except IntegrityError as error: + self.db.rollback() + replay = self.events.find_replay( + tenant_id=self._tenant(current_user), + actor_id=actor_id, + request_id=payload.request_id, + fingerprint=fingerprint, + ) + if replay is not None: + return replay + raise ApprovalTaskConflictError("审批任务已被并发修改,请刷新后重试。") from error + except Exception: + self.db.rollback() + raise + + def approve_child( + self, + task_id: str, + current_user: CurrentUserContext, + payload: ApprovalTaskDecisionAction, + ) -> ApprovalTaskMutationRead: + actor_id = self.access.actor_id(current_user) + event_payload = payload.model_dump(mode="json", exclude={"request_id"}) + fingerprint = payload_fingerprint( + task_id=task_id, + actor_id=actor_id, + event_type="task_approved", + payload=event_payload, + ) + replay = self.events.find_replay( + tenant_id=self._tenant(current_user), + actor_id=actor_id, + request_id=payload.request_id, + fingerprint=fingerprint, + ) + if replay is not None: + return replay + try: + task, claim = self._lock_context(task_id, current_user) + replay = self.events.find_replay( + tenant_id=self._tenant(current_user), + actor_id=actor_id, + request_id=payload.request_id, + fingerprint=fingerprint, + ) + if replay is not None: + self.db.commit() + return replay + if task.task_kind == "root": + raise ApprovalTaskConflictError("根任务请使用单据审批动作。") + self._ensure_expected_version(task, payload.expected_task_version) + self.access.require_action(task, current_user, action="approve", claim=claim) + before = task_state(task) + expected = task.version + now = datetime.now(UTC) + task.status = "completed" + task.decision = "approved" + task.opinion = payload.opinion or payload.reason + task.completed_at = now + task.version += 1 + task.updated_at = now + related = self._advance_node_family(task, claim=claim, now=now) + event, response = self.events.append_event( + task=task, + event_type="task_approved", + actor_id=actor_id, + actor_name=str(current_user.name or current_user.username), + actor_type="user", + request_id=payload.request_id, + expected_task_version=expected, + payload=event_payload, + before_json=before, + related_tasks=related, + occurred_at=now, + ) + event.response_json = response.model_dump(mode="json") + self.db.commit() + return response + except IntegrityError as error: + self.db.rollback() + replay = self.events.find_replay( + tenant_id=self._tenant(current_user), + actor_id=actor_id, + request_id=payload.request_id, + fingerprint=fingerprint, + ) + if replay is not None: + return replay + raise ApprovalTaskConflictError("审批任务已被并发修改,请刷新后重试。") from error + except Exception: + self.db.rollback() + raise + + def _simple_mutation( + self, + task_id: str, + current_user: CurrentUserContext, + *, + action: str, + payload: ApprovalTaskActionBase, + mutate, + ) -> ApprovalTaskMutationRead: + actor_id = self.access.actor_id(current_user) + event_payload = payload.model_dump(mode="json", exclude={"request_id"}) + fingerprint = payload_fingerprint( + task_id=task_id, + actor_id=actor_id, + event_type=action, + payload=event_payload, + ) + replay = self.events.find_replay( + tenant_id=self._tenant(current_user), + actor_id=actor_id, + request_id=payload.request_id, + fingerprint=fingerprint, + ) + if replay is not None: + return replay + try: + task, claim = self._lock_context(task_id, current_user) + replay = self.events.find_replay( + tenant_id=self._tenant(current_user), + actor_id=actor_id, + request_id=payload.request_id, + fingerprint=fingerprint, + ) + if replay is not None: + self.db.commit() + return replay + self._ensure_expected_version(task, payload.expected_task_version) + self.access.require_action(task, current_user, action=action, claim=claim) + before = task_state(task) + expected = task.version + mutate(task, actor_id=actor_id) + task.version += 1 + task.updated_at = datetime.now(UTC) + _, response = self.events.append_event( + task=task, + event_type=action, + actor_id=actor_id, + actor_name=str(current_user.name or current_user.username), + actor_type="user", + request_id=payload.request_id, + expected_task_version=expected, + payload=event_payload, + before_json=before, + ) + self.db.commit() + return response + except IntegrityError as error: + self.db.rollback() + replay = self.events.find_replay( + tenant_id=self._tenant(current_user), + actor_id=actor_id, + request_id=payload.request_id, + fingerprint=fingerprint, + ) + if replay is not None: + return replay + raise ApprovalTaskConflictError("审批任务已被并发修改,请刷新后重试。") from error + except Exception: + self.db.rollback() + raise + + def _read_context( + self, + task_id: str, + current_user: CurrentUserContext, + ) -> tuple[ApprovalTask, ExpenseClaim]: + tenant = self._tenant(current_user) + task = self.db.scalar( + select(ApprovalTask).where( + ApprovalTask.tenant_id == tenant, + ApprovalTask.id == str(task_id).strip(), + ) + ) + if task is None: + raise LookupError("Approval task not found.") + claim = self.db.scalar( + select(ExpenseClaim).where( + ExpenseClaim.id == task.claim_id, + ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(tenant), + ) + ) + if claim is None: + raise LookupError("Approval task not found.") + return task, claim + + def _lock_context( + self, + task_id: str, + current_user: CurrentUserContext, + ) -> tuple[ApprovalTask, ExpenseClaim]: + tenant = self._tenant(current_user) + locator = self.db.execute( + select(ApprovalTask.claim_id).where( + ApprovalTask.tenant_id == tenant, + ApprovalTask.id == str(task_id).strip(), + ) + ).one_or_none() + if locator is None: + raise LookupError("Approval task not found.") + claim_statement = select(ExpenseClaim).where( + ExpenseClaim.id == locator[0], + ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(tenant), + ) + task_statement = select(ApprovalTask).where( + ApprovalTask.tenant_id == tenant, + ApprovalTask.id == str(task_id).strip(), + ) + bind = self.db.get_bind() + if bind is not None and bind.dialect.name == "postgresql": + claim_statement = claim_statement.with_for_update() + task_statement = task_statement.with_for_update() + claim = self.db.scalar(claim_statement.execution_options(populate_existing=True)) + task = self.db.scalar(task_statement.execution_options(populate_existing=True)) + if claim is None or task is None or task.claim_id != claim.id: + raise LookupError("Approval task not found.") + return task, claim + + def _new_child( + self, + root: ApprovalTask, + *, + claim: ExpenseClaim, + employee, + task_kind: str, + sequence_order: int, + active: bool, + now: datetime, + ) -> ApprovalTask: + return ApprovalTask( + id=str(uuid.uuid4()), + tenant_id=root.tenant_id, + claim_id=root.claim_id, + expense_case_id=root.expense_case_id, + node_instance_id=root.node_instance_id, + node_entry_key=f"{root.id}:{task_kind}:{sequence_order}:{employee.id}", + parent_task_id=root.id, + task_kind=task_kind, + node_key=root.node_key, + node_label=root.node_label, + node_sequence=root.node_sequence, + sequence_order=sequence_order, + coordination_mode=root.coordination_mode, + owner_kind="employee", + owner_key=employee.id, + owner_employee_id=employee.id, + owner_name=str(employee.name or employee.employee_no), + assignee_kind="employee", + assignee_key=employee.id, + assignee_employee_id=employee.id, + assignee_name=str(employee.name or employee.employee_no), + status="pending" if active else "waiting", + decision=None, + version=1, + claim_status_snapshot=str(claim.status or "").strip().lower(), + claim_stage_snapshot=str(claim.approval_stage or "").strip(), + entered_at=now, + entered_at_source="workflow_event", + activated_at=now if active else None, + sla_hours_snapshot=root.sla_hours_snapshot, + due_at=now + timedelta(hours=root.sla_hours_snapshot), + escalation_level=0, + next_escalation_at=root.next_escalation_at, + priority_score=root.priority_score, + priority_tier=root.priority_tier, + priority_reasons_json=list(root.priority_reasons_json or []), + risk_level=root.risk_level, + open_risk_count=root.open_risk_count, + evidence_completeness=root.evidence_completeness, + batch_eligible=False, + batch_block_reasons_json=["加签或会签任务不能批量审批。"], + projection_updated_at=root.projection_updated_at, + created_at=now, + updated_at=now, + ) + + def _advance_node_family( + self, + task: ApprovalTask, + *, + claim: ExpenseClaim, + now: datetime, + ) -> list[ApprovalTask]: + root_statement = select(ApprovalTask).where( + ApprovalTask.tenant_id == task.tenant_id, + ApprovalTask.id == task.parent_task_id, + ) + bind = self.db.get_bind() + if bind is not None and bind.dialect.name == "postgresql": + root_statement = root_statement.with_for_update() + root = self.db.scalar(root_statement) + if root is None or root.status != "waiting": + raise ApprovalTaskConflictError("加签或会签根任务状态异常。") + related: list[ApprovalTask] = [] + if task.task_kind == "add_sign": + next_child = self.db.scalar( + select(ApprovalTask) + .where( + ApprovalTask.tenant_id == task.tenant_id, + ApprovalTask.parent_task_id == root.id, + ApprovalTask.status == "waiting", + ) + .order_by(ApprovalTask.sequence_order.asc()) + .limit(1) + ) + if next_child is not None: + self._activate_related( + next_child, + claim=claim, + now=now, + event_type="task_activated", + ) + related.append(next_child) + return related + elif self.db.scalar( + select(ApprovalTask.id).where( + ApprovalTask.tenant_id == task.tenant_id, + ApprovalTask.parent_task_id == root.id, + ApprovalTask.status.in_(("waiting", "pending")), + ApprovalTask.id != task.id, + ) + ) is not None: + return related + self._activate_related( + root, + claim=claim, + now=now, + event_type="root_reactivated", + coordination_mode="single", + ) + related.append(root) + return related + + def _activate_related( + self, + task: ApprovalTask, + *, + claim: ExpenseClaim, + now: datetime, + event_type: str, + coordination_mode: str | None = None, + ) -> None: + before = task_state(task) + expected = task.version + task.status = "pending" + if coordination_mode is not None: + task.coordination_mode = coordination_mode + task.activated_at = now + task.version += 1 + task.claim_status_snapshot = str(claim.status or "").strip().lower() + task.claim_stage_snapshot = str(claim.approval_stage or "").strip() + task.updated_at = now + self.events.append_event( + task=task, + event_type=event_type, + actor_id="system", + actor_name="审批任务引擎", + actor_type="system", + request_id=f"activate:{task.id}:{task.version}", + expected_task_version=expected, + payload={"reason": "participant_tasks_completed"}, + before_json=before, + occurred_at=now, + ) + + @staticmethod + def _apply_delegation_revoke(task: ApprovalTask, *, actor_id: str) -> None: + if not task.delegated_by: + raise ApprovalTaskConflictError("当前任务没有可撤销的委托。") + task.assignee_kind = task.owner_kind + task.assignee_key = task.owner_key + task.assignee_employee_id = task.owner_employee_id + task.assignee_name = task.owner_name + task.delegated_by = None + task.delegation_expires_at = None + + @staticmethod + def _apply_escalation(task: ApprovalTask, *, actor_id: str) -> None: + now = datetime.now(UTC) + if task.escalation_level >= 3: + raise ApprovalTaskConflictError("该任务已经达到最高升级等级。") + task.escalation_level += 1 + task.escalated_at = now + task.next_escalation_at = ( + None if task.escalation_level >= 3 else now + timedelta(hours=4) + ) + task.priority_score = min(100, int(task.priority_score) + 10) + task.priority_tier = "urgent" if task.priority_score >= 65 else "high" + task.priority_reasons_json = [ + *list(task.priority_reasons_json or []), + { + "code": f"sla_escalated_l{task.escalation_level}", + "label": f"审批超时已升级至 L{task.escalation_level}", + "weight": 10, + "tone": "danger", + }, + ] + + @staticmethod + def _ensure_expected_version(task: ApprovalTask, expected: int) -> None: + if task.version != expected: + raise ApprovalTaskVersionConflictError(task.version) + + @staticmethod + def _tenant(current_user: CurrentUserContext) -> str: + return ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id) + + +def _as_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) + + +def _derived_request(request_id: str, suffix: str) -> str: + value = f"{request_id}:{suffix}" + if len(value) <= 120: + return value + digest = hashlib.sha256(value.encode("utf-8")).hexdigest() + return f"derived:{digest}" diff --git a/server/src/app/services/approval_task_backfill.py b/server/src/app/services/approval_task_backfill.py new file mode 100644 index 0000000..911ba27 --- /dev/null +++ b/server/src/app/services/approval_task_backfill.py @@ -0,0 +1,761 @@ +from __future__ import annotations + +import hashlib +import uuid +from collections.abc import Sequence +from dataclasses import dataclass, replace +from datetime import UTC, datetime +from enum import StrEnum +from typing import Any + +from sqlalchemy import and_, func, or_, select +from sqlalchemy.orm import Session, selectinload + +from app.models.approval_task import ApprovalTask +from app.models.employee import Employee +from app.models.expense_case import BusinessEvent, ExpenseCaseLink +from app.models.financial_record import ExpenseClaim +from app.models.risk_disposition import RiskDisposition +from app.models.risk_observation import RiskObservation +from app.services.approval_task_projection import ( + DEFAULT_APPROVAL_SLA_HOURS, + ApprovalTaskProjectionService, +) +from app.services.approval_task_protocol import ApprovalTaskEventProtocol +from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy +from app.services.expense_claim_status_registry import normalize_expense_claim_state +from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin +from app.services.expense_claim_workflow_constants import ( + BUDGET_MANAGER_APPROVAL_STAGE, + DIRECT_MANAGER_APPROVAL_STAGE, + FINANCE_APPROVAL_STAGE, +) + +DEFAULT_BACKFILL_BATCH_SIZE = 100 +MAX_BACKFILL_BATCH_SIZE = 1000 + +_PENDING_STATUS_VALUES = frozenset( + { + "submitted", + "review", + "pending_review", + "approving", + "manager_review", + "budget_review", + "finance_review", + "in_approval", + "under_review", + } +) +_OPEN_TASK_STATUSES = frozenset({"waiting", "pending"}) +_ACTIVE_EMPLOYMENT_STATUSES = frozenset({"", "active", "employed", "在职", "正常"}) +_NODE_BY_STAGE = { + DIRECT_MANAGER_APPROVAL_STAGE: ("direct_manager", "直属领导审批", 1), + BUDGET_MANAGER_APPROVAL_STAGE: ("budget_manager", "预算管理者审批", 2), + FINANCE_APPROVAL_STAGE: ("finance", "财务审批", 3), +} + + +class ApprovalTaskBackfillDisposition(StrEnum): + ELIGIBLE = "eligible" + CREATED = "created" + EXISTING = "existing" + UNSUPPORTED_STAGE = "unsupported_stage" + ASSIGNEE_UNRESOLVED = "assignee_unresolved" + TENANT_CONFLICT = "tenant_conflict" + + +@dataclass(frozen=True, slots=True) +class ApprovalTaskBackfillCursor: + created_at: datetime + claim_id: str + + +@dataclass(frozen=True, slots=True) +class ApprovalTaskBackfillItem: + claim_id: str + claim_no: str + disposition: ApprovalTaskBackfillDisposition + node_key: str = "" + entered_at: datetime | None = None + entered_at_source: str = "" + assignee_key: str = "" + task_id: str | None = None + reason: str = "" + + +@dataclass(frozen=True, slots=True) +class ApprovalTaskBackfillPreview: + tenant_id: str + inspected: int + eligible: int + existing: int + skipped: int + has_more: bool + next_cursor: ApprovalTaskBackfillCursor | None + items: tuple[ApprovalTaskBackfillItem, ...] + + +@dataclass(frozen=True, slots=True) +class ApprovalTaskBackfillResult: + tenant_id: str + run_id: str + inspected: int + created: int + existing: int + skipped: int + has_more: bool + next_cursor: ApprovalTaskBackfillCursor | None + items: tuple[ApprovalTaskBackfillItem, ...] + + +@dataclass(frozen=True, slots=True) +class _Assignment: + kind: str + key: str + employee_id: str | None + name: str + + +@dataclass(slots=True) +class _BatchContext: + links_by_claim_id: dict[str, ExpenseCaseLink] + events_by_claim_id: dict[str, list[BusinessEvent]] + tasks_by_claim_id: dict[str, list[ApprovalTask]] + risks_by_claim_id: dict[ + str, + list[tuple[RiskObservation, RiskDisposition | None]], + ] + + +class ApprovalTaskBackfillService: + """显式、可预览、幂等地为历史待审批 Claim 创建根任务。 + + 不自行提交,也绝不能从 GET 隐式调用;``run`` 默认 dry-run。 + """ + + def __init__( + self, + db: Session, + *, + tenant_id: str, + created_before: datetime | None = None, + sla_hours: int = DEFAULT_APPROVAL_SLA_HOURS, + ) -> None: + self.db = db + self.tenant_id = ExpenseClaimTenantScopeMixin.normalize_tenant_id(tenant_id) + self.created_before = ( + self._aware(created_before, field_name="created_before") + if created_before is not None + else None + ) + self.sla_hours = int(sla_hours) + if self.sla_hours <= 0: + raise ValueError("sla_hours must be greater than zero") + self.claim_policy = ExpenseClaimAccessPolicy(db) + self.projections = ApprovalTaskProjectionService(db) + self.events = ApprovalTaskEventProtocol(db) + + def run( + self, + *, + dry_run: bool = True, + run_id: str | None = None, + batch_size: int = DEFAULT_BACKFILL_BATCH_SIZE, + after: ApprovalTaskBackfillCursor | None = None, + backfilled_at: datetime | None = None, + ) -> ApprovalTaskBackfillPreview | ApprovalTaskBackfillResult: + if dry_run: + return self.preview(batch_size=batch_size, after=after) + return self.apply_batch( + run_id=self._require_text(run_id, field_name="run_id", max_length=64), + batch_size=batch_size, + after=after, + backfilled_at=backfilled_at, + ) + + def preview( + self, + *, + batch_size: int = DEFAULT_BACKFILL_BATCH_SIZE, + after: ApprovalTaskBackfillCursor | None = None, + ) -> ApprovalTaskBackfillPreview: + """只读预览;不 add、不 flush、不 commit。""" + + with self.db.no_autoflush: + claims, has_more = self._load_claims( + batch_size=self._batch_size(batch_size), + after=after, + lock_rows=False, + ) + context = self._load_context(claims) + items = self._classify(claims, context=context) + return ApprovalTaskBackfillPreview( + tenant_id=self.tenant_id, + inspected=len(items), + eligible=self._count(items, ApprovalTaskBackfillDisposition.ELIGIBLE), + existing=self._count(items, ApprovalTaskBackfillDisposition.EXISTING), + skipped=self._skipped_count(items), + has_more=has_more, + next_cursor=self._next_cursor(claims), + items=items, + ) + + def apply_batch( + self, + *, + run_id: str, + batch_size: int = DEFAULT_BACKFILL_BATCH_SIZE, + after: ApprovalTaskBackfillCursor | None = None, + backfilled_at: datetime | None = None, + ) -> ApprovalTaskBackfillResult: + """锁定并应用一批,不 commit;失败时由调用方回滚整个批次。""" + + normalized_run_id = self._require_text(run_id, field_name="run_id", max_length=64) + applied_at = self._aware( + backfilled_at or datetime.now(UTC), + field_name="backfilled_at", + ) + claims, has_more = self._load_claims( + batch_size=self._batch_size(batch_size), + after=after, + lock_rows=True, + ) + context = self._load_context(claims) + classified = self._classify(claims, context=context) + claims_by_id = {str(claim.id): claim for claim in claims} + result_items: list[ApprovalTaskBackfillItem] = [] + + for item in classified: + if item.disposition is not ApprovalTaskBackfillDisposition.ELIGIBLE: + result_items.append(item) + continue + claim = claims_by_id[item.claim_id] + assignment = self._resolve_assignment(claim, node_key=item.node_key) + if assignment is None: + result_items.append( + replace( + item, + disposition=ApprovalTaskBackfillDisposition.ASSIGNEE_UNRESOLVED, + reason="当前审批节点无法解析到安全的处理人。", + ) + ) + continue + task = self._create_task( + claim, + item=item, + assignment=assignment, + context=context, + run_id=normalized_run_id, + backfilled_at=applied_at, + ) + result_items.append( + replace( + item, + disposition=ApprovalTaskBackfillDisposition.CREATED, + task_id=task.id, + ) + ) + + result = tuple(result_items) + return ApprovalTaskBackfillResult( + tenant_id=self.tenant_id, + run_id=normalized_run_id, + inspected=len(result), + created=self._count(result, ApprovalTaskBackfillDisposition.CREATED), + existing=self._count(result, ApprovalTaskBackfillDisposition.EXISTING), + skipped=self._skipped_count(result), + has_more=has_more, + next_cursor=self._next_cursor(claims), + items=result, + ) + + def derive_entered_at( + self, + claim: ExpenseClaim, + *, + events: Sequence[BusinessEvent], + approval_stage: str, + ) -> tuple[datetime, str, BusinessEvent | None]: + """按 BusinessEvent > submitted_at > updated_at > created_at 推导节点时间。""" + + matching_events = [ + event + for event in events + if self._event_enters_stage(event, approval_stage=approval_stage) + ] + if matching_events: + event = max( + matching_events, + key=lambda value: self._as_utc(value.occurred_at), + ) + return self._as_utc(event.occurred_at), "workflow_event", event + if claim.submitted_at is not None: + return self._as_utc(claim.submitted_at), "submitted_at", None + if claim.updated_at is not None: + return self._as_utc(claim.updated_at), "updated_at", None + if claim.created_at is not None: + return self._as_utc(claim.created_at), "backfill", None + raise ValueError(f"Claim {claim.id} 缺少可用于回填的时间。") + + def _load_claims( + self, + *, + batch_size: int, + after: ApprovalTaskBackfillCursor | None, + lock_rows: bool, + ) -> tuple[list[ExpenseClaim], bool]: + normalized_status = func.lower(func.trim(func.coalesce(ExpenseClaim.status, ""))) + stmt = ( + select(ExpenseClaim) + .options( + selectinload(ExpenseClaim.items), + selectinload(ExpenseClaim.employee).selectinload(Employee.manager), + selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit), + selectinload(ExpenseClaim.employee).selectinload(Employee.roles), + ) + .where( + normalized_status.in_(_PENDING_STATUS_VALUES), + ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(self.tenant_id), + ) + ) + if self.created_before is not None: + stmt = stmt.where(ExpenseClaim.created_at < self.created_before) + if after is not None: + claim_id = self._require_text( + after.claim_id, + field_name="after.claim_id", + max_length=36, + ) + stmt = stmt.where( + or_( + ExpenseClaim.created_at > after.created_at, + and_( + ExpenseClaim.created_at == after.created_at, + ExpenseClaim.id > claim_id, + ), + ) + ) + stmt = stmt.order_by(ExpenseClaim.created_at.asc(), ExpenseClaim.id.asc()).limit( + batch_size + 1 + ) + if lock_rows: + stmt = stmt.with_for_update() + rows = list(self.db.scalars(stmt).unique().all()) + return rows[:batch_size], len(rows) > batch_size + + def _load_context(self, claims: Sequence[ExpenseClaim]) -> _BatchContext: + claim_ids = [str(claim.id) for claim in claims] + if not claim_ids: + return _BatchContext({}, {}, {}, {}) + + links_by_claim_id = { + str(link.resource_id): link + for link in self.db.scalars( + select(ExpenseCaseLink).where( + ExpenseCaseLink.resource_type == "expense_claim", + ExpenseCaseLink.resource_id.in_(claim_ids), + ) + ).all() + } + events_by_claim_id: dict[str, list[BusinessEvent]] = {} + for event in self.db.scalars( + select(BusinessEvent) + .where( + BusinessEvent.tenant_id == self.tenant_id, + BusinessEvent.aggregate_type == "expense_claim", + BusinessEvent.aggregate_id.in_(claim_ids), + ) + .order_by(BusinessEvent.occurred_at.desc(), BusinessEvent.id.desc()) + ).all(): + events_by_claim_id.setdefault(str(event.aggregate_id), []).append(event) + + tasks_by_claim_id: dict[str, list[ApprovalTask]] = {} + for task in self.db.scalars( + select(ApprovalTask).where( + ApprovalTask.tenant_id == self.tenant_id, + ApprovalTask.claim_id.in_(claim_ids), + ApprovalTask.task_kind == "root", + ) + ).all(): + tasks_by_claim_id.setdefault(str(task.claim_id), []).append(task) + + risks_by_claim_id: dict[ + str, + list[tuple[RiskObservation, RiskDisposition | None]], + ] = {} + for observation, disposition in self.db.execute( + select(RiskObservation, RiskDisposition) + .outerjoin( + RiskDisposition, + (RiskDisposition.tenant_id == RiskObservation.tenant_id) + & (RiskDisposition.observation_id == RiskObservation.id), + ) + .where( + RiskObservation.tenant_id == self.tenant_id, + RiskObservation.claim_id.in_(claim_ids), + ) + ).all(): + risks_by_claim_id.setdefault(str(observation.claim_id), []).append( + (observation, disposition) + ) + return _BatchContext( + links_by_claim_id=links_by_claim_id, + events_by_claim_id=events_by_claim_id, + tasks_by_claim_id=tasks_by_claim_id, + risks_by_claim_id=risks_by_claim_id, + ) + + def _classify( + self, + claims: Sequence[ExpenseClaim], + *, + context: _BatchContext, + ) -> tuple[ApprovalTaskBackfillItem, ...]: + items: list[ApprovalTaskBackfillItem] = [] + for claim in claims: + claim_id = str(claim.id) + link = context.links_by_claim_id.get(claim_id) + if link is not None and link.tenant_id != self.tenant_id: + items.append( + self._item( + claim, + ApprovalTaskBackfillDisposition.TENANT_CONFLICT, + reason="费用单据已关联到其他租户。", + ) + ) + continue + + state = self._normalized_state(claim) + node_spec = _NODE_BY_STAGE.get(state.approval_stage) + if state.status != "submitted" or node_spec is None: + items.append( + self._item( + claim, + ApprovalTaskBackfillDisposition.UNSUPPORTED_STAGE, + reason="当前状态或审批阶段不属于可回填的待审批节点。", + ) + ) + continue + + node_key, _label, _sequence = node_spec + entered_at, entered_source, _source_event = self.derive_entered_at( + claim, + events=context.events_by_claim_id.get(claim_id, ()), + approval_stage=state.approval_stage, + ) + node_entry_key = self._node_entry_key( + claim_id=claim_id, + node_key=node_key, + entered_at=entered_at, + ) + previous_tasks = context.tasks_by_claim_id.get(claim_id, ()) + existing = next( + ( + task + for task in previous_tasks + if task.status in _OPEN_TASK_STATUSES or task.node_entry_key == node_entry_key + ), + None, + ) + if existing is not None: + items.append( + self._item( + claim, + ApprovalTaskBackfillDisposition.EXISTING, + node_key=node_key, + entered_at=entered_at, + entered_at_source=entered_source, + assignee_key=existing.assignee_key, + task_id=existing.id, + reason="当前 Claim 已有开放根任务或相同节点进入事实。", + ) + ) + continue + + assignment = self._resolve_assignment(claim, node_key=node_key) + if assignment is None: + items.append( + self._item( + claim, + ApprovalTaskBackfillDisposition.ASSIGNEE_UNRESOLVED, + node_key=node_key, + entered_at=entered_at, + entered_at_source=entered_source, + reason="当前审批节点无法解析到安全的处理人。", + ) + ) + continue + items.append( + self._item( + claim, + ApprovalTaskBackfillDisposition.ELIGIBLE, + node_key=node_key, + entered_at=entered_at, + entered_at_source=entered_source, + assignee_key=assignment.key, + ) + ) + return tuple(items) + + def _resolve_assignment( + self, + claim: ExpenseClaim, + *, + node_key: str, + ) -> _Assignment | None: + employee: Employee | None + if node_key == "direct_manager": + claim_employee = claim.employee + if claim_employee is None: + claim_employee = self.claim_policy.resolve_claim_employee_for_backfill(claim) + employee = claim_employee.manager if claim_employee is not None else None + if employee is None: + manager_name = self.claim_policy.resolve_claim_manager_name(claim) + employee = self.claim_policy.resolve_employee_by_identity_candidates([manager_name]) + elif node_key == "budget_manager": + employee = self.claim_policy.resolve_department_budget_manager(claim) + elif node_key == "finance": + employee = self.claim_policy.resolve_finance_approver(claim) + if employee is None: + return _Assignment("role", "finance", None, "财务审批角色") + else: + return None + if employee is None or employee.id == str(claim.employee_id or "").strip(): + return None + employment_status = str(employee.employment_status or "").strip().lower() + if employment_status not in _ACTIVE_EMPLOYMENT_STATUSES: + return ( + _Assignment("role", "finance", None, "财务审批角色") + if node_key == "finance" + else None + ) + return _Assignment( + kind="employee", + key=str(employee.id), + employee_id=str(employee.id), + name=str(employee.name or employee.employee_no or employee.id).strip(), + ) + + def _create_task( + self, + claim: ExpenseClaim, + *, + item: ApprovalTaskBackfillItem, + assignment: _Assignment, + context: _BatchContext, + run_id: str, + backfilled_at: datetime, + ) -> ApprovalTask: + if item.entered_at is None: + raise RuntimeError("eligible backfill item is missing entered_at") + state = self._normalized_state(claim) + node_key, node_label, node_sequence = _NODE_BY_STAGE[state.approval_stage] + link = context.links_by_claim_id.get(str(claim.id)) + node_entry_key = self._node_entry_key( + claim_id=str(claim.id), node_key=node_key, entered_at=item.entered_at + ) + task = ApprovalTask( + id=str(uuid.uuid4()), + tenant_id=self.tenant_id, + claim_id=str(claim.id), + expense_case_id=link.expense_case_id if link is not None else None, + node_instance_id=node_entry_key, + node_entry_key=node_entry_key, + parent_task_id=None, + task_kind="root", + node_key=node_key, + node_label=node_label, + node_sequence=node_sequence, + sequence_order=0, + coordination_mode="single", + owner_kind=assignment.kind, + owner_key=assignment.key, + owner_employee_id=assignment.employee_id, + owner_name=assignment.name, + assignee_kind=assignment.kind, + assignee_key=assignment.key, + assignee_employee_id=assignment.employee_id, + assignee_name=assignment.name, + status="pending", + decision=None, + version=1, + claim_status_snapshot=state.status, + claim_stage_snapshot=state.approval_stage, + entered_at=item.entered_at, + entered_at_source=item.entered_at_source, + activated_at=item.entered_at, + sla_hours_snapshot=self.sla_hours, + completed_at=None, + cancelled_at=None, + escalation_level=0, + ) + projection = self.projections.build( + claim, + tenant_id=self.tenant_id, + entered_at=item.entered_at, + now=backfilled_at, + sla_hours=self.sla_hours, + observation_rows=context.risks_by_claim_id.get(str(claim.id), ()), + ) + self.projections.apply(task, projection) + self.db.add(task) + self.db.flush() + + source_event = self._source_event( + context.events_by_claim_id.get(str(claim.id), ()), + approval_stage=state.approval_stage, + ) + payload = { + "backfill_run_id": run_id, + "history_reconstructed": False, + "current_node_reconstructed": True, + "claim_id": str(claim.id), + "claim_no": str(claim.claim_no or ""), + "approval_stage": state.approval_stage, + "entered_at": self._isoformat(item.entered_at), + "entered_at_source": item.entered_at_source, + } + event, _response = self.events.append_event( + task=task, + event_type="node_entered_backfilled", + actor_id="approval-task-backfill", + actor_name="审批任务历史回填", + actor_type="system", + request_id=self._request_id(run_id=run_id, task=task), + expected_task_version=0, + payload=payload, + before_json={}, + business_event_id=source_event.id if source_event is not None else None, + correlation_id=run_id, + occurred_at=backfilled_at, + ) + self.db.flush() + return task + + @staticmethod + def _item( + claim: ExpenseClaim, + disposition: ApprovalTaskBackfillDisposition, + **values: Any, + ) -> ApprovalTaskBackfillItem: + return ApprovalTaskBackfillItem( + claim_id=str(claim.id), + claim_no=str(claim.claim_no or ""), + disposition=disposition, + **values, + ) + + @staticmethod + def _event_enters_stage(event: BusinessEvent, *, approval_stage: str) -> bool: + payload = event.payload_json if isinstance(event.payload_json, dict) else {} + next_stage = str( + payload.get("next_approval_stage") or payload.get("approval_stage") or "" + ).strip() + next_status = str(payload.get("next_status") or "submitted").strip().lower() + return next_stage == approval_stage and next_status in _PENDING_STATUS_VALUES + + def _source_event( + self, + events: Sequence[BusinessEvent], + *, + approval_stage: str, + ) -> BusinessEvent | None: + matches = [ + event + for event in events + if self._event_enters_stage(event, approval_stage=approval_stage) + ] + return max(matches, key=lambda value: self._as_utc(value.occurred_at)) if matches else None + + @staticmethod + def _normalized_state(claim: ExpenseClaim) -> Any: + raw_status = str(claim.status or "").strip().lower() + status = "submitted" if raw_status in {"in_approval", "under_review"} else claim.status + return normalize_expense_claim_state( + status, + claim.approval_stage, + claim_no=claim.claim_no, + expense_type=claim.expense_type, + ) + + @staticmethod + def _node_digest(*, claim_id: str, node_key: str, entered_at: datetime) -> str: + raw = f"{claim_id}:{node_key}:{ApprovalTaskBackfillService._isoformat(entered_at)}" + return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:20] + + @classmethod + def _node_entry_key(cls, *, claim_id: str, node_key: str, entered_at: datetime) -> str: + digest = cls._node_digest( + claim_id=claim_id, + node_key=node_key, + entered_at=entered_at, + ) + return f"claim:{claim_id}:{node_key}:{digest}" + + @staticmethod + def _request_id(*, run_id: str, task: ApprovalTask) -> str: + digest = hashlib.sha256(f"{task.tenant_id}:{task.node_entry_key}".encode()).hexdigest()[:24] + return f"backfill:{run_id}:{digest}"[:120] + + @staticmethod + def _count( + items: Sequence[ApprovalTaskBackfillItem], + disposition: ApprovalTaskBackfillDisposition, + ) -> int: + return sum(1 for item in items if item.disposition is disposition) + + @staticmethod + def _skipped_count(items: Sequence[ApprovalTaskBackfillItem]) -> int: + return sum( + 1 + for item in items + if item.disposition + not in { + ApprovalTaskBackfillDisposition.ELIGIBLE, + ApprovalTaskBackfillDisposition.CREATED, + ApprovalTaskBackfillDisposition.EXISTING, + } + ) + + @staticmethod + def _next_cursor( + claims: Sequence[ExpenseClaim], + ) -> ApprovalTaskBackfillCursor | None: + if not claims: + return None + claim = claims[-1] + return ApprovalTaskBackfillCursor( + created_at=claim.created_at, + claim_id=str(claim.id), + ) + + @staticmethod + def _batch_size(value: int) -> int: + normalized = int(value) + if normalized < 1 or normalized > MAX_BACKFILL_BATCH_SIZE: + raise ValueError(f"batch_size must be between 1 and {MAX_BACKFILL_BATCH_SIZE}") + return normalized + + @staticmethod + def _require_text(value: str | None, *, field_name: str, max_length: int) -> str: + normalized = str(value or "").strip() + if not normalized: + raise ValueError(f"{field_name} must not be empty") + if len(normalized) > max_length: + raise ValueError(f"{field_name} must be at most {max_length} characters") + return normalized + + @staticmethod + def _aware(value: datetime, *, field_name: str) -> datetime: + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError(f"{field_name} must include a timezone") + return value.astimezone(UTC) + + @staticmethod + def _as_utc(value: datetime) -> datetime: + return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) + + @staticmethod + def _isoformat(value: datetime) -> str: + return ApprovalTaskBackfillService._as_utc(value).isoformat().replace("+00:00", "Z") diff --git a/server/src/app/services/approval_task_batch.py b/server/src/app/services/approval_task_batch.py new file mode 100644 index 0000000..795d44e --- /dev/null +++ b/server/src/app/services/approval_task_batch.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import hashlib +from collections.abc import Callable + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.api.deps import CurrentUserContext +from app.db.session import get_session_factory +from app.models.approval_action import ApprovalActionLedger +from app.models.approval_task import ApprovalTask +from app.schemas.approval_task import ( + ApprovalTaskBatchApproveCreate, + ApprovalTaskBatchApproveRead, + ApprovalTaskBatchItemRead, +) +from app.schemas.reimbursement import ExpenseClaimRead +from app.services.approval_action_protocol import ApprovalActionConflictError +from app.services.approval_task_access_policy import ApprovalTaskPermissionError +from app.services.approval_task_protocol import ApprovalTaskConflictError +from app.services.expense_claim_risk_gate import ExpenseClaimRiskBlockedError +from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin +from app.services.expense_claims import ExpenseClaimService + + +class ApprovalTaskBatchService: + """小批量审批编排;每一项使用独立数据库事务。""" + + def __init__( + self, + session_factory: Callable[[], Session] | None = None, + ) -> None: + self.session_factory = session_factory or get_session_factory() + + def approve( + self, + payload: ApprovalTaskBatchApproveCreate, + current_user: CurrentUserContext, + ) -> ApprovalTaskBatchApproveRead: + results = [ + self._approve_one( + item=item, + batch_request_id=payload.batch_request_id, + current_user=current_user, + ) + for item in payload.items + ] + counts = { + name: sum(item.status == name for item in results) + for name in ( + "succeeded", + "replayed", + "conflict", + "blocked", + "forbidden", + "failed", + ) + } + success_count = counts["succeeded"] + counts["replayed"] + overall = ( + "succeeded" + if success_count == len(results) + else "failed" + if success_count == 0 + else "partial" + ) + return ApprovalTaskBatchApproveRead( + batch_request_id=payload.batch_request_id, + status=overall, + succeeded_count=counts["succeeded"], + replayed_count=counts["replayed"], + conflict_count=counts["conflict"], + blocked_count=counts["blocked"], + forbidden_count=counts["forbidden"], + failed_count=counts["failed"], + items=results, + ) + + def _approve_one( + self, + *, + item, + batch_request_id: str, + current_user: CurrentUserContext, + ) -> ApprovalTaskBatchItemRead: + request_id = _item_request_id(batch_request_id, item.task_id) + tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id) + db = self.session_factory() + claim_id: str | None = None + try: + task = db.scalar( + select(ApprovalTask).where( + ApprovalTask.tenant_id == tenant, + ApprovalTask.id == item.task_id, + ) + ) + if task is None: + return self._result( + item.task_id, + None, + "conflict", + "TASK_NOT_FOUND", + "任务不存在或已移出当前租户。", + ) + claim_id = task.claim_id + if not task.batch_eligible: + reasons = ";".join(task.batch_block_reasons_json or []) + return self._result( + item.task_id, + claim_id, + "blocked", + "TASK_NOT_BATCH_ELIGIBLE", + reasons or "该任务不满足安全批量审批条件。", + ) + actor_id = str(current_user.username or "").strip().casefold() + replayed = db.scalar( + select(ApprovalActionLedger.id).where( + ApprovalActionLedger.tenant_id == tenant, + ApprovalActionLedger.actor_id == actor_id, + ApprovalActionLedger.request_id == request_id, + ApprovalActionLedger.completed_at.is_not(None), + ) + ) is not None + claim = ExpenseClaimService(db).approve_claim( + task.claim_id, + current_user, + opinion=item.opinion, + request_id=request_id, + expected_status=item.expected_status, + expected_approval_stage=item.expected_approval_stage, + task_id=task.id, + expected_task_version=item.expected_task_version, + ) + if claim is None: + return self._result( + item.task_id, + claim_id, + "conflict", + "CLAIM_NOT_FOUND", + "关联单据不存在或已不可见。", + ) + claim_read = ( + claim + if isinstance(claim, ExpenseClaimRead) + else ExpenseClaimRead.model_validate(claim) + ) + return self._result( + item.task_id, + claim_id, + "replayed" if replayed else "succeeded", + "REPLAYED" if replayed else "APPROVED", + "审批结果已安全重放。" if replayed else "审批通过。", + claim=claim_read.model_dump(mode="json"), + ) + except ApprovalTaskPermissionError as error: + return self._result(item.task_id, claim_id, "forbidden", "FORBIDDEN", str(error)) + except ExpenseClaimRiskBlockedError as error: + return self._result(item.task_id, claim_id, "blocked", "OPEN_HIGH_RISK", str(error)) + except (ApprovalActionConflictError, ApprovalTaskConflictError) as error: + return self._result(item.task_id, claim_id, "conflict", "CONCURRENT_CHANGE", str(error)) + except ValueError as error: + return self._result( + item.task_id, + claim_id, + "blocked", + "VALIDATION_BLOCKED", + str(error), + ) + except Exception: + return self._result( + item.task_id, + claim_id, + "failed", + "INTERNAL_ERROR", + "该任务处理失败,请稍后单独重试。", + ) + finally: + db.rollback() + db.close() + + @staticmethod + def _result( + task_id: str, + claim_id: str | None, + status: str, + code: str, + message: str, + *, + claim: dict | None = None, + ) -> ApprovalTaskBatchItemRead: + return ApprovalTaskBatchItemRead( + task_id=task_id, + claim_id=claim_id, + status=status, + code=code, + message=message, + claim=claim, + ) + + +def _item_request_id(batch_request_id: str, task_id: str) -> str: + value = f"batch:{batch_request_id}:{task_id}" + if len(value) <= 120: + return value + digest = hashlib.sha256(value.encode("utf-8")).hexdigest() + return f"batch:{digest}" diff --git a/server/src/app/services/approval_task_lifecycle.py b/server/src/app/services/approval_task_lifecycle.py new file mode 100644 index 0000000..7ea27f9 --- /dev/null +++ b/server/src/app/services/approval_task_lifecycle.py @@ -0,0 +1,499 @@ +from __future__ import annotations + +import uuid +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.api.deps import CurrentUserContext +from app.models.approval_action import ApprovalActionLedger +from app.models.approval_task import ApprovalTask +from app.models.employee import Employee +from app.models.expense_case import BusinessEvent, ExpenseCaseLink +from app.models.financial_record import ExpenseClaim +from app.schemas.approval_task import ApprovalTaskMutationRead +from app.services.approval_task_access_policy import ApprovalTaskAccessPolicy +from app.services.approval_task_protocol import ApprovalTaskEventProtocol, task_state +from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin +from app.services.expense_claim_workflow_constants import ( + BUDGET_MANAGER_APPROVAL_STAGE, + DIRECT_MANAGER_APPROVAL_STAGE, + FINANCE_APPROVAL_STAGE, +) + +OPEN_TASK_STATUSES = ("waiting", "pending") + + +class ApprovalTaskConfigurationError(ValueError): + pass + + +@dataclass(frozen=True, slots=True) +class ApprovalNode: + key: str + label: str + sla_hours: int + + +@dataclass(frozen=True, slots=True) +class ApprovalAssignment: + kind: str + key: str + employee_id: str | None + name: str + + +@dataclass(slots=True) +class ApprovalTaskDecisionContext: + task: ApprovalTask + expected_task_version: int + before_json: dict[str, object] + + +_NODES = { + DIRECT_MANAGER_APPROVAL_STAGE: ApprovalNode( + key="direct_manager", + label=DIRECT_MANAGER_APPROVAL_STAGE, + sla_hours=24, + ), + BUDGET_MANAGER_APPROVAL_STAGE: ApprovalNode( + key="budget_manager", + label=BUDGET_MANAGER_APPROVAL_STAGE, + sla_hours=12, + ), + FINANCE_APPROVAL_STAGE: ApprovalNode( + key="finance", + label=FINANCE_APPROVAL_STAGE, + sla_hours=8, + ), +} + + +def approval_node_for_stage(stage: object) -> ApprovalNode | None: + return _NODES.get(str(stage or "").strip()) + + +class ApprovalTaskLifecycleService: + """把既有费用状态机投影成可分配、可审计的个人审批任务。""" + + def __init__(self, db: Session) -> None: + self.db = db + self.access = ApprovalTaskAccessPolicy(db) + self.events = ApprovalTaskEventProtocol(db) + + def ensure_root_task( + self, + claim: ExpenseClaim, + *, + tenant_id: str, + entered_at: datetime, + entered_at_source: str, + business_event: BusinessEvent | None = None, + actor_id: str = "system", + actor_name: str = "系统", + request_id: str | None = None, + ) -> ApprovalTask | None: + node = approval_node_for_stage(claim.approval_stage) + if str(claim.status or "").strip().lower() != "submitted" or node is None: + return None + tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(tenant_id) + existing = self.db.scalar( + select(ApprovalTask).where( + ApprovalTask.tenant_id == tenant, + ApprovalTask.claim_id == claim.id, + ApprovalTask.task_kind == "root", + ApprovalTask.status.in_(OPEN_TASK_STATUSES), + ) + ) + if existing is not None: + if ( + existing.node_key == node.key + and existing.claim_stage_snapshot == str(claim.approval_stage or "").strip() + ): + return existing + self._supersede_stale_root(existing, claim=claim) + + assignment = self.resolve_assignment(claim, node=node) + sequence = int( + self.db.scalar( + select(func.max(ApprovalTask.node_sequence)).where( + ApprovalTask.tenant_id == tenant, + ApprovalTask.claim_id == claim.id, + ApprovalTask.task_kind == "root", + ) + ) + or 0 + ) + 1 + now = datetime.now(UTC) + normalized_entered_at = _aware_utc(entered_at) + task_id = str(uuid.uuid4()) + entry_seed = ( + business_event.id + if business_event is not None + else f"{normalized_entered_at.isoformat()}:{uuid.uuid4().hex[:12]}" + ) + task = ApprovalTask( + id=task_id, + tenant_id=tenant, + claim_id=claim.id, + expense_case_id=self._expense_case_id(claim, business_event=business_event), + node_instance_id=f"{claim.id}:{sequence}:{node.key}:{uuid.uuid4().hex[:12]}", + node_entry_key=f"{claim.id}:{sequence}:{node.key}:{entry_seed}", + parent_task_id=None, + task_kind="root", + node_key=node.key, + node_label=node.label, + node_sequence=sequence, + sequence_order=0, + coordination_mode="single", + owner_kind=assignment.kind, + owner_key=assignment.key, + owner_employee_id=assignment.employee_id, + owner_name=assignment.name, + assignee_kind=assignment.kind, + assignee_key=assignment.key, + assignee_employee_id=assignment.employee_id, + assignee_name=assignment.name, + delegated_by=None, + delegation_expires_at=None, + status="pending", + decision=None, + opinion=None, + version=1, + claim_status_snapshot=str(claim.status or "").strip().lower(), + claim_stage_snapshot=str(claim.approval_stage or "").strip(), + entered_at=normalized_entered_at, + entered_at_source=entered_at_source, + activated_at=normalized_entered_at, + sla_hours_snapshot=node.sla_hours, + due_at=normalized_entered_at + timedelta(hours=node.sla_hours), + completed_at=None, + cancelled_at=None, + escalation_level=0, + escalated_at=None, + next_escalation_at=normalized_entered_at + timedelta(hours=node.sla_hours), + priority_score=0, + priority_tier="normal", + priority_reasons_json=[], + risk_level="low", + open_risk_count=0, + evidence_completeness=0, + batch_eligible=False, + batch_block_reasons_json=[], + projection_updated_at=now, + created_at=now, + updated_at=now, + ) + self._apply_projection(task, claim=claim, now=now) + self.db.add(task) + self.db.flush() + event_request_id = str(request_id or "").strip() or f"node-enter:{task.id}" + self.events.append_event( + task=task, + event_type="node_entered", + actor_id=actor_id, + actor_name=actor_name, + actor_type="system" if actor_id == "system" else "user", + request_id=event_request_id, + expected_task_version=0, + payload={ + "claim_id": claim.id, + "node_key": node.key, + "node_label": node.label, + "entered_at_source": entered_at_source, + }, + before_json={}, + business_event_id=business_event.id if business_event is not None else None, + correlation_id=( + business_event.correlation_id if business_event is not None else None + ), + occurred_at=now, + ) + return task + + def begin_claim_decision( + self, + claim: ExpenseClaim, + current_user: CurrentUserContext, + *, + action: str, + task_id: str | None, + expected_task_version: int | None, + ) -> ApprovalTaskDecisionContext: + tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id) + statement = select(ApprovalTask).where( + ApprovalTask.tenant_id == tenant, + ApprovalTask.claim_id == claim.id, + ApprovalTask.status.in_(OPEN_TASK_STATUSES), + ) + if task_id: + statement = statement.where(ApprovalTask.id == str(task_id).strip()) + else: + statement = statement.where(ApprovalTask.task_kind == "root") + bind = self.db.get_bind() + if bind is not None and bind.dialect.name == "postgresql": + statement = statement.with_for_update() + task = self.db.scalar(statement.execution_options(populate_existing=True)) + if task is None and not task_id: + task = self.ensure_root_task( + claim, + tenant_id=tenant, + entered_at=claim.submitted_at or claim.updated_at or datetime.now(UTC), + entered_at_source=("submitted_at" if claim.submitted_at else "backfill"), + actor_id="system", + actor_name="系统兼容桥", + ) + if task is None: + raise ApprovalTaskConflictError("当前单据没有可执行的审批任务。") + if task.task_kind != "root" and action != "return": + raise ApprovalTaskConflictError("加签或会签通过不会直接推进单据节点。") + if task.status != "pending": + raise ApprovalTaskConflictError("当前审批任务正在等待加签或会签完成。") + if ( + task.claim_status_snapshot != str(claim.status or "").strip().lower() + or task.claim_stage_snapshot != str(claim.approval_stage or "").strip() + ): + raise ApprovalTaskConflictError("审批任务与单据节点不一致,请刷新任务队列。") + expected = int(expected_task_version or task.version) + if task.version != expected: + from app.services.approval_task_protocol import ( + ApprovalTaskVersionConflictError, + ) + + raise ApprovalTaskVersionConflictError(task.version) + self.access.require_action(task, current_user, action=action, claim=claim) + return ApprovalTaskDecisionContext( + task=task, + expected_task_version=expected, + before_json=task_state(task), + ) + + def complete_claim_decision( + self, + context: ApprovalTaskDecisionContext, + claim: ExpenseClaim, + current_user: CurrentUserContext, + *, + action: str, + opinion: str, + request_id: str, + ledger: ApprovalActionLedger, + business_event: BusinessEvent | None = None, + ) -> ApprovalTaskMutationRead: + task = context.task + now = datetime.now(UTC) + task.status = "completed" if action == "approve" else "returned" + task.decision = "approved" if action == "approve" else "returned" + task.opinion = str(opinion or "").strip() or None + task.version += 1 + task.claim_status_snapshot = str(claim.status or "").strip().lower() + task.claim_stage_snapshot = str(claim.approval_stage or "").strip() + task.completed_at = now + task.updated_at = now + related_tasks: list[ApprovalTask] = [] + if action == "return" and task.task_kind != "root": + related_tasks = self._cancel_node_family(task, claim=claim, now=now) + # Session 关闭了 autoflush;先持久化当前根任务的终态,后续创建下一 + # 节点时才不会把同一对象误判成仍开放并改写为 superseded。 + self.db.flush() + next_task = self.ensure_root_task( + claim, + tenant_id=task.tenant_id, + entered_at=( + business_event.occurred_at + if business_event is not None + else now + ), + entered_at_source=("workflow_event" if business_event is not None else "backfill"), + business_event=business_event, + actor_id="system", + actor_name="审批任务引擎", + ) + if next_task is not None: + related_tasks.append(next_task) + _, response = self.events.append_event( + task=task, + event_type=("task_approved" if action == "approve" else "task_returned"), + actor_id=self.access.actor_id(current_user), + actor_name=str(current_user.name or current_user.username), + actor_type="user", + request_id=request_id, + expected_task_version=context.expected_task_version, + payload={"opinion": str(opinion or "").strip()}, + before_json=context.before_json, + related_tasks=related_tasks, + approval_action_ledger_id=ledger.id, + business_event_id=business_event.id if business_event is not None else None, + correlation_id=( + business_event.correlation_id if business_event is not None else None + ), + occurred_at=now, + ) + return response + + def _cancel_node_family( + self, + task: ApprovalTask, + *, + claim: ExpenseClaim, + now: datetime, + ) -> list[ApprovalTask]: + statement = ( + select(ApprovalTask) + .where( + ApprovalTask.tenant_id == task.tenant_id, + ApprovalTask.node_instance_id == task.node_instance_id, + ApprovalTask.id != task.id, + ApprovalTask.status.in_(OPEN_TASK_STATUSES), + ) + .order_by(ApprovalTask.sequence_order.asc()) + ) + bind = self.db.get_bind() + if bind is not None and bind.dialect.name == "postgresql": + statement = statement.with_for_update() + related = list(self.db.scalars(statement).all()) + for item in related: + before = task_state(item) + expected = item.version + item.status = "cancelled" + item.decision = None + item.completed_at = None + item.cancelled_at = now + item.version += 1 + item.claim_status_snapshot = str(claim.status or "").strip().lower() + item.claim_stage_snapshot = str(claim.approval_stage or "").strip() + item.updated_at = now + self.events.append_event( + task=item, + event_type="task_cancelled", + actor_id="system", + actor_name="审批任务引擎", + actor_type="system", + request_id=f"cancel:{item.id}:{item.version}", + expected_task_version=expected, + payload={"reason": "participant_returned_claim"}, + before_json=before, + occurred_at=now, + ) + return related + + def resolve_assignment( + self, + claim: ExpenseClaim, + *, + node: ApprovalNode, + ) -> ApprovalAssignment: + if node.key == "direct_manager": + employee = self.access.claim_policy.resolve_claim_employee_for_backfill(claim) + manager = employee.manager if employee is not None else None + if manager is None: + manager = self.access.claim_policy.resolve_employee_by_identity_candidates( + [str(claim.manager_name or "").strip()] + ) + if manager is None: + raise ApprovalTaskConfigurationError( + "未配置可唯一识别的直属领导,无法创建审批任务。" + ) + return self._employee_assignment(manager) + if node.key == "budget_manager": + employee = self.access.claim_policy.resolve_department_budget_manager(claim) + if employee is None: + raise ApprovalTaskConfigurationError( + "未配置同部门 P8 预算管理者,无法创建审批任务。" + ) + return self._employee_assignment(employee) + finance = self.access.claim_policy.resolve_finance_approver(claim) + if finance is not None: + return self._employee_assignment(finance) + return ApprovalAssignment( + kind="role", + key="finance", + employee_id=None, + name="财务审批组", + ) + + def _supersede_stale_root( + self, + task: ApprovalTask, + *, + claim: ExpenseClaim, + ) -> None: + before = task_state(task) + now = datetime.now(UTC) + expected = task.version + task.status = "superseded" + task.decision = None + task.completed_at = None + task.cancelled_at = now + task.version += 1 + task.claim_status_snapshot = str(claim.status or "").strip().lower() + task.claim_stage_snapshot = str(claim.approval_stage or "").strip() + task.updated_at = now + self.events.append_event( + task=task, + event_type="task_superseded", + actor_id="system", + actor_name="审批任务引擎", + actor_type="system", + request_id=f"supersede:{task.id}:{task.version}", + expected_task_version=expected, + payload={"reason": "claim_stage_changed"}, + before_json=before, + occurred_at=now, + ) + + def _expense_case_id( + self, + claim: ExpenseClaim, + *, + business_event: BusinessEvent | None, + ) -> str | None: + if business_event is not None: + return business_event.expense_case_id + return self.db.scalar( + select(ExpenseCaseLink.expense_case_id).where( + ExpenseCaseLink.resource_type == "expense_claim", + ExpenseCaseLink.resource_id == claim.id, + ) + ) + + @staticmethod + def _employee_assignment(employee: Employee) -> ApprovalAssignment: + key = str(employee.id or employee.email or employee.employee_no).strip() + if not key: + raise ApprovalTaskConfigurationError("审批人缺少稳定身份标识。") + return ApprovalAssignment( + kind="employee", + key=key, + employee_id=employee.id, + name=str(employee.name or employee.email or employee.employee_no).strip(), + ) + + @staticmethod + def _read_without_permission(task: ApprovalTask): + from app.services.approval_task_protocol import task_read + + return task_read(task) + + @staticmethod + def _apply_projection( + task: ApprovalTask, + *, + claim: ExpenseClaim, + now: datetime, + ) -> None: + from app.services.approval_task_projection import ApprovalTaskProjectionService + + ApprovalTaskProjectionService.apply(task, claim=claim, now=now) + + +def _aware_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) + + +# Local alias keeps callers independent of protocol module layout. +from app.services.approval_task_protocol import ApprovalTaskConflictError # noqa: E402 diff --git a/server/src/app/services/approval_task_projection.py b/server/src/app/services/approval_task_projection.py new file mode 100644 index 0000000..cbf8a3b --- /dev/null +++ b/server/src/app/services/approval_task_projection.py @@ -0,0 +1,566 @@ +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from decimal import ROUND_HALF_UP, Decimal, InvalidOperation +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session, object_session + +from app.models.approval_task import ApprovalTask +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_open_claim_risk_flag, +) +from app.services.expense_claim_risk_stage import ( + risk_business_stage_for_claim, + risk_flag_business_stage, +) +from app.services.expense_claim_status_registry import ( + is_application_claim_reference, + normalize_expense_claim_state, +) +from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin +from app.services.expense_claim_workflow_constants import ( + BUDGET_MANAGER_APPROVAL_STAGE, + DIRECT_MANAGER_APPROVAL_STAGE, + FINANCE_APPROVAL_STAGE, +) + +DEFAULT_APPROVAL_SLA_HOURS = 24 +SUPPORTED_APPROVAL_STAGES = frozenset( + { + DIRECT_MANAGER_APPROVAL_STAGE, + BUDGET_MANAGER_APPROVAL_STAGE, + FINANCE_APPROVAL_STAGE, + } +) + +_RISK_ORDER = {"low": 0, "medium": 1, "high": 2, "critical": 3} +_RISK_WEIGHTS = {"low": 0, "medium": 14, "high": 30, "critical": 38} +_CLOSED_OBSERVATION_STATES = { + "false_positive", + "ignored", + "resolved", +} +_BATCH_AMOUNT_LIMIT = Decimal("10000") +_BATCH_BUDGET_USAGE_LIMIT = Decimal("90") +_COMPLETENESS_QUANTUM = Decimal("0.0001") + + +@dataclass(frozen=True, slots=True) +class ApprovalTaskProjection: + """审批队列的可重算快照;它不是审批放行的权威依据。""" + + priority_score: int + priority_tier: str + priority_reasons_json: tuple[dict[str, Any], ...] + risk_level: str + open_risk_count: int + evidence_completeness: Decimal + batch_eligible: bool + batch_block_reasons_json: tuple[str, ...] + due_at: datetime + next_escalation_at: datetime + projection_updated_at: datetime + + +class ApprovalTaskProjectionService: + """计算审批任务的排序、风险、材料和安全批处理投影。 + + 投影仅用于列表排序与操作提示。真正审批时仍必须重新读取 Claim、风险处置、 + 预算和任务版本,不能用这里的 ``batch_eligible`` 替代权威风控校验。 + """ + + def __init__(self, db: Session) -> None: + self.db = db + + def build( + self, + claim: ExpenseClaim, + *, + tenant_id: str, + entered_at: datetime, + now: datetime | None = None, + sla_hours: int = DEFAULT_APPROVAL_SLA_HOURS, + observation_rows: Sequence[tuple[RiskObservation, RiskDisposition | None]] | None = None, + ) -> ApprovalTaskProjection: + normalized_tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(tenant_id) + normalized_entered_at = self._aware(entered_at, field_name="entered_at") + generated_at = self._aware(now or datetime.now(UTC), field_name="now") + normalized_sla_hours = int(sla_hours) + if normalized_sla_hours <= 0: + raise ValueError("sla_hours must be greater than zero") + + rows = ( + list(observation_rows) + if observation_rows is not None + else self._load_observation_rows( + tenant_id=normalized_tenant, + claim_id=str(claim.id or "").strip(), + ) + ) + risk_level, open_risk_count = self._combined_risk_summary( + claim, + rows, + now=generated_at, + ) + evidence_completeness, missing_evidence = self._evidence_summary(claim) + budget_usage_rate = self._budget_usage_rate(claim.risk_flags_json) + amount = self._decimal(claim.amount) + due_at = normalized_entered_at + timedelta(hours=normalized_sla_hours) + waiting_hours = max( + Decimal("0"), + Decimal(str((generated_at - normalized_entered_at).total_seconds())) / Decimal("3600"), + ) + + reasons = self._priority_reasons( + risk_level=risk_level, + open_risk_count=open_risk_count, + waiting_hours=waiting_hours, + sla_hours=normalized_sla_hours, + budget_usage_rate=budget_usage_rate, + amount=amount, + missing_evidence=missing_evidence, + ) + score = min(100, sum(int(item.get("weight") or 0) for item in reasons)) + priority_tier = "urgent" if score >= 65 else "high" if score >= 40 else "normal" + + state = normalize_expense_claim_state( + claim.status, + claim.approval_stage, + claim_no=claim.claim_no, + expense_type=claim.expense_type, + ) + batch_block_reasons = self._batch_block_reasons( + approval_stage=state.approval_stage, + risk_level=risk_level, + open_risk_count=open_risk_count, + evidence_completeness=evidence_completeness, + amount=amount, + budget_usage_rate=budget_usage_rate, + overdue=generated_at >= due_at, + ) + return ApprovalTaskProjection( + priority_score=score, + priority_tier=priority_tier, + priority_reasons_json=tuple(reasons), + risk_level=risk_level, + open_risk_count=open_risk_count, + evidence_completeness=evidence_completeness, + batch_eligible=not batch_block_reasons, + batch_block_reasons_json=tuple(batch_block_reasons), + due_at=due_at, + next_escalation_at=due_at, + projection_updated_at=generated_at, + ) + + @staticmethod + def apply( + task: ApprovalTask, + projection: ApprovalTaskProjection | None = None, + *, + claim: ExpenseClaim | None = None, + now: datetime | None = None, + observation_rows: Sequence[tuple[RiskObservation, RiskDisposition | None]] | None = None, + ) -> ApprovalTask: + """计算(可选)并应用投影,不 flush、不 commit。 + + ``claim=`` 便捷形式会从持久化 Claim 取得 Session,因此即使 Task 尚未 add, + 仍能租户隔离地合并结构化风险;也保留显式 ``projection`` 形式供批处理复用。 + """ + + if projection is None: + if claim is None: + raise ValueError("claim is required when projection is not provided") + db = object_session(claim) or object_session(task) + if db is None and observation_rows is None: + raise ValueError( + "claim must be attached to a Session when observation_rows is omitted" + ) + projection = ApprovalTaskProjectionService(db).build( # type: ignore[arg-type] + claim, + tenant_id=str(task.tenant_id or "").strip(), + entered_at=task.entered_at, + now=now, + sla_hours=task.sla_hours_snapshot, + observation_rows=observation_rows, + ) + + task.priority_score = projection.priority_score + task.priority_tier = projection.priority_tier + task.priority_reasons_json = [dict(item) for item in projection.priority_reasons_json] + task.risk_level = projection.risk_level + task.open_risk_count = projection.open_risk_count + task.evidence_completeness = projection.evidence_completeness + task.batch_eligible = projection.batch_eligible + task.batch_block_reasons_json = list(projection.batch_block_reasons_json) + task.due_at = projection.due_at + task.next_escalation_at = projection.next_escalation_at + task.projection_updated_at = projection.projection_updated_at + return task + + def _load_observation_rows( + self, + *, + tenant_id: str, + claim_id: str, + ) -> list[tuple[RiskObservation, RiskDisposition | None]]: + if not claim_id: + return [] + return list( + self.db.execute( + select(RiskObservation, RiskDisposition) + .outerjoin( + RiskDisposition, + (RiskDisposition.tenant_id == RiskObservation.tenant_id) + & (RiskDisposition.observation_id == RiskObservation.id), + ) + .where( + RiskObservation.tenant_id == tenant_id, + RiskObservation.claim_id == claim_id, + ) + ).all() + ) + + @classmethod + def _combined_risk_summary( + cls, + claim: ExpenseClaim, + rows: Sequence[tuple[RiskObservation, RiskDisposition | None]], + *, + now: datetime, + ) -> tuple[str, int]: + claim_business_stage = risk_business_stage_for_claim( + is_application_claim=is_application_claim_reference( + claim_no=claim.claim_no, + expense_type=claim.expense_type, + ) + ) + persisted_level, persisted_count = cls._persisted_risk_summary( + rows, + claim_business_stage=claim_business_stage, + now=now, + ) + 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._raw_risk_summary( + claim.risk_flags_json, + claim_id=str(claim.id or "").strip(), + materialized_keys=materialized_keys, + claim_business_stage=claim_business_stage, + ) + return ( + max((persisted_level, raw_level), key=lambda value: _RISK_ORDER[value]), + persisted_count + raw_count, + ) + + @staticmethod + def _persisted_risk_summary( + rows: Sequence[tuple[RiskObservation, RiskDisposition | None]], + *, + claim_business_stage: str, + now: datetime, + ) -> tuple[str, int]: + level = "low" + count = 0 + for observation, disposition in rows: + observation_stage = risk_flag_business_stage( + {"control_stage": observation.control_stage}, + default="", + ) + if observation_stage and observation_stage != claim_business_stage: + continue + adjudication = ( + str( + disposition.adjudication + if disposition is not None + else observation.feedback_status or "" + ) + .strip() + .lower() + ) + lifecycle = ( + str(disposition.lifecycle_status if disposition is not None else "open") + .strip() + .lower() + ) + observation_status = str(observation.status or "").strip().lower() + active_waiver = False + if disposition is not None and lifecycle == "waived": + expires_at = disposition.waiver_expires_at + normalized_expiry = ( + expires_at.replace(tzinfo=UTC) + if expires_at is not None and expires_at.tzinfo is None + else expires_at.astimezone(UTC) + if expires_at is not None + else None + ) + active_waiver = ( + str(disposition.waiver_decision or "").strip().lower() == "approved" + and normalized_expiry is not None + and normalized_expiry > now + ) + if ( + adjudication == "false_positive" + or lifecycle == "resolved" + or active_waiver + or observation_status in _CLOSED_OBSERVATION_STATES + ): + 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 + + @staticmethod + def _raw_risk_summary( + raw_flags: Any, + *, + claim_id: str, + materialized_keys: set[str], + claim_business_stage: str, + ) -> tuple[str, int]: + flags = raw_flags if isinstance(raw_flags, list) else [raw_flags] + level = "low" + count = 0 + for flag in flags: + if not isinstance(flag, dict) or not is_open_claim_risk_flag(flag): + continue + flag_stage = risk_flag_business_stage(flag, default="") + if flag_stage and flag_stage != claim_business_stage: + continue + observation_key = claim_risk_flag_observation_key(flag, claim_id=claim_id) + if observation_key and observation_key in materialized_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 _evidence_summary(cls, claim: ExpenseClaim) -> tuple[Decimal, tuple[str, ...]]: + checks = [ + ("事由", bool(str(claim.reason or "").strip())), + ("地点", bool(str(claim.location or "").strip())), + ("费用明细", bool(list(claim.items or []))), + ] + if not is_application_claim_reference( + claim_no=claim.claim_no, + expense_type=claim.expense_type, + ): + checks.append( + ( + "票据", + int(claim.invoice_count or 0) > 0 + or any(str(item.invoice_id or "").strip() for item in list(claim.items or [])), + ) + ) + missing = tuple(label for label, present in checks if not present) + if not checks: + return Decimal("1.0000"), missing + present_count = len(checks) - len(missing) + completeness = (Decimal(present_count) / Decimal(len(checks))).quantize( + _COMPLETENESS_QUANTUM, rounding=ROUND_HALF_UP + ) + return completeness, missing + + @classmethod + def _priority_reasons( + cls, + *, + risk_level: str, + open_risk_count: int, + waiting_hours: Decimal, + sla_hours: int, + budget_usage_rate: Decimal | None, + amount: Decimal, + missing_evidence: tuple[str, ...], + ) -> list[dict[str, Any]]: + reasons: list[dict[str, Any]] = [] + risk_weight = _RISK_WEIGHTS[risk_level] + if risk_weight: + reasons.append( + { + "code": "open_risk", + "label": f"{risk_level.upper()} 风险 {open_risk_count} 项待复核", + "weight": risk_weight, + "tone": "danger" if risk_level in {"high", "critical"} else "warning", + } + ) + + sla = Decimal(sla_hours) + if waiting_hours >= sla: + reasons.append( + { + "code": "sla_overdue", + "label": f"已超过 {sla_hours} 小时审批 SLA", + "weight": 26, + "tone": "danger", + } + ) + elif waiting_hours >= sla * Decimal("0.6667"): + reasons.append( + { + "code": "sla_near_due", + "label": "审批 SLA 即将到期", + "weight": 16, + "tone": "warning", + } + ) + elif waiting_hours >= sla * Decimal("0.3333"): + reasons.append( + { + "code": "waiting", + "label": "审批等待时间较长", + "weight": 8, + "tone": "warning", + } + ) + + if budget_usage_rate is not None and budget_usage_rate >= _BATCH_BUDGET_USAGE_LIMIT: + reasons.append( + { + "code": "budget_pressure", + "label": f"审批后预算占用约 {budget_usage_rate.quantize(Decimal('1'))}%", + "weight": 18, + "tone": "danger" if budget_usage_rate >= 100 else "warning", + } + ) + if amount >= Decimal("50000"): + reasons.append( + { + "code": "large_amount", + "label": "大额费用需重点核对", + "weight": 15, + "tone": "warning", + } + ) + elif amount >= _BATCH_AMOUNT_LIMIT: + reasons.append( + { + "code": "amount_attention", + "label": "金额达到 1 万元复核线", + "weight": 8, + "tone": "warning", + } + ) + if missing_evidence: + reasons.append( + { + "code": "evidence_gap", + "label": "材料仍有缺口:" + "、".join(missing_evidence), + "weight": 12, + "tone": "warning", + } + ) + if not reasons: + reasons.append( + { + "code": "routine", + "label": "常规低风险待办", + "weight": 0, + "tone": "neutral", + } + ) + return reasons + + @staticmethod + def _batch_block_reasons( + *, + approval_stage: str, + risk_level: str, + open_risk_count: int, + evidence_completeness: Decimal, + amount: Decimal, + budget_usage_rate: Decimal | None, + overdue: bool, + ) -> list[str]: + reasons: list[str] = [] + if approval_stage not in SUPPORTED_APPROVAL_STAGES: + reasons.append("unsupported_approval_stage") + if open_risk_count > 0 or risk_level != "low": + reasons.append("open_risk") + if evidence_completeness < Decimal("1"): + reasons.append("evidence_incomplete") + if amount >= _BATCH_AMOUNT_LIMIT: + reasons.append("amount_requires_individual_review") + if budget_usage_rate is not None and budget_usage_rate >= _BATCH_BUDGET_USAGE_LIMIT: + reasons.append("budget_pressure") + if overdue: + reasons.append("sla_overdue") + return reasons + + @classmethod + def _budget_usage_rate(cls, raw_flags: Any) -> Decimal | None: + values: list[Decimal] = [] + flags = raw_flags if isinstance(raw_flags, list) else [raw_flags] + for flag in flags: + if not isinstance(flag, dict): + continue + candidates = [ + 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 candidates 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._optional_decimal(container.get(key)) + if value is None or value < 0: + continue + values.append(value * 100 if Decimal("0") < value <= 1 else value) + return max(values).quantize(Decimal("0.01")) if values else None + + @staticmethod + def _decimal(value: Any) -> Decimal: + try: + return Decimal(str(value or "0")) + except (InvalidOperation, TypeError, ValueError): + return Decimal("0") + + @staticmethod + def _optional_decimal(value: Any) -> Decimal | None: + if value is None or str(value).strip() == "": + return None + try: + return Decimal(str(value)) + except (InvalidOperation, TypeError, ValueError): + return None + + @staticmethod + def _aware(value: datetime, *, field_name: str) -> datetime: + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError(f"{field_name} must include a timezone") + return value.astimezone(UTC) diff --git a/server/src/app/services/approval_task_projection_refresh.py b/server/src/app/services/approval_task_projection_refresh.py new file mode 100644 index 0000000..d9d2cdb --- /dev/null +++ b/server/src/app/services/approval_task_projection_refresh.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.approval_task import ApprovalTask +from app.models.financial_record import ExpenseClaim +from app.services.approval_task_projection import ApprovalTaskProjectionService +from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin + +OPEN_TASK_STATUSES = ("waiting", "pending") + + +class ApprovalTaskProjectionRefreshService: + """在风险事实变化后,同事务刷新开放任务的只读队列投影。""" + + def __init__(self, db: Session) -> None: + self.db = db + self.projections = ApprovalTaskProjectionService(db) + + def refresh_claim( + self, + *, + tenant_id: str, + claim_id: str, + now: datetime | None = None, + ) -> list[str]: + tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(tenant_id) + normalized_claim_id = str(claim_id or "").strip() + if not normalized_claim_id: + return [] + claim_statement = select(ExpenseClaim).where( + ExpenseClaim.id == normalized_claim_id, + ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(tenant), + ) + task_statement = ( + select(ApprovalTask) + .where( + ApprovalTask.tenant_id == tenant, + ApprovalTask.claim_id == normalized_claim_id, + ApprovalTask.status.in_(OPEN_TASK_STATUSES), + ) + .order_by(ApprovalTask.id.asc()) + ) + bind = self.db.get_bind() + if bind is not None and bind.dialect.name == "postgresql": + claim_statement = claim_statement.with_for_update() + task_statement = task_statement.with_for_update() + claim = self.db.scalar(claim_statement.execution_options(populate_existing=True)) + if claim is None: + return [] + + generated_at = _as_utc(now or datetime.now(UTC)) + tasks = list( + self.db.scalars( + task_statement.execution_options(populate_existing=True) + ).all() + ) + for task in tasks: + projection = self.projections.build( + claim, + tenant_id=tenant, + entered_at=_as_utc(task.entered_at), + now=generated_at, + sla_hours=task.sla_hours_snapshot, + ) + escalation_reasons = [ + dict(reason) + for reason in list(task.priority_reasons_json or []) + if str(reason.get("code") or "").startswith("sla_escalated_l") + ] + reasons = [ + dict(reason) + for reason in projection.priority_reasons_json + if not ( + str(reason.get("code") or "") == "routine" and escalation_reasons + ) + ] + reasons.extend(escalation_reasons) + task.priority_reasons_json = _unique_reasons(reasons) + task.priority_score = min( + 100, + sum(int(reason.get("weight") or 0) for reason in task.priority_reasons_json), + ) + task.priority_tier = ( + "urgent" + if task.priority_score >= 65 + else "high" + if task.priority_score >= 40 + else "normal" + ) + task.risk_level = projection.risk_level + task.open_risk_count = projection.open_risk_count + task.evidence_completeness = projection.evidence_completeness + task.batch_eligible = projection.batch_eligible + task.batch_block_reasons_json = list(projection.batch_block_reasons_json) + task.projection_updated_at = generated_at + task.updated_at = generated_at + if tasks: + self.db.flush() + return [task.id for task in tasks] + + +def _unique_reasons(reasons: list[dict]) -> list[dict]: + unique: dict[str, dict] = {} + for index, reason in enumerate(reasons): + code = str(reason.get("code") or "reason").strip() + unique[f"{code}:{index}" if code in unique else code] = reason + return list(unique.values()) + + +def _as_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) diff --git a/server/src/app/services/approval_task_protocol.py b/server/src/app/services/approval_task_protocol.py new file mode 100644 index 0000000..3f5fc5d --- /dev/null +++ b/server/src/app/services/approval_task_protocol.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +import hashlib +import json +import uuid +from collections.abc import Mapping, Sequence +from datetime import UTC, datetime +from typing import Any + +from pydantic import ValidationError +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.approval_task import ApprovalTask, ApprovalTaskEvent +from app.schemas.approval_task import ( + ApprovalTaskEventRead, + ApprovalTaskMutationRead, + ApprovalTaskRead, +) +from app.services.approval_task_access_policy import ApprovalTaskPermission + + +class ApprovalTaskConflictError(ValueError): + pass + + +class ApprovalTaskVersionConflictError(ApprovalTaskConflictError): + def __init__(self, current_version: int) -> None: + self.current_version = current_version + super().__init__(f"审批任务版本已变化,当前版本为 {current_version},请刷新后重试。") + + +class ApprovalTaskIdempotencyConflictError(ApprovalTaskConflictError): + pass + + +def normalize_actor_id(value: object) -> str: + return str(value or "").strip().casefold() or "anonymous" + + +def payload_fingerprint( + *, + task_id: str, + actor_id: str, + event_type: str, + payload: Mapping[str, Any], +) -> str: + canonical = json.dumps( + { + "task_id": str(task_id), + "actor_id": normalize_actor_id(actor_id), + "event_type": str(event_type).strip(), + "payload": dict(payload), + }, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + default=_json_default, + ) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +class ApprovalTaskEventProtocol: + """审批任务 append-only 事件与不可变响应重放。""" + + def __init__(self, db: Session) -> None: + self.db = db + + def find_replay( + self, + *, + tenant_id: str, + actor_id: str, + request_id: str, + fingerprint: str, + ) -> ApprovalTaskMutationRead | None: + event = self.db.scalar( + select(ApprovalTaskEvent).where( + ApprovalTaskEvent.tenant_id == tenant_id, + ApprovalTaskEvent.actor_id == normalize_actor_id(actor_id), + ApprovalTaskEvent.request_id == str(request_id).strip(), + ) + ) + if event is None: + return None + if str(event.payload_fingerprint) != fingerprint: + raise ApprovalTaskIdempotencyConflictError( + "request_id 已被不同的审批任务动作使用。" + ) + response = self._response_from_event(event) + return response.model_copy(update={"replayed": True}) + + def append_event( + self, + *, + task: ApprovalTask, + event_type: str, + actor_id: str, + actor_name: str, + actor_type: str, + request_id: str, + expected_task_version: int, + payload: Mapping[str, Any], + before_json: Mapping[str, Any], + related_tasks: Sequence[ApprovalTask] = (), + permission: ApprovalTaskPermission | None = None, + approval_action_ledger_id: str | None = None, + business_event_id: str | None = None, + correlation_id: str | None = None, + causation_id: str | None = None, + occurred_at: datetime | None = None, + ) -> tuple[ApprovalTaskEvent, ApprovalTaskMutationRead]: + normalized_actor = normalize_actor_id(actor_id) + normalized_request = str(request_id or "").strip() + if not normalized_request: + raise ValueError("审批任务事件 request_id 不能为空。") + fingerprint = payload_fingerprint( + task_id=task.id, + actor_id=normalized_actor, + event_type=event_type, + payload=payload, + ) + event = ApprovalTaskEvent( + id=str(uuid.uuid4()), + tenant_id=task.tenant_id, + task_id=task.id, + node_instance_id=task.node_instance_id, + event_type=str(event_type).strip(), + actor_id=normalized_actor, + actor_name=str(actor_name or actor_id or "anonymous").strip() or "anonymous", + actor_type=str(actor_type or "user").strip(), + request_id=normalized_request, + expected_task_version=int(expected_task_version), + result_task_version=int(task.version), + payload_fingerprint=fingerprint, + payload_json=dict(payload), + before_json=dict(before_json), + after_json=task_state(task), + response_json={}, + approval_action_ledger_id=approval_action_ledger_id, + business_event_id=business_event_id, + correlation_id=correlation_id, + causation_id=causation_id, + ) + event.occurred_at = occurred_at or datetime.now(UTC) + response = ApprovalTaskMutationRead( + task=task_read(task, permission=permission), + event=ApprovalTaskEventRead.model_validate(event), + related_tasks=[task_read(item) for item in related_tasks], + replayed=False, + ) + event.response_json = response.model_dump(mode="json") + self.db.add(event) + return event, response + + def _response_from_event(self, event: ApprovalTaskEvent) -> ApprovalTaskMutationRead: + try: + response = ApprovalTaskMutationRead.model_validate(event.response_json) + except (TypeError, ValueError, ValidationError) as error: + raise ApprovalTaskConflictError( + "审批任务事件缺少可验证的原始响应快照,请刷新后重试。" + ) from error + if ( + response.event.id != event.id + or response.event.task_id != event.task_id + or response.event.request_id != event.request_id + or response.event.result_task_version != event.result_task_version + or response.task.id != event.task_id + or response.task.tenant_id != event.tenant_id + or response.task.version != event.result_task_version + ): + raise ApprovalTaskConflictError("审批任务事件响应快照身份校验失败。") + return response + + +def task_read( + task: ApprovalTask, + *, + permission: ApprovalTaskPermission | None = None, +) -> ApprovalTaskRead: + update: dict[str, Any] = {} + if permission is not None: + update = { + "can_act": permission.can_act, + "available_actions": list(permission.available_actions), + "read_only_reason": permission.read_only_reason, + } + return ApprovalTaskRead.model_validate(task).model_copy(update=update) + + +def task_state(task: ApprovalTask) -> dict[str, Any]: + return { + "status": task.status, + "decision": task.decision, + "opinion": task.opinion, + "version": task.version, + "coordination_mode": task.coordination_mode, + "owner_kind": task.owner_kind, + "owner_key": task.owner_key, + "owner_employee_id": task.owner_employee_id, + "owner_name": task.owner_name, + "assignee_kind": task.assignee_kind, + "assignee_key": task.assignee_key, + "assignee_employee_id": task.assignee_employee_id, + "assignee_name": task.assignee_name, + "delegated_by": task.delegated_by, + "delegation_expires_at": _json_default(task.delegation_expires_at), + "claim_status_snapshot": task.claim_status_snapshot, + "claim_stage_snapshot": task.claim_stage_snapshot, + "activated_at": _json_default(task.activated_at), + "completed_at": _json_default(task.completed_at), + "cancelled_at": _json_default(task.cancelled_at), + "escalation_level": task.escalation_level, + "escalated_at": _json_default(task.escalated_at), + "next_escalation_at": _json_default(task.next_escalation_at), + "projection_updated_at": _json_default(task.projection_updated_at), + } + + +def _json_default(value: object) -> object: + if isinstance(value, datetime): + return value.isoformat() + return value diff --git a/server/src/app/services/approval_task_query.py b/server/src/app/services/approval_task_query.py new file mode 100644 index 0000000..7a6d172 --- /dev/null +++ b/server/src/app/services/approval_task_query.py @@ -0,0 +1,278 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from sqlalchemy import and_, func, or_, select +from sqlalchemy.orm import Session, selectinload + +from app.api.deps import CurrentUserContext +from app.models.approval_task import ApprovalTask +from app.models.employee import Employee +from app.models.financial_record import ExpenseClaim +from app.schemas.approval_task import ( + ApprovalTaskListRead, + ApprovalTaskQueueItemRead, +) +from app.schemas.reimbursement import ExpenseClaimRead +from app.services.approval_task_access_policy import ApprovalTaskAccessPolicy +from app.services.approval_task_protocol import task_read +from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy +from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin + + +class ApprovalTaskQueryService: + """服务端分页的个人审批队列;GET 路径严格只读。""" + + def __init__(self, db: Session) -> None: + self.db = db + self.task_access = ApprovalTaskAccessPolicy(db) + self.claim_access = ExpenseClaimAccessPolicy(db) + + def list_tasks( + self, + current_user: CurrentUserContext, + *, + page: int = 1, + page_size: int = 20, + status: str = "pending", + risk_level: str | None = None, + sla_state: str | None = None, + node_key: str | None = None, + task_kind: str | None = None, + batch_eligible: bool | None = None, + assignee: str | None = None, + sort: str = "priority_desc", + keyword: str | None = None, + ) -> ApprovalTaskListRead: + page = max(int(page), 1) + page_size = min(max(int(page_size), 1), 200) + tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id) + now = datetime.now(UTC) + conditions = [ + ApprovalTask.tenant_id == tenant, + ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(tenant), + self._status_condition(status), + ] + visibility = self._visibility_condition(current_user) + if visibility is not None: + conditions.append(visibility) + if risk_level: + conditions.append(ApprovalTask.risk_level == str(risk_level).strip().lower()) + if node_key: + conditions.append(ApprovalTask.node_key == str(node_key).strip().lower()) + if task_kind: + normalized_kind = str(task_kind).strip().lower() + if normalized_kind not in {"root", "add_sign", "countersign"}: + raise ValueError("不支持的审批任务类型筛选。") + conditions.append(ApprovalTask.task_kind == normalized_kind) + if batch_eligible is not None: + conditions.append(ApprovalTask.batch_eligible.is_(bool(batch_eligible))) + normalized_assignee = str(assignee or "").strip() + if normalized_assignee: + assignee_pattern = f"%{normalized_assignee}%" + conditions.append( + or_( + ApprovalTask.assignee_name.ilike(assignee_pattern), + ApprovalTask.assignee_key.ilike(assignee_pattern), + ) + ) + normalized_sla_state = str(sla_state or "").strip().lower() + if normalized_sla_state == "overdue": + conditions.append(ApprovalTask.due_at < now) + elif normalized_sla_state == "due_soon": + conditions.extend( + ( + ApprovalTask.due_at >= now, + ApprovalTask.due_at <= now + timedelta(hours=4), + ) + ) + elif normalized_sla_state == "on_track": + conditions.append(ApprovalTask.due_at > now + timedelta(hours=4)) + elif normalized_sla_state == "escalated": + conditions.append(ApprovalTask.escalation_level > 0) + elif normalized_sla_state: + raise ValueError("不支持的审批任务 SLA 状态筛选。") + normalized_keyword = str(keyword or "").strip() + if normalized_keyword: + pattern = f"%{normalized_keyword}%" + conditions.append( + or_( + ExpenseClaim.claim_no.ilike(pattern), + ExpenseClaim.employee_name.ilike(pattern), + ExpenseClaim.reason.ilike(pattern), + ApprovalTask.assignee_name.ilike(pattern), + ApprovalTask.owner_name.ilike(pattern), + ) + ) + + total = int( + self.db.scalar( + select(func.count()) + .select_from(ApprovalTask) + .join(ExpenseClaim, ExpenseClaim.id == ApprovalTask.claim_id) + .where(*conditions) + ) + or 0 + ) + statement = ( + select(ApprovalTask, ExpenseClaim) + .join(ExpenseClaim, ExpenseClaim.id == ApprovalTask.claim_id) + .options( + selectinload(ExpenseClaim.items), + selectinload(ExpenseClaim.employee).selectinload(Employee.manager), + selectinload(ExpenseClaim.employee).selectinload(Employee.roles), + selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit), + ) + .where(*conditions) + .order_by(*self._ordering(sort)) + .offset((page - 1) * page_size) + .limit(page_size) + ) + items: list[ApprovalTaskQueueItemRead] = [] + for task, claim in self.db.execute(statement).unique().all(): + permission = self.task_access.evaluate(task, current_user, claim=claim) + if not permission.can_read: # SQL 与领域策略双重校验。 + continue + self.claim_access.attach_approval_snapshot(claim) + items.append( + ApprovalTaskQueueItemRead( + task=task_read(task, permission=permission), + claim=ExpenseClaimRead.model_validate(claim), + ) + ) + return ApprovalTaskListRead( + items=items, + total=total, + page=page, + page_size=page_size, + total_pages=(total + page_size - 1) // page_size if total else 0, + generated_at=now, + ) + + def get_task( + self, + task_id: str, + current_user: CurrentUserContext, + ) -> ApprovalTaskQueueItemRead | None: + tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id) + row = self.db.execute( + select(ApprovalTask, ExpenseClaim) + .join(ExpenseClaim, ExpenseClaim.id == ApprovalTask.claim_id) + .options( + selectinload(ExpenseClaim.items), + selectinload(ExpenseClaim.employee).selectinload(Employee.manager), + selectinload(ExpenseClaim.employee).selectinload(Employee.roles), + selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit), + ) + .where( + ApprovalTask.tenant_id == tenant, + ApprovalTask.id == str(task_id).strip(), + ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(tenant), + ) + ).unique().one_or_none() + if row is None: + return None + task, claim = row + permission = self.task_access.evaluate(task, current_user, claim=claim) + if not permission.can_read: + return None + self.claim_access.attach_approval_snapshot(claim) + return ApprovalTaskQueueItemRead( + task=task_read(task, permission=permission), + claim=ExpenseClaimRead.model_validate(claim), + ) + + def _visibility_condition(self, current_user: CurrentUserContext): + if current_user.is_admin: + return None + employee = self.claim_access.resolve_current_employee(current_user) + identities = { + str(current_user.username or "").strip().casefold(), + str(current_user.employee_id or "").strip().casefold(), + str(current_user.employee_no or "").strip().casefold(), + str(current_user.name or "").strip().casefold(), + } + employee_ids: set[str] = set() + if employee is not None: + identities.update( + { + str(employee.id or "").strip().casefold(), + str(employee.employee_no or "").strip().casefold(), + str(employee.email or "").strip().casefold(), + str(employee.name or "").strip().casefold(), + } + ) + employee_ids.add(employee.id) + if current_user.employee_id: + employee_ids.add(str(current_user.employee_id).strip()) + identities.discard("") + role_codes = ExpenseClaimAccessPolicy.normalize_role_codes(current_user) + employee_match = or_( + and_( + ApprovalTask.assignee_kind == "employee", + or_( + func.lower(ApprovalTask.assignee_key).in_(identities), + ApprovalTask.assignee_employee_id.in_(employee_ids), + ), + ), + and_( + ApprovalTask.owner_kind == "employee", + or_( + func.lower(ApprovalTask.owner_key).in_(identities), + ApprovalTask.owner_employee_id.in_(employee_ids), + ), + ), + ) + role_match = or_( + and_( + ApprovalTask.assignee_kind == "role", + func.lower(ApprovalTask.assignee_key).in_(role_codes), + ), + and_( + ApprovalTask.owner_kind == "role", + func.lower(ApprovalTask.owner_key).in_(role_codes), + ), + ) + return or_(employee_match, role_match) + + @staticmethod + def _status_condition(value: str): + normalized = str(value or "pending").strip().lower() + if normalized in {"open", "pending"}: + return ApprovalTask.status.in_(("waiting", "pending")) + if normalized == "processed": + return ApprovalTask.status.in_(("completed", "returned", "cancelled", "superseded")) + if normalized in { + "waiting", + "completed", + "returned", + "cancelled", + "superseded", + }: + return ApprovalTask.status == normalized + raise ValueError("不支持的审批任务状态筛选。") + + @staticmethod + def _ordering(value: str): + normalized = str(value or "priority_desc").strip().lower() + if normalized == "priority_desc": + return ( + ApprovalTask.priority_score.desc(), + ApprovalTask.due_at.asc(), + ApprovalTask.entered_at.asc(), + ApprovalTask.id.asc(), + ) + if normalized == "due_asc": + return ( + ApprovalTask.due_at.asc(), + ApprovalTask.priority_score.desc(), + ApprovalTask.entered_at.asc(), + ApprovalTask.id.asc(), + ) + if normalized == "entered_desc": + return ( + ApprovalTask.entered_at.desc(), + ApprovalTask.priority_score.desc(), + ApprovalTask.id.asc(), + ) + raise ValueError("不支持的审批任务排序方式。") diff --git a/server/src/app/services/approval_task_scheduler.py b/server/src/app/services/approval_task_scheduler.py new file mode 100644 index 0000000..9e56919 --- /dev/null +++ b/server/src/app/services/approval_task_scheduler.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import os +import threading + +from app.core.logging import get_logger +from app.db.session import get_session_factory +from app.services.approval_task_sla import ApprovalTaskSlaService + +logger = get_logger("app.services.approval_task_scheduler") + + +class ApprovalTaskScheduler: + def __init__(self) -> None: + interval = int(os.environ.get("X_FINANCIAL_APPROVAL_TASK_INTERVAL_SECONDS") or "60") + initial_delay = int( + os.environ.get("X_FINANCIAL_APPROVAL_TASK_INITIAL_DELAY_SECONDS") or "10" + ) + self._interval_seconds = max(30, interval) + self._initial_delay_seconds = max(1, initial_delay) + self._stop_event = threading.Event() + self._thread: threading.Thread | None = None + self._lock = threading.Lock() + + def start(self) -> None: + with self._lock: + if self._thread is not None and self._thread.is_alive(): + return + self._stop_event.clear() + self._thread = threading.Thread( + target=self._run_loop, + name="approval-task-scheduler", + daemon=True, + ) + self._thread.start() + logger.info( + "Approval task scheduler started interval=%ss", + self._interval_seconds, + ) + + def shutdown(self) -> None: + with self._lock: + thread = self._thread + self._thread = None + self._stop_event.set() + if thread is not None and thread.is_alive(): + thread.join(timeout=3) + logger.info("Approval task scheduler stopped") + + def _run_loop(self) -> None: + if self._stop_event.wait(self._initial_delay_seconds): + return + while not self._stop_event.is_set(): + try: + self._run_once() + except Exception: # pragma: no cover - scheduler best effort logging + logger.exception("Scheduled approval task SLA escalation failed") + if self._stop_event.wait(self._interval_seconds): + break + + @staticmethod + def _run_once() -> None: + db = get_session_factory()() + try: + escalated = ApprovalTaskSlaService(db).escalate_due_tasks() + db.commit() + if escalated: + logger.info("Approval task SLA escalated count=%s", len(escalated)) + except Exception: + db.rollback() + raise + finally: + db.close() + + +approval_task_scheduler = ApprovalTaskScheduler() diff --git a/server/src/app/services/approval_task_sla.py b/server/src/app/services/approval_task_sla.py new file mode 100644 index 0000000..bc96bc7 --- /dev/null +++ b/server/src/app/services/approval_task_sla.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.approval_task import ApprovalTask +from app.models.financial_record import ExpenseClaim +from app.services.approval_task_protocol import ApprovalTaskEventProtocol, task_state +from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin + +MAX_ESCALATION_LEVEL = 3 + + +class ApprovalTaskSlaService: + """按节点进入时间快照升级超时任务,不改变审批权限和 Claim 状态。""" + + def __init__(self, db: Session) -> None: + self.db = db + self.events = ApprovalTaskEventProtocol(db) + + def escalate_due_tasks( + self, + *, + now: datetime | None = None, + limit: int = 100, + ) -> list[str]: + checked_at = _as_utc(now or datetime.now(UTC)) + locators = list( + self.db.execute( + select( + ApprovalTask.id, + ApprovalTask.claim_id, + ApprovalTask.tenant_id, + ) + .where( + ApprovalTask.status.in_(("waiting", "pending")), + ApprovalTask.next_escalation_at.is_not(None), + ApprovalTask.next_escalation_at <= checked_at, + ApprovalTask.escalation_level < MAX_ESCALATION_LEVEL, + ) + .order_by( + ApprovalTask.next_escalation_at.asc(), + ApprovalTask.priority_score.desc(), + ApprovalTask.id.asc(), + ) + .limit(min(max(int(limit), 1), 500)) + ).all() + ) + escalated: list[str] = [] + for task_id, claim_id, tenant_id in locators: + if self._escalate_one( + task_id=str(task_id), + claim_id=str(claim_id), + tenant_id=str(tenant_id), + now=checked_at, + ): + escalated.append(str(task_id)) + return escalated + + def _escalate_one( + self, + *, + task_id: str, + claim_id: str, + tenant_id: str, + now: datetime, + ) -> bool: + claim_statement = select(ExpenseClaim).where( + ExpenseClaim.id == claim_id, + ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(tenant_id), + ) + task_statement = select(ApprovalTask).where( + ApprovalTask.tenant_id == tenant_id, + ApprovalTask.id == task_id, + ) + bind = self.db.get_bind() + if bind is not None and bind.dialect.name == "postgresql": + claim_statement = claim_statement.with_for_update() + task_statement = task_statement.with_for_update() + claim = self.db.scalar(claim_statement.execution_options(populate_existing=True)) + if claim is None: + return False + task = self.db.scalar(task_statement.execution_options(populate_existing=True)) + if ( + task is None + or task.status not in {"waiting", "pending"} + or task.next_escalation_at is None + or _as_utc(task.next_escalation_at) > now + or task.escalation_level >= MAX_ESCALATION_LEVEL + ): + return False + + before = task_state(task) + expected = task.version + task.escalation_level += 1 + task.escalated_at = now + task.version += 1 + task.updated_at = now + task.next_escalation_at = ( + None + if task.escalation_level >= MAX_ESCALATION_LEVEL + else now + timedelta(hours=4 * (2 ** (task.escalation_level - 1))) + ) + task.priority_score = min(100, int(task.priority_score) + 10) + task.priority_tier = "urgent" if task.priority_score >= 65 else "high" + reasons = list(task.priority_reasons_json or []) + reasons.append( + { + "code": f"sla_escalated_l{task.escalation_level}", + "label": f"审批超时已升级至 L{task.escalation_level}", + "weight": 10, + "tone": "danger", + } + ) + task.priority_reasons_json = reasons + self.events.append_event( + task=task, + event_type="sla_escalated", + actor_id="system", + actor_name="审批 SLA 调度器", + actor_type="system", + request_id=f"sla:{task.id}:{task.version}", + expected_task_version=expected, + payload={ + "level": task.escalation_level, + "due_at": task.due_at.isoformat(), + "next_escalation_at": ( + task.next_escalation_at.isoformat() + if task.next_escalation_at is not None + else None + ), + }, + before_json=before, + occurred_at=now, + ) + # 项目 Session 关闭了 autoflush;同一事务再次扫描前必须让版本、 + # 下次升级时间和唯一事件版本同时可见。 + self.db.flush() + return True + + +def _as_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) diff --git a/server/src/app/services/expense_claim_action_protocol.py b/server/src/app/services/expense_claim_action_protocol.py index 4a84c1c..5fabcb6 100644 --- a/server/src/app/services/expense_claim_action_protocol.py +++ b/server/src/app/services/expense_claim_action_protocol.py @@ -8,6 +8,7 @@ from sqlalchemy import select from app.api.deps import CurrentUserContext from app.models.approval_action import ApprovalActionLedger +from app.models.approval_task import ApprovalTask from app.models.financial_record import ExpenseClaim from app.schemas.reimbursement import ExpenseClaimRead from app.services.approval_action_protocol import ( @@ -34,6 +35,8 @@ class ExpenseClaimActionProtocolMixin: expected_approval_stage: str | None, payload: Mapping[str, Any], executor: ClaimActionExecutor, + task_id: str | None = None, + expected_task_version: int | None = None, ) -> ExpenseClaim | ExpenseClaimRead | None: protocol = ApprovalActionProtocol(self.db) tenant_id = ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id) @@ -55,6 +58,7 @@ class ExpenseClaimActionProtocolMixin: claim_loader=lambda: self._load_claim_for_action( claim_id, current_user, + task_id=task_id, ), ) if started.replayed: @@ -69,11 +73,48 @@ class ExpenseClaimActionProtocolMixin: if started.ledger is None: # pragma: no cover - defensive invariant raise RuntimeError("审批动作账本初始化失败。") + task_lifecycle = None + task_context = None + if action in {"approve", "return"}: + from app.services.approval_task_lifecycle import ( + ApprovalTaskLifecycleService, + ) + + task_lifecycle = ApprovalTaskLifecycleService(self.db) + task_context = task_lifecycle.begin_claim_decision( + started.claim, + current_user, + action=action, + task_id=task_id, + expected_task_version=expected_task_version, + ) + started.claim._approval_task_authorized = True + claim = executor( started.claim, started.ledger, normalized_request_id, ) + if task_lifecycle is not None and task_context is not None: + opinion = str( + payload.get("opinion") + or payload.get("reason") + or "" + ).strip() + task_lifecycle.complete_claim_decision( + task_context, + claim, + current_user, + action=action, + opinion=opinion, + request_id=normalized_request_id, + ledger=started.ledger, + business_event=getattr( + claim, + "_approval_task_business_event", + None, + ), + ) self.db.flush() self.db.refresh(claim) response = ExpenseClaimRead.model_validate( @@ -96,13 +137,31 @@ class ExpenseClaimActionProtocolMixin: self, claim_id: str, current_user: CurrentUserContext, + *, + task_id: str | None = None, ) -> ExpenseClaim | None: stmt = select(ExpenseClaim).where(ExpenseClaim.id == claim_id) - stmt = self._access_policy.apply_claim_scope( - stmt, - current_user, - include_approval_scope=True, - ) + if task_id: + tenant_id = ExpenseClaimTenantScopeMixin.normalize_tenant_id( + current_user.tenant_id + ) + task_claim_id = self.db.scalar( + select(ApprovalTask.claim_id).where( + ApprovalTask.tenant_id == tenant_id, + ApprovalTask.id == str(task_id).strip(), + ) + ) + if str(task_claim_id or "") != str(claim_id): + return None + stmt = stmt.where( + ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(tenant_id) + ) + else: + 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() diff --git a/server/src/app/services/expense_claim_approval_flow.py b/server/src/app/services/expense_claim_approval_flow.py index c261764..b34b070 100644 --- a/server/src/app/services/expense_claim_approval_flow.py +++ b/server/src/app/services/expense_claim_approval_flow.py @@ -36,6 +36,8 @@ class ExpenseClaimApprovalFlowMixin: request_id: str | None = None, expected_status: str | None = None, expected_approval_stage: str | None = None, + task_id: str | None = None, + expected_task_version: int | None = None, ): normalized_opinion = str(opinion or "").strip() return self._execute_claim_action( @@ -45,7 +47,11 @@ class ExpenseClaimApprovalFlowMixin: request_id=request_id, expected_status=expected_status, expected_approval_stage=expected_approval_stage, - payload={"opinion": normalized_opinion}, + payload={ + "opinion": normalized_opinion, + "task_id": str(task_id or "").strip() or None, + "expected_task_version": expected_task_version, + }, executor=lambda claim, ledger, normalized_request_id: self._approve_claim_once( claim, current_user, @@ -53,6 +59,8 @@ class ExpenseClaimApprovalFlowMixin: ledger=ledger, request_id=normalized_request_id, ), + task_id=task_id, + expected_task_version=expected_task_version, ) def _approve_claim_once( @@ -77,7 +85,9 @@ class ExpenseClaimApprovalFlowMixin: route_decision_flag: dict[str, Any] | None = None generated_draft = None if previous_stage == DIRECT_MANAGER_APPROVAL_STAGE: - if not self._access_policy.can_approve_claim(current_user, claim): + if not getattr(claim, "_approval_task_authorized", False) and 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" @@ -154,7 +164,9 @@ class ExpenseClaimApprovalFlowMixin: "{operator} 已审批通过,系统判断预算充足且无风险,流转至{next_stage}。" ) elif previous_stage == BUDGET_MANAGER_APPROVAL_STAGE: - if not self._access_policy.can_approve_claim(current_user, claim): + if not getattr(claim, "_approval_task_authorized", False) and 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" @@ -175,7 +187,9 @@ class ExpenseClaimApprovalFlowMixin: elif previous_stage == FINANCE_APPROVAL_STAGE: if is_application_claim: raise ValueError("费用申请需先完成预算管理者审批。") - if not self._access_policy.can_approve_claim(current_user, claim): + if not getattr(claim, "_approval_task_authorized", False) and 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" @@ -340,6 +354,7 @@ class ExpenseClaimApprovalFlowMixin: ), }, ) + claim._approval_task_business_event = _event if generated_draft is not None: self._expense_cases.record_claim_event( generated_draft, diff --git a/server/src/app/services/expense_claim_return_flow.py b/server/src/app/services/expense_claim_return_flow.py index f4cf8a1..2e55a95 100644 --- a/server/src/app/services/expense_claim_return_flow.py +++ b/server/src/app/services/expense_claim_return_flow.py @@ -21,6 +21,8 @@ class ExpenseClaimReturnFlowMixin: request_id: str | None = None, expected_status: str | None = None, expected_approval_stage: str | None = None, + task_id: str | None = None, + expected_task_version: int | None = None, ) -> ExpenseClaim | ExpenseClaimRead | None: normalized_reason = str(reason or "").strip() reason_code_payload = self._normalize_return_reason_code_payload(reason_codes) @@ -35,7 +37,12 @@ class ExpenseClaimReturnFlowMixin: request_id=request_id, expected_status=expected_status, expected_approval_stage=expected_approval_stage, - payload={"reason": normalized_reason, "reason_codes": normalized_codes}, + payload={ + "reason": normalized_reason, + "reason_codes": normalized_codes, + "task_id": str(task_id or "").strip() or None, + "expected_task_version": expected_task_version, + }, executor=lambda claim, ledger, normalized_request_id: self._return_claim_once( claim, current_user, @@ -44,6 +51,8 @@ class ExpenseClaimReturnFlowMixin: ledger=ledger, request_id=normalized_request_id, ), + task_id=task_id, + expected_task_version=expected_task_version, ) def _return_claim_once( @@ -64,7 +73,9 @@ class ExpenseClaimReturnFlowMixin: if normalized_status in {"approved", "completed", "paid"}: raise ValueError("已完成单据不允许退回。") - if not self._access_policy.can_return_claim(current_user, claim): + if not getattr(claim, "_approval_task_authorized", False) and not ( + self._access_policy.can_return_claim(current_user, claim) + ): raise ValueError("只有财务人员、高级财务人员或当前审批人可以退回报销单。") before_json = self._serialize_claim(claim) @@ -162,7 +173,7 @@ class ExpenseClaimReturnFlowMixin: business_stage=("expense_application" if is_application_claim else "reimbursement"), ) - self._expense_cases.record_claim_event( + _, return_event = self._expense_cases.record_claim_event( claim, event_type=("application_returned" if is_application_claim else "claim_returned"), actor_id=current_user.username, @@ -175,6 +186,7 @@ class ExpenseClaimReturnFlowMixin: "reason_codes": normalized_reason_codes, }, ) + claim._approval_task_business_event = return_event self.audit_service.log_action( actor=operator, action="expense_claim.return", diff --git a/server/src/app/services/expense_claim_risk_gate.py b/server/src/app/services/expense_claim_risk_gate.py index 903e8fa..ed62070 100644 --- a/server/src/app/services/expense_claim_risk_gate.py +++ b/server/src/app/services/expense_claim_risk_gate.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import UTC, datetime from sqlalchemy import select from sqlalchemy.orm import Session @@ -167,7 +168,18 @@ class ExpenseClaimRiskGate: if disposition is not None: adjudication = str(disposition.adjudication or "").strip().lower() lifecycle = str(disposition.lifecycle_status or "").strip().lower() + if lifecycle == "waived": + expires_at = _as_utc(disposition.waiver_expires_at) + return expires_at is not None and expires_at > datetime.now(UTC) 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" + + +def _as_utc(value: datetime | None) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) diff --git a/server/src/app/services/expense_claims.py b/server/src/app/services/expense_claims.py index f78ced4..99a3d29 100644 --- a/server/src/app/services/expense_claims.py +++ b/server/src/app/services/expense_claims.py @@ -384,6 +384,18 @@ class ExpenseClaimItemActionMixin: previous_status=str(before_json.get("status") or ""), previous_approval_stage=str(before_json.get("approval_stage") or ""), ) + from app.services.approval_task_lifecycle import ApprovalTaskLifecycleService + + ApprovalTaskLifecycleService(self.db).ensure_root_task( + claim, + tenant_id=current_user.tenant_id, + entered_at=submission_event.occurred_at, + entered_at_source="workflow_event", + business_event=submission_event, + actor_id="system", + actor_name="审批任务引擎", + request_id=f"node-enter:{submission_event.id}", + ) if before_commit is not None: before_commit(submission_event) diff --git a/server/src/app/services/risk_disposition_read_projection.py b/server/src/app/services/risk_disposition_read_projection.py new file mode 100644 index 0000000..abee762 --- /dev/null +++ b/server/src/app/services/risk_disposition_read_projection.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime + +from sqlalchemy.orm import Session + +from app.api.deps import CurrentUserContext +from app.models.risk_disposition import RiskDisposition +from app.models.risk_observation import RiskObservation +from app.schemas.risk_disposition import RiskDispositionRead +from app.schemas.risk_observation import RiskObservationRead +from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin +from app.services.risk_observation_access_policy import RiskObservationAccessPolicy +from app.services.risk_waiver_decision_policy import RiskWaiverDecisionPolicy + + +@dataclass(frozen=True, slots=True) +class RiskDispositionActionPermission: + available_actions: tuple[str, ...] + read_only_reason: str = "" + + +class RiskDispositionActionPolicy: + """根据当前用户与实时处置状态计算服务端权威动作集合。""" + + def __init__(self, db: Session) -> None: + self.db = db + self.access_policy = RiskObservationAccessPolicy(db) + self.waiver_policy = RiskWaiverDecisionPolicy(db) + + def evaluate( + self, + observation: RiskObservation, + disposition: RiskDisposition | RiskDispositionRead | None, + current_user: CurrentUserContext | None, + *, + now: datetime | None = None, + ) -> RiskDispositionActionPermission: + current_at = _as_utc(now) or datetime.now(UTC) + tenant_id = ExpenseClaimTenantScopeMixin.normalize_tenant_id(observation.tenant_id) + if current_user is None: + return RiskDispositionActionPermission((), "请先登录后再处理风险观察。") + if ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id) != tenant_id: + return RiskDispositionActionPermission((), "风险观察不在当前租户范围内。") + + adjudication = _adjudication(observation, disposition) + lifecycle = _lifecycle(observation, disposition) + can_manage = self.access_policy.can_manage_disposition(observation, current_user) + actions: list[str] = [] + if can_manage and lifecycle != "resolved": + if adjudication != "confirmed": + actions.append("confirm") + if adjudication != "false_positive": + actions.append("false_positive") + if lifecycle != "supplement_requested": + actions.append("request_supplement") + if adjudication == "confirmed": + if lifecycle != "remediation_in_progress": + actions.append("start_remediation") + if lifecycle != "waiver_requested" or not _text( + disposition.waiver_requester_id if disposition is not None else None + ): + actions.append("request_waiver") + actions.append("resolve") + + decision_reason = "" + if disposition is not None and lifecycle == "waiver_requested": + eligibility = self.waiver_policy.evaluate_decider( + disposition, + current_user=current_user, + tenant_id=tenant_id, + ) + decision_reason = eligibility.reason + if eligibility.allowed: + expires_at = _as_utc(disposition.waiver_expires_at) + if expires_at is not None and expires_at > current_at: + actions.append("approve_waiver") + actions.append("reject_waiver") + + available_actions = tuple(dict.fromkeys(actions)) + if available_actions: + return RiskDispositionActionPermission(available_actions) + if lifecycle == "resolved": + reason = "该风险已经解决,当前仅可查看审计记录。" + elif lifecycle == "waived" and _is_unexpired(disposition, now=current_at): + reason = "该风险处于有效豁免期,当前仅可查看审计记录。" + elif lifecycle == "waiver_requested" and decision_reason: + reason = decision_reason + elif not can_manage: + reason = "当前用户只有查看权限,无风险处置权限。" + else: + reason = "当前状态没有可执行的风险处置动作。" + return RiskDispositionActionPermission((), reason) + + +class RiskDispositionReadProjection: + """把同一权限投影注入风险列表、详情和处置详情响应。""" + + def __init__(self, db: Session) -> None: + self.policy = RiskDispositionActionPolicy(db) + + def observation_read( + self, + observation: RiskObservation, + current_user: CurrentUserContext, + ) -> RiskObservationRead: + disposition = observation.disposition + permission = self.policy.evaluate(observation, disposition, current_user) + disposition_read = ( + self._with_permission( + RiskDispositionRead.model_validate(disposition), + permission, + ) + if disposition is not None + else None + ) + return RiskObservationRead.model_validate(observation).model_copy( + update={ + "disposition": disposition_read, + "available_actions": list(permission.available_actions), + "read_only_reason": permission.read_only_reason, + } + ) + + def observation_list_read( + self, + observations: list[RiskObservation], + current_user: CurrentUserContext, + ) -> list[RiskObservationRead]: + return [self.observation_read(item, current_user) for item in observations] + + def disposition_read( + self, + disposition: RiskDisposition, + observation: RiskObservation, + current_user: CurrentUserContext, + ) -> RiskDispositionRead: + return self.disposition_snapshot_read( + RiskDispositionRead.model_validate(disposition), + observation, + current_user, + ) + + def disposition_snapshot_read( + self, + disposition: RiskDispositionRead, + observation: RiskObservation, + current_user: CurrentUserContext, + ) -> RiskDispositionRead: + """为幂等响应快照补权限字段,不改写快照中的历史业务状态。""" + + permission = self.policy.evaluate(observation, disposition, current_user) + return self._with_permission(disposition, permission) + + @staticmethod + def _with_permission( + disposition: RiskDispositionRead, + permission: RiskDispositionActionPermission, + ) -> RiskDispositionRead: + return disposition.model_copy( + update={ + "available_actions": list(permission.available_actions), + "read_only_reason": permission.read_only_reason, + } + ) + + +def _adjudication( + observation: RiskObservation, + disposition: RiskDisposition | RiskDispositionRead | None, +) -> str: + if disposition is not None: + return _text(disposition.adjudication).lower() or "unreviewed" + 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 _lifecycle( + observation: RiskObservation, + disposition: RiskDisposition | RiskDispositionRead | None, +) -> str: + if disposition is not None: + return _text(disposition.lifecycle_status).lower() or "open" + return "resolved" if _text(observation.status).lower() == "resolved" else "open" + + +def _is_unexpired( + disposition: RiskDisposition | RiskDispositionRead | None, + *, + now: datetime | None, +) -> bool: + expires_at = _as_utc(disposition.waiver_expires_at if disposition is not None else None) + current_at = _as_utc(now) or datetime.now(UTC) + return expires_at is not None and expires_at > current_at + + +def _as_utc(value: datetime | None) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) + + +def _text(value: object) -> str: + return str(value or "").strip() diff --git a/server/src/app/services/risk_dispositions.py b/server/src/app/services/risk_dispositions.py index 987f9ce..518a37a 100644 --- a/server/src/app/services/risk_dispositions.py +++ b/server/src/app/services/risk_dispositions.py @@ -24,12 +24,19 @@ from app.schemas.risk_disposition import ( RiskDispositionMutationRead, RiskDispositionRead, ) +from app.services.approval_task_projection_refresh import ( + ApprovalTaskProjectionRefreshService, +) from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin from app.services.risk_observation_access_policy import RiskObservationAccessPolicy +from app.services.risk_waiver_decision_policy import ( + RISK_WAIVER_DECISION_ACTIONS, + RiskWaiverDecisionPermissionError, + RiskWaiverDecisionPolicy, +) logger = get_logger("app.services.risk_dispositions") - class RiskDispositionConflictError(RuntimeError): pass @@ -145,12 +152,17 @@ class RiskDispositionService: 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, + is_waiver_decision = payload.action in RISK_WAIVER_DECISION_ACTIONS + if ( + not is_waiver_decision + and current_user is not None + and not RiskObservationAccessPolicy( + self.db + ).can_manage_locked_disposition( + observation, + current_user, + locked_claim=locked_claim, + ) ): raise RiskDispositionPermissionError("当前用户已不再是该单据的有效审批人。") @@ -180,9 +192,29 @@ class RiskDispositionService: ) self.db.add(disposition) - _validate_transition(disposition, payload) + waiver_policy = RiskWaiverDecisionPolicy(self.db) + waiver_actor_id, waiver_actor_name = waiver_policy.actor_identity( + current_user, + fallback_id=normalized_actor_id, + fallback_name=_text(actor_name) or normalized_actor_id, + ) + _validate_transition(disposition, payload, now=mutation_at) + if is_waiver_decision: + waiver_actor_id, waiver_actor_name = waiver_policy.require_decider( + disposition, + current_user=current_user, + tenant_id=normalized_tenant, + ) + before = _state(disposition) - _apply_action(disposition, observation, payload) + _apply_action( + disposition, + observation, + payload, + actor_id=waiver_actor_id, + actor_name=waiver_actor_name, + mutation_at=mutation_at, + ) disposition.version = current_version + 1 disposition.updated_at = mutation_at event = RiskDispositionEvent( @@ -192,11 +224,11 @@ class RiskDispositionService: 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", + actor_id=waiver_actor_id, + actor_name=waiver_actor_name, request_id=payload.request_id, payload_fingerprint=fingerprint, - comment=payload.comment, + comment=payload.comment or payload.waiver_reason, before_json=before, after_json=_state(disposition), created_at=mutation_at, @@ -216,6 +248,14 @@ class RiskDispositionService: payload, actor_name=actor_name, ) + # 风险事实与审批任务队列投影必须原子可见;autoflush 关闭时先显式 + # 落下 observation/disposition,再按最新风险状态重算开放任务。 + self.db.flush() + ApprovalTaskProjectionRefreshService(self.db).refresh_claim( + tenant_id=normalized_tenant, + claim_id=str(observation.claim_id or "").strip(), + now=mutation_at, + ) self.db.commit() self.db.refresh(disposition) self.db.refresh(event) @@ -226,7 +266,12 @@ class RiskDispositionService: response=response, legacy_feedback=legacy_feedback, ) - except (LookupError, RiskDispositionConflictError, RiskDispositionPermissionError): + except ( + LookupError, + RiskDispositionConflictError, + RiskDispositionPermissionError, + RiskWaiverDecisionPermissionError, + ): self.db.rollback() raise except IntegrityError as error: @@ -292,6 +337,18 @@ class RiskDispositionService: assignee=disposition.assignee, due_at=disposition.due_at, resolution=disposition.resolution, + waiver_requester_id=disposition.waiver_requester_id, + waiver_requester_name=disposition.waiver_requester_name, + waiver_requested_at=disposition.waiver_requested_at, + waiver_reason=disposition.waiver_reason, + waiver_scope=disposition.waiver_scope, + waiver_expires_at=disposition.waiver_expires_at, + waiver_conditions_json=list(disposition.waiver_conditions_json or []), + waiver_decision=disposition.waiver_decision, + waiver_decider_id=disposition.waiver_decider_id, + waiver_decider_name=disposition.waiver_decider_name, + waiver_decided_at=disposition.waiver_decided_at, + waiver_decision_reason=disposition.waiver_decision_reason, created_at=disposition.created_at, updated_at=disposition.updated_at, events=event_history, @@ -382,6 +439,18 @@ class RiskDispositionService: assignee=state.get("assignee"), due_at=state.get("due_at"), resolution=state.get("resolution"), + waiver_requester_id=state.get("waiver_requester_id"), + waiver_requester_name=state.get("waiver_requester_name"), + waiver_requested_at=state.get("waiver_requested_at"), + waiver_reason=state.get("waiver_reason"), + waiver_scope=state.get("waiver_scope"), + waiver_expires_at=state.get("waiver_expires_at"), + waiver_conditions_json=state.get("waiver_conditions_json") or [], + waiver_decision=state.get("waiver_decision"), + waiver_decider_id=state.get("waiver_decider_id"), + waiver_decider_name=state.get("waiver_decider_name"), + waiver_decided_at=state.get("waiver_decided_at"), + waiver_decision_reason=state.get("waiver_decision_reason"), created_at=history[0].created_at, updated_at=event.created_at, events=event_reads, @@ -468,6 +537,10 @@ def _apply_action( disposition: RiskDisposition, observation: RiskObservation, payload: RiskDispositionActionCreate, + *, + actor_id: str, + actor_name: str, + mutation_at: datetime, ) -> None: if payload.action == "confirm": disposition.adjudication = "confirmed" @@ -479,11 +552,48 @@ def _apply_action( observation.status = "false_positive" observation.feedback_status = "false_positive" return + if payload.action == "request_waiver": + disposition.lifecycle_status = "waiver_requested" + disposition.waiver_requester_id = actor_id + disposition.waiver_requester_name = actor_name + disposition.waiver_requested_at = mutation_at + disposition.waiver_reason = payload.waiver_reason + disposition.waiver_scope = payload.waiver_scope + disposition.waiver_expires_at = payload.waiver_expires_at + disposition.waiver_conditions_json = list(payload.waiver_conditions) + disposition.waiver_decision = None + disposition.waiver_decider_id = None + disposition.waiver_decider_name = None + disposition.waiver_decided_at = None + disposition.waiver_decision_reason = None + disposition.resolution = None + if payload.assignee is not None: + disposition.assignee = payload.assignee + if payload.due_at is not None: + disposition.due_at = payload.due_at + return + if payload.action == "approve_waiver": + disposition.lifecycle_status = "waived" + disposition.waiver_decision = "approved" + disposition.waiver_decider_id = actor_id + disposition.waiver_decider_name = actor_name + disposition.waiver_decided_at = mutation_at + disposition.waiver_decision_reason = payload.comment + observation.status = "waived" + return + if payload.action == "reject_waiver": + disposition.lifecycle_status = "waiver_rejected" + disposition.waiver_decision = "rejected" + disposition.waiver_decider_id = actor_id + disposition.waiver_decider_name = actor_name + disposition.waiver_decided_at = mutation_at + disposition.waiver_decision_reason = payload.comment + observation.status = "confirmed" + 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] @@ -501,7 +611,21 @@ def _apply_action( def _validate_transition( disposition: RiskDisposition, payload: RiskDispositionActionCreate, + *, + now: datetime, ) -> None: + if payload.action in RISK_WAIVER_DECISION_ACTIONS: + if disposition.lifecycle_status != "waiver_requested": + raise RiskDispositionConflictError("只有待决定的豁免申请才能批准或拒绝。") + if not _text(disposition.waiver_requester_id): + raise RiskDispositionConflictError( + "历史豁免申请缺少职责分离所需的申请人身份,请重新提交申请。" + ) + if payload.action == "approve_waiver": + expires_at = _as_utc(disposition.waiver_expires_at) + if expires_at is None or expires_at <= _as_utc(now): + raise RiskDispositionConflictError("风险豁免申请已过期,不能批准。") + return if disposition.lifecycle_status == "resolved": raise RiskDispositionConflictError("已解决的风险不能再次变更裁决或处置生命周期。") if payload.action == "confirm": @@ -519,7 +643,17 @@ def _validate_transition( "resolve": "resolved", }[payload.action] if disposition.lifecycle_status == target_lifecycle: - raise RiskDispositionConflictError("该风险已处于目标处置状态,请勿重复提交。") + if payload.action == "request_waiver" and not _text( + disposition.waiver_requester_id + ): + # 0014 前的旧申请没有申请人和有效期,允许以完整协议重新提交。 + pass + else: + raise RiskDispositionConflictError("该风险已处于目标处置状态,请勿重复提交。") + if payload.action == "request_waiver": + expires_at = _as_utc(payload.waiver_expires_at) + if expires_at is None or expires_at <= _as_utc(now): + raise RiskDispositionConflictError("风险豁免有效期必须晚于申请时间。") if payload.action == "request_supplement": return if disposition.adjudication != "confirmed": @@ -533,6 +667,30 @@ def _state(disposition: RiskDisposition) -> dict[str, object]: "assignee": disposition.assignee, "due_at": disposition.due_at.isoformat() if disposition.due_at else None, "resolution": disposition.resolution, + "waiver_requester_id": disposition.waiver_requester_id, + "waiver_requester_name": disposition.waiver_requester_name, + "waiver_requested_at": ( + disposition.waiver_requested_at.isoformat() + if disposition.waiver_requested_at + else None + ), + "waiver_reason": disposition.waiver_reason, + "waiver_scope": disposition.waiver_scope, + "waiver_expires_at": ( + disposition.waiver_expires_at.isoformat() + if disposition.waiver_expires_at + else None + ), + "waiver_conditions_json": list(disposition.waiver_conditions_json or []), + "waiver_decision": disposition.waiver_decision, + "waiver_decider_id": disposition.waiver_decider_id, + "waiver_decider_name": disposition.waiver_decider_name, + "waiver_decided_at": ( + disposition.waiver_decided_at.isoformat() + if disposition.waiver_decided_at + else None + ), + "waiver_decision_reason": disposition.waiver_decision_reason, "version": disposition.version, } @@ -581,3 +739,11 @@ def _tenant(value: str) -> str: def _text(value: object) -> str: return str(value or "").strip() + + +def _as_utc(value: datetime | None) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) diff --git a/server/src/app/services/risk_observations.py b/server/src/app/services/risk_observations.py index 8a7efa4..6b7891f 100644 --- a/server/src/app/services/risk_observations.py +++ b/server/src/app/services/risk_observations.py @@ -18,6 +18,9 @@ from app.schemas.risk_observation import ( RiskObservationDashboardRead, RiskObservationFeedbackCreate, ) +from app.services.approval_task_projection_refresh import ( + ApprovalTaskProjectionRefreshService, +) from app.services.expense_claim_risk_stage import normalize_risk_business_stage from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin @@ -50,13 +53,9 @@ class RiskObservationService: cache_key = str(getattr(bind, "url", "") or id(bind)) if cache_key in self._storage_ready_cache: return - Base.metadata.create_all( bind=bind, - tables=[ - RiskObservation.__table__, - RiskObservationFeedback.__table__, - ], + tables=[RiskObservation.__table__, RiskObservationFeedback.__table__], ) self._storage_ready_cache.add(cache_key) @@ -88,7 +87,6 @@ class RiskObservationService: claim_id, tenant_id=normalized_tenant_id, ) - item = self.db.scalar( select(RiskObservation).where( RiskObservation.tenant_id == normalized_tenant_id, @@ -101,7 +99,6 @@ class RiskObservationService: observation_key=observation_key, ) self.db.add(item) - item.subject_type = _text(payload.get("subject_type")) item.subject_key = _text(payload.get("subject_key")) item.subject_label = _text(payload.get("subject_label")) @@ -130,8 +127,12 @@ class RiskObservationService: item.similar_case_claim_ids_json = _list(payload.get("similar_case_claim_ids")) item.ontology_json = _risk_ontology_payload(payload) item.decision_trace_json = _risk_decision_trace_payload(payload) - self.db.flush() + if item.claim_id: + ApprovalTaskProjectionRefreshService(self.db).refresh_claim( + tenant_id=normalized_tenant_id, + claim_id=item.claim_id, + ) return item def lock_claim_for_risk_write( diff --git a/server/src/app/services/risk_waiver_decision_policy.py b/server/src/app/services/risk_waiver_decision_policy.py new file mode 100644 index 0000000..18de2e8 --- /dev/null +++ b/server/src/app/services/risk_waiver_decision_policy.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from app.api.deps import CurrentUserContext +from app.models.employee import Employee +from app.models.risk_disposition import RiskDisposition +from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy + +RISK_WAIVER_DECISION_ACTIONS = frozenset({"approve_waiver", "reject_waiver"}) + +_DECISION_ROLE_CODES = {"finance", "executive"} +_ACTIVE_EMPLOYMENT_STATUSES = {"active", "employed", "在职", "正常"} + + +class RiskWaiverDecisionPermissionError(RuntimeError): + pass + + +@dataclass(frozen=True, slots=True) +class RiskWaiverDecisionEligibility: + allowed: bool + actor_id: str = "" + actor_name: str = "" + reason: str = "" + + +class RiskWaiverDecisionPolicy: + """集中校验风险豁免决定人的租户、在职、角色和职责分离边界。""" + + def __init__(self, db: Session) -> None: + self.db = db + + def actor_identity( + self, + current_user: CurrentUserContext | None, + *, + fallback_id: str, + fallback_name: str, + ) -> tuple[str, str]: + employee = self._resolve_actor_employee(current_user) + if employee is None: + return ( + _text(fallback_id) or "anonymous", + _text(fallback_name) or _text(fallback_id) or "anonymous", + ) + return employee.id, _text(employee.name) or _text(fallback_name) or employee.id + + def require_decider( + self, + disposition: RiskDisposition, + *, + current_user: CurrentUserContext | None, + tenant_id: str, + ) -> tuple[str, str]: + eligibility = self.evaluate_decider( + disposition, + current_user=current_user, + tenant_id=tenant_id, + ) + if not eligibility.allowed: + raise RiskWaiverDecisionPermissionError(eligibility.reason) + return eligibility.actor_id, eligibility.actor_name + + def evaluate_decider( + self, + disposition: RiskDisposition, + *, + current_user: CurrentUserContext | None, + tenant_id: str, + ) -> RiskWaiverDecisionEligibility: + if current_user is None: + return RiskWaiverDecisionEligibility( + False, + reason="风险豁免决定必须由已登录处理人执行。", + ) + if _tenant(current_user.tenant_id) != tenant_id: + return RiskWaiverDecisionEligibility( + False, + reason="不能跨租户处理风险豁免决定。", + ) + + employee = self._resolve_actor_employee(current_user) + if employee is None: + return RiskWaiverDecisionEligibility( + False, + reason="风险豁免决定必须由企业在职员工执行。", + ) + employment_status = _text(employee.employment_status).lower() + if employment_status not in _ACTIVE_EMPLOYMENT_STATUSES: + return RiskWaiverDecisionEligibility( + False, + reason="当前处理人不是在职员工,不能决定风险豁免。", + ) + + role_codes = ExpenseClaimAccessPolicy.normalize_role_codes(current_user) + role_codes.update( + _text(role.role_code).lower() + for role in list(employee.roles or []) + if _text(role.role_code) + ) + if not role_codes & _DECISION_ROLE_CODES: + return RiskWaiverDecisionEligibility( + False, + reason="只有具有 finance 或 executive 明确角色的在职处理人可以决定风险豁免。", + ) + + requester_id = _text(disposition.waiver_requester_id) + if not requester_id: + return RiskWaiverDecisionEligibility( + False, + reason="历史豁免申请缺少申请人身份,不能执行豁免决定。", + ) + actor_aliases = { + employee.id.casefold(), + _text(employee.email).casefold(), + _text(employee.employee_no).casefold(), + _text(current_user.employee_id).casefold(), + _text(current_user.username).casefold(), + } + actor_aliases.discard("") + if requester_id.casefold() in actor_aliases: + return RiskWaiverDecisionEligibility( + False, + reason="风险豁免申请人不能批准或拒绝自己的申请。", + ) + return RiskWaiverDecisionEligibility( + True, + actor_id=employee.id, + actor_name=_text(employee.name) or employee.id, + ) + + def _resolve_actor_employee( + self, + current_user: CurrentUserContext | None, + ) -> Employee | None: + if current_user is None: + return None + employee_id = _text(current_user.employee_id) + if employee_id: + employee = self.db.get(Employee, employee_id) + if employee is not None: + return employee + return ExpenseClaimAccessPolicy(self.db).resolve_current_employee(current_user) + + +def _tenant(value: str) -> str: + return _text(value) or "default" + + +def _text(value: object) -> str: + return str(value or "").strip() diff --git a/server/tests/test_alembic_migrations.py b/server/tests/test_alembic_migrations.py index d989ce4..6a70b16 100644 --- a/server/tests/test_alembic_migrations.py +++ b/server/tests/test_alembic_migrations.py @@ -24,11 +24,13 @@ from app.db.migration_preflight import ( ) from app.db.schema_ownership import MIGRATION_OWNED_TABLES, create_legacy_schema from app.models.ai_memory import MemoryEntry +from app.models.approval_task import ApprovalTask, ApprovalTaskEvent +from app.models.risk_disposition import RiskDisposition, RiskDispositionEvent 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_0012" +HEAD_REVISION = "20260716_0014" SERVER_DIR = Path(__file__).resolve().parents[1] ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini" @@ -424,6 +426,11 @@ def _assert_head_schema(engine: Engine) -> None: "due_at", ), "ix_risk_dispositions_assignee": ("tenant_id", "assignee"), + "ix_risk_dispositions_tenant_waiver_expiry": ( + "tenant_id", + "lifecycle_status", + "waiver_expires_at", + ), }, ) _assert_check_constraint( @@ -441,6 +448,21 @@ def _assert_head_schema(engine: Engine) -> None: "risk_dispositions", "ck_risk_dispositions_version", ) + _assert_check_constraint( + engine, + "risk_dispositions", + "ck_risk_dispositions_waiver_request", + ) + _assert_check_constraint( + engine, + "risk_dispositions", + "ck_risk_dispositions_waiver_decision", + ) + _assert_check_constraint( + engine, + "risk_dispositions", + "ck_risk_dispositions_waiver_lifecycle", + ) _assert_unique_constraint( engine, "risk_disposition_events", @@ -495,6 +517,24 @@ def _assert_head_schema(engine: Engine) -> None: ) } assert "response_json" in risk_disposition_event_columns + risk_disposition_columns = { + str(item["name"]) + for item in inspect(engine).get_columns("risk_dispositions", schema="public") + } + assert { + "waiver_requester_id", + "waiver_requester_name", + "waiver_requested_at", + "waiver_reason", + "waiver_scope", + "waiver_expires_at", + "waiver_conditions_json", + "waiver_decision", + "waiver_decider_id", + "waiver_decider_name", + "waiver_decided_at", + "waiver_decision_reason", + }.issubset(risk_disposition_columns) with engine.connect() as connection: append_only_trigger_count = int( connection.scalar( @@ -509,6 +549,151 @@ def _assert_head_schema(engine: Engine) -> None: or 0 ) assert append_only_trigger_count == 1 + _assert_unique_constraint( + engine, + "approval_tasks", + "uq_approval_tasks_tenant_id", + ("tenant_id", "id"), + ) + _assert_unique_constraint( + engine, + "approval_tasks", + "uq_approval_tasks_tenant_node_entry", + ("tenant_id", "node_entry_key"), + ) + _assert_unique_constraint( + engine, + "approval_tasks", + "uq_approval_tasks_node_participant", + ("tenant_id", "node_instance_id", "assignee_kind", "assignee_key"), + ) + _assert_unique_constraint( + engine, + "approval_task_events", + "uq_approval_task_events_actor_request", + ("tenant_id", "actor_id", "request_id"), + ) + _assert_unique_constraint( + engine, + "approval_task_events", + "uq_approval_task_events_task_version", + ("tenant_id", "task_id", "result_task_version"), + ) + _assert_composite_foreign_key( + engine, + "approval_tasks", + ("tenant_id", "parent_task_id"), + "approval_tasks", + ) + _assert_composite_foreign_key( + engine, + "approval_tasks", + ("tenant_id", "expense_case_id"), + "expense_cases", + ) + _assert_composite_foreign_key( + engine, + "approval_task_events", + ("tenant_id", "task_id"), + "approval_tasks", + ) + _assert_no_foreign_key( + engine, + "approval_tasks", + ("claim_id",), + "expense_claims", + ) + _assert_indexes( + engine, + "approval_tasks", + { + "uq_approval_tasks_open_root_per_claim": ("tenant_id", "claim_id"), + "ix_approval_tasks_personal_inbox": ( + "tenant_id", + "assignee_kind", + "assignee_key", + "status", + "due_at", + ), + "ix_approval_tasks_tenant_queue": ( + "tenant_id", + "status", + "priority_score", + "due_at", + ), + "ix_approval_tasks_tenant_claim": ( + "tenant_id", + "claim_id", + "node_sequence", + ), + "ix_approval_tasks_tenant_node": ( + "tenant_id", + "node_instance_id", + "sequence_order", + ), + }, + ) + _assert_postgresql_index_predicate( + engine, + "approval_tasks", + "uq_approval_tasks_open_root_per_claim", + "task_kind", + "root", + "status", + "waiting", + "pending", + ) + _assert_indexes( + engine, + "approval_task_events", + { + "ix_approval_task_events_tenant_task_time": ( + "tenant_id", + "task_id", + "occurred_at", + ), + "ix_approval_task_events_tenant_node_time": ( + "tenant_id", + "node_instance_id", + "occurred_at", + ), + }, + ) + for constraint_name in ( + "ck_approval_tasks_task_kind", + "ck_approval_tasks_status", + "ck_approval_tasks_version", + "ck_approval_tasks_priority_score", + "ck_approval_tasks_evidence_completeness", + "ck_approval_task_events_type", + "ck_approval_task_events_actor_type", + "ck_approval_task_events_version", + ): + table_name = ( + "approval_task_events" + if constraint_name.startswith("ck_approval_task_events") + else "approval_tasks" + ) + _assert_check_constraint(engine, table_name, constraint_name) + approval_event_columns = { + str(item["name"]) + for item in inspect(engine).get_columns("approval_task_events", schema="public") + } + assert "response_json" in approval_event_columns + with engine.connect() as connection: + approval_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 = 'approval_task_events' " + "AND trigger.tgname = 'trg_approval_task_events_append_only' " + "AND NOT trigger.tgisinternal" + ) + ) + or 0 + ) + assert approval_append_only_trigger_count == 1 _assert_check_constraint( engine, "memory_entries", @@ -1335,6 +1520,10 @@ def _assert_base_schema(engine: Engine) -> None: ("20260716_0011_risk_disposition.py", "downgrade"), ("20260716_0012_risk_disposition_response_snapshot.py", "upgrade"), ("20260716_0012_risk_disposition_response_snapshot.py", "downgrade"), + ("20260716_0013_approval_tasks.py", "upgrade"), + ("20260716_0013_approval_tasks.py", "downgrade"), + ("20260716_0014_risk_waiver_decision.py", "upgrade"), + ("20260716_0014_risk_waiver_decision.py", "downgrade"), ], ) def test_postgresql_only_migrations_reject_other_dialects_before_mutation( @@ -1363,6 +1552,30 @@ def test_risk_disposition_snapshot_migration_refuses_lossy_downgrade() -> None: assert operation_guard.mutation_calls == [] +def test_approval_task_migration_refuses_non_empty_audit_chain_downgrade() -> None: + migration = _load_migration_module("20260716_0013_approval_tasks.py") + operation_guard = _UnsupportedDialectOperationGuard(dialect_name="postgresql") + operation_guard.bind.scalar = lambda _statement: 1 + migration.op = operation_guard + + with pytest.raises(RuntimeError, match="audit chain is not empty"): + migration.downgrade() + + assert operation_guard.mutation_calls == [] + + +def test_risk_waiver_migration_refuses_lossy_audit_downgrade() -> None: + migration = _load_migration_module("20260716_0014_risk_waiver_decision.py") + operation_guard = _UnsupportedDialectOperationGuard(dialect_name="postgresql") + operation_guard.bind.scalar = lambda _statement: 1 + migration.op = operation_guard + + with pytest.raises(RuntimeError, match="immutable waiver audit data exists"): + migration.downgrade() + + assert operation_guard.mutation_calls == [] + + def test_head_model_declares_soft_claim_reference_and_organization_only_active_index() -> None: claim_column = RiskObservation.__table__.c.claim_id assert not claim_column.foreign_keys @@ -1379,6 +1592,83 @@ def test_head_model_declares_soft_claim_reference_and_organization_only_active_i assert "scope_type IN ('department', 'enterprise')" in predicate +def test_approval_task_model_declares_tenant_safety_and_open_root_invariant() -> None: + claim_column = ApprovalTask.__table__.c.claim_id + assert not claim_column.foreign_keys + assert inspect(ApprovalTask).relationships["claim"].viewonly is True + + constraint_names = { + constraint.name for constraint in ApprovalTask.__table__.constraints + } + assert { + "uq_approval_tasks_tenant_id", + "uq_approval_tasks_tenant_node_entry", + "uq_approval_tasks_node_participant", + "fk_approval_tasks_tenant_parent", + "ck_approval_tasks_status", + "ck_approval_tasks_version", + "ck_approval_tasks_priority_score", + "ck_approval_tasks_evidence_completeness", + }.issubset(constraint_names) + + open_root_index = next( + index + for index in ApprovalTask.__table__.indexes + if index.name == "uq_approval_tasks_open_root_per_claim" + ) + assert open_root_index.unique is True + predicate = str(open_root_index.dialect_options["postgresql"]["where"]) + assert "task_kind = 'root'" in predicate + assert "status IN ('waiting', 'pending')" in predicate + + event_constraint_names = { + constraint.name for constraint in ApprovalTaskEvent.__table__.constraints + } + assert { + "uq_approval_task_events_actor_request", + "uq_approval_task_events_task_version", + "fk_approval_task_events_tenant_task", + "ck_approval_task_events_version", + }.issubset(event_constraint_names) + assert ApprovalTaskEvent.__table__.c.response_json.nullable is False + + +def test_risk_waiver_model_declares_decision_metadata_constraints() -> None: + constraint_names = { + constraint.name for constraint in RiskDisposition.__table__.constraints + } + assert { + "ck_risk_dispositions_waiver_request", + "ck_risk_dispositions_waiver_decision", + "ck_risk_dispositions_waiver_lifecycle", + }.issubset(constraint_names) + lifecycle_constraint = next( + constraint + for constraint in RiskDisposition.__table__.constraints + if constraint.name == "ck_risk_dispositions_lifecycle" + ) + assert "waived" in str(lifecycle_constraint.sqltext) + assert "waiver_rejected" in str(lifecycle_constraint.sqltext) + + waiver_index = next( + index + for index in RiskDisposition.__table__.indexes + if index.name == "ix_risk_dispositions_tenant_waiver_expiry" + ) + assert tuple(column.name for column in waiver_index.columns) == ( + "tenant_id", + "lifecycle_status", + "waiver_expires_at", + ) + event_action_constraint = next( + constraint + for constraint in RiskDispositionEvent.__table__.constraints + if constraint.name == "ck_risk_disposition_events_action" + ) + assert "approve_waiver" in str(event_action_constraint.sqltext) + assert "reject_waiver" in str(event_action_constraint.sqltext) + + @pytest.mark.parametrize( "database_url", [ diff --git a/server/tests/test_approval_task_actions.py b/server/tests/test_approval_task_actions.py new file mode 100644 index 0000000..7ec1948 --- /dev/null +++ b/server/tests/test_approval_task_actions.py @@ -0,0 +1,436 @@ +from __future__ import annotations + +from datetime import UTC, date, datetime, timedelta +from decimal import Decimal + +import pytest +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.api.deps import CurrentUserContext +from app.models.approval_task import ApprovalTask, ApprovalTaskEvent +from app.models.employee import Employee +from app.models.financial_record import ExpenseClaim, ExpenseClaimItem +from app.models.role import Role +from app.schemas.approval_task import ( + ApprovalTaskAssignmentAction, + ApprovalTaskDecisionAction, + ApprovalTaskParticipantsAction, +) +from app.schemas.reimbursement import ExpenseClaimRead +from app.services.approval_task_actions import ApprovalTaskActionService +from app.services.approval_task_lifecycle import ApprovalTaskLifecycleService +from app.services.approval_task_protocol import ApprovalTaskVersionConflictError +from app.services.approval_task_query import ApprovalTaskQueryService +from app.services.approval_task_sla import ApprovalTaskSlaService +from app.services.expense_cases import ExpenseCaseService +from app.services.expense_claims import ExpenseClaimService +from app.test_helpers.db import build_in_memory_session_factory + + +def _user( + employee: Employee, + *, + tenant_id: str = "tenant-approval-task", + roles: list[str] | None = None, + is_admin: bool = False, +) -> CurrentUserContext: + return CurrentUserContext( + username=employee.email, + name=employee.name, + role_codes=list(roles or []), + is_admin=is_admin, + tenant_id=tenant_id, + employee_id=employee.id, + employee_no=employee.employee_no, + ) + + +def _seed_root_task( + db: Session, + *, + tenant_id: str = "tenant-approval-task", + extra_managers: int = 1, +) -> tuple[ExpenseClaim, ApprovalTask, Employee, list[Employee]]: + manager_role = Role( + id="role-task-manager", + role_code="manager", + name="任务审批经理", + ) + manager = Employee( + id="employee-task-manager", + employee_no="M-TASK-001", + name="李经理", + email="manager-task@example.com", + roles=[manager_role], + ) + participants = [ + Employee( + id=f"employee-task-participant-{index}", + employee_no=f"M-TASK-{index + 1:03d}", + name=f"加签经理{index}", + email=f"participant-task-{index}@example.com", + roles=[manager_role], + ) + for index in range(1, extra_managers + 1) + ] + employee = Employee( + id="employee-task-owner", + employee_no="E-TASK-001", + name="张三", + email="owner-task@example.com", + manager=manager, + ) + occurred_at = datetime.now(UTC) - timedelta(hours=1) + claim = ExpenseClaim( + id="claim-task-001", + claim_no="RE-TASK-001", + employee=employee, + employee_name=employee.name, + department_name="市场部", + expense_type="transport", + reason="客户现场差旅", + location="上海", + amount=Decimal("88.00"), + currency="CNY", + invoice_count=1, + occurred_at=occurred_at, + submitted_at=occurred_at, + status="submitted", + approval_stage="直属领导审批", + risk_flags_json=[], + created_at=occurred_at, + updated_at=occurred_at, + ) + claim.items = [ + ExpenseClaimItem( + id="claim-task-item-001", + claim_id=claim.id, + item_date=date.today(), + item_type="transport", + item_reason="客户现场交通", + item_location="上海", + item_note="", + item_amount=Decimal("88.00"), + invoice_id="INV-TASK-001", + ) + ] + db.add_all([claim, *participants]) + db.flush() + _, event = ExpenseCaseService(db).record_claim_event( + claim, + event_type="claim_submitted", + actor_id=employee.email, + tenant_id=tenant_id, + idempotency_key="claim-task-submitted-001", + previous_status="draft", + previous_approval_stage="待提交", + ) + task = ApprovalTaskLifecycleService(db).ensure_root_task( + claim, + tenant_id=tenant_id, + entered_at=event.occurred_at, + entered_at_source="workflow_event", + business_event=event, + request_id=f"node-enter:{event.id}", + ) + assert task is not None + db.commit() + return claim, task, manager, participants + + +def test_queue_is_tenant_safe_and_admin_has_no_implicit_approval() -> None: + factory = build_in_memory_session_factory() + with factory() as db: + _claim, task, manager, _participants = _seed_root_task(db) + manager_queue = ApprovalTaskQueryService(db).list_tasks( + _user(manager, roles=["manager"]), + ) + + assert manager_queue.total == 1 + assert manager_queue.items[0].task.id == task.id + assert manager_queue.items[0].task.can_act is True + assert "approve" in manager_queue.items[0].task.available_actions + + admin = Employee( + id="employee-task-admin", + employee_no="A-TASK-001", + name="平台管理员", + email="admin-task@example.com", + ) + db.add(admin) + db.commit() + admin_queue = ApprovalTaskQueryService(db).list_tasks( + _user(admin, is_admin=True), + ) + assert admin_queue.total == 1 + assert admin_queue.items[0].task.can_act is False + assert "approve" not in admin_queue.items[0].task.available_actions + assert set(admin_queue.items[0].task.available_actions) >= { + "transfer", + "sla_escalate", + } + + other_tenant_queue = ApprovalTaskQueryService(db).list_tasks( + _user(manager, tenant_id="tenant-other", roles=["manager"]), + ) + assert other_tenant_queue.total == 0 + + +def test_delegated_assignee_can_approve_claim_and_replay_is_immutable() -> None: + factory = build_in_memory_session_factory() + with factory() as db: + claim, task, manager, participants = _seed_root_task(db) + delegate = participants[0] + action_service = ApprovalTaskActionService(db) + payload = ApprovalTaskAssignmentAction( + request_id="delegate-task-001", + expected_task_version=1, + reason="出差期间委托处理", + target_employee_id=delegate.id, + expires_at=datetime.now(UTC) + timedelta(days=1), + ) + + first = action_service.assign( + task.id, + _user(manager, roles=["manager"]), + action="delegate", + payload=payload, + ) + replay = action_service.assign( + task.id, + _user(manager, roles=["manager"]), + action="delegate", + payload=payload, + ) + + assert first.task.version == 2 + assert first.task.assignee_employee_id == delegate.id + assert replay.replayed is True + assert replay.task.model_dump(mode="json") == first.task.model_dump(mode="json") + + approved = ExpenseClaimService(db).approve_claim( + claim.id, + _user(delegate, roles=["manager"]), + opinion="已核对材料,同意", + request_id="approve-delegated-task-001", + expected_status="submitted", + expected_approval_stage="直属领导审批", + task_id=task.id, + expected_task_version=2, + ) + action_replay = ExpenseClaimService(db).approve_claim( + claim.id, + _user(delegate, roles=["manager"]), + opinion="已核对材料,同意", + request_id="approve-delegated-task-001", + expected_status="submitted", + expected_approval_stage="直属领导审批", + task_id=task.id, + expected_task_version=2, + ) + + assert approved is not None and action_replay is not None + assert ExpenseClaimRead.model_validate(approved).model_dump(mode="json") == ( + ExpenseClaimRead.model_validate(action_replay).model_dump(mode="json") + ) + persisted = db.get(ApprovalTask, task.id) + assert persisted is not None + assert persisted.status == "completed" + assert persisted.version == 3 + next_root = db.scalar( + select(ApprovalTask).where( + ApprovalTask.claim_id == claim.id, + ApprovalTask.task_kind == "root", + ApprovalTask.status == "pending", + ) + ) + assert next_root is not None + assert next_root.node_key == "finance" + assert ( + db.scalar( + select(func.count()) + .select_from(ApprovalTaskEvent) + .where( + ApprovalTaskEvent.task_id == task.id, + ApprovalTaskEvent.event_type == "task_approved", + ) + ) + == 1 + ) + + +def test_sequential_add_sign_reactivates_root_after_child_approval() -> None: + factory = build_in_memory_session_factory() + with factory() as db: + claim, root, manager, participants = _seed_root_task(db) + participant = participants[0] + service = ApprovalTaskActionService(db) + started = service.add_participants( + root.id, + _user(manager, roles=["manager"]), + action="add_sign", + payload=ApprovalTaskParticipantsAction( + request_id="add-sign-task-001", + expected_task_version=1, + reason="需要业务负责人补充复核", + participant_employee_ids=[participant.id], + ), + ) + + assert started.task.status == "waiting" + assert len(started.related_tasks) == 1 + child = started.related_tasks[0] + assert child.status == "pending" + completed = service.approve_child( + child.id, + _user(participant, roles=["manager"]), + ApprovalTaskDecisionAction( + request_id="approve-add-sign-task-001", + expected_task_version=1, + reason="补充复核完成", + opinion="材料真实完整", + ), + ) + + assert completed.task.status == "completed" + refreshed_root = db.get(ApprovalTask, root.id) + assert refreshed_root is not None + assert refreshed_root.status == "pending" + assert refreshed_root.coordination_mode == "single" + assert refreshed_root.version == 3 + assert refreshed_root.claim_stage_snapshot == claim.approval_stage + + +def test_parallel_countersign_waits_for_every_participant() -> None: + factory = build_in_memory_session_factory() + with factory() as db: + _claim, root, manager, participants = _seed_root_task(db, extra_managers=2) + service = ApprovalTaskActionService(db) + started = service.add_participants( + root.id, + _user(manager, roles=["manager"]), + action="countersign", + payload=ApprovalTaskParticipantsAction( + request_id="countersign-task-001", + expected_task_version=1, + reason="需要双人并行复核", + participant_employee_ids=[item.id for item in participants], + ), + ) + children = started.related_tasks + assert len(children) == 2 + assert {item.status for item in children} == {"pending"} + + service.approve_child( + children[0].id, + _user(participants[0], roles=["manager"]), + ApprovalTaskDecisionAction( + request_id="approve-countersign-task-001", + expected_task_version=1, + reason="第一位复核完成", + ), + ) + db.refresh(root) + assert root.status == "waiting" + + service.approve_child( + children[1].id, + _user(participants[1], roles=["manager"]), + ApprovalTaskDecisionAction( + request_id="approve-countersign-task-002", + expected_task_version=1, + reason="第二位复核完成", + ), + ) + db.refresh(root) + assert root.status == "pending" + assert root.version == 3 + + +def test_participant_return_cancels_open_node_family_and_returns_claim() -> None: + factory = build_in_memory_session_factory() + with factory() as db: + claim, root, manager, participants = _seed_root_task(db, extra_managers=2) + started = ApprovalTaskActionService(db).add_participants( + root.id, + _user(manager, roles=["manager"]), + action="countersign", + payload=ApprovalTaskParticipantsAction( + request_id="countersign-return-task-001", + expected_task_version=1, + reason="并行复核后再决定", + participant_employee_ids=[item.id for item in participants], + ), + ) + returned_child, sibling = started.related_tasks + + returned = ExpenseClaimService(db).return_claim( + claim.id, + _user(participants[0], roles=["manager"]), + reason="材料与实际行程不一致", + request_id="return-countersign-task-001", + expected_status="submitted", + expected_approval_stage="直属领导审批", + task_id=returned_child.id, + expected_task_version=1, + ) + + assert returned is not None + assert returned.status == "returned" + assert db.get(ApprovalTask, returned_child.id).status == "returned" + assert db.get(ApprovalTask, sibling.id).status == "cancelled" + assert db.get(ApprovalTask, root.id).status == "cancelled" + + +def test_task_action_rejects_stale_version() -> None: + factory = build_in_memory_session_factory() + with factory() as db: + _claim, task, manager, participants = _seed_root_task(db) + with pytest.raises(ApprovalTaskVersionConflictError) as captured: + ApprovalTaskActionService(db).assign( + task.id, + _user(manager, roles=["manager"]), + action="transfer", + payload=ApprovalTaskAssignmentAction( + request_id="transfer-task-stale-001", + expected_task_version=99, + reason="测试过期版本保护", + target_employee_id=participants[0].id, + ), + ) + + assert captured.value.current_version == 1 + + +def test_sla_scheduler_escalates_due_task_once_per_window() -> None: + factory = build_in_memory_session_factory() + with factory() as db: + _claim, task, _manager, _participants = _seed_root_task(db) + now = datetime.now(UTC) + task.next_escalation_at = now - timedelta(minutes=1) + db.commit() + + service = ApprovalTaskSlaService(db) + first = service.escalate_due_tasks(now=now) + repeated = service.escalate_due_tasks(now=now) + db.commit() + + assert first == [task.id] + assert repeated == [] + db.refresh(task) + assert task.escalation_level == 1 + assert task.version == 2 + persisted_next = task.next_escalation_at + assert persisted_next is not None + if persisted_next.tzinfo is None: + persisted_next = persisted_next.replace(tzinfo=UTC) + assert persisted_next == now + timedelta(hours=4) + event = db.scalar( + select(ApprovalTaskEvent).where( + ApprovalTaskEvent.task_id == task.id, + ApprovalTaskEvent.event_type == "sla_escalated", + ) + ) + assert event is not None + assert event.response_json["task"]["escalation_level"] == 1 diff --git a/server/tests/test_approval_task_backfill.py b/server/tests/test_approval_task_backfill.py new file mode 100644 index 0000000..92438d7 --- /dev/null +++ b/server/tests/test_approval_task_backfill.py @@ -0,0 +1,409 @@ +from __future__ import annotations + +import argparse +import importlib.util +from datetime import UTC, date, datetime, timedelta +from decimal import Decimal +from pathlib import Path + +import pytest +from sqlalchemy import create_engine, func, select +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.db.base import Base +from app.models.approval_task import ApprovalTask, ApprovalTaskEvent +from app.models.employee import Employee +from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink +from app.models.financial_record import ExpenseClaim, ExpenseClaimItem +from app.schemas.approval_task import ApprovalTaskMutationRead +from app.services.approval_task_backfill import ( + ApprovalTaskBackfillDisposition, + ApprovalTaskBackfillPreview, + ApprovalTaskBackfillService, +) +from app.services.approval_task_protocol import ApprovalTaskEventProtocol +from app.services.expense_claim_workflow_constants import ( + DIRECT_MANAGER_APPROVAL_STAGE, + FINANCE_APPROVAL_STAGE, +) + + +def _load_cli_module(): + path = Path(__file__).resolve().parents[1] / "scripts" / "backfill_approval_tasks.py" + spec = importlib.util.spec_from_file_location("backfill_approval_tasks_cli", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +CLI = _load_cli_module() + + +def _session() -> Session: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + return sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)() + + +def _persist_claim( + db: Session, + *, + suffix: str, + tenant_id: str, + stage: str = DIRECT_MANAGER_APPROVAL_STAGE, + status: str = "submitted", + with_manager: bool = True, + with_event: bool = True, +) -> tuple[ExpenseClaim, Employee | None, BusinessEvent | None]: + submitted_at = datetime(2026, 7, 14, 10, 0, tzinfo=UTC) + manager = ( + Employee( + id=f"manager-{suffix}", + employee_no=f"M-{suffix}", + name=f"经理{suffix}", + email=f"manager-{suffix}@example.com", + ) + if with_manager + else None + ) + employee = Employee( + id=f"employee-{suffix}", + employee_no=f"E-{suffix}", + name=f"员工{suffix}", + email=f"employee-{suffix}@example.com", + manager=manager, + ) + claim = ExpenseClaim( + id=f"claim-{suffix}", + claim_no=f"RE-BACKFILL-{suffix}", + employee=employee, + employee_name=employee.name, + department_name="市场部", + project_code="PRJ-BACKFILL", + expense_type="travel", + reason="客户现场差旅", + location="上海", + amount=Decimal("888.00"), + currency="CNY", + invoice_count=1, + occurred_at=submitted_at - timedelta(days=1), + submitted_at=submitted_at, + status=status, + approval_stage=stage, + risk_flags_json=[], + created_at=submitted_at - timedelta(hours=2), + updated_at=submitted_at, + ) + claim.items = [ + ExpenseClaimItem( + id=f"item-{suffix}", + claim_id=claim.id, + item_date=date(2026, 7, 14), + item_type="transport", + item_reason="客户现场交通", + item_location="上海", + item_note="", + item_amount=Decimal("888.00"), + invoice_id=f"invoice-{suffix}", + created_at=submitted_at, + updated_at=submitted_at, + ) + ] + expense_case = ExpenseCase( + id=f"case-{suffix}", + tenant_id=tenant_id, + case_no=f"CASE-RE-BACKFILL-{suffix}", + scene_code="travel", + title="客户现场差旅", + owner_employee_id=employee.id, + current_stage="reviewing", + status="active", + created_at=submitted_at - timedelta(hours=2), + updated_at=submitted_at, + ) + link = ExpenseCaseLink( + id=f"link-{suffix}", + tenant_id=tenant_id, + expense_case_id=expense_case.id, + resource_type="expense_claim", + resource_id=claim.id, + relation_type="claim", + created_at=submitted_at, + ) + event = None + if with_event: + event = BusinessEvent( + id=f"event-{suffix}", + tenant_id=tenant_id, + expense_case_id=expense_case.id, + aggregate_type="expense_claim", + aggregate_id=claim.id, + event_type="claim_submitted", + event_version=1, + idempotency_key=f"submit-{suffix}", + correlation_id=f"correlation-{suffix}", + actor_id=employee.id, + actor_type="user", + payload_json={ + "next_status": "submitted", + "next_approval_stage": stage, + }, + delivery_status="published", + occurred_at=submitted_at + timedelta(hours=1), + ) + db.add_all( + [ + *([manager] if manager is not None else []), + employee, + claim, + expense_case, + link, + *([event] if event is not None else []), + ] + ) + db.commit() + return claim, manager, event + + +def test_preview_is_default_read_only_tenant_scoped_and_prefers_business_event() -> None: + with _session() as db: + claim, manager, event = _persist_claim(db, suffix="A", tenant_id="tenant-a") + _persist_claim(db, suffix="B", tenant_id="tenant-b") + assert manager is not None and event is not None + service = ApprovalTaskBackfillService(db, tenant_id="tenant-a") + + result = service.run() + + assert isinstance(result, ApprovalTaskBackfillPreview) + assert result.inspected == 1 + assert result.eligible == 1 + assert result.existing == 0 + assert result.skipped == 0 + assert result.items[0].claim_id == claim.id + assert result.items[0].entered_at_source == "workflow_event" + assert result.items[0].entered_at == event.occurred_at + assert result.items[0].assignee_key == manager.id + assert db.scalar(select(func.count()).select_from(ApprovalTask)) == 0 + assert db.scalar(select(func.count()).select_from(ApprovalTaskEvent)) == 0 + + +def test_apply_creates_audited_projected_root_task_and_is_idempotent() -> None: + applied_at = datetime(2026, 7, 16, 12, 0, tzinfo=UTC) + with _session() as db: + claim, manager, source_event = _persist_claim( + db, + suffix="APPLY", + tenant_id="tenant-a", + ) + assert manager is not None and source_event is not None + service = ApprovalTaskBackfillService(db, tenant_id="tenant-a") + + first = service.apply_batch( + run_id="approval-task-backfill-run-1", + backfilled_at=applied_at, + ) + repeated = service.apply_batch( + run_id="approval-task-backfill-run-1", + backfilled_at=applied_at, + ) + + task = db.scalar(select(ApprovalTask).where(ApprovalTask.claim_id == claim.id)) + event = db.scalar(select(ApprovalTaskEvent).where(ApprovalTaskEvent.task_id == task.id)) + assert first.created == 1 + assert repeated.created == 0 + assert repeated.existing == 1 + assert task.task_kind == "root" + assert task.status == "pending" + assert task.tenant_id == "tenant-a" + assert task.expense_case_id == "case-APPLY" + assert task.node_key == "direct_manager" + assert task.assignee_employee_id == manager.id + assert task.entered_at_source == "workflow_event" + assert task.due_at > task.entered_at + assert task.projection_updated_at.replace(tzinfo=UTC) == applied_at + assert task.risk_level == "low" + assert task.evidence_completeness == Decimal("1.0000") + assert task.batch_eligible is False + assert "sla_overdue" in task.batch_block_reasons_json + assert event.event_type == "node_entered_backfilled" + assert event.expected_task_version == 0 + assert event.result_task_version == 1 + assert event.business_event_id == source_event.id + assert event.payload_json["history_reconstructed"] is False + assert event.payload_json["current_node_reconstructed"] is True + assert event.response_json["task"]["id"] == task.id + snapshot = ApprovalTaskMutationRead.model_validate(event.response_json) + replay = ApprovalTaskEventProtocol(db).find_replay( + tenant_id="tenant-a", + actor_id="approval-task-backfill", + request_id=event.request_id, + fingerprint=event.payload_fingerprint, + ) + assert snapshot.event.id == event.id + assert replay is not None and replay.replayed is True + assert replay.task.id == task.id + assert db.scalar(select(func.count()).select_from(ApprovalTask)) == 1 + assert db.scalar(select(func.count()).select_from(ApprovalTaskEvent)) == 1 + + +def test_unresolved_direct_manager_is_fail_closed_but_finance_role_is_safe_fallback() -> None: + with _session() as db: + unresolved, _manager, _event = _persist_claim( + db, + suffix="NO-MANAGER", + tenant_id="tenant-a", + with_manager=False, + ) + inactive, inactive_manager, _event = _persist_claim( + db, + suffix="INACTIVE-MANAGER", + tenant_id="tenant-a", + ) + assert inactive_manager is not None + inactive_manager.employment_status = "离职" + db.commit() + finance, _manager, _event = _persist_claim( + db, + suffix="FINANCE", + tenant_id="tenant-a", + stage=FINANCE_APPROVAL_STAGE, + with_manager=False, + ) + service = ApprovalTaskBackfillService(db, tenant_id="tenant-a") + + preview = service.preview() + + by_claim = {item.claim_id: item for item in preview.items} + assert by_claim[unresolved.id].disposition is ( + ApprovalTaskBackfillDisposition.ASSIGNEE_UNRESOLVED + ) + assert by_claim[inactive.id].disposition is ( + ApprovalTaskBackfillDisposition.ASSIGNEE_UNRESOLVED + ) + assert by_claim[finance.id].disposition is ApprovalTaskBackfillDisposition.ELIGIBLE + assert by_claim[finance.id].assignee_key == "finance" + assert preview.eligible == 1 + assert preview.skipped == 2 + + +def test_legacy_in_approval_state_is_canonicalized_without_mutating_claim() -> None: + with _session() as db: + claim, manager, _event = _persist_claim( + db, + suffix="LEGACY", + tenant_id="tenant-a", + status="in_approval", + stage="", + with_event=False, + ) + assert manager is not None + service = ApprovalTaskBackfillService(db, tenant_id="tenant-a") + + preview = service.preview() + applied = service.apply_batch( + run_id="approval-task-backfill-legacy", + backfilled_at=datetime(2026, 7, 16, 12, 0, tzinfo=UTC), + ) + task = db.scalar(select(ApprovalTask).where(ApprovalTask.claim_id == claim.id)) + + assert preview.eligible == 1 + assert preview.items[0].entered_at_source == "submitted_at" + assert applied.created == 1 + assert task.claim_status_snapshot == "submitted" + assert task.claim_stage_snapshot == DIRECT_MANAGER_APPROVAL_STAGE + assert task.assignee_employee_id == manager.id + assert claim.status == "in_approval" + assert claim.approval_stage == "" + + +def test_entered_at_fallback_order_is_explicit_and_deterministic() -> None: + submitted_at = datetime(2026, 7, 14, 10, 0, tzinfo=UTC) + claim = ExpenseClaim( + id="claim-time-fallback", + claim_no="RE-TIME-FALLBACK", + employee_name="张三", + department_name="市场部", + expense_type="travel", + reason="差旅", + location="上海", + amount=Decimal("100"), + occurred_at=submitted_at - timedelta(days=1), + submitted_at=submitted_at, + status="submitted", + approval_stage=DIRECT_MANAGER_APPROVAL_STAGE, + created_at=submitted_at - timedelta(hours=2), + updated_at=submitted_at - timedelta(hours=1), + ) + with _session() as db: + service = ApprovalTaskBackfillService(db, tenant_id="tenant-a") + entered_at, source, _event = service.derive_entered_at( + claim, + events=[], + approval_stage=DIRECT_MANAGER_APPROVAL_STAGE, + ) + assert (entered_at, source) == (submitted_at, "submitted_at") + + claim.submitted_at = None + entered_at, source, _event = service.derive_entered_at( + claim, + events=[], + approval_stage=DIRECT_MANAGER_APPROVAL_STAGE, + ) + assert (entered_at, source) == (claim.updated_at, "updated_at") + + claim.updated_at = None + entered_at, source, _event = service.derive_entered_at( + claim, + events=[], + approval_stage=DIRECT_MANAGER_APPROVAL_STAGE, + ) + assert (entered_at, source) == (claim.created_at, "backfill") + + +def test_backfill_cli_defaults_to_dry_run_and_requires_exact_apply_confirmation( + monkeypatch, +) -> None: + args = CLI.build_parser().parse_args( + [ + "--tenant-id", + "tenant-a", + "--created-before", + "2026-07-16T00:00:00+08:00", + "--expected-host", + "migration-probe", + "--expected-database", + "migration_probe", + ] + ) + assert args.apply is False + assert args.created_before == datetime(2026, 7, 15, 16, 0, tzinfo=UTC) + + apply_args = CLI.build_parser().parse_args( + [ + "--apply", + "--tenant-id", + "tenant-a", + "--created-before", + "2026-07-16T00:00:00Z", + "--expected-host", + "migration-probe", + "--expected-database", + "migration_probe", + ] + ) + monkeypatch.delenv("DATABASE_URL", raising=False) + with pytest.raises(CLI.BackfillCommandError) as exc_info: + CLI.run(apply_args) + assert exc_info.value.code == "confirm_target_required" + + +@pytest.mark.parametrize("value", ["", "2026-07-16T00:00:00", "not-a-time"]) +def test_backfill_cli_rejects_timestamp_without_explicit_timezone(value: str) -> None: + with pytest.raises(argparse.ArgumentTypeError): + CLI.parse_timestamp(value) diff --git a/server/tests/test_approval_task_concurrency_postgres.py b/server/tests/test_approval_task_concurrency_postgres.py new file mode 100644 index 0000000..f3f85e8 --- /dev/null +++ b/server/tests/test_approval_task_concurrency_postgres.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +import os +import uuid +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime, timedelta +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_task import ApprovalTask, ApprovalTaskEvent +from app.models.employee import Employee +from app.models.financial_record import ExpenseClaim +from app.models.role import Role +from app.schemas.approval_task import ApprovalTaskAssignmentAction +from app.services.approval_task_actions import ApprovalTaskActionService +from app.services.approval_task_lifecycle import ApprovalTaskLifecycleService +from app.services.approval_task_protocol import ApprovalTaskVersionConflictError +from app.services.expense_cases import ExpenseCaseService + +DATABASE_URL = os.environ.get("MIGRATION_TEST_DATABASE_URL", "").strip() +TENANT_ID = "tenant-approval-task-concurrency" + + +def test_concurrent_identical_delegation_replays_one_immutable_event() -> None: + database_url = _require_disposable_database_url() + 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] + try: + task_id, manager, delegate_ids = _seed_case(factory, suffix=suffix, delegates=1) + payload = ApprovalTaskAssignmentAction( + request_id=f"delegate-concurrent-{suffix}", + expected_task_version=1, + reason="并发幂等委托验证", + target_employee_id=delegate_ids[0], + expires_at=datetime.now(UTC) + timedelta(days=1), + ) + + def delegate_once(): + with factory() as db: + return ApprovalTaskActionService(db).assign( + task_id, + manager, + action="delegate", + payload=payload, + ) + + with ThreadPoolExecutor(max_workers=2) as pool: + futures = (pool.submit(delegate_once), pool.submit(delegate_once)) + responses = [future.result(timeout=10) for future in futures] + + assert sorted(response.replayed for response in responses) == [False, True] + assert responses[0].task.model_dump(mode="json") == responses[1].task.model_dump( + mode="json" + ) + with factory() as db: + task = db.get(ApprovalTask, task_id) + assert task is not None and task.version == 2 + assert task.assignee_employee_id == delegate_ids[0] + assert ( + db.scalar( + select(func.count()) + .select_from(ApprovalTaskEvent) + .where( + ApprovalTaskEvent.task_id == task_id, + ApprovalTaskEvent.request_id == payload.request_id, + ) + ) + == 1 + ) + finally: + engine.dispose() + + +def test_concurrent_distinct_delegations_allow_only_expected_version_winner() -> None: + database_url = _require_disposable_database_url() + 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] + try: + task_id, manager, delegate_ids = _seed_case(factory, suffix=suffix, delegates=2) + + def delegate_once(index: int): + with factory() as db: + try: + return ApprovalTaskActionService(db).assign( + task_id, + manager, + action="delegate", + payload=ApprovalTaskAssignmentAction( + request_id=f"delegate-race-{suffix}-{index}", + expected_task_version=1, + reason="并发版本竞争验证", + target_employee_id=delegate_ids[index], + expires_at=datetime.now(UTC) + timedelta(days=1), + ), + ) + except ApprovalTaskVersionConflictError as error: + return error + + with ThreadPoolExecutor(max_workers=2) as pool: + futures = (pool.submit(delegate_once, 0), pool.submit(delegate_once, 1)) + responses = [future.result(timeout=10) for future in futures] + + assert sum(not isinstance(item, Exception) for item in responses) == 1 + assert sum(isinstance(item, ApprovalTaskVersionConflictError) for item in responses) == 1 + with factory() as db: + task = db.get(ApprovalTask, task_id) + assert task is not None and task.version == 2 + assert task.assignee_employee_id in set(delegate_ids) + assert ( + db.scalar( + select(func.count()) + .select_from(ApprovalTaskEvent) + .where( + ApprovalTaskEvent.task_id == task_id, + ApprovalTaskEvent.event_type == "task_delegated", + ) + ) + == 1 + ) + finally: + engine.dispose() + + +def _seed_case( + factory: sessionmaker[Session], + *, + suffix: str, + delegates: int, +) -> tuple[str, CurrentUserContext, list[str]]: + with factory() as db: + role = db.scalar(select(Role).where(Role.role_code == "manager")) + if role is None: + role = Role( + id="role-appr-task-concur-manager", + role_code="manager", + name="并发审批经理", + ) + manager = Employee( + id=f"manager-{suffix}", + employee_no=f"M-{suffix}", + name="并发审批经理", + email=f"manager-{suffix}@example.com", + roles=[role], + ) + delegate_rows = [ + Employee( + id=f"delegate-{suffix}-{index}", + employee_no=f"D-{suffix}-{index}", + name=f"委托审批人{index + 1}", + email=f"delegate-{suffix}-{index}@example.com", + roles=[role], + ) + for index in range(delegates) + ] + claimant = Employee( + id=f"claimant-{suffix}", + employee_no=f"E-{suffix}", + name="并发报销申请人", + email=f"claimant-{suffix}@example.com", + manager=manager, + ) + occurred_at = datetime.now(UTC) - timedelta(hours=1) + claim = ExpenseClaim( + id=f"claim-{suffix}", + claim_no=f"RE-CONCURRENT-{suffix}", + employee=claimant, + employee_name=claimant.name, + department_name="并发验证部", + expense_type="transport", + reason="审批任务并发验证", + location="上海", + amount=Decimal("88.00"), + currency="CNY", + invoice_count=1, + occurred_at=occurred_at, + submitted_at=occurred_at, + status="submitted", + approval_stage="直属领导审批", + risk_flags_json=[], + ) + db.add_all([claim, *delegate_rows]) + db.flush() + _, business_event = ExpenseCaseService(db).record_claim_event( + claim, + event_type="claim_submitted", + actor_id=claimant.email, + tenant_id=TENANT_ID, + idempotency_key=f"submit-{suffix}", + previous_status="draft", + previous_approval_stage="待提交", + ) + task = ApprovalTaskLifecycleService(db).ensure_root_task( + claim, + tenant_id=TENANT_ID, + entered_at=business_event.occurred_at, + entered_at_source="workflow_event", + business_event=business_event, + request_id=f"node-enter-{suffix}", + ) + assert task is not None + db.commit() + return ( + task.id, + CurrentUserContext( + username=manager.email, + name=manager.name, + role_codes=["manager"], + is_admin=False, + tenant_id=TENANT_ID, + employee_id=manager.id, + employee_no=manager.employee_no, + ), + [row.id for row in delegate_rows], + ) + + +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_task_projection.py b/server/tests/test_approval_task_projection.py new file mode 100644 index 0000000..8bcd951 --- /dev/null +++ b/server/tests/test_approval_task_projection.py @@ -0,0 +1,485 @@ +from __future__ import annotations + +from datetime import UTC, date, datetime, timedelta +from decimal import Decimal + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app.db.base import Base +from app.models.approval_task import ApprovalTask +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_task_projection import ApprovalTaskProjectionService +from app.services.approval_task_projection_refresh import ( + ApprovalTaskProjectionRefreshService, +) +from app.services.risk_observations import RiskObservationService + + +def _claim( + *, + claim_id: str = "claim-projection-1", + claim_no: str = "RE-PROJECTION-1", + amount: str = "888.00", + invoice_count: int = 1, + risk_flags: list[dict] | None = None, +) -> ExpenseClaim: + occurred_at = datetime(2026, 7, 15, 9, 0, tzinfo=UTC) + claim = ExpenseClaim( + id=claim_id, + claim_no=claim_no, + employee_name="张三", + department_name="市场部", + project_code="PRJ-PROJECTION", + expense_type="application" if claim_no.startswith("AP-") else "travel", + reason="客户现场差旅", + location="上海", + amount=Decimal(amount), + currency="CNY", + invoice_count=invoice_count, + occurred_at=occurred_at, + submitted_at=occurred_at + timedelta(hours=1), + status="submitted", + approval_stage="直属领导审批", + risk_flags_json=list(risk_flags or []), + created_at=occurred_at, + updated_at=occurred_at + timedelta(hours=1), + ) + claim.items = [ + ExpenseClaimItem( + id=f"item-{claim_id}", + 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-PROJECTION" if invoice_count else None, + created_at=occurred_at, + updated_at=occurred_at, + ) + ] + return claim + + +def test_projection_materializes_explainable_priority_and_safe_batch_blocks() -> None: + now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC) + claim = _claim( + amount="60000.00", + risk_flags=[ + { + "source": "ai_pre_review", + "severity": "high", + "disposition": "review", + "route_decision": {"budget_result": {"metrics": {"after_usage_rate": "0.96"}}}, + } + ], + ) + + projection = ApprovalTaskProjectionService(None).build( # type: ignore[arg-type] + claim, + tenant_id="tenant-a", + entered_at=now - timedelta(hours=26), + now=now, + observation_rows=[], + ) + + reason_codes = {item["code"] for item in projection.priority_reasons_json} + assert projection.priority_score >= 85 + assert projection.priority_tier == "urgent" + assert projection.risk_level == "high" + assert projection.open_risk_count == 1 + assert projection.due_at == now - timedelta(hours=2) + assert projection.next_escalation_at == projection.due_at + assert reason_codes >= { + "open_risk", + "sla_overdue", + "budget_pressure", + "large_amount", + } + assert projection.batch_eligible is False + assert set(projection.batch_block_reasons_json) >= { + "open_risk", + "amount_requires_individual_review", + "budget_pressure", + "sla_overdue", + } + + +def test_resolved_materialized_risk_suppresses_stale_raw_flag_and_application_invoice() -> None: + now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC) + claim = _claim( + claim_id="claim-projection-resolved", + claim_no="AP-PROJECTION-1", + invoice_count=0, + risk_flags=[ + { + "severity": "critical", + "triggered": True, + "observation_key": "risk:projection:resolved", + } + ], + ) + observation = RiskObservation( + id="observation-projection-resolved", + tenant_id="tenant-a", + observation_key="risk:projection: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=98, + risk_level="critical", + confidence_score=0.99, + control_stage="expense_application", + control_mode="risk_observation", + automation_mode="manual", + source="financial_risk_graph", + algorithm_version="v1", + status="resolved", + feedback_status="confirmed", + ) + disposition = RiskDisposition( + id="disposition-projection-resolved", + tenant_id="tenant-a", + observation_id=observation.id, + adjudication="confirmed", + lifecycle_status="resolved", + version=2, + ) + + projection = ApprovalTaskProjectionService(None).build( # type: ignore[arg-type] + claim, + tenant_id="tenant-a", + entered_at=now - timedelta(hours=1), + now=now, + observation_rows=[(observation, disposition)], + ) + + assert projection.risk_level == "low" + assert projection.open_risk_count == 0 + assert projection.evidence_completeness == Decimal("1.0000") + assert projection.priority_score == 0 + assert projection.priority_reasons_json[0]["code"] == "routine" + assert projection.batch_eligible is True + assert projection.batch_block_reasons_json == () + + +def test_projection_marks_missing_evidence_and_apply_updates_task_without_persistence() -> None: + now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC) + claim = _claim(invoice_count=0) + claim.location = "" + claim.items = [] + projection = ApprovalTaskProjectionService(None).build( # type: ignore[arg-type] + claim, + tenant_id="tenant-a", + entered_at=now - timedelta(hours=1), + now=now, + observation_rows=[], + ) + task = ApprovalTask() + + returned = ApprovalTaskProjectionService.apply(task, projection) + + assert returned is task + assert task.evidence_completeness == Decimal("0.2500") + assert task.batch_eligible is False + assert "evidence_incomplete" in task.batch_block_reasons_json + assert {item["code"] for item in task.priority_reasons_json} >= {"evidence_gap"} + + +def test_projection_rejects_ambiguous_time_and_invalid_sla() -> None: + claim = _claim() + service = ApprovalTaskProjectionService(None) # type: ignore[arg-type] + with pytest.raises(ValueError, match="entered_at.*timezone"): + service.build( + claim, + tenant_id="tenant-a", + entered_at=datetime(2026, 7, 16, 10, 0), + observation_rows=[], + ) + with pytest.raises(ValueError, match="sla_hours"): + service.build( + claim, + tenant_id="tenant-a", + entered_at=datetime(2026, 7, 16, 10, 0, tzinfo=UTC), + sla_hours=0, + observation_rows=[], + ) + + +def test_apply_convenience_form_uses_claim_session_for_tenant_risks() -> None: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + db_factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) + now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC) + with db_factory() as db: + claim = _claim(claim_id="claim-projection-session") + observation = RiskObservation( + id="observation-projection-session", + tenant_id="tenant-a", + observation_key="risk:projection:session", + 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="policy_violation", + risk_signal="policy_violation", + title="政策风险", + description="等待人工处置。", + risk_score=80, + risk_level="high", + confidence_score=0.9, + control_stage="reimbursement", + control_mode="risk_observation", + automation_mode="manual", + source="financial_risk_graph", + algorithm_version="v1", + status="pending_review", + feedback_status="unreviewed", + ) + db.add_all([claim, observation]) + db.flush() + task = ApprovalTask( + tenant_id="tenant-a", + entered_at=now - timedelta(hours=1), + sla_hours_snapshot=12, + ) + + returned = ApprovalTaskProjectionService.apply(task, claim=claim, now=now) + + assert returned is task + assert task.risk_level == "high" + assert task.open_risk_count == 1 + assert task.due_at == now + timedelta(hours=11) + + +def test_projection_filters_other_business_stage_and_only_honors_active_waiver() -> None: + now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC) + claim = _claim( + claim_id="claim-projection-waiver", + claim_no="AP-PROJECTION-WAIVER", + risk_flags=[ + { + "severity": "critical", + "triggered": True, + "business_stage": "reimbursement", + } + ], + ) + other_stage = RiskObservation( + id="observation-other-stage", + tenant_id="tenant-a", + observation_key="risk:projection:other-stage", + 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="invoice", + risk_signal="invoice", + risk_level="critical", + control_stage="reimbursement", + status="pending_review", + feedback_status="unreviewed", + ) + waived = RiskObservation( + id="observation-active-waiver", + tenant_id="tenant-a", + observation_key="risk:projection:active-waiver", + 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="policy", + risk_signal="policy", + risk_level="high", + control_stage="expense_application", + status="pending_review", + feedback_status="confirmed", + ) + disposition = RiskDisposition( + tenant_id="tenant-a", + observation_id=waived.id, + adjudication="confirmed", + lifecycle_status="waived", + waiver_expires_at=now + timedelta(days=1), + waiver_decision="approved", + ) + service = ApprovalTaskProjectionService(None) # type: ignore[arg-type] + + active = service.build( + claim, + tenant_id="tenant-a", + entered_at=now - timedelta(hours=1), + now=now, + observation_rows=[(other_stage, None), (waived, disposition)], + ) + disposition.waiver_expires_at = now - timedelta(seconds=1) + expired = service.build( + claim, + tenant_id="tenant-a", + entered_at=now - timedelta(hours=1), + now=now, + observation_rows=[(other_stage, None), (waived, disposition)], + ) + + assert active.risk_level == "low" + assert active.open_risk_count == 0 + assert active.batch_eligible is True + assert expired.risk_level == "high" + assert expired.open_risk_count == 1 + assert expired.batch_eligible is False + + +def test_risk_upsert_refreshes_open_task_projection_without_resetting_sla_window() -> None: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + db_factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) + now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC) + with db_factory() as db: + claim = _claim(claim_id="claim-projection-refresh") + task = ApprovalTask( + id="task-projection-refresh", + tenant_id="default", + claim_id=claim.id, + expense_case_id=None, + node_instance_id="node-projection-refresh", + node_entry_key="node-entry-projection-refresh", + parent_task_id=None, + task_kind="root", + node_key="direct_manager", + node_label="直属领导审批", + node_sequence=1, + sequence_order=0, + coordination_mode="single", + owner_kind="role", + owner_key="manager", + owner_name="审批经理", + assignee_kind="role", + assignee_key="manager", + assignee_name="审批经理", + status="pending", + version=1, + claim_status_snapshot="submitted", + claim_stage_snapshot="直属领导审批", + entered_at=now - timedelta(hours=1), + entered_at_source="workflow_event", + activated_at=now - timedelta(hours=1), + sla_hours_snapshot=24, + due_at=now + timedelta(hours=23), + escalation_level=1, + next_escalation_at=now + timedelta(hours=2), + priority_score=10, + priority_tier="normal", + priority_reasons_json=[ + { + "code": "sla_escalated_l1", + "label": "审批超时已升级至 L1", + "weight": 10, + "tone": "danger", + } + ], + risk_level="low", + open_risk_count=0, + evidence_completeness=Decimal("1.0000"), + batch_eligible=True, + batch_block_reasons_json=[], + projection_updated_at=now, + created_at=now, + updated_at=now, + ) + db.add_all([claim, task]) + db.commit() + + observation = RiskObservationService(db).upsert_observation( + { + "observation_key": "risk:projection:refresh", + "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": "policy_violation", + "risk_signal": "policy_violation", + "title": "政策风险", + "description": "等待人工处置。", + "risk_score": 90, + "risk_level": "high", + "control_stage": "reimbursement", + "status": "pending_review", + }, + tenant_id="default", + ) + + db.refresh(task) + assert task.risk_level == "high" + assert task.open_risk_count == 1 + assert task.batch_eligible is False + next_escalation_at = task.next_escalation_at + assert next_escalation_at is not None + if next_escalation_at.tzinfo is None: + next_escalation_at = next_escalation_at.replace(tzinfo=UTC) + assert next_escalation_at == now + timedelta(hours=2) + assert task.escalation_level == 1 + assert any( + reason["code"] == "sla_escalated_l1" + for reason in task.priority_reasons_json + ) + + db.add( + RiskDisposition( + tenant_id="default", + observation_id=observation.id, + adjudication="confirmed", + lifecycle_status="waived", + waiver_requester_id="requester-projection-refresh", + waiver_requester_name="风险申请人", + waiver_requested_at=now - timedelta(hours=1), + waiver_reason="业务连续性需要", + waiver_scope="本次报销单", + waiver_decision="approved", + waiver_expires_at=now + timedelta(days=1), + waiver_conditions_json=["补充主管确认"], + waiver_decider_id="finance-projection-refresh", + waiver_decider_name="财务复核人", + waiver_decided_at=now, + waiver_decision_reason="风险受控且有补偿措施", + version=1, + ) + ) + db.flush() + ApprovalTaskProjectionRefreshService(db).refresh_claim( + tenant_id="default", + claim_id=claim.id, + now=now, + ) + + assert task.risk_level == "low" + assert task.open_risk_count == 0 + assert task.batch_eligible is True + next_escalation_at = task.next_escalation_at + assert next_escalation_at is not None + if next_escalation_at.tzinfo is None: + next_escalation_at = next_escalation_at.replace(tzinfo=UTC) + assert next_escalation_at == now + timedelta(hours=2) diff --git a/server/tests/test_approval_task_query_and_batch.py b/server/tests/test_approval_task_query_and_batch.py new file mode 100644 index 0000000..d21503c --- /dev/null +++ b/server/tests/test_approval_task_query_and_batch.py @@ -0,0 +1,600 @@ +from __future__ import annotations + +from collections.abc import Generator +from datetime import UTC, date, datetime, timedelta +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, func, select +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.api.deps import CurrentUserContext, get_db +from app.db.base import Base +from app.main import create_app +from app.models.approval_action import ApprovalActionLedger +from app.models.approval_task import ApprovalTask, ApprovalTaskEvent +from app.models.employee import Employee +from app.models.expense_case import ExpenseCase, ExpenseCaseLink +from app.models.financial_record import ExpenseClaim, ExpenseClaimItem +from app.models.role import Role +from app.schemas.approval_task import ( + ApprovalTaskBatchApproveCreate, + ApprovalTaskBatchApproveItem, +) +from app.services.approval_task_batch import ApprovalTaskBatchService +from app.services.approval_task_query import ApprovalTaskQueryService +from app.services.expense_claim_workflow_constants import ( + DIRECT_MANAGER_APPROVAL_STAGE, + FINANCE_APPROVAL_STAGE, +) + + +def _session_factory() -> sessionmaker[Session]: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + return sessionmaker( + bind=engine, + autoflush=False, + autocommit=False, + expire_on_commit=False, + ) + + +def _user( + *, + tenant_id: str = "tenant-a", + employee_id: str = "approver-1", + username: str = "approver@example.com", + role_codes: list[str] | None = None, + is_admin: bool = False, +) -> CurrentUserContext: + return CurrentUserContext( + username=username, + name="审批人", + role_codes=list(role_codes or ["manager"]), + is_admin=is_admin, + tenant_id=tenant_id, + employee_id=employee_id, + employee_no=employee_id, + ) + + +def _task_status_fields( + status: str, + *, + now: datetime, +) -> dict[str, object]: + if status == "completed": + return { + "decision": "approved", + "completed_at": now, + "cancelled_at": None, + } + if status == "returned": + return { + "decision": "returned", + "completed_at": now, + "cancelled_at": None, + } + if status in {"cancelled", "superseded"}: + return { + "decision": None, + "completed_at": None, + "cancelled_at": now, + } + return {"decision": None, "completed_at": None, "cancelled_at": None} + + +def _build_queue_task( + *, + tenant_id: str, + case_id: str, + index: int, + now: datetime, + assignee_id: str, + status: str = "pending", + risk_level: str | None = None, + node_key: str | None = None, + due_at: datetime | None = None, + reason: str | None = None, +) -> tuple[ExpenseClaim, ExpenseCaseLink, ApprovalTask]: + claim_id = f"queue-claim-{tenant_id[-1]}-{index:03d}" + task_id = f"queue-task-{tenant_id[-1]}-{index:03d}" + selected_node = node_key or ("direct_manager" if index % 2 == 0 else "finance") + stage = ( + DIRECT_MANAGER_APPROVAL_STAGE + if selected_node == "direct_manager" + else FINANCE_APPROVAL_STAGE + ) + # 任务进入节点后才会产生 SLA 截止时间;即使构造“已逾期”样本, + # due_at 也必须晚于 entered_at,保持测试数据满足真实表约束。 + entered_at = now - timedelta(hours=12 + index % 5, minutes=index % 7) + selected_due_at = due_at or ( + now - timedelta(hours=2) + if index % 3 == 0 + else now + timedelta(hours=2) + if index % 3 == 1 + else now + timedelta(hours=8) + ) + selected_risk = risk_level or ("high" if index % 5 == 0 else "low") + claim = ExpenseClaim( + id=claim_id, + claim_no=f"RE-QUEUE-{tenant_id[-1].upper()}-{index:03d}", + employee_id=None, + employee_name=f"申请人{index:03d}", + department_id=None, + department_name="市场部", + project_code="PRJ-QUEUE", + expense_type="misc", + reason=reason or f"队列分页测试费用 {index:03d}", + location="上海", + amount=Decimal("888.00") + Decimal(index), + currency="CNY", + invoice_count=1, + occurred_at=now - timedelta(days=1), + submitted_at=entered_at, + status="submitted", + approval_stage=stage, + risk_flags_json=[], + created_at=entered_at - timedelta(hours=1), + updated_at=entered_at, + ) + link = ExpenseCaseLink( + id=f"queue-link-{tenant_id[-1]}-{index:03d}", + tenant_id=tenant_id, + expense_case_id=case_id, + resource_type="expense_claim", + resource_id=claim_id, + relation_type="claim", + created_at=entered_at, + ) + lifecycle = _task_status_fields(status, now=now) + task = ApprovalTask( + id=task_id, + tenant_id=tenant_id, + claim_id=claim_id, + expense_case_id=case_id, + node_instance_id=f"queue-node-{tenant_id[-1]}-{index:03d}", + node_entry_key=f"queue-entry-{tenant_id[-1]}-{index:03d}", + parent_task_id=None, + task_kind="root", + node_key=selected_node, + node_label=stage, + node_sequence=1, + sequence_order=0, + coordination_mode="single", + owner_kind="employee", + owner_key=assignee_id, + owner_employee_id=assignee_id, + owner_name="审批人", + assignee_kind="employee", + assignee_key=assignee_id, + assignee_employee_id=assignee_id, + assignee_name="审批人张经理", + status=status, + decision=lifecycle["decision"], + version=2 if status != "pending" else 1, + claim_status_snapshot="submitted", + claim_stage_snapshot=stage, + entered_at=entered_at, + entered_at_source="submitted_at", + activated_at=entered_at, + sla_hours_snapshot=24, + due_at=selected_due_at, + completed_at=lifecycle["completed_at"], + cancelled_at=lifecycle["cancelled_at"], + escalation_level=0, + next_escalation_at=selected_due_at, + priority_score=(index % 4) * 25, + priority_tier="urgent" if index % 4 == 3 else "high" if index % 4 == 2 else "normal", + priority_reasons_json=[], + risk_level=selected_risk, + open_risk_count=1 if selected_risk != "low" else 0, + evidence_completeness=Decimal("1.0000"), + batch_eligible=(status == "pending" and selected_risk == "low"), + batch_block_reasons_json=[] if selected_risk == "low" else ["open_risk"], + projection_updated_at=now, + created_at=entered_at, + updated_at=now, + ) + return claim, link, task + + +def _seed_queue( + db: Session, + *, + tenant_id: str = "tenant-a", + pending_count: int = 27, + assignee_id: str = "approver-1", + include_processed: bool = False, +) -> tuple[datetime, list[ApprovalTask]]: + now = datetime.now(UTC).replace(microsecond=0) + case_id = f"queue-case-{tenant_id[-1]}" + expense_case = ExpenseCase( + id=case_id, + tenant_id=tenant_id, + case_no=f"CASE-QUEUE-{tenant_id[-1].upper()}", + scene_code="misc", + title="审批队列测试", + current_stage="reviewing", + status="active", + created_at=now - timedelta(days=2), + updated_at=now, + ) + db.add(expense_case) + tasks: list[ApprovalTask] = [] + for index in range(pending_count): + claim, link, task = _build_queue_task( + tenant_id=tenant_id, + case_id=case_id, + index=index, + now=now, + assignee_id=assignee_id, + reason=("火星专项客户拜访" if index == 7 else None), + ) + db.add_all([claim, link, task]) + tasks.append(task) + if include_processed: + for offset, status in enumerate( + ("completed", "returned", "cancelled", "superseded"), + start=pending_count, + ): + claim, link, task = _build_queue_task( + tenant_id=tenant_id, + case_id=case_id, + index=offset, + now=now, + assignee_id=assignee_id, + status=status, + ) + db.add_all([claim, link, task]) + tasks.append(task) + db.commit() + return now, tasks + + +def test_query_uses_real_database_pagination_and_stable_persisted_sorting() -> None: + factory = _session_factory() + with factory() as db: + _now, tasks = _seed_queue(db) + expected = sorted( + tasks, + key=lambda task: ( + -task.priority_score, + task.due_at, + task.entered_at, + task.id, + ), + ) + + page = ApprovalTaskQueryService(db).list_tasks( + _user(), + page=2, + page_size=10, + ) + repeated = ApprovalTaskQueryService(db).list_tasks( + _user(), + page=2, + page_size=10, + ) + + expected_ids = [task.id for task in expected[10:20]] + assert page.total == 27 + assert page.page == 2 + assert page.page_size == 10 + assert page.total_pages == 3 + assert [item.task.id for item in page.items] == expected_ids + assert [item.task.id for item in repeated.items] == expected_ids + + +def test_query_supports_status_risk_sla_node_and_keyword_filters() -> None: + factory = _session_factory() + with factory() as db: + _now, tasks = _seed_queue(db, include_processed=True) + tasks[0].escalation_level = 1 + db.commit() + service = ApprovalTaskQueryService(db) + user = _user() + + assert service.list_tasks(user, status="processed").total == 4 + assert service.list_tasks(user, status="completed").total == 1 + assert service.list_tasks(user, risk_level="high").total == 6 + assert service.list_tasks(user, sla_state="overdue").total == 9 + assert service.list_tasks(user, sla_state="due_soon").total == 9 + assert service.list_tasks(user, sla_state="on_track").total == 9 + assert service.list_tasks(user, sla_state="escalated").total == 1 + assert service.list_tasks(user, node_key="finance").total == 13 + keyword = service.list_tasks(user, keyword="火星专项") + assert keyword.total == 1 + assert keyword.items[0].claim.reason == "火星专项客户拜访" + with pytest.raises(ValueError, match="SLA"): + service.list_tasks(user, sla_state="unknown") + + +def test_query_enforces_tenant_scope_and_admin_visibility_without_action_authority() -> None: + factory = _session_factory() + with factory() as db: + _seed_queue(db, tenant_id="tenant-a", pending_count=3) + _seed_queue( + db, + tenant_id="tenant-b", + pending_count=2, + assignee_id="other-approver", + ) + service = ApprovalTaskQueryService(db) + + personal = service.list_tasks(_user(tenant_id="tenant-a")) + admin_a = service.list_tasks( + _user( + tenant_id="tenant-a", + employee_id="admin-a", + username="admin-a@example.com", + role_codes=["admin"], + is_admin=True, + ) + ) + admin_b = service.list_tasks( + _user( + tenant_id="tenant-b", + employee_id="admin-b", + username="admin-b@example.com", + role_codes=["admin"], + is_admin=True, + ) + ) + + assert personal.total == 3 + assert all(item.task.can_act for item in personal.items) + assert admin_a.total == 3 + assert all(item.task.can_act is False for item in admin_a.items) + assert all("approve" not in item.task.available_actions for item in admin_a.items) + assert admin_b.total == 2 + assert {item.task.tenant_id for item in admin_b.items} == {"tenant-b"} + + +def _seed_batch( + db: Session, +) -> tuple[CurrentUserContext, ApprovalTask, ApprovalTask]: + now = datetime.now(UTC).replace(microsecond=0) + finance_role = Role( + id="batch-role-finance", + role_code="finance", + name="批处理财务", + ) + finance = Employee( + id="batch-finance", + employee_no="BATCH-FINANCE", + name="财务审批人", + email="batch-finance@example.com", + roles=[finance_role], + ) + owner = Employee( + id="batch-owner", + employee_no="BATCH-OWNER", + name="批处理申请人", + email="batch-owner@example.com", + ) + expense_case = ExpenseCase( + id="batch-case", + tenant_id="tenant-a", + case_no="CASE-BATCH-APPROVAL", + scene_code="misc", + title="批量审批事务测试", + owner_employee_id=owner.id, + current_stage="reviewing", + status="active", + created_at=now - timedelta(days=1), + updated_at=now, + ) + db.add_all([finance_role, finance, owner, expense_case]) + tasks: list[ApprovalTask] = [] + for index, eligible in enumerate((True, False), start=1): + claim_id = f"batch-claim-{index}" + claim = ExpenseClaim( + id=claim_id, + claim_no=f"RE-BATCH-{index:03d}", + employee=owner, + employee_name=owner.name, + department_name="市场部", + project_code="PRJ-BATCH", + expense_type="misc", + reason=f"批量审批测试 {index}", + location="上海", + amount=Decimal("88.00"), + currency="CNY", + invoice_count=1, + occurred_at=now - timedelta(days=1), + submitted_at=now - timedelta(hours=1), + status="submitted", + approval_stage=FINANCE_APPROVAL_STAGE, + risk_flags_json=[], + created_at=now - timedelta(days=1), + updated_at=now, + ) + claim.items = [ + ExpenseClaimItem( + id=f"batch-item-{index}", + claim_id=claim_id, + item_date=date.today(), + item_type="misc", + item_reason="测试", + item_location="上海", + item_note="", + item_amount=Decimal("88.00"), + invoice_id=f"BATCH-INVOICE-{index}", + created_at=now, + updated_at=now, + ) + ] + link = ExpenseCaseLink( + id=f"batch-link-{index}", + tenant_id="tenant-a", + expense_case_id=expense_case.id, + resource_type="expense_claim", + resource_id=claim_id, + relation_type="claim", + created_at=now, + ) + task = ApprovalTask( + id=f"batch-task-{index}", + tenant_id="tenant-a", + claim_id=claim_id, + expense_case_id=expense_case.id, + node_instance_id=f"batch-node-{index}", + node_entry_key=f"batch-entry-{index}", + parent_task_id=None, + task_kind="root", + node_key="finance", + node_label=FINANCE_APPROVAL_STAGE, + node_sequence=1, + sequence_order=0, + coordination_mode="single", + owner_kind="employee", + owner_key=finance.id, + owner_employee_id=finance.id, + owner_name=finance.name, + assignee_kind="employee", + assignee_key=finance.id, + assignee_employee_id=finance.id, + assignee_name=finance.name, + status="pending", + decision=None, + version=1, + claim_status_snapshot="submitted", + claim_stage_snapshot=FINANCE_APPROVAL_STAGE, + entered_at=now - timedelta(hours=1), + entered_at_source="submitted_at", + activated_at=now - timedelta(hours=1), + sla_hours_snapshot=8, + due_at=now + timedelta(hours=7), + escalation_level=0, + next_escalation_at=now + timedelta(hours=7), + priority_score=0, + priority_tier="normal", + priority_reasons_json=[], + risk_level="low" if eligible else "high", + open_risk_count=0 if eligible else 1, + evidence_completeness=Decimal("1.0000"), + batch_eligible=eligible, + batch_block_reasons_json=[] if eligible else ["open_risk"], + projection_updated_at=now, + created_at=now, + updated_at=now, + ) + db.add_all([claim, link, task]) + tasks.append(task) + db.commit() + return ( + _user( + tenant_id="tenant-a", + employee_id=finance.id, + username=finance.email, + role_codes=["finance"], + ), + tasks[0], + tasks[1], + ) + + +def test_batch_items_use_independent_transactions_and_retry_replays_success() -> None: + factory = _session_factory() + with factory() as db: + user, successful_task, blocked_task = _seed_batch(db) + payload = ApprovalTaskBatchApproveCreate( + batch_request_id="batch-request-001", + items=[ + ApprovalTaskBatchApproveItem( + task_id=successful_task.id, + expected_task_version=1, + expected_status="submitted", + expected_approval_stage=FINANCE_APPROVAL_STAGE, + opinion="批量审批同意", + ), + ApprovalTaskBatchApproveItem( + task_id=blocked_task.id, + expected_task_version=1, + expected_status="submitted", + expected_approval_stage=FINANCE_APPROVAL_STAGE, + opinion="批量审批同意", + ), + ], + ) + + first = ApprovalTaskBatchService(factory).approve(payload, user) + repeated = ApprovalTaskBatchService(factory).approve(payload, user) + + assert first.status == "partial" + assert first.succeeded_count == 1 + assert first.blocked_count == 1 + assert [item.status for item in first.items] == ["succeeded", "blocked"] + assert repeated.status == "partial" + assert repeated.replayed_count == 1 + assert repeated.blocked_count == 1 + assert [item.status for item in repeated.items] == ["replayed", "blocked"] + with factory() as db: + successful_claim = db.get(ExpenseClaim, successful_task.claim_id) + blocked_claim = db.get(ExpenseClaim, blocked_task.claim_id) + persisted_success = db.get(ApprovalTask, successful_task.id) + persisted_blocked = db.get(ApprovalTask, blocked_task.id) + assert successful_claim.status == "pending_payment" + assert successful_claim.approval_stage == "待付款" + assert persisted_success.status == "completed" + assert persisted_success.decision == "approved" + assert blocked_claim.status == "submitted" + assert persisted_blocked.status == "pending" + assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 1 + assert db.scalar(select(func.count()).select_from(ApprovalTaskEvent)) == 1 + + +def test_approval_task_list_http_contract_preserves_pagination_metadata() -> None: + factory = _session_factory() + with factory() as db: + _now, tasks = _seed_queue(db) + expected = sorted( + tasks, + key=lambda task: ( + -task.priority_score, + task.due_at, + task.entered_at, + task.id, + ), + ) + app = create_app() + install_legacy_header_auth_override(app) + + def override_db() -> Generator[Session, None, None]: + with factory() as db: + yield db + + app.dependency_overrides[get_db] = override_db + client = TestClient(app) + try: + response = client.get( + "/api/v1/approval-tasks", + params={"page": 3, "page_size": 10, "status": "pending"}, + headers={ + "X-Auth-Username": "approver@example.com", + "X-Auth-Employee-Id": "approver-1", + "X-Auth-Role-Codes": "manager", + "X-Auth-Tenant-Id": "tenant-a", + }, + ) + finally: + client.close() + app.dependency_overrides.clear() + + body = response.json() + assert response.status_code == 200 + assert body["total"] == 27 + assert body["page"] == 3 + assert body["page_size"] == 10 + assert body["total_pages"] == 3 + assert len(body["items"]) == 7 + assert body["items"][0]["task"]["id"] == expected[20].id diff --git a/server/tests/test_expense_claim_risk_gate.py b/server/tests/test_expense_claim_risk_gate.py index f3504f2..5ae06fe 100644 --- a/server/tests/test_expense_claim_risk_gate.py +++ b/server/tests/test_expense_claim_risk_gate.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from decimal import Decimal import pytest @@ -52,6 +52,48 @@ def test_high_risk_requires_false_positive_or_resolved_disposition() -> None: gate.ensure_approvable(claim, tenant_id="default") +def test_only_unexpired_approved_waiver_bypasses_risk_gate() -> None: + with _session() as db: + claim = _claim(claim_id="claim-risk-waiver-gate") + observation = _observation(claim, observation_id="risk-waiver-gate") + now = datetime.now(UTC) + disposition = RiskDisposition( + tenant_id="default", + observation_id=observation.id, + adjudication="confirmed", + lifecycle_status="waived", + waiver_requester_id="waiver-requester", + waiver_requester_name="豁免申请人", + waiver_requested_at=now - timedelta(days=2), + waiver_reason="临时无法取得纸质材料。", + waiver_scope="仅限本次客户拜访。", + waiver_expires_at=now + timedelta(days=2), + waiver_conditions_json=["到期前补件"], + waiver_decision="approved", + waiver_decider_id="waiver-decider", + waiver_decider_name="豁免决定人", + waiver_decided_at=now - timedelta(days=1), + waiver_decision_reason="补偿控制充分,同意限时豁免。", + ) + db.add_all([claim, observation, disposition]) + db.commit() + gate = ExpenseClaimRiskGate(db) + + gate.ensure_approvable(claim, tenant_id="default") + + disposition.waiver_expires_at = now - timedelta(hours=1) + db.commit() + with pytest.raises(ExpenseClaimRiskBlockedError): + gate.ensure_approvable(claim, tenant_id="default") + + disposition.lifecycle_status = "waiver_rejected" + disposition.waiver_decision = "rejected" + disposition.waiver_decision_reason = "补偿控制不足,拒绝豁免。" + db.commit() + with pytest.raises(ExpenseClaimRiskBlockedError): + 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") diff --git a/server/tests/test_migration_preflight.py b/server/tests/test_migration_preflight.py index 376d311..1d9703d 100644 --- a/server/tests/test_migration_preflight.py +++ b/server/tests/test_migration_preflight.py @@ -149,6 +149,14 @@ def test_known_revision_requires_and_accepts_its_exact_owned_table_set( "20260716_0012", MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0012"] - {"risk_disposition_events"}, ), + ( + "20260716_0013", + MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0013"] - {"approval_task_events"}, + ), + ( + "20260716_0014", + MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0014"] - {"risk_disposition_events"}, + ), ], ) def test_known_revision_with_missing_or_unexpected_owned_tables_is_rejected( diff --git a/server/tests/test_risk_disposition_read_projection.py b/server/tests/test_risk_disposition_read_projection.py new file mode 100644 index 0000000..e18d678 --- /dev/null +++ b/server/tests/test_risk_disposition_read_projection.py @@ -0,0 +1,616 @@ +from __future__ import annotations + +from collections.abc import Generator +from dataclasses import dataclass +from datetime import UTC, date, 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 +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.approval_task import ApprovalTask +from app.models.employee import Employee +from app.models.financial_record import ExpenseClaim, ExpenseClaimItem +from app.models.risk_disposition import RiskDisposition +from app.models.risk_observation import RiskObservation +from app.schemas.risk_disposition import RiskDispositionActionCreate +from app.services.approval_task_projection_refresh import ( + ApprovalTaskProjectionRefreshService, +) +from app.services.risk_disposition_read_projection import RiskDispositionActionPolicy +from app.services.risk_dispositions import RiskDispositionService +from app.services.risk_observations import RiskObservationService + + +def test_available_actions_enforce_decider_separation_tenant_state_and_expiry( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false") + client, factory = _build_client() + client.close() + with factory() as db: + seeded = _seed_waiver_request(db) + policy = RiskDispositionActionPolicy(db) + + decider_actions = set( + policy.evaluate( + seeded.observation, + seeded.disposition, + seeded.decider, + ).available_actions + ) + assert {"approve_waiver", "reject_waiver"} <= decider_actions + + for user in (seeded.requester, seeded.admin, seeded.inactive_finance): + actions = set( + policy.evaluate( + seeded.observation, + seeded.disposition, + user, + ).available_actions + ) + assert "approve_waiver" not in actions + assert "reject_waiver" not in actions + + foreign_permission = policy.evaluate( + seeded.observation, + seeded.disposition, + _user(seeded.decider_employee, roles=["finance"], tenant_id="tenant-b"), + ) + assert foreign_permission.available_actions == () + assert "租户" in foreign_permission.read_only_reason + + expires_at = seeded.disposition.waiver_expires_at + assert expires_at is not None + expired_actions = set( + policy.evaluate( + seeded.observation, + seeded.disposition, + seeded.decider, + now=_as_utc(expires_at) + timedelta(seconds=1), + ).available_actions + ) + assert "approve_waiver" not in expired_actions + assert "reject_waiver" in expired_actions + + seeded.disposition.lifecycle_status = "waived" + seeded.disposition.waiver_decision = "approved" + waived_actions = set( + policy.evaluate( + seeded.observation, + seeded.disposition, + seeded.decider, + ).available_actions + ) + assert "approve_waiver" not in waived_actions + assert "reject_waiver" not in waived_actions + + seeded.disposition.lifecycle_status = "open" + seeded.disposition.waiver_decision = None + seeded.disposition.waiver_requester_id = None + open_actions = set( + policy.evaluate( + seeded.observation, + seeded.disposition, + seeded.decider, + ).available_actions + ) + assert "request_waiver" in open_actions + + +def test_read_and_mutation_endpoints_return_server_projected_actions( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false") + client, factory = _build_client() + with factory() as db: + seeded = _seed_waiver_request(db) + unreviewed = RiskObservationService(db).upsert_observation( + _observation_payload("risk:projection:unreviewed") + ) + db.commit() + observation_id = seeded.observation.id + unreviewed_id = unreviewed.id + + decider_headers = _headers(seeded.decider_employee, roles=["finance"]) + claim_response = client.get( + "/api/v1/risk-observations/claim/claim-risk-projection", + headers=decider_headers, + ) + assert claim_response.status_code == 200 + by_id = {item["id"]: item for item in claim_response.json()} + requested_item = by_id[observation_id] + assert {"approve_waiver", "reject_waiver"} <= set( + requested_item["available_actions"] + ) + assert requested_item["read_only_reason"] == "" + assert requested_item["disposition"]["available_actions"] == requested_item[ + "available_actions" + ] + assert requested_item["disposition"]["read_only_reason"] == "" + assert by_id[unreviewed_id]["disposition"] is None + assert {"confirm", "false_positive"} <= set( + by_id[unreviewed_id]["available_actions"] + ) + + for path in ( + "/api/v1/risk-observations", + f"/api/v1/risk-observations/{observation_id}", + f"/api/v1/risk-observations/{observation_id}/disposition", + ): + response = client.get(path, headers=decider_headers) + assert response.status_code == 200 + payload = response.json() + if path.endswith("/risk-observations"): + payload = next(item for item in payload["items"] if item["id"] == observation_id) + if not path.endswith("/disposition"): + payload = payload["disposition"] + assert {"approve_waiver", "reject_waiver"} <= set(payload["available_actions"]) + assert payload["read_only_reason"] == "" + + for employee, roles, is_admin in ( + (seeded.requester_employee, ["finance"], False), + (seeded.admin_employee, [], True), + ): + response = client.get( + f"/api/v1/risk-observations/{observation_id}/disposition", + headers=_headers(employee, roles=roles, is_admin=is_admin), + ) + assert response.status_code == 200 + assert "approve_waiver" not in response.json()["available_actions"] + assert "reject_waiver" not in response.json()["available_actions"] + + mutation_response = client.post( + f"/api/v1/risk-observations/{observation_id}/disposition/actions", + headers=decider_headers, + json={ + "action": "reject_waiver", + "expected_version": 2, + "request_id": "projection-reject-waiver", + "comment": "当前证据不足,不同意风险豁免。", + }, + ) + assert mutation_response.status_code == 200 + mutation_disposition = mutation_response.json()["disposition"] + assert "available_actions" in mutation_disposition + assert "read_only_reason" in mutation_disposition + assert "approve_waiver" not in mutation_disposition["available_actions"] + assert "reject_waiver" not in mutation_disposition["available_actions"] + + +def test_disposition_mutations_refresh_task_risk_without_resetting_sla( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false") + client, factory = _build_client() + client.close() + with factory() as db: + owner = _employee("projection-owner") + requester_employee = _employee("projection-requester") + decider_employee = _employee("projection-decider") + claim = _claim(owner.id) + observation = RiskObservationService(db).upsert_observation( + _observation_payload("risk:projection:task-refresh") + ) + now = datetime.now(UTC) + task = _approval_task(claim, now=now) + db.add_all([owner, requester_employee, decider_employee, claim, task]) + db.commit() + + original_sla = ( + _as_utc(task.entered_at), + task.sla_hours_snapshot, + _as_utc(task.due_at), + _as_utc(task.next_escalation_at), + task.escalation_level, + ) + requester = _user(requester_employee, roles=["finance"]) + decider = _user(decider_employee, roles=["finance"]) + service = RiskDispositionService(db) + + service.execute_action( + observation.id, + RiskDispositionActionCreate( + action="confirm", + expected_version=0, + request_id="task-refresh-confirm-risk", + ), + tenant_id="default", + actor_id=requester_employee.id, + actor_name=requester_employee.name, + current_user=requester, + ) + db.refresh(task) + assert task.risk_level == "high" + assert task.open_risk_count == 1 + assert task.batch_eligible is False + assert "open_risk" in task.batch_block_reasons_json + _assert_sla_window(task, original_sla) + + service.execute_action( + observation.id, + _waiver_request(version=1, request_id="task-refresh-waiver-request-1"), + tenant_id="default", + actor_id=requester_employee.id, + actor_name=requester_employee.name, + current_user=requester, + ) + service.execute_action( + observation.id, + RiskDispositionActionCreate( + action="reject_waiver", + expected_version=2, + request_id="task-refresh-reject-waiver", + comment="补偿措施不足,拒绝豁免。", + ), + tenant_id="default", + actor_id=decider_employee.id, + actor_name=decider_employee.name, + current_user=decider, + ) + db.refresh(task) + assert task.open_risk_count == 1 + assert task.batch_eligible is False + _assert_sla_window(task, original_sla) + + service.execute_action( + observation.id, + _waiver_request(version=3, request_id="task-refresh-waiver-request-2"), + tenant_id="default", + actor_id=requester_employee.id, + actor_name=requester_employee.name, + current_user=requester, + ) + service.execute_action( + observation.id, + RiskDispositionActionCreate( + action="approve_waiver", + expected_version=4, + request_id="task-refresh-approve-waiver", + comment="补偿措施完整,同意限时豁免。", + ), + tenant_id="default", + actor_id=decider_employee.id, + actor_name=decider_employee.name, + current_user=decider, + ) + db.refresh(task) + assert task.risk_level == "low" + assert task.open_risk_count == 0 + assert task.evidence_completeness == Decimal("1.0000") + assert task.batch_eligible is True + _assert_sla_window(task, original_sla) + + disposition = service.get_disposition(observation.id, tenant_id="default") + assert disposition is not None + refresh_at = datetime.now(UTC) + disposition.waiver_requested_at = refresh_at - timedelta(days=2) + disposition.waiver_expires_at = refresh_at - timedelta(hours=1) + db.flush() + ApprovalTaskProjectionRefreshService(db).refresh_claim( + tenant_id="default", + claim_id=claim.id, + now=refresh_at, + ) + db.commit() + db.refresh(task) + assert task.risk_level == "high" + assert task.open_risk_count == 1 + assert task.batch_eligible is False + _assert_sla_window(task, original_sla) + + +@dataclass(slots=True) +class _SeededWaiver: + observation: RiskObservation + disposition: RiskDisposition + requester_employee: Employee + decider_employee: Employee + admin_employee: Employee + requester: CurrentUserContext + decider: CurrentUserContext + admin: CurrentUserContext + inactive_finance: CurrentUserContext + + +def _seed_waiver_request(db: Session) -> _SeededWaiver: + owner = _employee("owner") + requester_employee = _employee("requester") + decider_employee = _employee("decider") + admin_employee = _employee("admin") + inactive_employee = _employee("inactive", employment_status="离职") + db.add_all( + [ + owner, + requester_employee, + decider_employee, + admin_employee, + inactive_employee, + _claim(owner.id), + ] + ) + observation = RiskObservationService(db).upsert_observation( + _observation_payload("risk:projection:waiver") + ) + db.commit() + + requester = _user(requester_employee, roles=["finance"]) + service = RiskDispositionService(db) + service.execute_action( + observation.id, + RiskDispositionActionCreate( + action="confirm", + expected_version=0, + request_id="projection-confirm-risk", + ), + tenant_id="default", + actor_id=requester_employee.id, + actor_name=requester_employee.name, + current_user=requester, + ) + requested = service.execute_action( + observation.id, + RiskDispositionActionCreate( + action="request_waiver", + expected_version=1, + request_id="projection-request-waiver", + waiver_reason="客户现场暂时无法补齐原件。", + waiver_scope="仅限本次报销单的当前风险观察。", + waiver_expires_at=datetime.now(UTC) + timedelta(days=2), + waiver_conditions=["到期前补齐原件"], + ), + tenant_id="default", + actor_id=requester_employee.id, + actor_name=requester_employee.name, + current_user=requester, + ) + disposition = service.get_disposition(observation.id, tenant_id="default") + assert disposition is not None + assert requested.disposition.version == 2 + return _SeededWaiver( + observation=observation, + disposition=disposition, + requester_employee=requester_employee, + decider_employee=decider_employee, + admin_employee=admin_employee, + requester=requester, + decider=_user(decider_employee, roles=["finance"]), + admin=_user(admin_employee, roles=[], is_admin=True), + inactive_finance=_user(inactive_employee, roles=["finance"]), + ) + + +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, + expire_on_commit=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]: + with factory() as db: + yield db + + app.dependency_overrides[get_db] = override_db + return TestClient(app), factory + + +def _employee(suffix: str, *, employment_status: str = "在职") -> Employee: + return Employee( + id=f"emp-{suffix}", + employee_no=f"E-{suffix.upper()}", + name=f"员工-{suffix}", + email=f"{suffix}@risk-projection.example.com", + position="财务专员", + grade="P6", + employment_status=employment_status, + ) + + +def _user( + employee: Employee, + *, + roles: list[str], + is_admin: bool = False, + tenant_id: str = "default", +) -> CurrentUserContext: + return CurrentUserContext( + username=employee.email, + name=employee.name, + role_codes=roles, + is_admin=is_admin, + tenant_id=tenant_id, + employee_no=employee.employee_no, + employee_id=employee.id, + ) + + +def _headers( + employee: Employee, + *, + roles: list[str], + is_admin: bool = False, +) -> dict[str, str]: + return { + "X-Auth-Username": employee.email, + "X-Auth-Name": employee.id, + "X-Auth-Role-Codes": ",".join(roles), + "X-Auth-Is-Admin": str(is_admin).lower(), + "X-Auth-Employee-No": employee.employee_no, + "X-Auth-Employee-Id": employee.id, + "X-Auth-Tenant-Id": "default", + } + + +def _waiver_request(*, version: int, request_id: str) -> RiskDispositionActionCreate: + return RiskDispositionActionCreate( + action="request_waiver", + expected_version=version, + request_id=request_id, + waiver_reason="客户现场暂时无法补齐原件。", + waiver_scope="仅限当前报销单的当前风险观察。", + waiver_expires_at=datetime.now(UTC) + timedelta(days=2), + waiver_conditions=["到期前补齐原件"], + ) + + +def _claim(owner_id: str) -> ExpenseClaim: + now = datetime.now(UTC) + claim = ExpenseClaim( + id="claim-risk-projection", + claim_no="BX-RISK-PROJECTION", + employee_id=owner_id, + 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=[], + ) + claim.items = [ + ExpenseClaimItem( + id="item-risk-projection", + claim_id=claim.id, + item_date=date.today(), + item_type="travel", + item_reason="客户拜访交通费", + item_location="上海", + item_note="", + item_amount=claim.amount, + invoice_id="INV-RISK-PROJECTION", + created_at=now, + updated_at=now, + ) + ] + return claim + + +def _approval_task(claim: ExpenseClaim, *, now: datetime) -> ApprovalTask: + return ApprovalTask( + id="task-risk-projection", + tenant_id="default", + claim_id=claim.id, + expense_case_id=None, + node_instance_id="node-risk-projection", + node_entry_key="node-entry-risk-projection", + parent_task_id=None, + task_kind="root", + node_key="finance", + node_label="财务审批", + node_sequence=3, + sequence_order=0, + coordination_mode="single", + owner_kind="role", + owner_key="finance", + owner_name="财务审批人", + assignee_kind="role", + assignee_key="finance", + assignee_name="财务审批人", + status="pending", + version=1, + claim_status_snapshot="submitted", + claim_stage_snapshot="财务审批", + entered_at=now - timedelta(hours=1), + entered_at_source="workflow_event", + activated_at=now - timedelta(hours=1), + sla_hours_snapshot=24, + due_at=now + timedelta(hours=23), + escalation_level=1, + next_escalation_at=now + timedelta(hours=7), + priority_score=10, + priority_tier="normal", + priority_reasons_json=[ + { + "code": "sla_escalated_l1", + "label": "审批已升级至 L1", + "weight": 10, + "tone": "danger", + } + ], + risk_level="low", + open_risk_count=0, + evidence_completeness=Decimal("1.0000"), + batch_eligible=True, + batch_block_reasons_json=[], + projection_updated_at=now, + created_at=now, + updated_at=now, + ) + + +def _assert_sla_window( + task: ApprovalTask, + expected: tuple[datetime, int, datetime, datetime, int], +) -> None: + actual = ( + _as_utc(task.entered_at), + task.sla_hours_snapshot, + _as_utc(task.due_at), + _as_utc(task.next_escalation_at), + task.escalation_level, + ) + assert actual == expected + + +def _observation_payload(observation_key: str) -> dict[str, object]: + return { + "observation_key": observation_key, + "subject_type": "expense_claim", + "subject_key": "claim:claim-risk-projection", + "subject_label": "BX-RISK-PROJECTION", + "claim_id": "claim-risk-projection", + "claim_no": "BX-RISK-PROJECTION", + "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": {}, + } + + +def _as_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) diff --git a/server/tests/test_risk_dispositions.py b/server/tests/test_risk_dispositions.py index d869447..83bd176 100644 --- a/server/tests/test_risk_dispositions.py +++ b/server/tests/test_risk_dispositions.py @@ -26,6 +26,7 @@ from app.services.risk_dispositions import ( RiskDispositionPermissionError, RiskDispositionService, RiskDispositionVersionConflictError, + RiskWaiverDecisionPermissionError, ) from app.services.risk_observation_access_policy import RiskObservationAccessPolicy from app.services.risk_observations import RiskObservationService @@ -467,7 +468,9 @@ def test_risk_observation_api_enforces_pool_claim_and_typed_action_permissions( "action": "request_waiver", "expected_version": 0, "request_id": "api-stale-request-001", - "comment": "申请风险豁免复核", + "waiver_reason": "客户现场条件导致合规材料暂缺", + "waiver_scope": "仅限本次客户拜访的行程材料", + "waiver_expires_at": (datetime.now(UTC) + timedelta(days=3)).isoformat(), }, ) changed_replay = client.post( @@ -637,9 +640,315 @@ def test_disposition_rechecks_current_approver_after_claim_stage_changes() -> No assert db.scalar(select(func.count()).select_from(RiskDispositionEvent)) == 0 +def test_waiver_approval_enforces_separation_of_duties_and_replays_snapshot( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false") + with _build_session() as db: + requester = Employee( + id="waiver-requester", + employee_no="W-REQUESTER", + name="豁免申请人", + email="waiver.requester@example.com", + employment_status="在职", + ) + decider = Employee( + id="waiver-decider", + employee_no="W-DECIDER", + name="豁免决定人", + email="waiver.decider@example.com", + employment_status="在职", + ) + admin_viewer = Employee( + id="waiver-admin", + employee_no="W-ADMIN", + name="仅管理员", + email="waiver.admin@example.com", + employment_status="在职", + ) + inactive_decider = Employee( + id="waiver-inactive", + employee_no="W-INACTIVE", + name="离职财务", + email="waiver.inactive@example.com", + employment_status="离职", + ) + db.add_all([_employee(), _claim(), requester, decider, admin_viewer, inactive_decider]) + db.flush() + observation = RiskObservationService(db).upsert_observation( + _observation_payload("risk:waiver:sod") + ) + observation_id = observation.id + db.commit() + service = RiskDispositionService(db) + requester_user = _user(requester, roles=["finance"]) + decider_user = _user(decider, roles=["finance"]) + + service.execute_action( + observation_id, + _action("confirm", version=0, request_id="waiver-confirm-request-001"), + tenant_id="default", + actor_id=requester.id, + actor_name=requester.name, + current_user=requester_user, + ) + requested = service.execute_action( + observation_id, + RiskDispositionActionCreate( + action="request_waiver", + expected_version=1, + request_id="waiver-application-request-001", + waiver_reason="客户现场临时封控,无法在审批前取得原件。", + waiver_scope="仅限本次客户拜访的纸质行程确认材料。", + waiver_expires_at=datetime.now(UTC) + timedelta(days=5), + waiver_conditions=["三日内补交扫描件", "到期前由财务复核"], + ), + tenant_id="default", + actor_id=requester.id, + actor_name=requester.name, + current_user=requester_user, + ) + + assert requested.disposition.lifecycle_status == "waiver_requested" + assert requested.disposition.waiver_requester_id == requester.id + assert requested.disposition.waiver_reason == "客户现场临时封控,无法在审批前取得原件。" + assert requested.disposition.waiver_conditions_json == [ + "三日内补交扫描件", + "到期前由财务复核", + ] + + decision = RiskDispositionActionCreate( + action="approve_waiver", + expected_version=2, + request_id="waiver-approve-request-001", + comment="条件明确且有效期受控,同意本次例外。", + ) + with pytest.raises(RiskWaiverDecisionPermissionError, match="不能批准或拒绝自己的"): + service.execute_action( + observation_id, + decision, + tenant_id="default", + actor_id=requester.id, + actor_name=requester.name, + current_user=requester_user, + ) + with pytest.raises(RiskWaiverDecisionPermissionError, match="明确角色"): + service.execute_action( + observation_id, + decision, + tenant_id="default", + actor_id=admin_viewer.id, + actor_name=admin_viewer.name, + current_user=_user(admin_viewer, roles=[], is_admin=True), + ) + with pytest.raises(RiskWaiverDecisionPermissionError, match="不是在职员工"): + service.execute_action( + observation_id, + decision, + tenant_id="default", + actor_id=inactive_decider.id, + actor_name=inactive_decider.name, + current_user=_user(inactive_decider, roles=["finance"]), + ) + with pytest.raises(RiskWaiverDecisionPermissionError, match="跨租户"): + service.execute_action( + observation_id, + decision, + tenant_id="default", + actor_id=decider.id, + actor_name=decider.name, + current_user=_user(decider, roles=["finance"], tenant_id="tenant-b"), + ) + + approved = service.execute_action( + observation_id, + decision, + tenant_id="default", + actor_id=decider.id, + actor_name=decider.name, + current_user=decider_user, + ) + replayed = service.execute_action( + observation_id, + decision, + tenant_id="default", + actor_id=decider.id, + actor_name=decider.name, + current_user=decider_user, + ) + + assert approved.disposition.lifecycle_status == "waived" + assert approved.disposition.waiver_decision == "approved" + assert approved.disposition.waiver_decider_id == decider.id + assert approved.disposition.waiver_decision_reason == decision.comment + assert replayed.replayed is True + assert replayed.event.id == approved.event.id + assert replayed.disposition.waiver_requester_id == requester.id + assert replayed.disposition.waiver_decider_id == decider.id + assert db.scalar(select(func.count()).select_from(RiskDispositionEvent)) == 3 + + +def test_waiver_rejection_is_explicit_and_decisions_require_requested_state( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false") + with _build_session() as db: + requester = Employee( + id="waiver-reject-requester", + employee_no="W-REJECT-REQ", + name="拒绝流程申请人", + email="waiver.reject.requester@example.com", + employment_status="在职", + ) + decider = Employee( + id="waiver-reject-decider", + employee_no="W-REJECT-DEC", + name="拒绝流程决定人", + email="waiver.reject.decider@example.com", + employment_status="在职", + ) + db.add_all([_employee(), _claim(), requester, decider]) + db.flush() + observation = RiskObservationService(db).upsert_observation( + _observation_payload("risk:waiver:rejected") + ) + observation_id = observation.id + db.commit() + service = RiskDispositionService(db) + requester_user = _user(requester, roles=["finance"]) + decider_user = _user(decider, roles=["executive"]) + + with pytest.raises(RiskDispositionConflictError, match="只有待决定"): + service.execute_action( + observation_id, + RiskDispositionActionCreate( + action="reject_waiver", + expected_version=0, + request_id="waiver-reject-without-request", + comment="当前没有可拒绝的申请。", + ), + tenant_id="default", + actor_id=decider.id, + actor_name=decider.name, + current_user=decider_user, + ) + + service.execute_action( + observation_id, + _action("confirm", version=0, request_id="waiver-reject-confirm"), + tenant_id="default", + actor_id=requester.id, + actor_name=requester.name, + current_user=requester_user, + ) + service.execute_action( + observation_id, + RiskDispositionActionCreate( + action="request_waiver", + expected_version=1, + request_id="waiver-reject-application", + waiver_reason="希望免除本次高风险拦截。", + waiver_scope="本次重复票据风险。", + waiver_expires_at=datetime.now(UTC) + timedelta(days=2), + waiver_conditions=[], + ), + tenant_id="default", + actor_id=requester.id, + actor_name=requester.name, + current_user=requester_user, + ) + rejected = service.execute_action( + observation_id, + RiskDispositionActionCreate( + action="reject_waiver", + expected_version=2, + request_id="waiver-reject-decision", + comment="缺少足够补偿控制,拒绝豁免。", + ), + tenant_id="default", + actor_id=decider.id, + actor_name=decider.name, + current_user=decider_user, + ) + + assert rejected.disposition.lifecycle_status == "waiver_rejected" + assert rejected.disposition.waiver_decision == "rejected" + assert rejected.disposition.waiver_decider_id == decider.id + assert rejected.event.action == "reject_waiver" + + +def test_waiver_self_decision_endpoint_returns_typed_forbidden_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false") + client, session_factory = _build_client() + requester = Employee( + id="waiver-api-requester", + employee_no="W-API-REQ", + name="接口豁免申请人", + email="waiver.api.requester@example.com", + employment_status="在职", + ) + requester_user = _user(requester, roles=["finance"]) + requester_headers = { + "X-Auth-Username": requester.email, + "X-Auth-Name": "Waiver Requester", + "X-Auth-Employee-No": requester.employee_no, + "X-Auth-Employee-Id": requester.id, + "X-Auth-Role-Codes": "finance", + } + with session_factory() as db: + db.add_all([_employee(), _claim(), requester]) + db.flush() + observation = RiskObservationService(db).upsert_observation( + _observation_payload("risk:waiver:api-sod") + ) + observation_id = observation.id + db.commit() + service = RiskDispositionService(db) + service.execute_action( + observation_id, + _action("confirm", version=0, request_id="waiver-api-confirm"), + tenant_id="default", + actor_id=requester.id, + actor_name=requester.name, + current_user=requester_user, + ) + service.execute_action( + observation_id, + RiskDispositionActionCreate( + action="request_waiver", + expected_version=1, + request_id="waiver-api-application", + waiver_reason="接口申请职责分离验证。", + waiver_scope="仅限当前风险观察。", + waiver_expires_at=datetime.now(UTC) + timedelta(days=2), + ), + tenant_id="default", + actor_id=requester.id, + actor_name=requester.name, + current_user=requester_user, + ) + + response = client.post( + f"/api/v1/risk-observations/{observation_id}/disposition/actions", + headers=requester_headers, + json={ + "action": "approve_waiver", + "expected_version": 2, + "request_id": "waiver-api-self-approve", + "comment": "尝试自批。", + }, + ) + + assert response.status_code == 403 + assert response.json()["detail"]["code"] == "RISK_WAIVER_DECISION_FORBIDDEN" + assert "不能批准或拒绝自己的" in response.json()["detail"]["message"] + + @pytest.mark.parametrize( "action", - ["false_positive", "request_supplement", "request_waiver"], + ["false_positive", "request_supplement"], ) def test_evidence_sensitive_actions_require_server_side_comment(action: str) -> None: with pytest.raises(ValueError, match="必须填写 comment"): @@ -650,21 +959,39 @@ def test_evidence_sensitive_actions_require_server_side_comment(action: str) -> ) +def test_waiver_request_requires_reason_scope_and_expiry() -> None: + with pytest.raises(ValueError, match="必须填写原因、范围和有效期"): + RiskDispositionActionCreate( + action="request_waiver", + expected_version=0, + request_id="request-waiver-fields-missing", + ) + + def _action( action: str, *, version: int, request_id: str, ) -> RiskDispositionActionCreate: + waiver_fields: dict[str, object] = {} + if action == "request_waiver": + waiver_fields = { + "waiver_reason": "客户现场条件导致合规材料暂缺", + "waiver_scope": "仅限本次客户拜访的行程材料", + "waiver_expires_at": datetime.now(UTC) + timedelta(days=3), + "waiver_conditions": ["补齐客户确认函", "到期前完成复核"], + } return RiskDispositionActionCreate( action=action, expected_version=version, request_id=request_id, comment=( "风险处置说明" - if action in {"false_positive", "request_supplement", "request_waiver"} + if action in {"false_positive", "request_supplement"} else None ), + **waiver_fields, ) @@ -713,6 +1040,24 @@ def _employee() -> Employee: ) +def _user( + employee: Employee, + *, + roles: list[str], + is_admin: bool = False, + tenant_id: str = "default", +) -> CurrentUserContext: + return CurrentUserContext( + username=employee.email, + name=employee.name, + role_codes=roles, + is_admin=is_admin, + tenant_id=tenant_id, + employee_no=employee.employee_no, + employee_id=employee.id, + ) + + def _claim() -> ExpenseClaim: now = datetime(2026, 7, 16, tzinfo=UTC) return ExpenseClaim( diff --git a/server/tests/test_schema_ownership.py b/server/tests/test_schema_ownership.py index a9bf2e3..4f853cf 100644 --- a/server/tests/test_schema_ownership.py +++ b/server/tests/test_schema_ownership.py @@ -20,6 +20,8 @@ def test_create_legacy_schema_never_creates_migration_owned_tables() -> None: "ai_decision_feedback", "ai_decisions", "approval_action_ledgers", + "approval_task_events", + "approval_tasks", "auth_sessions", "attachment_association_jobs", "business_events", 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 5837dcc..2b87a37 100644 --- a/web/src/assets/styles/components/risk-observation-evidence-card.css +++ b/web/src/assets/styles/components/risk-observation-evidence-card.css @@ -252,13 +252,15 @@ .risk-disposition-badges em.confirmed, .risk-disposition-badges em.remediation_in_progress, -.risk-disposition-badges em.waiver_requested { +.risk-disposition-badges em.waiver_requested, +.risk-disposition-badges em.waiver_rejected { background: var(--warning-soft); color: var(--warning-active); } .risk-disposition-badges em.false_positive, -.risk-disposition-badges em.resolved { +.risk-disposition-badges em.resolved, +.risk-disposition-badges em.waived { background: var(--success-soft); color: var(--success); } @@ -318,8 +320,7 @@ } .risk-disposition-resolution, -.risk-disposition-complete, -.risk-disposition-error { +.risk-disposition-complete { margin: 0; color: #475569; font-size: 12px; @@ -328,7 +329,34 @@ } .risk-disposition-error { + display: grid; + gap: 2px; + padding: 9px 10px; + border: 1px solid var(--danger-line); + border-radius: 5px; + background: rgba(239, 68, 68, .06); color: var(--danger); + font-size: 12px; + line-height: 1.5; +} + +.risk-disposition-error strong { + font-weight: 900; +} + +.risk-disposition-error span { + font-weight: 700; +} + +.risk-disposition-read-only { + display: flex; + align-items: flex-start; + gap: 6px; + margin: 0; + color: #64748b; + font-size: 12px; + font-weight: 700; + line-height: 1.55; } .risk-evidence-meta { diff --git a/web/src/assets/styles/components/risk-waiver-action-dialog.css b/web/src/assets/styles/components/risk-waiver-action-dialog.css new file mode 100644 index 0000000..e27732b --- /dev/null +++ b/web/src/assets/styles/components/risk-waiver-action-dialog.css @@ -0,0 +1,231 @@ +.risk-waiver-dialog-backdrop { + position: fixed; + z-index: 1800; + inset: 0; + display: grid; + place-items: center; + overflow-y: auto; + padding: 24px; + background: rgba(15, 23, 42, .52); + backdrop-filter: blur(2px); +} + +.risk-waiver-dialog { + width: min(620px, 100%); + max-height: calc(100vh - 48px); + overflow-y: auto; + display: grid; + gap: 14px; + padding: 18px; + border: 1px solid #dbe5ef; + border-radius: 10px; + background: #fff; + box-shadow: 0 24px 70px rgba(15, 23, 42, .24); +} + +.risk-waiver-dialog header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.risk-waiver-dialog header > div { + display: grid; + gap: 3px; +} + +.risk-waiver-dialog header span { + color: #64748b; + font-size: 12px; + font-weight: 850; +} + +.risk-waiver-dialog header h3 { + margin: 0; + color: #0f172a; + font-size: 18px; + font-weight: 900; +} + +.risk-waiver-dialog header button { + width: 32px; + height: 32px; + border: 1px solid #dbe5ef; + border-radius: 6px; + background: #fff; + color: #64748b; + cursor: pointer; +} + +.risk-waiver-dialog-notice { + margin: 0; + padding: 10px 12px; + border: 1px solid var(--warning-line); + border-radius: 6px; + background: var(--warning-soft); + color: var(--warning-active); + font-size: 12px; + font-weight: 750; + line-height: 1.6; +} + +.risk-waiver-dialog-notice.danger { + border-color: var(--danger-line); + background: rgba(239, 68, 68, .07); + color: var(--danger); +} + +.risk-waiver-dialog-summary { + display: grid; + gap: 8px; + margin: 0; + padding: 12px; + border: 1px solid #e6edf5; + border-radius: 6px; + background: #f8fafc; +} + +.risk-waiver-dialog-summary div { + display: grid; + grid-template-columns: 76px minmax(0, 1fr); + gap: 10px; +} + +.risk-waiver-dialog-summary dt, +.risk-waiver-dialog-summary dd { + margin: 0; + font-size: 12px; + line-height: 1.55; +} + +.risk-waiver-dialog-summary dt { + color: #64748b; + font-weight: 850; +} + +.risk-waiver-dialog-summary dd { + color: #0f172a; + font-weight: 700; + overflow-wrap: anywhere; +} + +.risk-waiver-dialog form, +.risk-waiver-dialog label { + display: grid; + gap: 8px; +} + +.risk-waiver-dialog form { + gap: 13px; +} + +.risk-waiver-dialog label > span { + color: #334155; + font-size: 12px; + font-weight: 850; +} + +.risk-waiver-dialog label em, +.risk-waiver-dialog label small { + margin-left: 4px; + color: var(--danger); + font-size: 11px; + font-style: normal; + font-weight: 800; +} + +.risk-waiver-dialog label small { + color: #94a3b8; +} + +.risk-waiver-dialog textarea, +.risk-waiver-dialog input { + width: 100%; + padding: 9px 10px; + border: 1px solid #dbe5ef; + border-radius: 5px; + background: #fff; + color: #0f172a; + font: inherit; + font-size: 12px; + line-height: 1.55; +} + +.risk-waiver-dialog textarea { + min-height: 72px; + resize: vertical; +} + +.risk-waiver-dialog textarea:focus, +.risk-waiver-dialog input:focus { + border-color: var(--theme-primary); + box-shadow: 0 0 0 3px var(--theme-focus-ring); + outline: 0; +} + +.risk-waiver-dialog-validation { + margin: 0; + color: var(--danger); + font-size: 12px; + font-weight: 800; +} + +.risk-waiver-dialog footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 4px; +} + +.risk-waiver-dialog footer button { + min-height: 34px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 14px; + border: 1px solid #dbe5ef; + border-radius: 5px; + background: #fff; + color: #334155; + font-size: 12px; + font-weight: 850; + cursor: pointer; +} + +.risk-waiver-dialog footer button.primary { + border-color: var(--warning-line); + background: var(--warning-soft); + color: var(--warning-active); +} + +.risk-waiver-dialog footer button.primary.danger { + border-color: var(--danger-line); + background: rgba(239, 68, 68, .07); + color: var(--danger); +} + +.risk-waiver-dialog button:disabled, +.risk-waiver-dialog textarea:disabled, +.risk-waiver-dialog input:disabled { + cursor: not-allowed; + opacity: .66; +} + +@media (max-width: 640px) { + .risk-waiver-dialog-backdrop { + align-items: end; + padding: 0; + } + + .risk-waiver-dialog { + width: 100%; + max-height: 92vh; + border-radius: 12px 12px 0 0; + } + + .risk-waiver-dialog-summary div { + grid-template-columns: minmax(0, 1fr); + gap: 2px; + } +} diff --git a/web/src/assets/styles/components/risk-waiver-record.css b/web/src/assets/styles/components/risk-waiver-record.css new file mode 100644 index 0000000..a85f78c --- /dev/null +++ b/web/src/assets/styles/components/risk-waiver-record.css @@ -0,0 +1,171 @@ +.risk-waiver-record { + display: grid; + gap: 10px; + padding: 11px; + border: 1px solid #e6edf5; + border-radius: 6px; + background: rgba(255, 255, 255, .82); +} + +.risk-waiver-record-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.risk-waiver-record-head strong { + color: #0f172a; + font-size: 12px; + font-weight: 900; +} + +.risk-waiver-record-head em { + padding: 3px 7px; + border-radius: 4px; + background: var(--warning-soft); + color: var(--warning-active); + font-size: 11px; + font-style: normal; + font-weight: 850; +} + +.risk-waiver-record-head em.approved { + background: var(--success-soft); + color: var(--success); +} + +.risk-waiver-record-head em.rejected { + background: rgba(239, 68, 68, .08); + color: var(--danger); +} + +.risk-waiver-record dl { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 9px 14px; + margin: 0; +} + +.risk-waiver-record dl > div { + min-width: 0; + display: grid; + gap: 3px; +} + +.risk-waiver-record dl > div.wide { + grid-column: 1 / -1; +} + +.risk-waiver-record dt, +.risk-waiver-record dd { + margin: 0; + font-size: 12px; + line-height: 1.55; +} + +.risk-waiver-record dt { + color: #64748b; + font-weight: 850; +} + +.risk-waiver-record dd { + color: #0f172a; + font-weight: 700; + overflow-wrap: anywhere; +} + +.risk-waiver-record dd.expired { + color: var(--danger); +} + +.risk-waiver-record dd small, +.risk-waiver-record dd span { + display: block; + margin-top: 2px; + color: #64748b; + font-size: 11px; + font-weight: 650; +} + +.risk-waiver-record ul { + display: grid; + gap: 3px; + margin: 0; + padding-left: 18px; +} + +.risk-waiver-history { + display: grid; + gap: 8px; + padding-top: 2px; +} + +.risk-waiver-history h4 { + margin: 0; + color: #334155; + font-size: 12px; + font-weight: 900; +} + +.risk-waiver-history ol { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.risk-waiver-history li { + display: grid; + gap: 3px; + padding: 9px 10px; + border-left: 3px solid #cbd5e1; + background: #f8fafc; +} + +.risk-waiver-history li > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.risk-waiver-history strong, +.risk-waiver-history em, +.risk-waiver-history p, +.risk-waiver-history span, +.risk-waiver-history code { + margin: 0; + color: #475569; + font-size: 11px; + font-style: normal; + line-height: 1.5; +} + +.risk-waiver-history strong { + color: #0f172a; + font-size: 12px; + font-weight: 850; +} + +.risk-waiver-history em, +.risk-waiver-history code { + color: #64748b; + font-weight: 750; +} + +.risk-waiver-history code { + overflow-wrap: anywhere; + font-family: inherit; +} + +@media (max-width: 960px) { + .risk-waiver-record dl { + grid-template-columns: minmax(0, 1fr); + } + + .risk-waiver-record dl > div.wide { + grid-column: auto; + } +} diff --git a/web/src/components/approval/ApprovalAssignmentDialog.vue b/web/src/components/approval/ApprovalAssignmentDialog.vue new file mode 100644 index 0000000..8b9feff --- /dev/null +++ b/web/src/components/approval/ApprovalAssignmentDialog.vue @@ -0,0 +1,409 @@ + + + + + diff --git a/web/src/components/approval/ApprovalBatchResultPanel.vue b/web/src/components/approval/ApprovalBatchResultPanel.vue new file mode 100644 index 0000000..807a13d --- /dev/null +++ b/web/src/components/approval/ApprovalBatchResultPanel.vue @@ -0,0 +1,193 @@ + + + + + diff --git a/web/src/components/approval/ApprovalParticipantsDialog.vue b/web/src/components/approval/ApprovalParticipantsDialog.vue new file mode 100644 index 0000000..2aca68c --- /dev/null +++ b/web/src/components/approval/ApprovalParticipantsDialog.vue @@ -0,0 +1,497 @@ + + + + + diff --git a/web/src/components/approval/ApprovalTaskQueue.vue b/web/src/components/approval/ApprovalTaskQueue.vue new file mode 100644 index 0000000..78433c8 --- /dev/null +++ b/web/src/components/approval/ApprovalTaskQueue.vue @@ -0,0 +1,696 @@ + + + + + diff --git a/web/src/components/approval/ApprovalTaskWorkspace.vue b/web/src/components/approval/ApprovalTaskWorkspace.vue new file mode 100644 index 0000000..6ea50f2 --- /dev/null +++ b/web/src/components/approval/ApprovalTaskWorkspace.vue @@ -0,0 +1,390 @@ + + + + + diff --git a/web/src/components/layout/TopBar.vue b/web/src/components/layout/TopBar.vue index 31fc4f8..7a1241e 100644 --- a/web/src/components/layout/TopBar.vue +++ b/web/src/components/layout/TopBar.vue @@ -328,21 +328,6 @@ - - + + + diff --git a/web/src/components/travel/RiskWaiverRecord.vue b/web/src/components/travel/RiskWaiverRecord.vue new file mode 100644 index 0000000..085da78 --- /dev/null +++ b/web/src/components/travel/RiskWaiverRecord.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/web/src/composables/useApprovalTaskQueue.js b/web/src/composables/useApprovalTaskQueue.js new file mode 100644 index 0000000..8ab27dd --- /dev/null +++ b/web/src/composables/useApprovalTaskQueue.js @@ -0,0 +1,339 @@ +import { computed, ref } from 'vue' + +import { + batchApproveApprovalTasks, + fetchApprovalTaskDetail, + fetchApprovalTasks +} from '../services/approvalTasks.js' +import { resolveApprovalTaskRetryKey } from '../views/scripts/approvalTaskRetry.js' +import { + createApprovalTaskSelectionState, + reconcileApprovalTaskSelectionAfterBatch, + reconcileApprovalTaskSelectionWithPage, + resolveCurrentPageApprovalSelection, + syncApprovalTaskSelectionFilters, + toggleApprovalTaskSelection, + toggleCurrentPageApprovalSelection +} from '../views/scripts/approvalTaskQueueState.js' + +const DEFAULT_FILTERS = Object.freeze({ + status: 'pending', + nodeKey: '', + taskKind: '', + riskLevel: '', + slaState: '', + keyword: '', + sort: 'priority_desc', + batchEligible: null +}) +const MAX_BATCH_SELECTION = 20 + +function toText(value) { + return String(value ?? '').trim() +} + +function toPositiveInteger(value, fallback = 1) { + const number = Math.trunc(Number(value)) + return Number.isFinite(number) && number > 0 ? number : fallback +} + +function taskIdOf(item = {}) { + return toText(item?.task?.id ?? item?.id ?? item?.task_id ?? item?.taskId) +} + +export function normalizeApprovalTaskQueueFilters(filters = {}) { + return { + ...DEFAULT_FILTERS, + ...filters, + status: filters.status ?? DEFAULT_FILTERS.status, + nodeKey: filters.nodeKey ?? filters.node_key ?? DEFAULT_FILTERS.nodeKey, + taskKind: filters.taskKind ?? filters.task_kind ?? DEFAULT_FILTERS.taskKind, + riskLevel: filters.riskLevel ?? filters.risk_level ?? DEFAULT_FILTERS.riskLevel, + slaState: filters.slaState ?? filters.sla_state ?? DEFAULT_FILTERS.slaState, + keyword: toText(filters.keyword), + sort: filters.sort ?? DEFAULT_FILTERS.sort, + batchEligible: typeof (filters.batchEligible ?? filters.batch_eligible) === 'boolean' + ? Boolean(filters.batchEligible ?? filters.batch_eligible) + : null + } +} + +export function useApprovalTaskQueue(options = {}) { + const services = { + fetchApprovalTasks, + fetchApprovalTaskDetail, + batchApproveApprovalTasks, + ...(options.services || {}) + } + const page = ref(toPositiveInteger(options.initialPage, 1)) + const pageSize = ref(toPositiveInteger(options.initialPageSize, 20)) + const filters = ref(normalizeApprovalTaskQueueFilters(options.initialFilters)) + const items = ref([]) + const total = ref(0) + const totalPages = ref(0) + const generatedAt = ref('') + const loading = ref(false) + const batchBusy = ref(false) + const errorMessage = ref('') + const lastBatchResult = ref(null) + const pendingBatchRequestId = ref('') + const pendingBatchFingerprint = ref('') + const selectionState = ref(createApprovalTaskSelectionState({ filters: filters.value })) + const selectedSnapshots = new Map() + const taskRefreshSequences = new Map() + let listRequestSequence = 0 + + const selectedTaskIds = computed(() => selectionState.value.selectedTaskIds) + const pageSelection = computed(() => + resolveCurrentPageApprovalSelection(items.value, selectedTaskIds.value) + ) + const selectedCount = computed(() => selectedTaskIds.value.length) + + function updateSelection(nextTaskIds) { + const selectedSet = new Set(nextTaskIds) + selectionState.value = { + ...selectionState.value, + selectedTaskIds: [...selectedSet] + } + for (const taskId of [...selectedSnapshots.keys()]) { + if (!selectedSet.has(taskId)) { + selectedSnapshots.delete(taskId) + } + } + for (const item of items.value) { + const taskId = taskIdOf(item) + if (taskId && selectedSet.has(taskId)) { + selectedSnapshots.set(taskId, item) + } + } + } + + function applyListResult(result) { + items.value = Array.isArray(result?.items) ? result.items : [] + total.value = Math.max(0, Number(result?.total) || 0) + totalPages.value = Math.max(0, Number(result?.totalPages) || 0) + generatedAt.value = toText(result?.generatedAt) + page.value = toPositiveInteger(result?.page, page.value) + pageSize.value = toPositiveInteger(result?.pageSize, pageSize.value) + updateSelection(reconcileApprovalTaskSelectionWithPage( + selectedTaskIds.value, + items.value + )) + } + + async function loadQueue(loadOptions = {}) { + const requestSequence = ++listRequestSequence + if (!loadOptions.silent) { + loading.value = true + } + errorMessage.value = '' + try { + const result = await services.fetchApprovalTasks({ + ...filters.value, + page: page.value, + pageSize: pageSize.value + }, loadOptions.requestOptions) + if (requestSequence !== listRequestSequence) { + return { ignored: true, result } + } + applyListResult(result) + return { ignored: false, result } + } catch (error) { + if (requestSequence !== listRequestSequence) { + return { ignored: true, error } + } + errorMessage.value = error?.message || '审批任务列表加载失败。' + throw error + } finally { + if (requestSequence === listRequestSequence) { + loading.value = false + } + } + } + + function setFilters(nextFilters = {}, setOptions = {}) { + const normalized = normalizeApprovalTaskQueueFilters({ + ...filters.value, + ...nextFilters + }) + selectionState.value = syncApprovalTaskSelectionFilters( + selectionState.value, + normalized + ) + if (!selectionState.value.selectedTaskIds.length) { + selectedSnapshots.clear() + } + filters.value = normalized + page.value = 1 + return setOptions.reload === false ? Promise.resolve(null) : loadQueue() + } + + function setPage(nextPage, setOptions = {}) { + page.value = toPositiveInteger(nextPage, 1) + return setOptions.reload === false ? Promise.resolve(null) : loadQueue() + } + + function setPageSize(nextPageSize, setOptions = {}) { + pageSize.value = toPositiveInteger(nextPageSize, pageSize.value) + page.value = 1 + return setOptions.reload === false ? Promise.resolve(null) : loadQueue() + } + + function toggleTask(item, selected) { + const nextSelection = toggleApprovalTaskSelection(selectedTaskIds.value, item, selected) + if (nextSelection.length > MAX_BATCH_SELECTION) { + errorMessage.value = `批量审批每次最多选择 ${MAX_BATCH_SELECTION} 项任务。` + return + } + updateSelection(nextSelection) + } + + function toggleCurrentPage(selected) { + const nextSelection = toggleCurrentPageApprovalSelection( + selectedTaskIds.value, + items.value, + selected + ) + if (nextSelection.length > MAX_BATCH_SELECTION) { + errorMessage.value = `批量审批每次最多选择 ${MAX_BATCH_SELECTION} 项,已保留当前结果中的前 ${MAX_BATCH_SELECTION} 项。` + updateSelection(nextSelection.slice(0, MAX_BATCH_SELECTION)) + return + } + updateSelection(nextSelection) + } + + function clearSelection() { + updateSelection([]) + } + + function replaceTaskItem(taskId, nextItem) { + const index = items.value.findIndex((item) => taskIdOf(item) === taskId) + if (index < 0) { + return + } + if (!nextItem) { + items.value = items.value.filter((_, itemIndex) => itemIndex !== index) + total.value = Math.max(0, total.value - 1) + } else { + items.value = items.value.map((item, itemIndex) => itemIndex === index ? nextItem : item) + } + updateSelection(reconcileApprovalTaskSelectionWithPage( + selectedTaskIds.value, + items.value + )) + } + + async function refreshTask(taskId, refreshOptions = {}) { + const normalizedTaskId = toText(taskId) + if (!normalizedTaskId) { + return null + } + const sequence = (taskRefreshSequences.get(normalizedTaskId) || 0) + 1 + taskRefreshSequences.set(normalizedTaskId, sequence) + try { + const nextItem = await services.fetchApprovalTaskDetail( + normalizedTaskId, + refreshOptions.requestOptions + ) + if (taskRefreshSequences.get(normalizedTaskId) !== sequence) { + return null + } + replaceTaskItem(normalizedTaskId, nextItem) + return nextItem + } catch (error) { + if (taskRefreshSequences.get(normalizedTaskId) !== sequence) { + return null + } + if (Number(error?.status) === 404) { + replaceTaskItem(normalizedTaskId, null) + return null + } + errorMessage.value = error?.message || '审批任务刷新失败。' + throw error + } + } + + async function batchApprove(batchOptions = {}) { + if (batchBusy.value) { + return null + } + const selectedItems = selectedTaskIds.value.map((taskId) => selectedSnapshots.get(taskId)) + if (!selectedItems.length || selectedItems.some((item) => !item)) { + throw new Error('所选审批任务快照已失效,请刷新列表后重新选择。') + } + const opinion = toText(batchOptions.opinion) + const payloadItems = selectedItems.map((item) => opinion ? { ...item, opinion } : item) + const retryKey = resolveApprovalTaskRetryKey({ + action: 'batch-approve', + scopeId: 'queue', + payload: { items: payloadItems }, + previousFingerprint: pendingBatchFingerprint.value, + previousRequestId: pendingBatchRequestId.value + }) + pendingBatchRequestId.value = retryKey.requestId.slice(0, 80) + pendingBatchFingerprint.value = retryKey.fingerprint + batchBusy.value = true + errorMessage.value = '' + try { + const result = await services.batchApproveApprovalTasks({ + batchRequestId: pendingBatchRequestId.value, + items: payloadItems + }, batchOptions.requestOptions) + lastBatchResult.value = result + pendingBatchRequestId.value = '' + pendingBatchFingerprint.value = '' + updateSelection(reconcileApprovalTaskSelectionAfterBatch( + selectedTaskIds.value, + result + )) + try { + await loadQueue({ silent: true }) + } catch { + // 批量结果已经落定,列表刷新失败由 errorMessage 明确展示,不覆盖真实结果。 + } + return result + } catch (error) { + errorMessage.value = error?.message || '批量审批失败,请刷新任务状态后重试。' + throw error + } finally { + batchBusy.value = false + } + } + + function clearBatchResult() { + lastBatchResult.value = null + } + + function clearError() { + errorMessage.value = '' + } + + return { + batchApprove, + batchBusy, + clearBatchResult, + clearError, + clearSelection, + errorMessage, + filters, + generatedAt, + items, + lastBatchResult, + loadQueue, + loading, + page, + pageSelection, + pageSize, + pendingBatchRequestId, + refreshTask, + selectedCount, + selectedTaskIds, + setFilters, + setPage, + setPageSize, + toggleCurrentPage, + toggleTask, + total, + totalPages + } +} diff --git a/web/src/composables/useDocumentCenterArchiveRows.js b/web/src/composables/useDocumentCenterArchiveRows.js new file mode 100644 index 0000000..790aca7 --- /dev/null +++ b/web/src/composables/useDocumentCenterArchiveRows.js @@ -0,0 +1,43 @@ +import { ref } from 'vue' + +import { + REIMBURSEMENT_LIST_PREVIEW_PARAMS, + extractExpenseClaimItems, + fetchArchivedExpenseClaims +} from '../services/reimbursements.js' +import { buildDocumentRow } from '../utils/documentCenterViewModel.js' +import { mapExpenseClaimToRequest } from './useRequests.js' + +export function useDocumentCenterArchiveRows(options = {}) { + const archiveRows = ref([]) + const supportingLoading = ref(false) + const supportingError = ref('') + let requestSequence = 0 + + async function loadSupportingRows() { + const sequence = ++requestSequence + supportingLoading.value = true + supportingError.value = '' + try { + const payload = await fetchArchivedExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS) + if (sequence !== requestSequence) return + archiveRows.value = extractExpenseClaimItems(payload) + .map(mapExpenseClaimToRequest) + .map((item) => buildDocumentRow(item, { + source: 'archive', + archived: true, + currentUser: options.currentUser?.value, + viewedDocumentKeys: options.viewedDocumentKeys?.value + })) + .filter(Boolean) + } catch (error) { + if (sequence !== requestSequence) return + archiveRows.value = [] + supportingError.value = error?.message || '归档数据加载失败。' + } finally { + if (sequence === requestSequence) supportingLoading.value = false + } + } + + return { archiveRows, supportingError, supportingLoading, loadSupportingRows } +} diff --git a/web/src/services/approvalTasks.js b/web/src/services/approvalTasks.js new file mode 100644 index 0000000..c2bee5b --- /dev/null +++ b/web/src/services/approvalTasks.js @@ -0,0 +1,535 @@ +import { apiRequest } from './api.js' + +const APPROVAL_TASKS_PATH = '/approval-tasks' +const DEFAULT_PAGE_SIZE = 20 +const MAX_PAGE_SIZE = 200 +const DEFAULT_TIMEOUT_MS = 5000 + +function toObject(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? value : {} +} + +function toArray(value) { + return Array.isArray(value) ? value : [] +} + +function toText(value) { + return String(value ?? '').trim() +} + +function toNumber(value, fallback = 0) { + const number = Number(value) + return Number.isFinite(number) ? number : fallback +} + +function toInteger(value, fallback = 0) { + return Math.trunc(toNumber(value, fallback)) +} + +function toPositiveInteger(value, fallback = 1, maximum = Number.MAX_SAFE_INTEGER) { + return Math.min(maximum, Math.max(1, toInteger(value, fallback))) +} + +function toNullableObject(value) { + const object = toObject(value) + return Object.keys(object).length ? object : null +} + +function uniqueTexts(value) { + return [...new Set(toArray(value).map(toText).filter(Boolean))] +} + +function normalizePriorityReason(item = {}) { + return { + code: toText(item.code), + label: toText(item.label), + weight: toNumber(item.weight), + tone: toText(item.tone || 'normal') || 'normal' + } +} + +export function createApprovalTaskRequestId(action = 'action', taskId = 'task') { + const normalizedAction = toText(action).replace(/[^a-z0-9_-]+/gi, '-') || 'action' + const normalizedTaskId = toText(taskId).replace(/[^a-z0-9_-]+/gi, '-') || 'task' + const suffix = typeof globalThis.crypto?.randomUUID === 'function' + ? globalThis.crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}` + return `approval:${normalizedAction}:${normalizedTaskId}:${suffix}`.slice(0, 120) +} + +export function normalizeApprovalTask(item = {}) { + const task = toObject(item?.task || item) + if (!toText(task.id)) { + return null + } + + return { + id: toText(task.id), + tenantId: toText(task.tenant_id ?? task.tenantId), + claimId: toText(task.claim_id ?? task.claimId), + expenseCaseId: toText(task.expense_case_id ?? task.expenseCaseId), + nodeInstanceId: toText(task.node_instance_id ?? task.nodeInstanceId), + nodeEntryKey: toText(task.node_entry_key ?? task.nodeEntryKey), + parentTaskId: toText(task.parent_task_id ?? task.parentTaskId), + taskKind: toText(task.task_kind ?? task.taskKind ?? 'root') || 'root', + nodeKey: toText(task.node_key ?? task.nodeKey), + nodeLabel: toText(task.node_label ?? task.nodeLabel), + nodeSequence: toInteger(task.node_sequence ?? task.nodeSequence), + sequenceOrder: toInteger(task.sequence_order ?? task.sequenceOrder), + coordinationMode: toText(task.coordination_mode ?? task.coordinationMode ?? 'single') || 'single', + ownerKind: toText(task.owner_kind ?? task.ownerKind), + ownerKey: toText(task.owner_key ?? task.ownerKey), + ownerEmployeeId: toText(task.owner_employee_id ?? task.ownerEmployeeId), + ownerName: toText(task.owner_name ?? task.ownerName), + assigneeKind: toText(task.assignee_kind ?? task.assigneeKind), + assigneeKey: toText(task.assignee_key ?? task.assigneeKey), + assigneeEmployeeId: toText(task.assignee_employee_id ?? task.assigneeEmployeeId), + assigneeName: toText(task.assignee_name ?? task.assigneeName), + delegatedBy: toText(task.delegated_by ?? task.delegatedBy), + delegationExpiresAt: toText(task.delegation_expires_at ?? task.delegationExpiresAt), + status: toText(task.status), + decision: toText(task.decision), + opinion: toText(task.opinion), + version: Math.max(0, toInteger(task.version)), + claimStatusSnapshot: toText(task.claim_status_snapshot ?? task.claimStatusSnapshot), + claimStageSnapshot: toText(task.claim_stage_snapshot ?? task.claimStageSnapshot), + enteredAt: toText(task.entered_at ?? task.enteredAt), + enteredAtSource: toText(task.entered_at_source ?? task.enteredAtSource), + activatedAt: toText(task.activated_at ?? task.activatedAt), + slaHoursSnapshot: Math.max(0, toInteger(task.sla_hours_snapshot ?? task.slaHoursSnapshot)), + dueAt: toText(task.due_at ?? task.dueAt), + completedAt: toText(task.completed_at ?? task.completedAt), + cancelledAt: toText(task.cancelled_at ?? task.cancelledAt), + escalationLevel: Math.max(0, toInteger(task.escalation_level ?? task.escalationLevel)), + escalatedAt: toText(task.escalated_at ?? task.escalatedAt), + nextEscalationAt: toText(task.next_escalation_at ?? task.nextEscalationAt), + priorityScore: Math.max(0, Math.min(100, toNumber(task.priority_score ?? task.priorityScore))), + priorityTier: toText(task.priority_tier ?? task.priorityTier ?? 'normal') || 'normal', + priorityReasons: toArray(task.priority_reasons_json ?? task.priorityReasons) + .map(normalizePriorityReason) + .filter((reason) => reason.code || reason.label), + riskLevel: toText(task.risk_level ?? task.riskLevel ?? 'low') || 'low', + openRiskCount: Math.max(0, toInteger(task.open_risk_count ?? task.openRiskCount)), + evidenceCompleteness: Math.max( + 0, + Math.min(1, toNumber(task.evidence_completeness ?? task.evidenceCompleteness)) + ), + batchEligible: Boolean(task.batch_eligible ?? task.batchEligible), + batchBlockReasons: uniqueTexts(task.batch_block_reasons_json ?? task.batchBlockReasons), + projectionUpdatedAt: toText(task.projection_updated_at ?? task.projectionUpdatedAt), + createdAt: toText(task.created_at ?? task.createdAt), + updatedAt: toText(task.updated_at ?? task.updatedAt), + canAct: Boolean(task.can_act ?? task.canAct), + availableActions: uniqueTexts(task.available_actions ?? task.availableActions), + readOnlyReason: toText(task.read_only_reason ?? task.readOnlyReason) + } +} + +export function normalizeApprovalTaskQueueItem(item = {}) { + const source = toObject(item) + const task = normalizeApprovalTask(source.task || source) + if (!task) { + return null + } + return { + task, + claim: toNullableObject(source.claim), + id: task.id, + claimId: task.claimId + } +} + +export function normalizeApprovalTaskEvent(item = {}) { + const event = toObject(item) + if (!toText(event.id)) { + return null + } + return { + id: toText(event.id), + tenantId: toText(event.tenant_id ?? event.tenantId), + taskId: toText(event.task_id ?? event.taskId), + nodeInstanceId: toText(event.node_instance_id ?? event.nodeInstanceId), + eventType: toText(event.event_type ?? event.eventType), + actorId: toText(event.actor_id ?? event.actorId), + actorName: toText(event.actor_name ?? event.actorName), + actorType: toText(event.actor_type ?? event.actorType), + requestId: toText(event.request_id ?? event.requestId), + expectedTaskVersion: Math.max( + 0, + toInteger(event.expected_task_version ?? event.expectedTaskVersion) + ), + resultTaskVersion: Math.max( + 0, + toInteger(event.result_task_version ?? event.resultTaskVersion) + ), + payload: toObject(event.payload_json ?? event.payload), + before: toObject(event.before_json ?? event.before), + after: toObject(event.after_json ?? event.after), + approvalActionLedgerId: toText( + event.approval_action_ledger_id ?? event.approvalActionLedgerId + ), + businessEventId: toText(event.business_event_id ?? event.businessEventId), + correlationId: toText(event.correlation_id ?? event.correlationId), + causationId: toText(event.causation_id ?? event.causationId), + occurredAt: toText(event.occurred_at ?? event.occurredAt) + } +} + +export function normalizeApprovalTaskMutation(payload = {}) { + const envelope = toObject(payload) + const mutation = toObject(envelope.mutation || envelope) + return { + task: normalizeApprovalTask(mutation.task), + event: normalizeApprovalTaskEvent(mutation.event), + relatedTasks: toArray(mutation.related_tasks ?? mutation.relatedTasks) + .map(normalizeApprovalTask) + .filter(Boolean), + replayed: Boolean(mutation.replayed), + claim: toNullableObject(envelope.claim ?? mutation.claim) + } +} + +export function normalizeApprovalTaskList(payload = {}) { + const source = toObject(payload) + const items = toArray(source.items).map(normalizeApprovalTaskQueueItem).filter(Boolean) + const total = Math.max(items.length, toInteger(source.total, items.length)) + const pageSize = toPositiveInteger( + source.page_size ?? source.pageSize, + DEFAULT_PAGE_SIZE, + MAX_PAGE_SIZE + ) + const page = toPositiveInteger(source.page, 1) + const totalPages = Math.max( + total ? 1 : 0, + toInteger(source.total_pages ?? source.totalPages, Math.ceil(total / pageSize)) + ) + return { + items, + total, + page, + pageSize, + totalPages, + generatedAt: toText(source.generated_at ?? source.generatedAt) + } +} + +export function normalizeApprovalTaskCandidate(item = {}) { + const source = toObject(item) + const employeeId = toText(source.employee_id ?? source.employeeId) + if (!employeeId) { + return null + } + return { + employeeId, + employeeNo: toText(source.employee_no ?? source.employeeNo), + name: toText(source.name), + email: toText(source.email), + qualified: Boolean(source.qualified), + reason: toText(source.reason) + } +} + +export function normalizeApprovalTaskCandidateList(payload = {}) { + const source = Array.isArray(payload) ? { items: payload } : toObject(payload) + const items = toArray(source.items).map(normalizeApprovalTaskCandidate).filter(Boolean) + return { + items, + total: Math.max(items.length, toInteger(source.total, items.length)) + } +} + +export function normalizeApprovalTaskBatchResult(payload = {}) { + const source = toObject(payload) + const items = toArray(source.items).map((item) => ({ + taskId: toText(item?.task_id ?? item?.taskId), + claimId: toText(item?.claim_id ?? item?.claimId), + status: toText(item?.status || 'failed') || 'failed', + code: toText(item?.code), + message: toText(item?.message), + claim: toNullableObject(item?.claim) + })).filter((item) => item.taskId) + const countStatus = (status) => items.filter((item) => item.status === status).length + return { + batchRequestId: toText(source.batch_request_id ?? source.batchRequestId), + status: toText(source.status || 'failed') || 'failed', + succeededCount: Math.max(0, toInteger(source.succeeded_count ?? source.succeededCount, countStatus('succeeded'))), + replayedCount: Math.max(0, toInteger(source.replayed_count ?? source.replayedCount, countStatus('replayed'))), + conflictCount: Math.max(0, toInteger(source.conflict_count ?? source.conflictCount, countStatus('conflict'))), + blockedCount: Math.max(0, toInteger(source.blocked_count ?? source.blockedCount, countStatus('blocked'))), + forbiddenCount: Math.max(0, toInteger(source.forbidden_count ?? source.forbiddenCount, countStatus('forbidden'))), + failedCount: Math.max(0, toInteger(source.failed_count ?? source.failedCount, countStatus('failed'))), + items + } +} + +function appendQueryValue(search, key, value) { + if (Array.isArray(value)) { + value.map(toText).filter(Boolean).forEach((item) => search.append(key, item)) + return + } + const normalized = toText(value) + if (normalized) { + search.set(key, normalized) + } +} + +function buildApprovalTaskListQuery(params = {}) { + const search = new URLSearchParams() + search.set('page', String(toPositiveInteger(params.page, 1))) + search.set( + 'page_size', + String(toPositiveInteger(params.pageSize ?? params.page_size, DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE)) + ) + appendQueryValue(search, 'status', params.status) + appendQueryValue(search, 'node_key', params.nodeKey ?? params.node_key) + appendQueryValue(search, 'task_kind', params.taskKind ?? params.task_kind) + appendQueryValue(search, 'risk_level', params.riskLevel ?? params.risk_level) + appendQueryValue(search, 'sla_state', params.slaState ?? params.sla_state) + appendQueryValue(search, 'keyword', params.keyword) + appendQueryValue(search, 'sort', params.sort) + appendQueryValue(search, 'assignee', params.assignee) + if (typeof (params.batchEligible ?? params.batch_eligible) === 'boolean') { + search.set( + 'batch_eligible', + String(Boolean(params.batchEligible ?? params.batch_eligible)) + ) + } + return search.toString() +} + +function requestOptions(options, timeoutMessage) { + return { + ...options, + timeoutMs: toNumber(options?.timeoutMs, DEFAULT_TIMEOUT_MS), + timeoutMessage + } +} + +function requireTaskId(taskId) { + const normalized = toText(taskId) + if (!normalized) { + throw new Error('审批任务缺少任务标识。') + } + return normalized +} + +function buildBaseActionPayload(action, taskId, payload = {}) { + const expectedTaskVersion = toInteger( + payload.expectedTaskVersion ?? payload.expected_task_version + ) + if (expectedTaskVersion < 1) { + throw new Error('审批动作缺少有效的任务版本。') + } + const reason = toText(payload.reason) + if (reason.length < 2) { + throw new Error('审批动作需要填写至少 2 个字的原因。') + } + return { + request_id: toText(payload.requestId ?? payload.request_id) + || createApprovalTaskRequestId(action, taskId), + expected_task_version: expectedTaskVersion, + reason + } +} + +function buildAssignmentActionPayload(action, taskId, payload) { + const targetEmployeeId = toText(payload.targetEmployeeId ?? payload.target_employee_id) + if (!targetEmployeeId) { + throw new Error('审批任务需要选择目标员工。') + } + const body = { + ...buildBaseActionPayload(action, taskId, payload), + target_employee_id: targetEmployeeId + } + const expiresAt = toText(payload.expiresAt ?? payload.expires_at) + if (expiresAt) { + body.expires_at = expiresAt + } + return body +} + +function buildParticipantsActionPayload(action, taskId, payload) { + const participantEmployeeIds = uniqueTexts( + payload.participantEmployeeIds ?? payload.participant_employee_ids + ) + if (!participantEmployeeIds.length) { + throw new Error('审批任务需要至少选择一名参与人。') + } + return { + ...buildBaseActionPayload(action, taskId, payload), + participant_employee_ids: participantEmployeeIds + } +} + +async function mutateApprovalTask(taskId, path, payload, options = {}) { + const normalizedTaskId = requireTaskId(taskId) + const response = await apiRequest( + `${APPROVAL_TASKS_PATH}/${encodeURIComponent(normalizedTaskId)}/${path}`, + { + ...requestOptions(options, '审批任务操作超时,请刷新任务状态后重试。'), + method: 'POST', + body: JSON.stringify(payload) + } + ) + return normalizeApprovalTaskMutation(response) +} + +export async function fetchApprovalTasks(params = {}, options = {}) { + const payload = await apiRequest( + `${APPROVAL_TASKS_PATH}?${buildApprovalTaskListQuery(params)}`, + requestOptions(options, '审批任务列表加载超时,请稍后重试。') + ) + return normalizeApprovalTaskList(payload) +} + +export async function fetchApprovalTaskDetail(taskId, options = {}) { + const normalizedTaskId = requireTaskId(taskId) + const payload = await apiRequest( + `${APPROVAL_TASKS_PATH}/${encodeURIComponent(normalizedTaskId)}`, + requestOptions(options, '审批任务详情加载超时,请稍后重试。') + ) + return normalizeApprovalTaskQueueItem(payload) +} + +export async function fetchApprovalTaskCandidates(taskId, params = {}, options = {}) { + const normalizedTaskId = requireTaskId(taskId) + const search = new URLSearchParams() + appendQueryValue(search, 'action', params.action) + appendQueryValue(search, 'keyword', params.keyword) + search.set('limit', String(toPositiveInteger(params.limit, 20, 100))) + const payload = await apiRequest( + `${APPROVAL_TASKS_PATH}/${encodeURIComponent(normalizedTaskId)}/candidates?${search}`, + requestOptions(options, '审批候选人加载超时,请稍后重试。') + ) + return normalizeApprovalTaskCandidateList(payload) +} + +export function delegateApprovalTask(taskId, payload = {}, options = {}) { + const normalizedTaskId = requireTaskId(taskId) + return mutateApprovalTask( + normalizedTaskId, + 'delegate', + buildAssignmentActionPayload('delegate', normalizedTaskId, payload), + options + ) +} + +export function revokeApprovalTaskDelegation(taskId, payload = {}, options = {}) { + const normalizedTaskId = requireTaskId(taskId) + return mutateApprovalTask( + normalizedTaskId, + 'delegation/revoke', + buildBaseActionPayload('delegation-revoke', normalizedTaskId, payload), + options + ) +} + +export function transferApprovalTask(taskId, payload = {}, options = {}) { + const normalizedTaskId = requireTaskId(taskId) + return mutateApprovalTask( + normalizedTaskId, + 'transfer', + buildAssignmentActionPayload('transfer', normalizedTaskId, payload), + options + ) +} + +export function addSignApprovalTask(taskId, payload = {}, options = {}) { + const normalizedTaskId = requireTaskId(taskId) + return mutateApprovalTask( + normalizedTaskId, + 'add-sign', + buildParticipantsActionPayload('add-sign', normalizedTaskId, payload), + options + ) +} + +export function countersignApprovalTask(taskId, payload = {}, options = {}) { + const normalizedTaskId = requireTaskId(taskId) + return mutateApprovalTask( + normalizedTaskId, + 'countersign', + buildParticipantsActionPayload('countersign', normalizedTaskId, payload), + options + ) +} + +export function escalateApprovalTask(taskId, payload = {}, options = {}) { + const normalizedTaskId = requireTaskId(taskId) + return mutateApprovalTask( + normalizedTaskId, + 'escalate', + buildBaseActionPayload('sla-escalate', normalizedTaskId, payload), + options + ) +} + +export function approveApprovalTask(taskId, payload = {}, options = {}) { + const normalizedTaskId = requireTaskId(taskId) + const body = buildBaseActionPayload('approve', normalizedTaskId, payload) + const opinion = toText(payload.opinion) + if (opinion) { + body.opinion = opinion + } + return mutateApprovalTask(normalizedTaskId, 'approve', body, options) +} + +export function returnApprovalTask(taskId, payload = {}, options = {}) { + const normalizedTaskId = requireTaskId(taskId) + const body = { + ...buildBaseActionPayload('return', normalizedTaskId, payload), + reason_codes: uniqueTexts(payload.reasonCodes ?? payload.reason_codes) + } + return mutateApprovalTask(normalizedTaskId, 'return', body, options) +} + +function normalizeBatchApproveItem(item = {}) { + const source = toObject(item?.task || item) + const taskId = toText(item.task_id ?? item.taskId ?? source.id) + const expectedTaskVersion = toInteger( + item.expected_task_version ?? item.expectedTaskVersion ?? source.version + ) + const expectedStatus = toText( + item.expected_status ?? item.expectedStatus + ?? source.claim_status_snapshot ?? source.claimStatusSnapshot + ) + const expectedApprovalStage = toText( + item.expected_approval_stage ?? item.expectedApprovalStage + ?? source.claim_stage_snapshot ?? source.claimStageSnapshot + ) + if (!taskId || expectedTaskVersion < 1 || !expectedStatus || !expectedApprovalStage) { + throw new Error('批量审批项缺少任务、版本、单据状态或审批节点快照。') + } + const body = { + task_id: taskId, + expected_task_version: expectedTaskVersion, + expected_status: expectedStatus, + expected_approval_stage: expectedApprovalStage + } + const opinion = toText(item.opinion) + if (opinion) { + body.opinion = opinion + } + return body +} + +export async function batchApproveApprovalTasks(payload = {}, options = {}) { + const items = toArray(payload.items).map(normalizeBatchApproveItem) + if (!items.length || items.length > 20) { + throw new Error('批量审批每次需要选择 1 至 20 项任务。') + } + if (new Set(items.map((item) => item.task_id)).size !== items.length) { + throw new Error('批量审批不能包含重复任务。') + } + const batchRequestId = toText(payload.batchRequestId ?? payload.batch_request_id) + || createApprovalTaskRequestId('batch-approve', 'batch').slice(0, 80) + const response = await apiRequest(`${APPROVAL_TASKS_PATH}/batch-approve`, { + ...requestOptions(options, '批量审批超时,请刷新任务状态后查看实际结果。'), + method: 'POST', + body: JSON.stringify({ + batch_request_id: batchRequestId, + items + }) + }) + return normalizeApprovalTaskBatchResult(response) +} diff --git a/web/src/services/riskObservations.js b/web/src/services/riskObservations.js index 062a5a7..38be72e 100644 --- a/web/src/services/riskObservations.js +++ b/web/src/services/riskObservations.js @@ -13,6 +13,14 @@ function toObject(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {} } +function uniqueTexts(value, limit = Number.POSITIVE_INFINITY) { + return [...new Set( + toArray(value) + .map((item) => String(item || '').trim()) + .filter(Boolean) + )].slice(0, limit) +} + export function createRiskDispositionRequestId(action, observationId) { const normalizedAction = String(action || 'action').trim() || 'action' const normalizedObservationId = String(observationId || 'risk').trim() || 'risk' @@ -35,14 +43,48 @@ export function normalizeRiskDisposition(item = {}) { assignee: String(item.assignee || '').trim(), dueAt: String(item.due_at || item.dueAt || '').trim(), resolution: String(item.resolution || '').trim(), + availableActions: uniqueTexts(item.available_actions ?? item.availableActions), + readOnlyReason: String(item.read_only_reason ?? item.readOnlyReason ?? '').trim(), + waiverRequesterId: String( + item.waiver_requester_id || item.waiverRequesterId || '' + ).trim(), + waiverRequesterName: String( + item.waiver_requester_name || item.waiverRequesterName || '' + ).trim(), + waiverRequestedAt: String( + item.waiver_requested_at || item.waiverRequestedAt || '' + ).trim(), + waiverReason: String(item.waiver_reason || item.waiverReason || '').trim(), + waiverScope: String(item.waiver_scope || item.waiverScope || '').trim(), + waiverExpiresAt: String( + item.waiver_expires_at || item.waiverExpiresAt || '' + ).trim(), + waiverConditions: uniqueTexts( + item.waiver_conditions_json + ?? item.waiver_conditions + ?? item.waiverConditions + ), + waiverDecision: String(item.waiver_decision || item.waiverDecision || '').trim(), + waiverDeciderId: String(item.waiver_decider_id || item.waiverDeciderId || '').trim(), + waiverDeciderName: String( + item.waiver_decider_name || item.waiverDeciderName || '' + ).trim(), + waiverDecidedAt: String(item.waiver_decided_at || item.waiverDecidedAt || '').trim(), + waiverDecisionReason: String( + item.waiver_decision_reason || item.waiverDecisionReason || '' + ).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)), + actorId: String(event?.actor_id || event?.actorId || '').trim(), actorName: String(event?.actor_name || event?.actorName || '').trim(), + requestId: String(event?.request_id || event?.requestId || '').trim(), comment: String(event?.comment || '').trim(), + beforeState: toObject(event?.before_json || event?.beforeState), + afterState: toObject(event?.after_json || event?.afterState), createdAt: String(event?.created_at || event?.createdAt || '').trim() })) } @@ -91,6 +133,8 @@ export function normalizeRiskObservation(item = {}) { ), feedbackItems: toArray(item.feedback_items || item.feedbackItems), disposition: normalizeRiskDisposition(item.disposition), + availableActions: uniqueTexts(item.available_actions ?? item.availableActions), + readOnlyReason: String(item.read_only_reason ?? item.readOnlyReason ?? '').trim(), createdAt: String(item.created_at || item.createdAt || '').trim(), updatedAt: String(item.updated_at || item.updatedAt || '').trim() } @@ -211,10 +255,23 @@ export async function executeRiskDispositionAction(observationId, payload = {}) const resolution = String(payload.resolution || '').trim() const assignee = String(payload.assignee || '').trim() const dueAt = String(payload.dueAt || payload.due_at || '').trim() + const waiverReason = String(payload.waiverReason || payload.waiver_reason || '').trim() + const waiverScope = String(payload.waiverScope || payload.waiver_scope || '').trim() + const waiverExpiresAt = String( + payload.waiverExpiresAt || payload.waiver_expires_at || '' + ).trim() + const waiverConditions = uniqueTexts( + payload.waiverConditions ?? payload.waiver_conditions, + 20 + ) if (comment) body.comment = comment if (resolution) body.resolution = resolution if (assignee) body.assignee = assignee if (dueAt) body.due_at = dueAt + if (waiverReason) body.waiver_reason = waiverReason + if (waiverScope) body.waiver_scope = waiverScope + if (waiverExpiresAt) body.waiver_expires_at = waiverExpiresAt + if (waiverConditions.length) body.waiver_conditions = waiverConditions const result = await apiRequest( `/risk-observations/${encodeURIComponent(normalizedObservationId)}/disposition/actions`, @@ -232,3 +289,55 @@ export async function executeRiskDispositionAction(observationId, payload = {}) requestId } } + +export function normalizeRiskDispositionError(error) { + const code = String(error?.code || '').trim() + const status = Number(error?.status || 0) + const serverMessage = String(error?.message || '').trim() + if (code === 'RISK_DISPOSITION_VERSION_CONFLICT') { + return { + code, + title: '风险状态已更新', + message: serverMessage || '其他处理人已更新该风险,请刷新后重新确认。', + shouldRefresh: true + } + } + if (code === 'RISK_WAIVER_DECISION_FORBIDDEN' || status === 403) { + return { + code: code || 'RISK_DISPOSITION_FORBIDDEN', + title: '当前账号不能执行此操作', + message: serverMessage || '服务端未授予当前风险处置动作。', + shouldRefresh: true + } + } + if (code === 'REQUEST_TIMEOUT') { + return { + code, + title: '提交结果暂未确认', + message: serverMessage || '请求已超时,请刷新状态后再决定是否重试。', + shouldRefresh: true + } + } + if (status === 409) { + return { + code: code || 'RISK_DISPOSITION_CONFLICT', + title: '当前状态不允许该操作', + message: serverMessage || '风险处置状态发生冲突,请刷新后重试。', + shouldRefresh: true + } + } + if (status === 422) { + return { + code: code || 'RISK_DISPOSITION_VALIDATION_FAILED', + title: '提交内容不完整', + message: serverMessage || '请检查必填项、有效期和输入长度。', + shouldRefresh: false + } + } + return { + code: code || 'RISK_DISPOSITION_REQUEST_FAILED', + title: '风险处置提交失败', + message: serverMessage || '请稍后重试;如持续失败,请联系系统管理员。', + shouldRefresh: false + } +} diff --git a/web/src/utils/documentCenterViewModel.js b/web/src/utils/documentCenterViewModel.js index 7aa68c4..8a9ac6a 100644 --- a/web/src/utils/documentCenterViewModel.js +++ b/web/src/utils/documentCenterViewModel.js @@ -44,7 +44,12 @@ export const DOCUMENT_CENTER_QUERY_KEYS = new Set([ 'dc_scene', 'dc_q', 'dc_start', - 'dc_end' + 'dc_end', + 'dc_review_page', + 'dc_review_page_size', + 'dc_review_q', + 'dc_review_risk', + 'dc_review_sla' ]) export const riskLevelTabs = ['全部', '高风险', '中风险', '低风险', '无风险'] export const RISK_TONE_META = { diff --git a/web/src/views/AppShellRouteView.vue b/web/src/views/AppShellRouteView.vue index 65e3768..3a0de41 100644 --- a/web/src/views/AppShellRouteView.vue +++ b/web/src/views/AppShellRouteView.vue @@ -96,7 +96,6 @@ @update:active-range="activeRange = $event" @update:custom-range="customRange = $event" @update:overview-dashboard="overviewDashboard = $event" - @batch-approve="toast('已批量通过 23 条审批任务。')" @new-application="openExpenseApplicationCreate" @open-document="openWorkbenchDocument" @navigate="handleNavigate" @@ -183,6 +182,7 @@ @create-request="openTravelCreate" @create-application="openExpenseApplicationCreate" @reload="reloadRequests" + @request-updated="handleRequestUpdated" @summary-change="documentSummary = $event" /> diff --git a/web/src/views/DocumentsCenterView.vue b/web/src/views/DocumentsCenterView.vue index 4b2a287..a7fe0b6 100644 --- a/web/src/views/DocumentsCenterView.vue +++ b/web/src/views/DocumentsCenterView.vue @@ -11,14 +11,27 @@ > {{ tab.label }} - + {{ tab.badgeCount > 99 ? '99+' : tab.badgeCount }} -
+ + + @@ -254,19 +268,19 @@