feat(approval): add task workflow and waiver decisions

This commit is contained in:
caoxiaozhu
2026-07-16 16:52:12 +08:00
parent 28b834edd3
commit 242d68c36f
89 changed files with 16313 additions and 294 deletions

View File

@@ -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 拒绝。 - 批准、退回和付款均使用动作协议:客户端在确认时冻结 `request_id`、预期状态和预期审批节点;服务端以租户 + 操作人 + 请求 ID 唯一账本、请求指纹、PostgreSQL advisory lock、Claim 行锁和同事务业务事件确保相同请求可安全重放,不同内容或陈旧快照以 409 拒绝。
- 高风险门禁同时读取持久化风险观察和尚未落表的原始风险标记;未处置的严重/高危可行动风险在路由、预算和状态修改前阻断,原始重大风险持久化失败时 fail-closed。仅明确标记为 `route_review` 的路由型风险进入对应审批节点而不冒充已解决。 - 高风险门禁同时读取持久化风险观察和尚未落表的原始风险标记;未处置的严重/高危可行动风险在路由、预算和状态修改前阻断,原始重大风险持久化失败时 fail-closed。仅明确标记为 `route_review` 的路由型风险进入对应审批节点而不冒充已解决。
- 门禁只消费与当前单据业务阶段一致的显式风险:申请阶段不会被明确标记为 `reimbursement` 的风险阻断,反之亦然;缺少阶段的未知重大风险仍保守处理。`route_review` 是显式路由语义,审批路由可以继续但风险仍保留在证据和后续复核中。 - 门禁只消费与当前单据业务阶段一致的显式风险:申请阶段不会被明确标记为 `reimbursement` 的风险阻断,反之亦然;缺少阶段的未知重大风险仍保守处理。`route_review` 是显式路由语义,审批路由可以继续但风险仍保留在证据和后续复核中。
- 风险处置采用判定与生命周期双状态:支持确认风险、误报、补件、开始整改、申请豁免完成处置;每个动作绑定请求指纹、预期版本、操作人和只追加事件。完整证据只对管理员或当前审批人开放,风险池对财务/管理/预算角色开放,处置写入由管理员或锁内重新确认的当前审批人完成 - 风险处置采用判定与生命周期双状态:支持确认风险、误报、补件、开始整改、申请豁免、财务/管理层批准或拒绝豁免,以及完成处置;每个动作绑定请求指纹、预期版本、操作人和只追加事件。申请人不得自批/自拒,仅在职 finance/executive 且非申请人可决定,过期申请 fail-closed。完整证据只对管理员或当前审批人开放,风险池对财务/管理/预算角色开放。
- 并发锁顺序固定为 Claim → RiskObservation → RiskDisposition审批动作、人工处置和 Hermes 扫描共用 Claim 锁。扫描在锁外计算、锁内校验状态与更新时间,过期快照丢弃并等待下一轮,避免旧图结果覆盖刚完成的人工处置。 - 并发锁顺序固定为 Claim → RiskObservation → RiskDisposition审批动作、人工处置和 Hermes 扫描共用 Claim 锁。扫描在锁外计算、锁内校验状态与更新时间,过期快照丢弃并等待下一轮,避免旧图结果覆盖刚完成的人工处置。
- 所有单据动作首次完整 API 响应写入动作账本;风险处置首次完整响应在 append-only 事件 INSERT 前原子写入。相同请求重放只返回首次版本,风险旧事件无快照时也只从目标版本及以前的审计链重建,不读取当前 Claim、当前处置投影或后续事件。 - 所有单据动作首次完整 API 响应写入动作账本;风险处置首次完整响应在 append-only 事件 INSERT 前原子写入。相同请求重放只返回首次版本,风险旧事件无快照时也只从目标版本及以前的审计链重建,不读取当前 Claim、当前处置投影或后续事件。
- 尚未完成边界工作台当前仍为服务内排序后截断SLA 仍基于申请提交时间而不是当前节点进入时间;批量审批、委托/转交、加签/会签和豁免批准/拒绝将在本阶段后续切片完成 - 审批任务支持低风险批量通过、委托/撤销委托、永久转交、顺序加签、并行会签和手动/调度 SLA 升级SLA 以当前节点进入时间为基准。前端“待我审核”完全消费服务端分页、筛选、`available_actions` 与版本,筛选和页码可从详情返回恢复。尚未完成的主要边界是统一必要性/政策证据摘要、真实企微/钉钉/邮件触达及外部结果回写
#### CFO 价值看板 #### 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审批动作协议批准、退回和付款统一接入租户化幂等账本、请求指纹、乐观状态/节点前置条件、advisory lock 与 Claim 行锁;完全相同的网络重试不重复扣减预算、写审批、生成事件或付款,不同内容和陈旧状态稳定返回 409。前端确认流在用户确认时冻结请求和前置条件并保持服务端状态与展示标签分离。
- 2026-07-16风险门禁与处置高危/严重可行动风险在任何审批副作用前阻断;类型化风险处置覆盖确认、误报、补件、整改、豁免申请和完成处置,具备乐观版本、权限守卫和只追加审计。门禁合并持久观察与未匹配原始风险,重大观察落库失败 fail-closed审批、处置和 Hermes 扫描通过 Claim 公共锁协调,扫描过期快照不会覆盖新事实。 - 2026-07-16风险门禁与处置高危/严重可行动风险在任何审批副作用前阻断;类型化风险处置覆盖确认、误报、补件、整改、豁免申请和完成处置,具备乐观版本、权限守卫和只追加审计。门禁合并持久观察与未匹配原始风险,重大观察落库失败 fail-closed审批、处置和 Hermes 扫描通过 Claim 公共锁协调,扫描过期快照不会覆盖新事实。
- 2026-07-16审批工作台审批中心接入可解释优先级投影展示风险、预算、金额、等待时长、证据完整度和 advisory-only AI 建议;风险证据卡读取真实持久字段、显示生命周期和明确动作,但不提供绕过风险门禁的直接审批入口。 - 2026-07-16审批工作台审批中心接入可解释优先级投影展示风险、预算、金额、等待时长、证据完整度和 advisory-only AI 建议;风险证据卡读取真实持久字段、显示生命周期和明确动作,但不提供绕过风险门禁的直接审批入口。

View File

@@ -177,8 +177,10 @@
证据:`20260716_0012` 为 append-only 风险事件增加 `response_json`;新事件在 INSERT 前原子写入完整响应重放只返回目标事件版本及以前的事件。0012 前无快照事件从 `after_json``version <= target` 的审计链安全重建,不读取当前处置投影。 证据:`20260716_0012` 为 append-only 风险事件增加 `response_json`;新事件在 INSERT 前原子写入完整响应重放只返回目标事件版本及以前的事件。0012 前无快照事件从 `after_json``version <= target` 的审计链安全重建,不读取当前处置投影。
- [x] [CONCEPT: 审批例外工作台] 建立高风险门禁与类型化风险处置生命周期。 - [x] [CONCEPT: 审批例外工作台] 建立高风险门禁与类型化风险处置生命周期。
证据:严重/高危可行动风险在任何审批副作用前阻断;确认风险、误报、补件、整改、豁免申请和完成处置具有乐观版本、请求指纹、租户权限、只追加事件和 Claim 共用锁。未持久化的原始高风险仍保守阻断,重大风险持久化失败 fail-closed。 证据:严重/高危可行动风险在任何审批副作用前阻断;确认风险、误报、补件、整改、豁免申请和完成处置具有乐观版本、请求指纹、租户权限、只追加事件和 Claim 共用锁。未持久化的原始高风险仍保守阻断,重大风险持久化失败 fail-closed。
- [ ] [CONCEPT: 审批例外工作台] 补齐批量审批、委托、转交、加签、会签和超时升级交互。 - [x] [CONCEPT: 审批例外工作台] 补齐批量审批、委托、转交、加签、会签和超时升级交互。
当前进度:单笔批准/退回/付款协议、原响应快照重放、优先级工作台、风险证据和处置状态已完成;批量动作、委托/转交、加签/会签、当前节点进入时间 SLA、豁免批准/拒绝及真正的数据库分页尚未实现,不以现有单笔基础冒充完成 证据:`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: 审批例外工作台] 接入真实企微/钉钉/邮件触达和处理结果回写。
- [ ] [CONCEPT: AI 记忆与自动化设置] 新增“我的 AI 记忆”,支持来源解释、修改、忘记和关闭个性化。 - [ ] [CONCEPT: AI 记忆与自动化设置] 新增“我的 AI 记忆”,支持来源解释、修改、忘记和关闭个性化。
- [x] [CONCEPT: AI 记忆与自动化设置] 在申请核对表展示常用出行方式的记忆来源、证据数量和“忘记此偏好”,并在保存/提交后区分候选记录与已应用回执。 - [x] [CONCEPT: AI 记忆与自动化设置] 在申请核对表展示常用出行方式的记忆来源、证据数量和“忘记此偏好”,并在保存/提交后区分候选记录与已应用回执。
@@ -226,8 +228,8 @@
- [ ] [CONCEPT: 测试方案] 为 Alembic baseline、升级、旧数据迁移和回滚边界补充 Postgres 集成测试。 - [ ] [CONCEPT: 测试方案] 为 Alembic baseline、升级、旧数据迁移和回滚边界补充 Postgres 集成测试。
- [x] [CONCEPT: 测试方案] 为当前 migration-owned schema 切片补充一次性 PostgreSQL 集成测试和危险 URL 防误连门禁。 - [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 仍保留在上一条未完成项中。 证据:`test_alembic_migrations.py` 默认无显式 URL 时跳过,主机和库名必须带 disposable 标记;当前 0012 Head 在 tmpfs PostgreSQL 17 中通过完整迁移验证,覆盖空库升级、重复升级、审批动作账本、风险处置复合租户约束、只追加事件触发器、不可变响应快照及有数据降级拒绝、组织 active 脏数据升级前拒绝、版本化 few-shot 无损降级拒绝、外键级联、base 降级、legacy 哨兵保留、漂移拒绝和再次升级;临时容器自动清理,持久化开发库未被修改。完整 legacy baseline 仍保留在上一条未完成项中。
- [x] [CONCEPT: 测试方案] 验证审批动作幂等、风险门禁、风险处置权限和共同锁顺序。 - [x] [CONCEPT: 测试方案] 验证审批动作幂等、任务编排、风险门禁、豁免决定权限和共同锁顺序。
证据:容器内审批、费用 Case、路由、租户、风险和严格重放组合 182 项,迁移/所有权 54 项、前端审批/风险 73 项通过;一次性 PostgreSQL 17 上迁移与真实并发共 17 项通过,证明风险重新打开持有 Claim 锁时审批等待,随后按最新风险阻断且不留下动作账本或审批副作用 证据:容器内本阶段后端回归 `122 passed, 4 skipped`、前端审批/单据中心/风险专项 `60 passed`、Ruff 与生产构建通过;一次性 PostgreSQL 17 空库迁移循环 `1 passed`、审批任务和风险并发 `3 passed`,证明同 request ID 并发只生成一个事件、陈旧版本只有一个胜者、风险重新打开与审批竞争时按 Claim 公共锁读取最新事实
- [ ] [CONCEPT: 测试方案] 为连接器幂等、重试、回执、失败恢复、重复付款和对账补充测试。 - [ ] [CONCEPT: 测试方案] 为连接器幂等、重试、回执、失败恢复、重复付款和对账补充测试。
- [ ] [CONCEPT: 测试方案] 跑通申请 → 票据 → 报销 → 预审 → 审批 → 付款 → 入账 → 归档端到端。 - [ ] [CONCEPT: 测试方案] 跑通申请 → 票据 → 报销 → 预审 → 审批 → 付款 → 入账 → 归档端到端。
当前进度:申请批准 → 自动报销草稿 → 票据归集 → 预审 → 报销提交已在同一 Case 中跑通付款回执、ERP 入账和对账仍未接入。 当前进度:申请批准 → 自动报销草稿 → 票据归集 → 预审 → 报销提交已在同一 Case 中跑通付款回执、ERP 入账和对账仍未接入。

View File

@@ -0,0 +1,8 @@
## 修复记录
- 2026-07-16 16:50:44 CST修复 SQLAlchemy Session 关闭 autoflush 后的两处审批任务状态错误。根任务完成后先显式 flush 再生成下一节点,避免仍被查询为开放任务并误标 supersededSLA 扫描每次升级后 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 个用户维护的财务规则工作簿及历史未跟踪开发文档。

View File

@@ -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 CSTGit 拉取检查确认上游无新增提交、本地 ahead 16本记录基于当前未提交修复未纳入用户维护的财务规则工作簿和历史开发日志。

View File

@@ -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")

View File

@@ -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)

View File

@@ -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())

View File

@@ -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

View File

@@ -14,6 +14,8 @@ from app.schemas.reimbursement import (
ExpenseClaimReturnPayload, ExpenseClaimReturnPayload,
) )
from app.services.approval_action_protocol import ApprovalActionConflictError 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_risk_gate import ExpenseClaimRiskBlockedError
from app.services.expense_claims import ExpenseClaimService from app.services.expense_claims import ExpenseClaimService
@@ -43,6 +45,10 @@ def _raise_action_error(error: ValueError) -> NoReturn:
) from error ) from error
if isinstance(error, ApprovalActionConflictError): if isinstance(error, ApprovalActionConflictError):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error 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 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, request_id=payload.request_id,
expected_status=payload.expected_status, expected_status=payload.expected_status,
expected_approval_stage=payload.expected_approval_stage, expected_approval_stage=payload.expected_approval_stage,
task_id=payload.task_id,
expected_task_version=payload.expected_task_version,
) )
except ValueError as error: except ValueError as error:
_raise_action_error(error) _raise_action_error(error)
@@ -117,6 +125,8 @@ def approve_expense_claim(
request_id=payload.request_id, request_id=payload.request_id,
expected_status=payload.expected_status, expected_status=payload.expected_status,
expected_approval_stage=payload.expected_approval_stage, expected_approval_stage=payload.expected_approval_stage,
task_id=payload.task_id,
expected_task_version=payload.expected_task_version,
) )
except ValueError as error: except ValueError as error:
_raise_action_error(error) _raise_action_error(error)

View File

@@ -20,11 +20,13 @@ from app.schemas.risk_observation import (
RiskObservationListRead, RiskObservationListRead,
RiskObservationRead, RiskObservationRead,
) )
from app.services.risk_disposition_read_projection import RiskDispositionReadProjection
from app.services.risk_dispositions import ( from app.services.risk_dispositions import (
RiskDispositionConflictError, RiskDispositionConflictError,
RiskDispositionPermissionError, RiskDispositionPermissionError,
RiskDispositionService, RiskDispositionService,
RiskDispositionVersionConflictError, RiskDispositionVersionConflictError,
RiskWaiverDecisionPermissionError,
) )
from app.services.risk_observation_access_policy import RiskObservationAccessPolicy from app.services.risk_observation_access_policy import RiskObservationAccessPolicy
from app.services.risk_observations import RiskObservationService from app.services.risk_observations import RiskObservationService
@@ -69,7 +71,16 @@ def list_risk_observations(
limit=limit, limit=limit,
offset=offset, 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( @router.get(
@@ -105,10 +116,14 @@ def list_claim_risk_observations(
) -> list[RiskObservationRead]: ) -> list[RiskObservationRead]:
if not RiskObservationAccessPolicy(db).can_read_claim_risks(claim_id, current_user): if not RiskObservationAccessPolicy(db).can_read_claim_risks(claim_id, current_user):
raise _not_found() raise _not_found()
return RiskObservationService(db).list_claim_observations( observations = RiskObservationService(db).list_claim_observations(
claim_id, claim_id,
tenant_id=current_user.tenant_id, tenant_id=current_user.tenant_id,
) )
return RiskDispositionReadProjection(db).observation_list_read(
observations,
current_user,
)
@router.get( @router.get(
@@ -123,10 +138,14 @@ def list_execution_log_risk_observations(
current_user: CurrentUser, current_user: CurrentUser,
) -> list[RiskObservationRead]: ) -> list[RiskObservationRead]:
_require_pool_access(db, current_user) _require_pool_access(db, current_user)
return RiskObservationService(db).list_execution_log_observations( observations = RiskObservationService(db).list_execution_log_observations(
execution_log_id, execution_log_id,
tenant_id=current_user.tenant_id, tenant_id=current_user.tenant_id,
) )
return RiskDispositionReadProjection(db).observation_list_read(
observations,
current_user,
)
@router.get( @router.get(
@@ -156,7 +175,10 @@ def get_risk_observation(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Risk observation not found", detail="Risk observation not found",
) )
return observation return RiskDispositionReadProjection(db).observation_read(
observation,
current_user,
)
@router.get( @router.get(
@@ -178,7 +200,11 @@ def get_risk_observation_disposition(
) )
if disposition is None: if disposition is None:
raise _not_found("Risk disposition not found") raise _not_found("Risk disposition not found")
return disposition return RiskDispositionReadProjection(db).disposition_read(
disposition,
observation,
current_user,
)
@router.post( @router.post(
@@ -199,7 +225,19 @@ def execute_risk_disposition_action(
observation_key_or_id, observation_key_or_id,
payload, 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( @router.post(
@@ -303,6 +341,14 @@ def _execute_action(
"message": "风险处置状态已更新,请刷新证据链后重试。", "message": "风险处置状态已更新,请刷新证据链后重试。",
}, },
) from None ) 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: except RiskDispositionPermissionError as error:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,

View File

@@ -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_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.agent_traces import router as agent_traces_router
from app.api.v1.endpoints.analytics import router as analytics_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.approval_workbench import router as approval_workbench_router
from app.api.v1.endpoints.attachment_association_jobs import ( from app.api.v1.endpoints.attachment_association_jobs import (
router as attachment_association_jobs_router, 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(agent_traces_router, tags=["agent-traces"])
router.include_router(analytics_router, tags=["analytics"]) router.include_router(analytics_router, tags=["analytics"])
router.include_router(approval_workbench_router, tags=["approval-workbench"]) 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(attachment_association_jobs_router, tags=["attachment-association-jobs"])
router.include_router(audit_logs_router, tags=["audit-logs"]) router.include_router(audit_logs_router, tags=["audit-logs"])
router.include_router(knowledge_router, tags=["knowledge"]) router.include_router(knowledge_router, tags=["knowledge"])

View File

@@ -14,6 +14,7 @@ from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutco
from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink
from app.models.approval import ApprovalRecord from app.models.approval import ApprovalRecord
from app.models.approval_action import ApprovalActionLedger 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.attachment_association_job import AttachmentAssociationJob
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
from app.models.auth_session import AuthSession from app.models.auth_session import AuthSession
@@ -63,6 +64,8 @@ __all__ = [
"AIDecisionFeedback", "AIDecisionFeedback",
"ApprovalRecord", "ApprovalRecord",
"ApprovalActionLedger", "ApprovalActionLedger",
"ApprovalTask",
"ApprovalTaskEvent",
"AttachmentAssociationJob", "AttachmentAssociationJob",
"AuditLog", "AuditLog",
"AuthSession", "AuthSession",

View File

@@ -191,8 +191,54 @@ MIGRATION_OWNED_TABLES_BY_REVISION: dict[str, frozenset[str]] = {
"approval_action_ledgers", "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") raise RuntimeError("latest Alembic revision must own the centralized migration table set")
# 0008 之前这三张表由旧 bootstrap / 风险服务按需创建。迁移前置检查允许 # 0008 之前这三张表由旧 bootstrap / 风险服务按需创建。迁移前置检查允许
@@ -266,6 +312,8 @@ def _validate_connection(connection: Connection) -> MigrationPreflightState:
"20260716_0010", "20260716_0010",
"20260716_0011", "20260716_0011",
"20260716_0012", "20260716_0012",
"20260716_0013",
"20260716_0014",
} }
else frozenset() else frozenset()
) )

View File

@@ -8,6 +8,8 @@ MIGRATION_OWNED_TABLES: frozenset[str] = frozenset(
{ {
"auth_sessions", "auth_sessions",
"approval_action_ledgers", "approval_action_ledgers",
"approval_task_events",
"approval_tasks",
"attachment_association_jobs", "attachment_association_jobs",
"ai_application_preview_decisions", "ai_application_preview_decisions",
"ai_decisions", "ai_decisions",

View File

@@ -1,9 +1,9 @@
from __future__ import annotations from __future__ import annotations
import threading
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from logging import Logger from logging import Logger
import threading
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware 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.middleware.logging import AccessLogMiddleware
from app.schemas.common import RootStatusRead from app.schemas.common import RootStatusRead
from app.services.agent_foundation import prepare_agent_foundation 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.digital_employee_reminder_scheduler import digital_employee_reminder_scheduler
from app.services.employee import prepare_employee_directory from app.services.employee import EmployeeService, prepare_employee_directory
from app.services.employee import EmployeeService
from app.services.employee_profile_scheduler import employee_profile_scheduler from app.services.employee_profile_scheduler import employee_profile_scheduler
from app.services.finance_dashboard_scheduler import finance_dashboard_scheduler from app.services.finance_dashboard_scheduler import finance_dashboard_scheduler
from app.services.finance_report_scheduler import finance_report_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: def _effective_server_workers(settings: object) -> int:
server_workers = getattr(settings, "server_workers", None) server_workers = getattr(settings, "server_workers", None)
web_concurrency = getattr(settings, "web_concurrency", 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: try:
return max(1, int(workers or 1)) return max(1, int(workers or 1))
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -100,6 +104,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
schedulers_started = _should_start_background_schedulers(settings) schedulers_started = _should_start_background_schedulers(settings)
if schedulers_started: if schedulers_started:
knowledge_index_scheduler.start() knowledge_index_scheduler.start()
approval_task_scheduler.start()
finance_dashboard_scheduler.start() finance_dashboard_scheduler.start()
employee_profile_scheduler.start() employee_profile_scheduler.start()
digital_employee_reminder_scheduler.start() digital_employee_reminder_scheduler.start()
@@ -118,6 +123,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
) )
yield yield
if schedulers_started: if schedulers_started:
approval_task_scheduler.shutdown()
finance_report_scheduler.shutdown() finance_report_scheduler.shutdown()
digital_employee_reminder_scheduler.shutdown() digital_employee_reminder_scheduler.shutdown()
employee_profile_scheduler.shutdown() employee_profile_scheduler.shutdown()

View File

@@ -12,6 +12,7 @@ from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutco
from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink
from app.models.approval import ApprovalRecord from app.models.approval import ApprovalRecord
from app.models.approval_action import ApprovalActionLedger 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.attachment_association_job import AttachmentAssociationJob
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
from app.models.auth_session import AuthSession from app.models.auth_session import AuthSession
@@ -57,6 +58,8 @@ __all__ = [
"AIApplicationPreviewDecision", "AIApplicationPreviewDecision",
"ApprovalRecord", "ApprovalRecord",
"ApprovalActionLedger", "ApprovalActionLedger",
"ApprovalTask",
"ApprovalTaskEvent",
"AttachmentAssociationJob", "AttachmentAssociationJob",
"AuditLog", "AuditLog",
"AuthSession", "AuthSession",

View File

@@ -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")

View File

@@ -46,10 +46,46 @@ class RiskDisposition(Base):
), ),
CheckConstraint( CheckConstraint(
"lifecycle_status IN ('open', 'supplement_requested', " "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", name="ck_risk_dispositions_lifecycle",
), ),
CheckConstraint("version >= 0", name="ck_risk_dispositions_version"), 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( ForeignKeyConstraint(
["tenant_id", "observation_id"], ["tenant_id", "observation_id"],
["risk_observations.tenant_id", "risk_observations.id"], ["risk_observations.tenant_id", "risk_observations.id"],
@@ -63,6 +99,12 @@ class RiskDisposition(Base):
"due_at", "due_at",
), ),
Index("ix_risk_dispositions_assignee", "tenant_id", "assignee"), 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) 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) assignee: Mapped[str | None] = mapped_column(String(120), nullable=True)
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
resolution: Mapped[str | None] = mapped_column(Text(), 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( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
nullable=False, nullable=False,
@@ -128,7 +190,8 @@ class RiskDispositionEvent(Base):
), ),
CheckConstraint( CheckConstraint(
"action IN ('confirm', 'false_positive', 'request_supplement', " "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", name="ck_risk_disposition_events_action",
), ),
CheckConstraint("version > 0", name="ck_risk_disposition_events_version"), CheckConstraint("version > 0", name="ck_risk_disposition_events_version"),

View File

@@ -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 = ""

View File

@@ -4,7 +4,7 @@ from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from typing import Any, Literal 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 from app.services.expense_claim_budget_risk_flags import dedupe_budget_risk_flags
@@ -298,10 +298,26 @@ class ExpenseClaimActionProtocolPayload(BaseModel):
class ExpenseClaimReturnPayload(ExpenseClaimActionProtocolPayload): class ExpenseClaimReturnPayload(ExpenseClaimActionProtocolPayload):
reason: str | None = Field(default=None, max_length=500) reason: str | None = Field(default=None, max_length=500)
reason_codes: list[str] = Field(default_factory=list, max_length=10) 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): class ExpenseClaimApprovalPayload(ExpenseClaimActionProtocolPayload):
opinion: str | None = Field(default=None, max_length=500) 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): class ExpenseClaimPaymentPayload(ExpenseClaimActionProtocolPayload):

View File

@@ -12,6 +12,8 @@ RiskDispositionAction = Literal[
"start_remediation", "start_remediation",
"resolve", "resolve",
"request_waiver", "request_waiver",
"approve_waiver",
"reject_waiver",
] ]
RiskAdjudication = Literal["unreviewed", "confirmed", "false_positive"] RiskAdjudication = Literal["unreviewed", "confirmed", "false_positive"]
RiskLifecycleStatus = Literal[ RiskLifecycleStatus = Literal[
@@ -19,6 +21,8 @@ RiskLifecycleStatus = Literal[
"supplement_requested", "supplement_requested",
"remediation_in_progress", "remediation_in_progress",
"waiver_requested", "waiver_requested",
"waived",
"waiver_rejected",
"resolved", "resolved",
] ]
@@ -31,8 +35,20 @@ class RiskDispositionActionCreate(BaseModel):
assignee: str | None = Field(default=None, max_length=120) assignee: str | None = Field(default=None, max_length=120)
due_at: datetime | None = None due_at: datetime | None = None
resolution: str | None = Field(default=None, max_length=2000) 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 @classmethod
def normalize_text(cls, value: Any) -> Any: def normalize_text(cls, value: Any) -> Any:
if value is None: if value is None:
@@ -40,14 +56,42 @@ class RiskDispositionActionCreate(BaseModel):
normalized = str(value).strip() normalized = str(value).strip()
return normalized or None 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") @model_validator(mode="after")
def validate_action_fields(self) -> RiskDispositionActionCreate: def validate_action_fields(self) -> RiskDispositionActionCreate:
if self.action == "resolve" and not self.resolution: if self.action == "resolve" and not self.resolution:
raise ValueError("resolve 动作必须填写 resolution") raise ValueError("resolve 动作必须填写 resolution")
if self.action in {"false_positive", "request_supplement", "request_waiver"} and not ( if self.action in {"false_positive", "request_supplement"} and not self.comment:
self.comment
):
raise ValueError(f"{self.action} 动作必须填写 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( if self.action in {"confirm", "false_positive"} and any(
value is not None for value in (self.assignee, self.due_at, self.resolution) value is not None for value in (self.assignee, self.due_at, self.resolution)
): ):
@@ -85,6 +129,20 @@ class RiskDispositionRead(BaseModel):
assignee: str | None assignee: str | None
due_at: datetime | None due_at: datetime | None
resolution: str | 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 created_at: datetime
updated_at: datetime updated_at: datetime
events: list[RiskDispositionEventRead] = Field(default_factory=list) events: list[RiskDispositionEventRead] = Field(default_factory=list)

View File

@@ -5,7 +5,7 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator 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[ RiskObservationStatus = Literal[
"pending_review", "pending_review",
@@ -92,6 +92,8 @@ class RiskObservationRead(BaseModel):
updated_at: datetime updated_at: datetime
feedback_items: list[RiskObservationFeedbackRead] = Field(default_factory=list) feedback_items: list[RiskObservationFeedbackRead] = Field(default_factory=list)
disposition: RiskDispositionRead | None = None disposition: RiskDispositionRead | None = None
available_actions: list[RiskDispositionAction] = Field(default_factory=list)
read_only_reason: str = ""
class RiskObservationListRead(BaseModel): class RiskObservationListRead(BaseModel):

View File

@@ -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)

View File

@@ -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}"

View File

@@ -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")

View File

@@ -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}"

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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("不支持的审批任务排序方式。")

View File

@@ -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()

View File

@@ -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)

View File

@@ -8,6 +8,7 @@ from sqlalchemy import select
from app.api.deps import CurrentUserContext from app.api.deps import CurrentUserContext
from app.models.approval_action import ApprovalActionLedger from app.models.approval_action import ApprovalActionLedger
from app.models.approval_task import ApprovalTask
from app.models.financial_record import ExpenseClaim from app.models.financial_record import ExpenseClaim
from app.schemas.reimbursement import ExpenseClaimRead from app.schemas.reimbursement import ExpenseClaimRead
from app.services.approval_action_protocol import ( from app.services.approval_action_protocol import (
@@ -34,6 +35,8 @@ class ExpenseClaimActionProtocolMixin:
expected_approval_stage: str | None, expected_approval_stage: str | None,
payload: Mapping[str, Any], payload: Mapping[str, Any],
executor: ClaimActionExecutor, executor: ClaimActionExecutor,
task_id: str | None = None,
expected_task_version: int | None = None,
) -> ExpenseClaim | ExpenseClaimRead | None: ) -> ExpenseClaim | ExpenseClaimRead | None:
protocol = ApprovalActionProtocol(self.db) protocol = ApprovalActionProtocol(self.db)
tenant_id = ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id) 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_loader=lambda: self._load_claim_for_action(
claim_id, claim_id,
current_user, current_user,
task_id=task_id,
), ),
) )
if started.replayed: if started.replayed:
@@ -69,11 +73,48 @@ class ExpenseClaimActionProtocolMixin:
if started.ledger is None: # pragma: no cover - defensive invariant if started.ledger is None: # pragma: no cover - defensive invariant
raise RuntimeError("审批动作账本初始化失败。") 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( claim = executor(
started.claim, started.claim,
started.ledger, started.ledger,
normalized_request_id, 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.flush()
self.db.refresh(claim) self.db.refresh(claim)
response = ExpenseClaimRead.model_validate( response = ExpenseClaimRead.model_validate(
@@ -96,8 +137,26 @@ class ExpenseClaimActionProtocolMixin:
self, self,
claim_id: str, claim_id: str,
current_user: CurrentUserContext, current_user: CurrentUserContext,
*,
task_id: str | None = None,
) -> ExpenseClaim | None: ) -> ExpenseClaim | None:
stmt = select(ExpenseClaim).where(ExpenseClaim.id == claim_id) stmt = select(ExpenseClaim).where(ExpenseClaim.id == claim_id)
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 = self._access_policy.apply_claim_scope(
stmt, stmt,
current_user, current_user,

View File

@@ -36,6 +36,8 @@ class ExpenseClaimApprovalFlowMixin:
request_id: str | None = None, request_id: str | None = None,
expected_status: str | None = None, expected_status: str | None = None,
expected_approval_stage: 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() normalized_opinion = str(opinion or "").strip()
return self._execute_claim_action( return self._execute_claim_action(
@@ -45,7 +47,11 @@ class ExpenseClaimApprovalFlowMixin:
request_id=request_id, request_id=request_id,
expected_status=expected_status, expected_status=expected_status,
expected_approval_stage=expected_approval_stage, 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( executor=lambda claim, ledger, normalized_request_id: self._approve_claim_once(
claim, claim,
current_user, current_user,
@@ -53,6 +59,8 @@ class ExpenseClaimApprovalFlowMixin:
ledger=ledger, ledger=ledger,
request_id=normalized_request_id, request_id=normalized_request_id,
), ),
task_id=task_id,
expected_task_version=expected_task_version,
) )
def _approve_claim_once( def _approve_claim_once(
@@ -77,7 +85,9 @@ class ExpenseClaimApprovalFlowMixin:
route_decision_flag: dict[str, Any] | None = None route_decision_flag: dict[str, Any] | None = None
generated_draft = None generated_draft = None
if previous_stage == DIRECT_MANAGER_APPROVAL_STAGE: 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("只有当前直属领导审批人可以审批通过该单据。") raise ValueError("只有当前直属领导审批人可以审批通过该单据。")
self._ensure_claim_has_no_blocking_risk(claim, current_user) self._ensure_claim_has_no_blocking_risk(claim, current_user)
approval_source = "manual_approval" approval_source = "manual_approval"
@@ -154,7 +164,9 @@ class ExpenseClaimApprovalFlowMixin:
"{operator} 已审批通过,系统判断预算充足且无风险,流转至{next_stage}" "{operator} 已审批通过,系统判断预算充足且无风险,流转至{next_stage}"
) )
elif previous_stage == BUDGET_MANAGER_APPROVAL_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("只有当前预算管理者可以审批通过该单据。") raise ValueError("只有当前预算管理者可以审批通过该单据。")
self._ensure_claim_has_no_blocking_risk(claim, current_user) self._ensure_claim_has_no_blocking_risk(claim, current_user)
approval_source = "budget_approval" approval_source = "budget_approval"
@@ -175,7 +187,9 @@ class ExpenseClaimApprovalFlowMixin:
elif previous_stage == FINANCE_APPROVAL_STAGE: elif previous_stage == FINANCE_APPROVAL_STAGE:
if is_application_claim: if is_application_claim:
raise ValueError("费用申请需先完成预算管理者审批。") 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("只有财务人员可以完成财务终审。") raise ValueError("只有财务人员可以完成财务终审。")
self._ensure_claim_has_no_blocking_risk(claim, current_user) self._ensure_claim_has_no_blocking_risk(claim, current_user)
approval_source = "finance_approval" approval_source = "finance_approval"
@@ -340,6 +354,7 @@ class ExpenseClaimApprovalFlowMixin:
), ),
}, },
) )
claim._approval_task_business_event = _event
if generated_draft is not None: if generated_draft is not None:
self._expense_cases.record_claim_event( self._expense_cases.record_claim_event(
generated_draft, generated_draft,

View File

@@ -21,6 +21,8 @@ class ExpenseClaimReturnFlowMixin:
request_id: str | None = None, request_id: str | None = None,
expected_status: str | None = None, expected_status: str | None = None,
expected_approval_stage: str | None = None, expected_approval_stage: str | None = None,
task_id: str | None = None,
expected_task_version: int | None = None,
) -> ExpenseClaim | ExpenseClaimRead | None: ) -> ExpenseClaim | ExpenseClaimRead | None:
normalized_reason = str(reason or "").strip() normalized_reason = str(reason or "").strip()
reason_code_payload = self._normalize_return_reason_code_payload(reason_codes) reason_code_payload = self._normalize_return_reason_code_payload(reason_codes)
@@ -35,7 +37,12 @@ class ExpenseClaimReturnFlowMixin:
request_id=request_id, request_id=request_id,
expected_status=expected_status, expected_status=expected_status,
expected_approval_stage=expected_approval_stage, 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( executor=lambda claim, ledger, normalized_request_id: self._return_claim_once(
claim, claim,
current_user, current_user,
@@ -44,6 +51,8 @@ class ExpenseClaimReturnFlowMixin:
ledger=ledger, ledger=ledger,
request_id=normalized_request_id, request_id=normalized_request_id,
), ),
task_id=task_id,
expected_task_version=expected_task_version,
) )
def _return_claim_once( def _return_claim_once(
@@ -64,7 +73,9 @@ class ExpenseClaimReturnFlowMixin:
if normalized_status in {"approved", "completed", "paid"}: if normalized_status in {"approved", "completed", "paid"}:
raise ValueError("已完成单据不允许退回。") 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("只有财务人员、高级财务人员或当前审批人可以退回报销单。") raise ValueError("只有财务人员、高级财务人员或当前审批人可以退回报销单。")
before_json = self._serialize_claim(claim) before_json = self._serialize_claim(claim)
@@ -162,7 +173,7 @@ class ExpenseClaimReturnFlowMixin:
business_stage=("expense_application" if is_application_claim else "reimbursement"), 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, claim,
event_type=("application_returned" if is_application_claim else "claim_returned"), event_type=("application_returned" if is_application_claim else "claim_returned"),
actor_id=current_user.username, actor_id=current_user.username,
@@ -175,6 +186,7 @@ class ExpenseClaimReturnFlowMixin:
"reason_codes": normalized_reason_codes, "reason_codes": normalized_reason_codes,
}, },
) )
claim._approval_task_business_event = return_event
self.audit_service.log_action( self.audit_service.log_action(
actor=operator, actor=operator,
action="expense_claim.return", action="expense_claim.return",

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -167,7 +168,18 @@ class ExpenseClaimRiskGate:
if disposition is not None: if disposition is not None:
adjudication = str(disposition.adjudication or "").strip().lower() adjudication = str(disposition.adjudication or "").strip().lower()
lifecycle = str(disposition.lifecycle_status 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" return adjudication == "false_positive" or lifecycle == "resolved"
status = str(observation.status or "").strip().lower() status = str(observation.status or "").strip().lower()
feedback_status = str(observation.feedback_status or "").strip().lower() feedback_status = str(observation.feedback_status or "").strip().lower()
return status in _LEGACY_CLOSED_STATUSES or feedback_status == "false_positive" 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)

View File

@@ -384,6 +384,18 @@ class ExpenseClaimItemActionMixin:
previous_status=str(before_json.get("status") or ""), previous_status=str(before_json.get("status") or ""),
previous_approval_stage=str(before_json.get("approval_stage") 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: if before_commit is not None:
before_commit(submission_event) before_commit(submission_event)

View File

@@ -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()

View File

@@ -24,12 +24,19 @@ from app.schemas.risk_disposition import (
RiskDispositionMutationRead, RiskDispositionMutationRead,
RiskDispositionRead, RiskDispositionRead,
) )
from app.services.approval_task_projection_refresh import (
ApprovalTaskProjectionRefreshService,
)
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
from app.services.risk_observation_access_policy import RiskObservationAccessPolicy 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") logger = get_logger("app.services.risk_dispositions")
class RiskDispositionConflictError(RuntimeError): class RiskDispositionConflictError(RuntimeError):
pass pass
@@ -145,12 +152,17 @@ class RiskDispositionService:
raise RiskDispositionConflictError( raise RiskDispositionConflictError(
"Risk observation claim changed concurrently; reload and retry." "Risk observation claim changed concurrently; reload and retry."
) )
if current_user is not None and not RiskObservationAccessPolicy( 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 self.db
).can_manage_locked_disposition( ).can_manage_locked_disposition(
observation, observation,
current_user, current_user,
locked_claim=locked_claim, locked_claim=locked_claim,
)
): ):
raise RiskDispositionPermissionError("当前用户已不再是该单据的有效审批人。") raise RiskDispositionPermissionError("当前用户已不再是该单据的有效审批人。")
@@ -180,9 +192,29 @@ class RiskDispositionService:
) )
self.db.add(disposition) 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) 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.version = current_version + 1
disposition.updated_at = mutation_at disposition.updated_at = mutation_at
event = RiskDispositionEvent( event = RiskDispositionEvent(
@@ -192,11 +224,11 @@ class RiskDispositionService:
observation_id=observation.id, observation_id=observation.id,
version=disposition.version, version=disposition.version,
action=payload.action, action=payload.action,
actor_id=normalized_actor_id, actor_id=waiver_actor_id,
actor_name=_text(actor_name) or _text(actor_id) or "anonymous", actor_name=waiver_actor_name,
request_id=payload.request_id, request_id=payload.request_id,
payload_fingerprint=fingerprint, payload_fingerprint=fingerprint,
comment=payload.comment, comment=payload.comment or payload.waiver_reason,
before_json=before, before_json=before,
after_json=_state(disposition), after_json=_state(disposition),
created_at=mutation_at, created_at=mutation_at,
@@ -216,6 +248,14 @@ class RiskDispositionService:
payload, payload,
actor_name=actor_name, 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.commit()
self.db.refresh(disposition) self.db.refresh(disposition)
self.db.refresh(event) self.db.refresh(event)
@@ -226,7 +266,12 @@ class RiskDispositionService:
response=response, response=response,
legacy_feedback=legacy_feedback, legacy_feedback=legacy_feedback,
) )
except (LookupError, RiskDispositionConflictError, RiskDispositionPermissionError): except (
LookupError,
RiskDispositionConflictError,
RiskDispositionPermissionError,
RiskWaiverDecisionPermissionError,
):
self.db.rollback() self.db.rollback()
raise raise
except IntegrityError as error: except IntegrityError as error:
@@ -292,6 +337,18 @@ class RiskDispositionService:
assignee=disposition.assignee, assignee=disposition.assignee,
due_at=disposition.due_at, due_at=disposition.due_at,
resolution=disposition.resolution, 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, created_at=disposition.created_at,
updated_at=disposition.updated_at, updated_at=disposition.updated_at,
events=event_history, events=event_history,
@@ -382,6 +439,18 @@ class RiskDispositionService:
assignee=state.get("assignee"), assignee=state.get("assignee"),
due_at=state.get("due_at"), due_at=state.get("due_at"),
resolution=state.get("resolution"), 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, created_at=history[0].created_at,
updated_at=event.created_at, updated_at=event.created_at,
events=event_reads, events=event_reads,
@@ -468,6 +537,10 @@ def _apply_action(
disposition: RiskDisposition, disposition: RiskDisposition,
observation: RiskObservation, observation: RiskObservation,
payload: RiskDispositionActionCreate, payload: RiskDispositionActionCreate,
*,
actor_id: str,
actor_name: str,
mutation_at: datetime,
) -> None: ) -> None:
if payload.action == "confirm": if payload.action == "confirm":
disposition.adjudication = "confirmed" disposition.adjudication = "confirmed"
@@ -479,11 +552,48 @@ def _apply_action(
observation.status = "false_positive" observation.status = "false_positive"
observation.feedback_status = "false_positive" observation.feedback_status = "false_positive"
return 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 = { lifecycle_by_action = {
"request_supplement": "supplement_requested", "request_supplement": "supplement_requested",
"start_remediation": "remediation_in_progress", "start_remediation": "remediation_in_progress",
"request_waiver": "waiver_requested",
"resolve": "resolved", "resolve": "resolved",
} }
disposition.lifecycle_status = lifecycle_by_action[payload.action] disposition.lifecycle_status = lifecycle_by_action[payload.action]
@@ -501,7 +611,21 @@ def _apply_action(
def _validate_transition( def _validate_transition(
disposition: RiskDisposition, disposition: RiskDisposition,
payload: RiskDispositionActionCreate, payload: RiskDispositionActionCreate,
*,
now: datetime,
) -> None: ) -> 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": if disposition.lifecycle_status == "resolved":
raise RiskDispositionConflictError("已解决的风险不能再次变更裁决或处置生命周期。") raise RiskDispositionConflictError("已解决的风险不能再次变更裁决或处置生命周期。")
if payload.action == "confirm": if payload.action == "confirm":
@@ -519,7 +643,17 @@ def _validate_transition(
"resolve": "resolved", "resolve": "resolved",
}[payload.action] }[payload.action]
if disposition.lifecycle_status == target_lifecycle: if disposition.lifecycle_status == target_lifecycle:
if payload.action == "request_waiver" and not _text(
disposition.waiver_requester_id
):
# 0014 前的旧申请没有申请人和有效期,允许以完整协议重新提交。
pass
else:
raise RiskDispositionConflictError("该风险已处于目标处置状态,请勿重复提交。") 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": if payload.action == "request_supplement":
return return
if disposition.adjudication != "confirmed": if disposition.adjudication != "confirmed":
@@ -533,6 +667,30 @@ def _state(disposition: RiskDisposition) -> dict[str, object]:
"assignee": disposition.assignee, "assignee": disposition.assignee,
"due_at": disposition.due_at.isoformat() if disposition.due_at else None, "due_at": disposition.due_at.isoformat() if disposition.due_at else None,
"resolution": disposition.resolution, "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, "version": disposition.version,
} }
@@ -581,3 +739,11 @@ def _tenant(value: str) -> str:
def _text(value: object) -> str: def _text(value: object) -> str:
return str(value or "").strip() 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)

View File

@@ -18,6 +18,9 @@ from app.schemas.risk_observation import (
RiskObservationDashboardRead, RiskObservationDashboardRead,
RiskObservationFeedbackCreate, 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_risk_stage import normalize_risk_business_stage
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
@@ -50,13 +53,9 @@ class RiskObservationService:
cache_key = str(getattr(bind, "url", "") or id(bind)) cache_key = str(getattr(bind, "url", "") or id(bind))
if cache_key in self._storage_ready_cache: if cache_key in self._storage_ready_cache:
return return
Base.metadata.create_all( Base.metadata.create_all(
bind=bind, bind=bind,
tables=[ tables=[RiskObservation.__table__, RiskObservationFeedback.__table__],
RiskObservation.__table__,
RiskObservationFeedback.__table__,
],
) )
self._storage_ready_cache.add(cache_key) self._storage_ready_cache.add(cache_key)
@@ -88,7 +87,6 @@ class RiskObservationService:
claim_id, claim_id,
tenant_id=normalized_tenant_id, tenant_id=normalized_tenant_id,
) )
item = self.db.scalar( item = self.db.scalar(
select(RiskObservation).where( select(RiskObservation).where(
RiskObservation.tenant_id == normalized_tenant_id, RiskObservation.tenant_id == normalized_tenant_id,
@@ -101,7 +99,6 @@ class RiskObservationService:
observation_key=observation_key, observation_key=observation_key,
) )
self.db.add(item) self.db.add(item)
item.subject_type = _text(payload.get("subject_type")) item.subject_type = _text(payload.get("subject_type"))
item.subject_key = _text(payload.get("subject_key")) item.subject_key = _text(payload.get("subject_key"))
item.subject_label = _text(payload.get("subject_label")) 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.similar_case_claim_ids_json = _list(payload.get("similar_case_claim_ids"))
item.ontology_json = _risk_ontology_payload(payload) item.ontology_json = _risk_ontology_payload(payload)
item.decision_trace_json = _risk_decision_trace_payload(payload) item.decision_trace_json = _risk_decision_trace_payload(payload)
self.db.flush() self.db.flush()
if item.claim_id:
ApprovalTaskProjectionRefreshService(self.db).refresh_claim(
tenant_id=normalized_tenant_id,
claim_id=item.claim_id,
)
return item return item
def lock_claim_for_risk_write( def lock_claim_for_risk_write(

View File

@@ -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()

View File

@@ -24,11 +24,13 @@ from app.db.migration_preflight import (
) )
from app.db.schema_ownership import MIGRATION_OWNED_TABLES, create_legacy_schema from app.db.schema_ownership import MIGRATION_OWNED_TABLES, create_legacy_schema
from app.models.ai_memory import MemoryEntry 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 from app.models.risk_observation import RiskObservation
MIGRATION_TEST_DATABASE_URL = os.getenv("MIGRATION_TEST_DATABASE_URL", "").strip() MIGRATION_TEST_DATABASE_URL = os.getenv("MIGRATION_TEST_DATABASE_URL", "").strip()
LEGACY_PROBE_TABLE = "legacy_migration_probe_records" LEGACY_PROBE_TABLE = "legacy_migration_probe_records"
HEAD_REVISION = "20260716_0012" HEAD_REVISION = "20260716_0014"
SERVER_DIR = Path(__file__).resolve().parents[1] SERVER_DIR = Path(__file__).resolve().parents[1]
ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini" ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini"
@@ -424,6 +426,11 @@ def _assert_head_schema(engine: Engine) -> None:
"due_at", "due_at",
), ),
"ix_risk_dispositions_assignee": ("tenant_id", "assignee"), "ix_risk_dispositions_assignee": ("tenant_id", "assignee"),
"ix_risk_dispositions_tenant_waiver_expiry": (
"tenant_id",
"lifecycle_status",
"waiver_expires_at",
),
}, },
) )
_assert_check_constraint( _assert_check_constraint(
@@ -441,6 +448,21 @@ def _assert_head_schema(engine: Engine) -> None:
"risk_dispositions", "risk_dispositions",
"ck_risk_dispositions_version", "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( _assert_unique_constraint(
engine, engine,
"risk_disposition_events", "risk_disposition_events",
@@ -495,6 +517,24 @@ def _assert_head_schema(engine: Engine) -> None:
) )
} }
assert "response_json" in risk_disposition_event_columns 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: with engine.connect() as connection:
append_only_trigger_count = int( append_only_trigger_count = int(
connection.scalar( connection.scalar(
@@ -509,6 +549,151 @@ def _assert_head_schema(engine: Engine) -> None:
or 0 or 0
) )
assert append_only_trigger_count == 1 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( _assert_check_constraint(
engine, engine,
"memory_entries", "memory_entries",
@@ -1335,6 +1520,10 @@ def _assert_base_schema(engine: Engine) -> None:
("20260716_0011_risk_disposition.py", "downgrade"), ("20260716_0011_risk_disposition.py", "downgrade"),
("20260716_0012_risk_disposition_response_snapshot.py", "upgrade"), ("20260716_0012_risk_disposition_response_snapshot.py", "upgrade"),
("20260716_0012_risk_disposition_response_snapshot.py", "downgrade"), ("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( 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 == [] 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: def test_head_model_declares_soft_claim_reference_and_organization_only_active_index() -> None:
claim_column = RiskObservation.__table__.c.claim_id claim_column = RiskObservation.__table__.c.claim_id
assert not claim_column.foreign_keys 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 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( @pytest.mark.parametrize(
"database_url", "database_url",
[ [

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime, timedelta
from decimal import Decimal from decimal import Decimal
import pytest import pytest
@@ -52,6 +52,48 @@ def test_high_risk_requires_false_positive_or_resolved_disposition() -> None:
gate.ensure_approvable(claim, tenant_id="default") 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: def test_medium_and_foreign_tenant_risks_do_not_block_claim() -> None:
with _session() as db: with _session() as db:
claim = _claim(claim_id="claim-risk-nonblocking") claim = _claim(claim_id="claim-risk-nonblocking")

View File

@@ -149,6 +149,14 @@ def test_known_revision_requires_and_accepts_its_exact_owned_table_set(
"20260716_0012", "20260716_0012",
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0012"] - {"risk_disposition_events"}, 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( def test_known_revision_with_missing_or_unexpected_owned_tables_is_rejected(

View File

@@ -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)

View File

@@ -26,6 +26,7 @@ from app.services.risk_dispositions import (
RiskDispositionPermissionError, RiskDispositionPermissionError,
RiskDispositionService, RiskDispositionService,
RiskDispositionVersionConflictError, RiskDispositionVersionConflictError,
RiskWaiverDecisionPermissionError,
) )
from app.services.risk_observation_access_policy import RiskObservationAccessPolicy from app.services.risk_observation_access_policy import RiskObservationAccessPolicy
from app.services.risk_observations import RiskObservationService 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", "action": "request_waiver",
"expected_version": 0, "expected_version": 0,
"request_id": "api-stale-request-001", "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( 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 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( @pytest.mark.parametrize(
"action", "action",
["false_positive", "request_supplement", "request_waiver"], ["false_positive", "request_supplement"],
) )
def test_evidence_sensitive_actions_require_server_side_comment(action: str) -> None: def test_evidence_sensitive_actions_require_server_side_comment(action: str) -> None:
with pytest.raises(ValueError, match="必须填写 comment"): 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( def _action(
action: str, action: str,
*, *,
version: int, version: int,
request_id: str, request_id: str,
) -> RiskDispositionActionCreate: ) -> 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( return RiskDispositionActionCreate(
action=action, action=action,
expected_version=version, expected_version=version,
request_id=request_id, request_id=request_id,
comment=( comment=(
"风险处置说明" "风险处置说明"
if action in {"false_positive", "request_supplement", "request_waiver"} if action in {"false_positive", "request_supplement"}
else None 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: def _claim() -> ExpenseClaim:
now = datetime(2026, 7, 16, tzinfo=UTC) now = datetime(2026, 7, 16, tzinfo=UTC)
return ExpenseClaim( return ExpenseClaim(

View File

@@ -20,6 +20,8 @@ def test_create_legacy_schema_never_creates_migration_owned_tables() -> None:
"ai_decision_feedback", "ai_decision_feedback",
"ai_decisions", "ai_decisions",
"approval_action_ledgers", "approval_action_ledgers",
"approval_task_events",
"approval_tasks",
"auth_sessions", "auth_sessions",
"attachment_association_jobs", "attachment_association_jobs",
"business_events", "business_events",

View File

@@ -252,13 +252,15 @@
.risk-disposition-badges em.confirmed, .risk-disposition-badges em.confirmed,
.risk-disposition-badges em.remediation_in_progress, .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); background: var(--warning-soft);
color: var(--warning-active); color: var(--warning-active);
} }
.risk-disposition-badges em.false_positive, .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); background: var(--success-soft);
color: var(--success); color: var(--success);
} }
@@ -318,8 +320,7 @@
} }
.risk-disposition-resolution, .risk-disposition-resolution,
.risk-disposition-complete, .risk-disposition-complete {
.risk-disposition-error {
margin: 0; margin: 0;
color: #475569; color: #475569;
font-size: 12px; font-size: 12px;
@@ -328,7 +329,34 @@
} }
.risk-disposition-error { .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); 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 { .risk-evidence-meta {

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,409 @@
<template>
<ConfirmDialog
:open="open"
:badge="modeMeta.badge"
:title="modeMeta.title"
:description="modeMeta.description"
cancel-text="取消"
:confirm-text="modeMeta.confirmText"
busy-text="提交中..."
:confirm-disabled="confirmDisabled"
:busy="busy"
@close="closeDialog"
@confirm="submitAction"
>
<div class="assignment-form">
<p v-if="permissionReason" class="assignment-error" role="alert">
{{ permissionReason }}
</p>
<template v-if="requiresCandidate">
<label class="candidate-search">
<span>候选审批人</span>
<div>
<input
v-model="candidateKeyword"
type="search"
placeholder="输入姓名、工号或邮箱"
:disabled="busy"
@keydown.enter.prevent="loadCandidates"
/>
<button type="button" :disabled="candidateLoading || busy" @click="loadCandidates">
<i :class="candidateLoading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-magnify'"></i>
查询
</button>
</div>
</label>
<div class="candidate-list" role="radiogroup" aria-label="审批候选人">
<p v-if="candidateLoading">正在加载候选人...</p>
<p v-else-if="candidateError" class="assignment-error">{{ candidateError }}</p>
<p v-else-if="!candidates.length">暂无符合条件的候选人</p>
<label
v-for="candidate in candidates"
v-else
:key="candidate.employeeId"
:class="{ disabled: !candidate.qualified }"
>
<input
v-model="targetEmployeeId"
type="radio"
:value="candidate.employeeId"
:disabled="!candidate.qualified || busy"
/>
<span>
<strong>{{ candidate.name || candidate.employeeNo }}</strong>
<small>{{ candidate.employeeNo }} · {{ candidate.email }}</small>
<em v-if="!candidate.qualified">{{ candidate.reason || '不符合当前动作资格' }}</em>
</span>
</label>
</div>
</template>
<label v-if="mode === 'delegate'" class="assignment-field">
<span>委托失效时间可选</span>
<input v-model="expiresAt" type="datetime-local" :disabled="busy" />
<small>到期后的恢复行为由服务端审批任务策略决定</small>
</label>
<label class="assignment-field">
<span>{{ modeMeta.reasonLabel }} <em>必填</em></span>
<textarea
v-model="reason"
maxlength="500"
:disabled="busy"
:placeholder="modeMeta.reasonPlaceholder"
></textarea>
<small>{{ reason.length }}/500</small>
</label>
<p v-if="submitError" class="assignment-error" role="alert">{{ submitError }}</p>
</div>
</ConfirmDialog>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import {
delegateApprovalTask,
fetchApprovalTaskCandidates,
revokeApprovalTaskDelegation,
transferApprovalTask
} from '../../services/approvalTasks.js'
import { resolveApprovalTaskRetryKey } from '../../views/scripts/approvalTaskRetry.js'
import ConfirmDialog from '../shared/ConfirmDialog.vue'
const MODE_METADATA = {
delegate: {
badge: '临时委托',
title: '委托当前审批任务',
description: '委托只改变当前任务的实际处理人,原任务责任人与到期策略以服务端记录为准。',
confirmText: '确认委托',
reasonLabel: '委托原因',
reasonPlaceholder: '说明委托原因和需要候选人关注的事项。'
},
transfer: {
badge: '审批转交',
title: '转交当前审批任务',
description: '转交会永久改变当前任务处理人,请确认候选人具备服务端返回的处理资格。',
confirmText: '确认转交',
reasonLabel: '转交原因',
reasonPlaceholder: '说明转交原因和后续责任边界。'
},
delegation_revoke: {
badge: '撤销委托',
title: '撤销当前任务委托',
description: '服务端会按最新任务版本复核是否仍可撤销,并记录完整审计事件。',
confirmText: '确认撤销',
reasonLabel: '撤销原因',
reasonPlaceholder: '说明撤销委托的原因。'
}
}
const props = defineProps({
open: { type: Boolean, default: false },
task: { type: Object, default: () => ({}) },
mode: {
type: String,
default: 'delegate',
validator: (value) => ['delegate', 'transfer', 'delegation_revoke'].includes(value)
},
serviceOverrides: { type: Object, default: () => ({}) }
})
const emit = defineEmits(['close', 'completed'])
const candidateKeyword = ref('')
const candidates = ref([])
const candidateLoading = ref(false)
const candidateError = ref('')
const targetEmployeeId = ref('')
const expiresAt = ref('')
const reason = ref('')
const busy = ref(false)
const submitError = ref('')
const requestId = ref('')
const requestFingerprint = ref('')
let candidateRequestSequence = 0
const normalizedTask = computed(() => props.task?.task || props.task || {})
const taskId = computed(() => String(normalizedTask.value.id || '').trim())
const availableActions = computed(() => new Set(
Array.isArray(normalizedTask.value.availableActions)
? normalizedTask.value.availableActions
: Array.isArray(normalizedTask.value.available_actions)
? normalizedTask.value.available_actions
: []
))
const modeMeta = computed(() => MODE_METADATA[props.mode] || MODE_METADATA.delegate)
const requiresCandidate = computed(() => ['delegate', 'transfer'].includes(props.mode))
const canAct = computed(() =>
Boolean(taskId.value)
&& Number(normalizedTask.value.version) >= 1
&& availableActions.value.has(props.mode)
)
const permissionReason = computed(() => {
if (canAct.value) return ''
return String(
normalizedTask.value.readOnlyReason
?? normalizedTask.value.read_only_reason
?? '服务端未授权当前审批动作。'
).trim()
})
const selectedCandidate = computed(() =>
candidates.value.find((candidate) => candidate.employeeId === targetEmployeeId.value) || null
)
const confirmDisabled = computed(() =>
!canAct.value
|| reason.value.trim().length < 2
|| (requiresCandidate.value && !selectedCandidate.value?.qualified)
)
watch(
[() => props.open, taskId, () => props.mode],
([open]) => {
if (!open) return
resetForm()
if (requiresCandidate.value && canAct.value) {
void loadCandidates()
}
}
)
function resetForm() {
candidateRequestSequence += 1
candidateKeyword.value = ''
candidates.value = []
candidateError.value = ''
targetEmployeeId.value = ''
expiresAt.value = ''
reason.value = ''
submitError.value = ''
requestId.value = ''
requestFingerprint.value = ''
}
async function loadCandidates() {
if (!taskId.value || !requiresCandidate.value || !canAct.value) return
const sequence = ++candidateRequestSequence
candidateLoading.value = true
candidateError.value = ''
try {
const service = props.serviceOverrides.fetchApprovalTaskCandidates
|| fetchApprovalTaskCandidates
const result = await service(taskId.value, {
action: props.mode,
keyword: candidateKeyword.value,
limit: 50
})
if (sequence !== candidateRequestSequence) return
candidates.value = Array.isArray(result?.items) ? result.items : []
if (!candidates.value.some((candidate) => candidate.employeeId === targetEmployeeId.value)) {
targetEmployeeId.value = ''
}
} catch (error) {
if (sequence === candidateRequestSequence) {
candidates.value = []
candidateError.value = error?.message || '审批候选人加载失败。'
}
} finally {
if (sequence === candidateRequestSequence) {
candidateLoading.value = false
}
}
}
function resolveExpiresAt() {
if (!expiresAt.value) return ''
const date = new Date(expiresAt.value)
if (Number.isNaN(date.getTime())) {
throw new Error('委托失效时间格式无效。')
}
return date.toISOString()
}
async function submitAction() {
if (confirmDisabled.value || busy.value) return
const serviceMap = {
delegate: props.serviceOverrides.delegateApprovalTask || delegateApprovalTask,
transfer: props.serviceOverrides.transferApprovalTask || transferApprovalTask,
delegation_revoke: props.serviceOverrides.revokeApprovalTaskDelegation
|| revokeApprovalTaskDelegation
}
busy.value = true
submitError.value = ''
try {
const payload = {
expectedTaskVersion: Number(normalizedTask.value.version),
reason: reason.value.trim()
}
if (requiresCandidate.value) {
payload.targetEmployeeId = targetEmployeeId.value
}
if (props.mode === 'delegate' && expiresAt.value) {
payload.expiresAt = resolveExpiresAt()
}
const retryKey = resolveApprovalTaskRetryKey({
action: props.mode,
scopeId: taskId.value || 'task',
payload,
previousFingerprint: requestFingerprint.value,
previousRequestId: requestId.value
})
requestId.value = retryKey.requestId
requestFingerprint.value = retryKey.fingerprint
payload.requestId = retryKey.requestId
const mutation = await serviceMap[props.mode](taskId.value, payload)
emit('completed', { mode: props.mode, mutation })
emit('close')
} catch (error) {
submitError.value = error?.message || '审批任务分配操作失败。'
} finally {
busy.value = false
}
}
function closeDialog() {
if (!busy.value) emit('close')
}
</script>
<style scoped>
.assignment-form,
.assignment-field,
.candidate-search,
.candidate-list {
display: grid;
gap: 8px;
}
.candidate-search > span,
.assignment-field > span {
color: #334155;
font-size: 12px;
font-weight: 850;
}
.candidate-search > div {
display: flex;
gap: 8px;
}
.candidate-search input,
.assignment-field input,
.assignment-field textarea {
width: 100%;
min-height: 38px;
padding: 8px 10px;
border: 1px solid #dbe5ef;
border-radius: 6px;
outline: none;
}
.candidate-search button {
flex: 0 0 auto;
padding: 0 13px;
border: 1px solid var(--theme-primary);
border-radius: 6px;
background: #fff;
color: var(--theme-primary-active);
font-weight: 800;
}
.candidate-list {
max-height: 210px;
overflow-y: auto;
}
.candidate-list > p {
margin: 0;
padding: 12px;
border-radius: 6px;
background: #f8fafc;
color: #64748b;
font-size: 12px;
}
.candidate-list label {
display: flex;
align-items: flex-start;
gap: 9px;
padding: 9px;
border: 1px solid #e2e8f0;
border-radius: 6px;
}
.candidate-list label.disabled {
background: #f8fafc;
opacity: 0.7;
}
.candidate-list label span,
.candidate-list label strong,
.candidate-list label small,
.candidate-list label em {
display: block;
}
.candidate-list label strong {
color: #0f172a;
font-size: 13px;
}
.candidate-list label small {
margin-top: 2px;
color: #64748b;
}
.candidate-list label em {
margin-top: 3px;
color: #b45309;
font-size: 11px;
font-style: normal;
}
.assignment-field textarea {
min-height: 82px;
resize: vertical;
}
.assignment-field small {
color: #64748b;
font-size: 11px;
}
.assignment-field em {
color: #b91c1c;
font-style: normal;
}
.assignment-error {
margin: 0;
padding: 9px 10px;
border: 1px solid #fecaca;
border-radius: 6px;
background: #fef2f2;
color: #b91c1c;
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,193 @@
<template>
<aside v-if="result" class="approval-batch-result" aria-live="polite">
<header>
<div>
<span>批量审批结果</span>
<strong>{{ statusLabel }}</strong>
</div>
<button type="button" aria-label="关闭批量审批结果" @click="emit('close')">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="approval-batch-summary">
<span class="success">成功 {{ successCount }}</span>
<span v-if="result.blockedCount" class="warning">风险阻断 {{ result.blockedCount }}</span>
<span v-if="result.conflictCount" class="warning">状态冲突 {{ result.conflictCount }}</span>
<span v-if="result.forbiddenCount" class="danger">无权限 {{ result.forbiddenCount }}</span>
<span v-if="result.failedCount" class="danger">失败 {{ result.failedCount }}</span>
</div>
<ul v-if="result.items?.length">
<li v-for="item in result.items" :key="`${item.taskId}:${item.status}`">
<em :class="item.status">{{ itemStatusLabel(item.status) }}</em>
<div>
<strong>{{ item.claimId || item.taskId }}</strong>
<span>{{ item.message || item.code || '服务器未返回详细说明。' }}</span>
</div>
</li>
</ul>
<footer v-if="retryableCount">
<button type="button" @click="emit('retry-failed')">
重新处理保留的 {{ retryableCount }}
</button>
</footer>
</aside>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
result: { type: Object, default: null },
retryableCount: { type: Number, default: 0 }
})
const emit = defineEmits(['close', 'retry-failed'])
const successCount = computed(() =>
Number(props.result?.succeededCount || 0) + Number(props.result?.replayedCount || 0)
)
const statusLabel = computed(() => {
if (props.result?.status === 'succeeded') return '全部处理成功'
if (props.result?.status === 'partial') return '部分任务未完成'
return '批量处理失败'
})
function itemStatusLabel(status) {
return {
succeeded: '成功',
replayed: '已处理',
conflict: '冲突',
blocked: '阻断',
forbidden: '无权限',
failed: '失败'
}[String(status || '')] || '未知'
}
</script>
<style scoped>
.approval-batch-result {
display: grid;
gap: 12px;
padding: 16px;
border: 1px solid #dbe5ef;
border-radius: 8px;
background: #fff;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.12);
}
.approval-batch-result header,
.approval-batch-result header > div,
.approval-batch-result li,
.approval-batch-summary {
display: flex;
align-items: center;
}
.approval-batch-result header {
justify-content: space-between;
gap: 12px;
}
.approval-batch-result header > div {
gap: 10px;
}
.approval-batch-result header span,
.approval-batch-result li span {
color: #64748b;
font-size: 12px;
}
.approval-batch-result header strong,
.approval-batch-result li strong {
color: #0f172a;
font-size: 13px;
}
.approval-batch-result header button {
width: 32px;
height: 32px;
border: 0;
border-radius: 6px;
background: #f1f5f9;
color: #475569;
}
.approval-batch-summary {
flex-wrap: wrap;
gap: 8px;
}
.approval-batch-summary span,
.approval-batch-result li em {
padding: 3px 8px;
border-radius: 999px;
font-size: 11px;
font-style: normal;
font-weight: 800;
}
.success,
.approval-batch-result li em.succeeded,
.approval-batch-result li em.replayed {
background: #ecfdf5;
color: #047857;
}
.warning,
.approval-batch-result li em.blocked,
.approval-batch-result li em.conflict {
background: #fffbeb;
color: #b45309;
}
.danger,
.approval-batch-result li em.forbidden,
.approval-batch-result li em.failed {
background: #fef2f2;
color: #b91c1c;
}
.approval-batch-result ul {
display: grid;
gap: 7px;
max-height: 260px;
margin: 0;
padding: 0;
overflow-y: auto;
list-style: none;
}
.approval-batch-result li {
align-items: flex-start;
gap: 9px;
padding: 9px;
border: 1px solid #edf2f7;
border-radius: 6px;
background: #f8fafc;
}
.approval-batch-result li div {
display: grid;
gap: 3px;
}
.approval-batch-result footer {
display: flex;
justify-content: flex-end;
}
.approval-batch-result footer button {
min-height: 36px;
padding: 0 14px;
border: 1px solid var(--theme-primary);
border-radius: 6px;
background: #fff;
color: var(--theme-primary-active);
font-weight: 800;
}
</style>

View File

@@ -0,0 +1,497 @@
<template>
<ConfirmDialog
:open="open"
:badge="modeMeta.badge"
:title="modeMeta.title"
:description="modeMeta.description"
cancel-text="取消"
:confirm-text="modeMeta.confirmText"
busy-text="提交中..."
:confirm-disabled="confirmDisabled"
:busy="busy"
@close="closeDialog"
@confirm="submitAction"
>
<div class="participants-form">
<p v-if="permissionReason" class="participants-error" role="alert">
{{ permissionReason }}
</p>
<label class="participants-search">
<span>参与人</span>
<div>
<input
v-model="candidateKeyword"
type="search"
placeholder="输入姓名、工号或邮箱"
:disabled="busy"
@keydown.enter.prevent="loadCandidates"
/>
<button type="button" :disabled="candidateLoading || busy" @click="loadCandidates">
<i :class="candidateLoading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-magnify'"></i>
查询
</button>
</div>
</label>
<div class="participants-candidates" aria-label="可选审批参与人">
<p v-if="candidateLoading">正在加载候选人...</p>
<p v-else-if="candidateError" class="participants-error">{{ candidateError }}</p>
<p v-else-if="!candidates.length">暂无符合条件的候选人</p>
<label
v-for="candidate in candidates"
v-else
:key="candidate.employeeId"
:class="{ disabled: !candidate.qualified }"
>
<input
type="checkbox"
:checked="selectedParticipantIds.includes(candidate.employeeId)"
:disabled="!candidate.qualified || busy"
@change="toggleParticipant(candidate, $event.target.checked)"
/>
<span>
<strong>{{ candidate.name || candidate.employeeNo }}</strong>
<small>{{ candidate.employeeNo }} · {{ candidate.email }}</small>
<em v-if="!candidate.qualified">{{ candidate.reason || '不符合当前动作资格' }}</em>
</span>
</label>
</div>
<section v-if="selectedParticipants.length" class="selected-participants">
<header>
<strong>{{ mode === 'add_sign' ? '顺序加签队列' : '并行会签成员' }}</strong>
<span>{{ selectedParticipants.length }}/10</span>
</header>
<ol>
<li v-for="(candidate, index) in selectedParticipants" :key="candidate.employeeId">
<span>{{ mode === 'add_sign' ? index + 1 : '•' }}</span>
<strong>{{ candidate.name || candidate.employeeNo }}</strong>
<div>
<button
v-if="mode === 'add_sign'"
type="button"
aria-label="上移参与人"
:disabled="index === 0 || busy"
@click="moveParticipant(index, -1)"
></button>
<button
v-if="mode === 'add_sign'"
type="button"
aria-label="下移参与人"
:disabled="index === selectedParticipants.length - 1 || busy"
@click="moveParticipant(index, 1)"
></button>
<button type="button" :disabled="busy" @click="removeParticipant(candidate.employeeId)">
移除
</button>
</div>
</li>
</ol>
</section>
<label class="participants-reason">
<span>{{ modeMeta.reasonLabel }} <em>必填</em></span>
<textarea
v-model="reason"
maxlength="500"
:disabled="busy"
:placeholder="modeMeta.reasonPlaceholder"
></textarea>
<small>{{ reason.length }}/500</small>
</label>
<p v-if="submitError" class="participants-error" role="alert">{{ submitError }}</p>
</div>
</ConfirmDialog>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import {
addSignApprovalTask,
countersignApprovalTask,
fetchApprovalTaskCandidates
} from '../../services/approvalTasks.js'
import { resolveApprovalTaskRetryKey } from '../../views/scripts/approvalTaskRetry.js'
import ConfirmDialog from '../shared/ConfirmDialog.vue'
const MODE_METADATA = {
add_sign: {
badge: '顺序加签',
title: '添加顺序审批参与人',
description: '参与人将按所列顺序处理,全部完成后再回到原审批任务;最终规则以服务端任务编排为准。',
confirmText: '确认加签',
reasonLabel: '加签原因',
reasonPlaceholder: '说明为什么需要这些参与人依次核对。'
},
countersign: {
badge: '并行会签',
title: '添加并行会签成员',
description: '成员将并行收到审批任务,通过和退回规则由服务端会签策略统一执行。',
confirmText: '确认会签',
reasonLabel: '会签原因',
reasonPlaceholder: '说明为什么需要多位参与人共同核对。'
}
}
const props = defineProps({
open: { type: Boolean, default: false },
task: { type: Object, default: () => ({}) },
mode: {
type: String,
default: 'add_sign',
validator: (value) => ['add_sign', 'countersign'].includes(value)
},
serviceOverrides: { type: Object, default: () => ({}) }
})
const emit = defineEmits(['close', 'completed'])
const candidateKeyword = ref('')
const candidates = ref([])
const candidateLoading = ref(false)
const candidateError = ref('')
const selectedParticipantIds = ref([])
const reason = ref('')
const busy = ref(false)
const submitError = ref('')
const requestId = ref('')
const requestFingerprint = ref('')
let candidateRequestSequence = 0
const normalizedTask = computed(() => props.task?.task || props.task || {})
const taskId = computed(() => String(normalizedTask.value.id || '').trim())
const availableActions = computed(() => new Set(
Array.isArray(normalizedTask.value.availableActions)
? normalizedTask.value.availableActions
: Array.isArray(normalizedTask.value.available_actions)
? normalizedTask.value.available_actions
: []
))
const modeMeta = computed(() => MODE_METADATA[props.mode] || MODE_METADATA.add_sign)
const canAct = computed(() =>
Boolean(taskId.value)
&& Number(normalizedTask.value.version) >= 1
&& availableActions.value.has(props.mode)
)
const permissionReason = computed(() => {
if (canAct.value) return ''
return String(
normalizedTask.value.readOnlyReason
?? normalizedTask.value.read_only_reason
?? '服务端未授权当前审批动作。'
).trim()
})
const candidateIndex = computed(() => new Map(
candidates.value.map((candidate) => [candidate.employeeId, candidate])
))
const selectedParticipants = computed(() =>
selectedParticipantIds.value
.map((employeeId) => candidateIndex.value.get(employeeId))
.filter(Boolean)
)
const confirmDisabled = computed(() =>
!canAct.value
|| !selectedParticipantIds.value.length
|| selectedParticipantIds.value.length > 10
|| reason.value.trim().length < 2
)
watch(
[() => props.open, taskId, () => props.mode],
([open]) => {
if (!open) return
resetForm()
if (canAct.value) void loadCandidates()
}
)
function resetForm() {
candidateRequestSequence += 1
candidateKeyword.value = ''
candidates.value = []
candidateError.value = ''
selectedParticipantIds.value = []
reason.value = ''
submitError.value = ''
requestId.value = ''
requestFingerprint.value = ''
}
async function loadCandidates() {
if (!taskId.value || !canAct.value) return
const sequence = ++candidateRequestSequence
candidateLoading.value = true
candidateError.value = ''
try {
const service = props.serviceOverrides.fetchApprovalTaskCandidates
|| fetchApprovalTaskCandidates
const result = await service(taskId.value, {
action: props.mode,
keyword: candidateKeyword.value,
limit: 50
})
if (sequence !== candidateRequestSequence) return
const nextCandidates = Array.isArray(result?.items) ? result.items : []
const selectedCandidateMap = new Map(
selectedParticipants.value.map((candidate) => [candidate.employeeId, candidate])
)
nextCandidates.forEach((candidate) => selectedCandidateMap.set(candidate.employeeId, candidate))
candidates.value = [...selectedCandidateMap.values()]
} catch (error) {
if (sequence === candidateRequestSequence) {
candidateError.value = error?.message || '审批候选人加载失败。'
}
} finally {
if (sequence === candidateRequestSequence) {
candidateLoading.value = false
}
}
}
function toggleParticipant(candidate, selected) {
if (!candidate?.qualified) return
const next = [...selectedParticipantIds.value]
const index = next.indexOf(candidate.employeeId)
if (selected && index < 0) {
if (next.length >= 10) {
submitError.value = '加签或会签参与人最多 10 名。'
return
}
next.push(candidate.employeeId)
} else if (!selected && index >= 0) {
next.splice(index, 1)
}
selectedParticipantIds.value = next
submitError.value = ''
}
function removeParticipant(employeeId) {
selectedParticipantIds.value = selectedParticipantIds.value.filter((id) => id !== employeeId)
}
function moveParticipant(index, offset) {
const targetIndex = index + offset
if (targetIndex < 0 || targetIndex >= selectedParticipantIds.value.length) return
const next = [...selectedParticipantIds.value]
;[next[index], next[targetIndex]] = [next[targetIndex], next[index]]
selectedParticipantIds.value = next
}
async function submitAction() {
if (confirmDisabled.value || busy.value) return
const service = props.mode === 'add_sign'
? props.serviceOverrides.addSignApprovalTask || addSignApprovalTask
: props.serviceOverrides.countersignApprovalTask || countersignApprovalTask
busy.value = true
submitError.value = ''
try {
const payload = {
expectedTaskVersion: Number(normalizedTask.value.version),
reason: reason.value.trim(),
participantEmployeeIds: [...selectedParticipantIds.value]
}
const retryKey = resolveApprovalTaskRetryKey({
action: props.mode,
scopeId: taskId.value || 'task',
payload,
previousFingerprint: requestFingerprint.value,
previousRequestId: requestId.value
})
requestId.value = retryKey.requestId
requestFingerprint.value = retryKey.fingerprint
payload.requestId = retryKey.requestId
const mutation = await service(taskId.value, payload)
emit('completed', { mode: props.mode, mutation })
emit('close')
} catch (error) {
submitError.value = error?.message || '审批参与人操作失败。'
} finally {
busy.value = false
}
}
function closeDialog() {
if (!busy.value) emit('close')
}
</script>
<style scoped>
.participants-form,
.participants-search,
.participants-candidates,
.selected-participants,
.participants-reason {
display: grid;
gap: 8px;
}
.participants-search > span,
.participants-reason > span {
color: #334155;
font-size: 12px;
font-weight: 850;
}
.participants-search > div {
display: flex;
gap: 8px;
}
.participants-search input,
.participants-reason textarea {
width: 100%;
min-height: 38px;
padding: 8px 10px;
border: 1px solid #dbe5ef;
border-radius: 6px;
outline: none;
}
.participants-search button,
.selected-participants button {
padding: 0 10px;
border: 1px solid #dbe5ef;
border-radius: 5px;
background: #fff;
color: #475569;
font-weight: 800;
}
.participants-candidates {
max-height: 190px;
overflow-y: auto;
}
.participants-candidates > p {
margin: 0;
padding: 10px;
border-radius: 6px;
background: #f8fafc;
color: #64748b;
font-size: 12px;
}
.participants-candidates label {
display: flex;
align-items: flex-start;
gap: 9px;
padding: 8px;
border: 1px solid #e2e8f0;
border-radius: 6px;
}
.participants-candidates label.disabled {
background: #f8fafc;
opacity: 0.7;
}
.participants-candidates label strong,
.participants-candidates label small,
.participants-candidates label em {
display: block;
}
.participants-candidates label strong {
color: #0f172a;
font-size: 13px;
}
.participants-candidates label small {
margin-top: 2px;
color: #64748b;
}
.participants-candidates label em {
margin-top: 3px;
color: #b45309;
font-size: 11px;
font-style: normal;
}
.selected-participants {
padding: 10px;
border: 1px solid #dbe5ef;
border-radius: 6px;
background: #f8fafc;
}
.selected-participants header,
.selected-participants li,
.selected-participants li div {
display: flex;
align-items: center;
}
.selected-participants header {
justify-content: space-between;
}
.selected-participants header strong,
.selected-participants header span {
color: #334155;
font-size: 12px;
}
.selected-participants ol {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.selected-participants li {
gap: 8px;
padding: 6px 8px;
border-radius: 5px;
background: #fff;
}
.selected-participants li > span {
min-width: 20px;
color: var(--theme-primary-active);
font-weight: 850;
}
.selected-participants li > strong {
flex: 1;
color: #0f172a;
font-size: 12px;
}
.selected-participants li div {
gap: 4px;
}
.selected-participants button {
min-height: 26px;
font-size: 11px;
}
.participants-reason textarea {
min-height: 80px;
resize: vertical;
}
.participants-reason small {
color: #64748b;
font-size: 11px;
}
.participants-reason em {
color: #b91c1c;
font-style: normal;
}
.participants-error {
margin: 0;
padding: 9px 10px;
border: 1px solid #fecaca;
border-radius: 6px;
background: #fef2f2;
color: #b91c1c;
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,696 @@
<template>
<section class="approval-task-queue" aria-label="审批任务工作台">
<header class="approval-task-toolbar">
<div class="approval-task-filters">
<label class="approval-task-search">
<i class="mdi mdi-magnify"></i>
<input
v-model="draftFilters.keyword"
type="search"
placeholder="搜索单号、申请人或部门"
@keydown.enter.prevent="applyFilters"
/>
</label>
<label>
<span>风险</span>
<select v-model="draftFilters.riskLevel" @change="applyFilters">
<option value="">全部风险</option>
<option value="critical">重大风险</option>
<option value="high">高风险</option>
<option value="medium">中风险</option>
<option value="low">低风险</option>
</select>
</label>
<label>
<span>SLA</span>
<select v-model="draftFilters.slaState" @change="applyFilters">
<option value="">全部 SLA</option>
<option value="due_soon">即将超时</option>
<option value="overdue">已超时</option>
<option value="escalated">已升级</option>
</select>
</label>
<button type="button" class="filter-apply" @click="applyFilters">查询</button>
</div>
<div class="approval-task-batch-actions">
<span v-if="queue.selectedCount.value">已选择 {{ queue.selectedCount.value }} </span>
<button
type="button"
:disabled="!queue.selectedCount.value || queue.batchBusy.value"
@click="batchConfirmOpen = true"
>
<i class="mdi mdi-check-all"></i>
批量通过
</button>
<button type="button" :disabled="queue.loading.value" @click="reload">
<i :class="queue.loading.value ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-refresh'"></i>
刷新
</button>
</div>
</header>
<p v-if="queue.errorMessage.value" class="approval-task-error" role="alert">
{{ queue.errorMessage.value }}
</p>
<ApprovalBatchResultPanel
:result="queue.lastBatchResult.value"
:retryable-count="queue.selectedCount.value"
@close="queue.clearBatchResult"
@retry-failed="batchConfirmOpen = true"
/>
<div class="approval-task-table-wrap" :aria-busy="queue.loading.value">
<div v-if="queue.loading.value && !rows.length" class="approval-task-state">
<i class="mdi mdi-loading mdi-spin"></i>
正在加载审批任务
</div>
<div v-else-if="!rows.length" class="approval-task-state">
<i class="mdi mdi-clipboard-check-outline"></i>
当前筛选条件下没有审批任务
</div>
<table v-else>
<thead>
<tr>
<th class="selection-column">
<input
type="checkbox"
aria-label="选择当前页可批量审批的任务"
:checked="queue.pageSelection.value.allSelected"
:indeterminate="queue.pageSelection.value.indeterminate"
:disabled="!queue.pageSelection.value.eligibleCount"
@change="queue.toggleCurrentPage($event.target.checked)"
/>
</th>
<th>单号 / 申请人</th>
<th>金额</th>
<th>当前节点</th>
<th>优先级</th>
<th>风险 / 证据</th>
<th>SLA / 升级</th>
<th>当前处理人</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, index) in rows"
:key="row.id"
:ref="(element) => setRowRef(row.id, element)"
:class="{
focused: focusedTaskId === row.id,
readonly: !row.actions.length
}"
:tabindex="focusedTaskId === row.id || (!focusedTaskId && index === 0) ? 0 : -1"
@click="openDetail(row.source)"
@focus="focusedTaskId = row.id"
@keydown="handleRowKeydown($event, index, row)"
>
<td class="selection-column" @click.stop>
<input
type="checkbox"
:aria-label="`选择审批任务 ${row.claimNo}`"
:checked="selectedSet.has(row.id)"
:disabled="!selectionMeta(row.source).selectable"
:title="selectionMeta(row.source).reason"
@change="queue.toggleTask(row.source, $event.target.checked)"
/>
</td>
<td>
<strong>{{ row.claimNo }}</strong>
<span>{{ row.applicant }} · {{ row.department }}</span>
</td>
<td><strong>{{ row.amountLabel }}</strong></td>
<td>
<strong>{{ row.nodeLabel }}</strong>
<span>{{ row.taskKind === 'root' ? '主任务' : row.taskKind }}</span>
</td>
<td>
<em class="priority" :class="row.priorityTier">
{{ row.priorityScore }} · {{ row.priorityTier }}
</em>
</td>
<td>
<em class="risk" :class="row.riskLevel">{{ row.riskLabel }}</em>
<span>{{ row.openRiskCount }} 项开放风险 · 证据 {{ row.evidenceLabel }}</span>
</td>
<td>
<strong class="sla" :class="row.sla.tone" :title="row.sla.title">
{{ row.sla.label }}
</strong>
<span v-if="row.sla.escalationLabel">
已升级 {{ row.sla.escalationLabel }}
</span>
<span v-else>尚未升级</span>
</td>
<td>
<strong>{{ row.assigneeName }}</strong>
<span v-if="row.readOnlyReason" :title="row.readOnlyReason">
{{ row.readOnlyReason }}
</span>
</td>
<td @click.stop>
<div v-if="row.actions.length" class="row-actions">
<button
v-for="action in row.actions"
:key="action.action"
type="button"
:class="action.tone"
@click="emitAction(action.action, row.source)"
>
<i :class="action.icon"></i>
{{ action.label }}
</button>
</div>
<span v-else class="readonly-label" :title="row.readOnlyReason">
只读
</span>
</td>
</tr>
</tbody>
</table>
</div>
<EnterprisePagination
v-if="rows.length || queue.total.value"
:current-page="queue.page.value"
:page-size="queue.pageSize.value"
:page-size-options="pageSizeOptions"
:total="queue.total.value"
:total-pages="Math.max(1, queue.totalPages.value)"
:summary="pageSummary"
@update:current-page="changePage"
@page-size-change="changePageSize"
/>
<ConfirmDialog
:open="batchConfirmOpen"
badge="批量审批"
:title="`确认通过所选 ${queue.selectedCount.value} 项审批任务吗?`"
description="服务器会逐项复核权限、任务版本、单据状态和风险前置条件;部分失败不会被显示为全部成功。"
cancel-text="返回核对"
confirm-text="确认批量通过"
busy-text="逐项审批中..."
confirm-icon="mdi mdi-check-all"
:busy="queue.batchBusy.value"
:confirm-disabled="!queue.selectedCount.value"
@close="closeBatchConfirm"
@confirm="confirmBatchApprove"
>
<label class="batch-opinion">
<span>统一审批意见可选</span>
<textarea
v-model="batchOpinion"
maxlength="500"
:disabled="queue.batchBusy.value"
placeholder="不填写则由服务端使用审批任务默认意见"
></textarea>
</label>
</ConfirmDialog>
</section>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useApprovalTaskQueue } from '../../composables/useApprovalTaskQueue.js'
import {
resolveApprovalTaskKeyboardCommand,
resolveApprovalTaskRow
} from '../../views/scripts/approvalTaskQueueViewModel.js'
import { resolveApprovalTaskSelectionMeta } from '../../views/scripts/approvalTaskQueueState.js'
import ConfirmDialog from '../shared/ConfirmDialog.vue'
import EnterprisePagination from '../shared/EnterprisePagination.vue'
import ApprovalBatchResultPanel from './ApprovalBatchResultPanel.vue'
const props = defineProps({
initialFilters: { type: Object, default: () => ({}) },
initialPage: { type: Number, default: 1 },
initialPageSize: { type: Number, default: 20 },
refreshToken: { type: [String, Number], default: 0 },
serviceOverrides: { type: Object, default: () => ({}) }
})
const emit = defineEmits([
'open-detail',
'action',
'batch-result',
'selection-change',
'loaded',
'state-change',
'error'
])
const queue = useApprovalTaskQueue({
initialFilters: props.initialFilters,
initialPage: props.initialPage,
initialPageSize: props.initialPageSize,
services: props.serviceOverrides
})
const draftFilters = reactive({
keyword: String(props.initialFilters.keyword || ''),
riskLevel: String(props.initialFilters.riskLevel || props.initialFilters.risk_level || ''),
slaState: String(props.initialFilters.slaState || props.initialFilters.sla_state || '')
})
const focusedTaskId = ref('')
const batchConfirmOpen = ref(false)
const batchOpinion = ref('')
const clock = ref(Date.now())
const rowRefs = new Map()
const pageSizeOptions = [10, 20].map((value) => ({ label: `${value} 条/页`, value }))
const rows = computed(() =>
queue.items.value.map((item) => resolveApprovalTaskRow(item, clock.value))
)
const selectedSet = computed(() => new Set(queue.selectedTaskIds.value))
const pageSummary = computed(() =>
`${queue.total.value} 条,当前第 ${queue.page.value} / ${Math.max(1, queue.totalPages.value)}`
)
watch(
() => queue.selectedTaskIds.value,
(selectedTaskIds) => emit('selection-change', [...selectedTaskIds]),
{ deep: true }
)
watch(
() => props.refreshToken,
() => void reload()
)
watch(
() => rows.value.map((row) => row.id).join('|'),
() => {
if (!rows.value.some((row) => row.id === focusedTaskId.value)) {
focusedTaskId.value = rows.value[0]?.id || ''
}
}
)
let clockTimer = 0
onMounted(() => {
clockTimer = window.setInterval(() => {
clock.value = Date.now()
}, 60000)
void reload()
})
onBeforeUnmount(() => {
if (clockTimer) window.clearInterval(clockTimer)
})
async function reload() {
try {
const response = await queue.loadQueue()
publishQueueResponse(response)
} catch (error) {
emit('error', error)
}
}
async function applyFilters() {
try {
const response = await queue.setFilters({ ...draftFilters })
publishQueueResponse(response)
} catch (error) {
emit('error', error)
}
}
async function changePage(page) {
try {
publishQueueResponse(await queue.setPage(page))
focusFirstRow()
} catch (error) {
emit('error', error)
}
}
async function changePageSize(pageSize) {
try {
publishQueueResponse(await queue.setPageSize(pageSize))
focusFirstRow()
} catch (error) {
emit('error', error)
}
}
function publishQueueResponse(response) {
if (!response || response.ignored) return
emit('loaded', response.result)
emit('state-change', {
filters: { ...queue.filters.value },
page: queue.page.value,
pageSize: queue.pageSize.value
})
}
function selectionMeta(item) {
return resolveApprovalTaskSelectionMeta(item)
}
function setRowRef(taskId, element) {
if (element) {
rowRefs.set(taskId, element)
} else {
rowRefs.delete(taskId)
}
}
function focusRow(index) {
if (!rows.value.length) return
const normalizedIndex = Math.min(Math.max(index, 0), rows.value.length - 1)
const row = rows.value[normalizedIndex]
focusedTaskId.value = row.id
nextTick(() => rowRefs.get(row.id)?.focus())
}
function focusFirstRow() {
nextTick(() => focusRow(0))
}
function handleRowKeydown(event, index, row) {
const command = resolveApprovalTaskKeyboardCommand(event)
if (!command) return
event.preventDefault()
if (command === 'open') {
openDetail(row.source)
} else if (command === 'toggle') {
queue.toggleTask(row.source)
} else if (command === 'next') {
focusRow(index + 1)
} else if (command === 'previous') {
focusRow(index - 1)
}
}
function openDetail(item) {
emit('open-detail', item)
}
function emitAction(action, item) {
emit('action', { action, item })
}
function closeBatchConfirm() {
if (!queue.batchBusy.value) {
batchConfirmOpen.value = false
}
}
async function confirmBatchApprove() {
try {
const result = await queue.batchApprove({ opinion: batchOpinion.value })
if (result) {
batchConfirmOpen.value = false
batchOpinion.value = ''
emit('batch-result', result)
}
} catch (error) {
emit('error', error)
}
}
defineExpose({
clearSelection: queue.clearSelection,
queue,
refreshTask: queue.refreshTask,
reload
})
</script>
<style scoped>
.approval-task-queue {
display: grid;
gap: 12px;
min-width: 0;
}
.approval-task-toolbar,
.approval-task-filters,
.approval-task-batch-actions,
.approval-task-filters label,
.row-actions {
display: flex;
align-items: center;
}
.approval-task-toolbar {
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
}
.approval-task-filters,
.approval-task-batch-actions {
gap: 8px;
flex-wrap: wrap;
}
.approval-task-filters label {
min-height: 38px;
gap: 6px;
padding: 0 10px;
border: 1px solid #dbe5ef;
border-radius: 6px;
background: #fff;
color: #64748b;
font-size: 12px;
font-weight: 750;
}
.approval-task-search {
width: min(320px, 70vw);
}
.approval-task-filters input,
.approval-task-filters select {
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: #0f172a;
}
.approval-task-search input {
flex: 1;
}
.approval-task-toolbar button,
.row-actions button {
min-height: 36px;
padding: 0 12px;
border: 1px solid #dbe5ef;
border-radius: 6px;
background: #fff;
color: #334155;
font-weight: 800;
}
.approval-task-batch-actions button:first-of-type,
.filter-apply,
.row-actions button.primary {
border-color: var(--theme-primary);
background: var(--theme-primary);
color: #fff;
}
.approval-task-toolbar button:disabled,
.row-actions button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.approval-task-batch-actions span {
color: #475569;
font-size: 12px;
font-weight: 800;
}
.approval-task-error {
margin: 0;
padding: 10px 12px;
border: 1px solid #fecaca;
border-radius: 6px;
background: #fef2f2;
color: #b91c1c;
font-size: 13px;
}
.approval-task-table-wrap {
min-width: 0;
overflow-x: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
}
.approval-task-state {
min-height: 220px;
display: grid;
place-content: center;
justify-items: center;
gap: 8px;
color: #64748b;
}
.approval-task-state i {
font-size: 28px;
}
table {
width: 100%;
min-width: 1280px;
border-collapse: collapse;
}
th,
td {
padding: 11px 10px;
border-bottom: 1px solid #edf2f7;
text-align: left;
vertical-align: middle;
}
th {
background: #f8fafc;
color: #64748b;
font-size: 12px;
font-weight: 850;
}
tbody tr {
cursor: pointer;
outline: none;
}
tbody tr:hover,
tbody tr.focused,
tbody tr:focus-visible {
background: #f5faff;
box-shadow: inset 3px 0 var(--theme-primary);
}
tbody tr.readonly {
background: #fbfcfe;
}
td strong,
td span {
display: block;
}
td strong {
color: #0f172a;
font-size: 13px;
}
td span {
margin-top: 3px;
color: #64748b;
font-size: 11px;
}
.selection-column {
width: 44px;
text-align: center;
}
.selection-column input {
width: 16px;
height: 16px;
accent-color: var(--theme-primary);
}
em.priority,
em.risk,
.sla,
.readonly-label {
display: inline-flex;
width: fit-content;
padding: 3px 7px;
border-radius: 999px;
font-size: 11px;
font-style: normal;
font-weight: 850;
}
.priority.urgent,
.risk.critical,
.risk.high,
.sla.danger {
background: #fef2f2;
color: #b91c1c;
}
.priority.high,
.risk.medium,
.sla.warning {
background: #fffbeb;
color: #b45309;
}
.priority.normal,
.risk.low,
.sla.safe {
background: #ecfdf5;
color: #047857;
}
.sla.neutral,
.readonly-label {
background: #f1f5f9;
color: #64748b;
}
.row-actions {
gap: 5px;
flex-wrap: wrap;
}
.row-actions button {
min-height: 30px;
padding: 0 8px;
font-size: 11px;
}
.row-actions button.danger {
border-color: #fecaca;
color: #b91c1c;
}
.row-actions button.warning {
border-color: #fde68a;
color: #b45309;
}
.batch-opinion {
display: grid;
gap: 7px;
}
.batch-opinion span {
color: #334155;
font-size: 12px;
font-weight: 850;
}
.batch-opinion textarea {
min-height: 80px;
padding: 10px;
border: 1px solid #dbe5ef;
border-radius: 6px;
resize: vertical;
}
@media (max-width: 900px) {
.approval-task-toolbar {
align-items: stretch;
}
.approval-task-filters,
.approval-task-batch-actions {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,390 @@
<template>
<section class="approval-task-workspace">
<p v-if="statusMessage" class="workspace-message success" aria-live="polite">
<i class="mdi mdi-check-circle-outline"></i>
{{ statusMessage }}
</p>
<p v-if="workspaceError" class="workspace-message error" role="alert">
<i class="mdi mdi-alert-circle-outline"></i>
{{ workspaceError }}
</p>
<ApprovalTaskQueue
ref="queueRef"
:initial-filters="initialFilters"
:initial-page="initialPage"
:initial-page-size="initialPageSize"
:refresh-token="refreshToken"
:service-overrides="serviceOverrides"
@open-detail="openDetail"
@action="openAction"
@batch-result="handleBatchResult"
@loaded="handleQueueLoaded"
@state-change="$emit('state-change', $event)"
@error="handleQueueError"
/>
<ApprovalAssignmentDialog
:open="assignmentOpen"
:task="activeItem"
:mode="actionMode"
:service-overrides="serviceOverrides"
@close="closeAction"
@completed="handleMutationCompleted"
/>
<ApprovalParticipantsDialog
:open="participantsOpen"
:task="activeItem"
:mode="actionMode"
:service-overrides="serviceOverrides"
@close="closeAction"
@completed="handleMutationCompleted"
/>
<ConfirmDialog
:open="decisionOpen"
:badge="decisionMeta.badge"
:badge-tone="decisionMeta.badgeTone"
:title="decisionMeta.title"
:description="decisionMeta.description"
cancel-text="返回核对"
:confirm-text="decisionMeta.confirmText"
busy-text="提交中..."
:confirm-tone="decisionMeta.confirmTone"
:confirm-icon="decisionMeta.icon"
:busy="actionBusy"
:confirm-disabled="decisionReason.trim().length < 2"
:close-on-mask="false"
@close="closeAction"
@confirm="submitDecision"
>
<p v-if="actionError" class="workspace-message error" role="alert">
{{ actionError }}
</p>
<label class="workspace-reason">
<span>{{ decisionMeta.reasonLabel }} <em>必填</em></span>
<textarea
v-model="decisionReason"
maxlength="500"
:disabled="actionBusy"
:placeholder="decisionMeta.placeholder"
></textarea>
<small>{{ decisionReason.trim().length }}/500</small>
</label>
</ConfirmDialog>
<ReturnReasonDialog
:open="returnOpen"
:busy="actionBusy"
:claim-no="activeClaimNo"
:application="activeIsApplication"
:error="actionError"
@close="closeAction"
@confirm="submitReturn"
/>
</section>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import {
approveApprovalTask,
escalateApprovalTask,
fetchApprovalTasks,
returnApprovalTask
} from '../../services/approvalTasks.js'
import { isApplicationDocumentNo } from '../../utils/documentClassification.js'
import { resolveApprovalTaskRetryKey } from '../../views/scripts/approvalTaskRetry.js'
import ConfirmDialog from '../shared/ConfirmDialog.vue'
import ReturnReasonDialog from '../shared/ReturnReasonDialog.vue'
import ApprovalAssignmentDialog from './ApprovalAssignmentDialog.vue'
import ApprovalParticipantsDialog from './ApprovalParticipantsDialog.vue'
import ApprovalTaskQueue from './ApprovalTaskQueue.vue'
const DECISION_META = {
approve: {
badge: '审批通过',
badgeTone: 'info',
title: '确认通过当前审批任务吗?',
description: '提交时会重新校验任务版本、审批权限、开放风险和单据状态。',
confirmText: '确认通过',
confirmTone: 'primary',
icon: 'mdi mdi-check-circle-outline',
reasonLabel: '审批意见',
placeholder: '写明核对结论,至少 2 个字。'
},
sla_escalate: {
badge: '任务升级',
badgeTone: 'warning',
title: '确认立即升级该审批任务吗?',
description: '升级只改变任务优先级和 SLA 审计信息,不会替代审批人的最终决定。',
confirmText: '确认升级',
confirmTone: 'warning',
icon: 'mdi mdi-arrow-up-bold-circle-outline',
reasonLabel: '升级原因',
placeholder: '说明需要立即升级的业务原因。'
}
}
const ASSIGNMENT_ACTIONS = new Set(['delegate', 'transfer', 'delegation_revoke'])
const PARTICIPANT_ACTIONS = new Set(['add_sign', 'countersign'])
const props = defineProps({
initialFilters: { type: Object, default: () => ({}) },
initialPage: { type: Number, default: 1 },
initialPageSize: { type: Number, default: 20 },
refreshToken: { type: [String, Number], default: 0 },
serviceOverrides: { type: Object, default: () => ({}) }
})
const emit = defineEmits(['open-document', 'request-updated', 'loaded', 'batch-result', 'state-change'])
const queueRef = ref(null)
const activeItem = ref(null)
const actionMode = ref('')
const decisionReason = ref('')
const actionBusy = ref(false)
const actionError = ref('')
const queueError = ref('')
const summaryError = ref('')
const statusMessage = ref('')
const requestId = ref('')
const requestFingerprint = ref('')
const activeTask = computed(() => activeItem.value?.task || activeItem.value || {})
const activeClaim = computed(() => activeItem.value?.claim || {})
const activeTaskId = computed(() => String(activeTask.value.id || '').trim())
const activeClaimNo = computed(() => String(
activeClaim.value.claim_no || activeClaim.value.claimNo || activeTask.value.claimId || ''
).trim())
const activeIsApplication = computed(() => (
isApplicationDocumentNo(activeClaimNo.value)
|| ['application', 'expense_application'].includes(String(
activeClaim.value.expense_type || activeClaim.value.expenseType || ''
).trim())
))
const assignmentOpen = computed(() => ASSIGNMENT_ACTIONS.has(actionMode.value))
const participantsOpen = computed(() => PARTICIPANT_ACTIONS.has(actionMode.value))
const decisionOpen = computed(() => ['approve', 'sla_escalate'].includes(actionMode.value))
const returnOpen = computed(() => actionMode.value === 'return')
const decisionMeta = computed(() => DECISION_META[actionMode.value] || DECISION_META.approve)
const workspaceError = computed(() => queueError.value || summaryError.value)
let summaryRequestSequence = 0
onMounted(() => void loadTaskSummary())
watch(() => props.refreshToken, () => void loadTaskSummary())
function openDetail(item) {
const task = item?.task || item || {}
const claim = item?.claim || {}
const claimId = String(task.claimId || task.claim_id || item?.claimId || '').trim()
emit('open-document', {
claimId,
id: claimId,
claimNo: String(claim.claim_no || claim.claimNo || '').trim(),
detailLookupOnly: true
})
}
function openAction(payload = {}) {
const action = String(payload.action || '').trim()
if (![...ASSIGNMENT_ACTIONS, ...PARTICIPANT_ACTIONS, 'approve', 'return', 'sla_escalate'].includes(action)) {
return
}
activeItem.value = payload.item || null
actionMode.value = action
decisionReason.value = ''
actionError.value = ''
statusMessage.value = ''
requestId.value = ''
requestFingerprint.value = ''
}
function closeAction() {
if (actionBusy.value) return
activeItem.value = null
actionMode.value = ''
decisionReason.value = ''
actionError.value = ''
requestId.value = ''
requestFingerprint.value = ''
}
async function submitDecision() {
if (actionBusy.value || decisionReason.value.trim().length < 2) return
const service = actionMode.value === 'approve'
? props.serviceOverrides.approveApprovalTask || approveApprovalTask
: props.serviceOverrides.escalateApprovalTask || escalateApprovalTask
const payload = {
expectedTaskVersion: Number(activeTask.value.version),
reason: decisionReason.value.trim()
}
if (actionMode.value === 'approve') payload.opinion = decisionReason.value.trim()
attachRetryRequestId(payload)
await executeAction(() => service(activeTaskId.value, payload))
}
async function submitReturn(payload = {}) {
if (actionBusy.value) return
const service = props.serviceOverrides.returnApprovalTask || returnApprovalTask
const actionPayload = {
expectedTaskVersion: Number(activeTask.value.version),
reason: String(payload.reason || '').trim(),
reasonCodes: Array.isArray(payload.reason_codes) ? payload.reason_codes : []
}
attachRetryRequestId(actionPayload)
await executeAction(() => service(activeTaskId.value, actionPayload))
}
function attachRetryRequestId(payload) {
const retryKey = resolveApprovalTaskRetryKey({
action: actionMode.value,
scopeId: activeTaskId.value || 'task',
payload,
previousFingerprint: requestFingerprint.value,
previousRequestId: requestId.value
})
requestId.value = retryKey.requestId
requestFingerprint.value = retryKey.fingerprint
payload.requestId = retryKey.requestId
}
async function executeAction(executor) {
actionBusy.value = true
actionError.value = ''
try {
const mutation = await executor()
await finishMutation(actionMode.value, mutation)
} catch (error) {
actionError.value = error?.message || '审批任务操作失败,请按当前 request_id 安全重试。'
} finally {
actionBusy.value = false
}
}
async function handleMutationCompleted(payload = {}) {
await finishMutation(payload.mode || actionMode.value, payload.mutation)
}
async function finishMutation(mode, mutation) {
const labels = {
approve: '审批通过',
return: '退回',
delegate: '委托',
delegation_revoke: '撤销委托',
transfer: '转交',
add_sign: '加签',
countersign: '会签',
sla_escalate: '任务升级'
}
statusMessage.value = mutation?.replayed
? `${labels[mode] || '任务操作'}结果已安全重放。`
: `${labels[mode] || '任务操作'}已完成。`
if (mutation?.claim) emit('request-updated', { claim: mutation.claim })
activeItem.value = null
actionMode.value = ''
decisionReason.value = ''
requestId.value = ''
requestFingerprint.value = ''
try {
await queueRef.value?.reload?.()
} catch {
queueError.value = '操作已成功,但任务列表刷新失败,请稍后手动刷新。'
}
await loadTaskSummary()
}
function handleBatchResult(result) {
statusMessage.value = result?.status === 'succeeded'
? `批量审批完成:成功 ${Number(result.succeededCount || 0) + Number(result.replayedCount || 0)} 项。`
: '批量审批已完成,部分任务需要单独处理。'
const completedItems = (Array.isArray(result?.items) ? result.items : [])
.filter((item) => ['succeeded', 'replayed'].includes(String(item?.status || '').toLowerCase()))
const claims = completedItems.map((item) => item?.claim).filter(Boolean)
const claimIds = completedItems.map((item) => item?.claimId).filter(Boolean)
if (completedItems.length) {
emit('request-updated', { claim: claims[0], claimId: claimIds[0], claimIds })
}
void loadTaskSummary()
emit('batch-result', result)
}
function handleQueueError(error) {
queueError.value = error?.message || '审批任务队列加载失败。'
}
function handleQueueLoaded() {
queueError.value = ''
}
async function loadTaskSummary() {
const sequence = ++summaryRequestSequence
try {
const service = props.serviceOverrides.fetchApprovalTasks || fetchApprovalTasks
const result = await service({ status: 'pending', page: 1, pageSize: 1 })
if (sequence !== summaryRequestSequence) return
summaryError.value = ''
emit('loaded', result)
} catch (error) {
if (sequence === summaryRequestSequence) {
summaryError.value = error?.message || '审批任务总数同步失败。'
}
}
}
</script>
<style scoped>
.approval-task-workspace {
display: grid;
gap: 10px;
min-width: 0;
}
.workspace-message {
display: flex;
align-items: center;
gap: 7px;
margin: 0;
padding: 9px 11px;
border: 1px solid;
border-radius: 7px;
font-size: 12px;
font-weight: 750;
}
.workspace-message.success {
border-color: #a7f3d0;
background: #ecfdf5;
color: #047857;
}
.workspace-message.error {
border-color: #fecaca;
background: #fef2f2;
color: #b91c1c;
}
.workspace-reason {
display: grid;
gap: 7px;
}
.workspace-reason span,
.workspace-reason small {
color: #475569;
font-size: 12px;
}
.workspace-reason em {
color: #b91c1c;
font-style: normal;
}
.workspace-reason textarea {
min-height: 90px;
padding: 10px;
border: 1px solid #dbe5ef;
border-radius: 6px;
resize: vertical;
}
</style>

View File

@@ -328,21 +328,6 @@
</div> </div>
</template> </template>
<template v-else-if="isApproval">
<div class="kpi-chips">
<div v-for="kpi in approvalKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
<span class="chip-value">{{ kpi.value }}<small>{{ kpi.unit }}</small></span>
<span class="chip-label">{{ kpi.label }}</span>
<span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
</div>
</div>
<div class="topbar-spacer"></div>
<button class="create-top-btn" type="button">
<i class="mdi mdi-check-circle"></i>
<span>批量通过</span>
</button>
</template>
<template v-else-if="isPolicies"> <template v-else-if="isPolicies">
<div class="kpi-chips"> <div class="kpi-chips">
<div v-for="kpi in knowledgeKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }"> <div v-for="kpi in knowledgeKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
@@ -377,7 +362,6 @@ import { createCurrentYearDateRange } from '../../utils/dateRangeDefaults.js'
import { resolveDocumentNotificationId } from '../../utils/documentCenterNewState.js' import { resolveDocumentNotificationId } from '../../utils/documentCenterNewState.js'
import EnterpriseSelect from '../shared/EnterpriseSelect.vue' import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
import { import {
APPROVAL_KPIS,
CHAT_KPIS, CHAT_KPIS,
buildDigitalEmployeeWorkRecordKpis, buildDigitalEmployeeWorkRecordKpis,
buildDocumentKpis, buildDocumentKpis,
@@ -452,7 +436,6 @@ const emit = defineEmits([
'update:activeRange', 'update:activeRange',
'update:customRange', 'update:customRange',
'update:overviewDashboard', 'update:overviewDashboard',
'batchApprove',
'openChat', 'openChat',
'newApplication', 'newApplication',
'openDocument', 'openDocument',
@@ -466,7 +449,6 @@ const isRequestDetail = computed(() => ['requests', 'documents', 'audit', 'digit
const isDocuments = computed(() => props.activeView === 'documents' && !props.detailMode) const isDocuments = computed(() => props.activeView === 'documents' && !props.detailMode)
const isRequests = computed(() => props.activeView === 'requests') const isRequests = computed(() => props.activeView === 'requests')
const isDigitalEmployees = computed(() => props.activeView === 'digitalEmployees') const isDigitalEmployees = computed(() => props.activeView === 'digitalEmployees')
const isApproval = computed(() => props.activeView === 'approval')
const isPolicies = computed(() => props.activeView === 'policies') const isPolicies = computed(() => props.activeView === 'policies')
const isEmployees = computed(() => props.activeView === 'employees') const isEmployees = computed(() => props.activeView === 'employees')
const eyebrowLabel = computed(() => ( const eyebrowLabel = computed(() => (
@@ -756,20 +738,13 @@ function openNotification(item) {
const requestKpis = computed(() => buildRequestKpis(props.requestSummary ?? {})) const requestKpis = computed(() => buildRequestKpis(props.requestSummary ?? {}))
const documentKpis = computed(() => buildDocumentKpis(props.documentSummary ?? {})) const documentKpis = computed(() => buildDocumentKpis(props.documentSummary ?? {}))
const showDigitalEmployeeWorkRecordKpis = computed(() => { const showDigitalEmployeeWorkRecordKpis = computed(() => {
const summary = props.digitalEmployeeSummary ?? {} const summary = props.digitalEmployeeSummary ?? {}
return isDigitalEmployees.value && summary.section === 'workRecords' return isDigitalEmployees.value && summary.section === 'workRecords'
}) })
const digitalEmployeeWorkRecordKpis = computed(() => buildDigitalEmployeeWorkRecordKpis(props.digitalEmployeeSummary ?? {})) const digitalEmployeeWorkRecordKpis = computed(() => buildDigitalEmployeeWorkRecordKpis(props.digitalEmployeeSummary ?? {}))
const chatKpis = CHAT_KPIS const chatKpis = CHAT_KPIS
const approvalKpis = APPROVAL_KPIS
const knowledgeKpis = computed(() => buildKnowledgeKpis(props.knowledgeSummary ?? {})) const knowledgeKpis = computed(() => buildKnowledgeKpis(props.knowledgeSummary ?? {}))
const employeeKpis = computed(() => buildEmployeeKpis(props.employeeSummary ?? {})) const employeeKpis = computed(() => buildEmployeeKpis(props.employeeSummary ?? {}))
const { const {
calendarOpen, calendarOpen,

View File

@@ -5,13 +5,6 @@ export const CHAT_KPIS = [
{ label: '平均响应时长', value: 2.1, unit: 's', meta: '较昨日 -0.3s', trend: 'down', color: '#f59e0b' } { label: '平均响应时长', value: 2.1, unit: 's', meta: '较昨日 -0.3s', trend: 'down', color: '#f59e0b' }
] ]
export const APPROVAL_KPIS = [
{ label: '待审批单据', value: 12, unit: '单', meta: '较昨日 +3', trend: 'up', color: 'var(--theme-primary)' },
{ label: '高风险单据', value: 4, unit: '单', meta: '较昨日 +1', trend: 'up', color: '#ef4444' },
{ label: '即将超时', value: 3, unit: '单', meta: '30 分钟内', trend: 'down', color: '#f59e0b' },
{ label: '今日已处理', value: 28, unit: '单', meta: '通过率 86%', trend: 'up', color: 'var(--success)' }
]
export function buildRequestKpis(summary = {}) { export function buildRequestKpis(summary = {}) {
const total = Number(summary.total ?? 0) const total = Number(summary.total ?? 0)
const draft = Number(summary.draft ?? 0) const draft = Number(summary.draft ?? 0)

View File

@@ -16,6 +16,7 @@
@confirm="handleConfirm" @confirm="handleConfirm"
> >
<div class="return-reason-dialog"> <div class="return-reason-dialog">
<small v-if="error" class="error" role="alert">{{ error }}</small>
<div class="return-reason-section"> <div class="return-reason-section">
<span>{{ optionsTitle }}</span> <span>{{ optionsTitle }}</span>
<div class="return-reason-options" role="group" :aria-label="optionsAriaLabel"> <div class="return-reason-options" role="group" :aria-label="optionsAriaLabel">
@@ -114,6 +115,7 @@ const props = defineProps({
busy: { type: Boolean, default: false }, busy: { type: Boolean, default: false },
claimNo: { type: String, default: '' }, claimNo: { type: String, default: '' },
application: { type: Boolean, default: false }, application: { type: Boolean, default: false },
error: { type: String, default: '' },
title: { type: String, default: '确认退回该单据吗?' }, title: { type: String, default: '确认退回该单据吗?' },
description: { description: {
type: String, type: String,
@@ -238,6 +240,16 @@ function handleConfirm() {
gap: 14px; gap: 14px;
} }
.return-reason-dialog > small.error {
padding: 9px 11px;
border: 1px solid #fecaca;
border-radius: 7px;
background: #fef2f2;
color: #b91c1c;
font-size: 12px;
line-height: 1.5;
}
.return-reason-section { .return-reason-section {
display: grid; display: grid;
gap: 8px; gap: 8px;

View File

@@ -77,12 +77,14 @@
<p v-if="currentDisposition.resolution" class="risk-disposition-resolution"> <p v-if="currentDisposition.resolution" class="risk-disposition-resolution">
{{ currentDisposition.resolution }} {{ currentDisposition.resolution }}
</p> </p>
<RiskWaiverRecord :disposition="currentDisposition" :expired="waiverExpired" />
<template v-if="dispositionActions.length"> <template v-if="dispositionActions.length">
<textarea <textarea
v-if="nonWaiverActions.length"
v-model="dispositionNote" v-model="dispositionNote"
:disabled="dispositionBusy" :disabled="dispositionBusy"
maxlength="1000" maxlength="1000"
placeholder="填写复核依据、补充要求或解决说明(误报、补材料、豁免和解决时必填)" placeholder="填写复核依据、补充要求或解决说明(误报、补材料和解决时必填)"
></textarea> ></textarea>
<div class="risk-disposition-actions"> <div class="risk-disposition-actions">
<button <button
@@ -91,7 +93,7 @@
type="button" type="button"
:class="action.tone" :class="action.tone"
:disabled="dispositionBusy" :disabled="dispositionBusy"
@click="submitDispositionAction(action.action)" @click="handleDispositionAction(action.action)"
> >
<i :class="dispositionBusyAction === action.action ? 'mdi mdi-loading mdi-spin' : action.icon"></i> <i :class="dispositionBusyAction === action.action ? 'mdi mdi-loading mdi-spin' : action.icon"></i>
{{ action.label }} {{ action.label }}
@@ -99,7 +101,22 @@
</div> </div>
</template> </template>
<p v-else class="risk-disposition-complete">{{ dispositionCompleteMessage }}</p> <p v-else class="risk-disposition-complete">{{ dispositionCompleteMessage }}</p>
<p v-if="dispositionError" class="risk-disposition-error">{{ dispositionError }}</p> <p
v-if="currentDisposition.readOnlyReason"
class="risk-disposition-read-only"
>
<i class="mdi mdi-lock-outline"></i>
{{ currentDisposition.readOnlyReason }}
</p>
<div
v-if="dispositionError"
class="risk-disposition-error"
role="alert"
:data-error-code="dispositionError.code"
>
<strong>{{ dispositionError.title }}</strong>
<span>{{ dispositionError.message }}</span>
</div>
</section> </section>
<div class="risk-evidence-grid"> <div class="risk-evidence-grid">
@@ -179,17 +196,28 @@
</button> </button>
</div> </div>
</template> </template>
<RiskWaiverActionDialog
:open="Boolean(waiverDialogAction)"
:action="waiverDialogAction"
:busy="dispositionBusy"
:disposition="currentDisposition"
@cancel="closeWaiverDialog"
@confirm="confirmWaiverAction"
/>
</article> </article>
</template> </template>
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { import {
createRiskDispositionRequestId, createRiskDispositionRequestId,
executeRiskDispositionAction, executeRiskDispositionAction,
fetchClaimRiskObservations fetchClaimRiskObservations,
normalizeRiskDispositionError
} from '../../services/riskObservations.js' } from '../../services/riskObservations.js'
import RiskWaiverActionDialog from './RiskWaiverActionDialog.vue'
import RiskWaiverRecord from './RiskWaiverRecord.vue'
const props = defineProps({ const props = defineProps({
claimId: { type: String, default: '' } claimId: { type: String, default: '' }
@@ -201,10 +229,47 @@ const errorMessage = ref('')
const activeObservationKey = ref('') const activeObservationKey = ref('')
const dispositionNote = ref('') const dispositionNote = ref('')
const dispositionBusyAction = ref('') const dispositionBusyAction = ref('')
const dispositionError = ref('') const dispositionError = ref(null)
const dispositionRequest = ref({ key: '', requestId: '' }) const dispositionRequest = ref({ key: '', requestId: '' })
const waiverDialogAction = ref('')
const waiverClock = ref(Date.now())
const detailRegionId = 'risk-observation-active-detail' const detailRegionId = 'risk-observation-active-detail'
let loadSequence = 0 let loadSequence = 0
let waiverExpiryTimer = 0
const waiverActions = new Set(['request_waiver', 'approve_waiver', 'reject_waiver'])
const actionCatalog = Object.freeze({
confirm: {
action: 'confirm', label: '确认风险', icon: 'mdi mdi-shield-check', tone: 'danger'
},
false_positive: {
action: 'false_positive', label: '标记误报', icon: 'mdi mdi-shield-off-outline', tone: 'safe'
},
request_supplement: {
action: 'request_supplement',
label: '请求补材料',
icon: 'mdi mdi-file-plus-outline',
tone: 'warning'
},
start_remediation: {
action: 'start_remediation', label: '启动整改', icon: 'mdi mdi-tools', tone: 'warning'
},
request_waiver: {
action: 'request_waiver', label: '申请豁免', icon: 'mdi mdi-file-sign', tone: 'neutral'
},
approve_waiver: {
action: 'approve_waiver',
label: '批准豁免',
icon: 'mdi mdi-check-decagram-outline',
tone: 'safe'
},
reject_waiver: {
action: 'reject_waiver', label: '拒绝豁免', icon: 'mdi mdi-close-octagon-outline', tone: 'danger'
},
resolve: {
action: 'resolve', label: '确认已解决', icon: 'mdi mdi-check-decagram-outline', tone: 'safe'
}
})
const visible = computed(() => const visible = computed(() =>
loading.value || Boolean(errorMessage.value) || observations.value.length > 0 loading.value || Boolean(errorMessage.value) || observations.value.length > 0
@@ -239,10 +304,20 @@ const currentDisposition = computed(() => {
: 'unreviewed', : 'unreviewed',
lifecycleStatus: status === 'resolved' ? 'resolved' : 'open', lifecycleStatus: status === 'resolved' ? 'resolved' : 'open',
version: 0, version: 0,
resolution: '' resolution: '',
availableActions: mainObservation.value?.availableActions || [],
readOnlyReason: mainObservation.value?.availableActions?.length
? ''
: mainObservation.value?.readOnlyReason
|| '服务端未返回可执行动作,当前风险仅供查看。',
waiverConditions: []
} }
}) })
const dispositionBusy = computed(() => Boolean(dispositionBusyAction.value)) const dispositionBusy = computed(() => Boolean(dispositionBusyAction.value))
const waiverExpired = computed(() => {
const expiry = new Date(currentDisposition.value.waiverExpiresAt)
return !Number.isNaN(expiry.getTime()) && expiry.getTime() <= waiverClock.value
})
const dispositionStateLabel = computed(() => { const dispositionStateLabel = computed(() => {
if (currentDisposition.value.adjudication === 'false_positive') { if (currentDisposition.value.adjudication === 'false_positive') {
return '该观察已判定为误报,不再阻断审批' return '该观察已判定为误报,不再阻断审批'
@@ -250,69 +325,47 @@ const dispositionStateLabel = computed(() => {
if (currentDisposition.value.lifecycleStatus === 'resolved') { if (currentDisposition.value.lifecycleStatus === 'resolved') {
return '风险已完成处置,不再阻断审批' return '风险已完成处置,不再阻断审批'
} }
if (currentDisposition.value.lifecycleStatus === 'waived') {
return waiverExpired.value
? '风险豁免已经到期,将继续阻断审批'
: `风险已限时豁免,有效至 ${formatDateTime(currentDisposition.value.waiverExpiresAt)}`
}
if (currentDisposition.value.lifecycleStatus === 'waiver_rejected') {
return '风险豁免已拒绝,仍会阻断审批'
}
if (currentDisposition.value.lifecycleStatus === 'waiver_requested') {
return '豁免申请等待决定,当前仍会阻断审批'
}
if (['high', 'critical'].includes(String(mainObservation.value?.riskLevel || ''))) { if (['high', 'critical'].includes(String(mainObservation.value?.riskLevel || ''))) {
return '高风险未关闭,将阻断审批通过' return '高风险未关闭,将阻断审批通过'
} }
return '请结合证据完成人工裁决' return '请结合证据完成人工裁决'
}) })
const dispositionActions = computed(() => { const dispositionActions = computed(() => (
const adjudication = currentDisposition.value.adjudication (currentDisposition.value.availableActions || [])
const lifecycle = currentDisposition.value.lifecycleStatus .map((action) => actionCatalog[action])
if (adjudication === 'false_positive' || lifecycle === 'resolved') { .filter(Boolean)
return []
}
if (adjudication !== 'confirmed') {
const actions = [
{ action: 'confirm', label: '确认风险', icon: 'mdi mdi-shield-check', tone: 'danger' },
{ action: 'false_positive', label: '标记误报', icon: 'mdi mdi-shield-off-outline', tone: 'safe' }
]
if (lifecycle !== 'supplement_requested') {
actions.push({
action: 'request_supplement',
label: '请求补材料',
icon: 'mdi mdi-file-plus-outline',
tone: 'warning'
})
}
return actions
}
return [
{
action: 'request_supplement',
label: '请求补材料',
icon: 'mdi mdi-file-plus-outline',
tone: 'warning'
},
{
action: 'start_remediation',
label: '启动整改',
icon: 'mdi mdi-tools',
tone: 'warning'
},
{
action: 'request_waiver',
label: '申请豁免',
icon: 'mdi mdi-file-sign',
tone: 'neutral'
},
{
action: 'resolve',
label: '确认已解决',
icon: 'mdi mdi-check-decagram-outline',
tone: 'safe'
}
].filter((item) => (
item.action === 'resolve'
|| (item.action === 'request_supplement' && lifecycle !== 'supplement_requested')
|| (item.action === 'start_remediation' && lifecycle !== 'remediation_in_progress')
|| (item.action === 'request_waiver' && lifecycle !== 'waiver_requested')
))
})
const dispositionCompleteMessage = computed(() => (
currentDisposition.value.adjudication === 'false_positive'
? '该风险已标记为误报;如需更正,请由管理员通过审计流程处理。'
: '该风险已解决,处置事件会保留在追加式审计链中。'
)) ))
const nonWaiverActions = computed(() => (
dispositionActions.value.filter((item) => !waiverActions.has(item.action))
))
const dispositionCompleteMessage = computed(() => {
if (currentDisposition.value.lifecycleStatus === 'waiver_rejected') {
return '豁免已拒绝;风险维持阻断,需完成整改或其他服务端允许的处置。'
}
if (currentDisposition.value.lifecycleStatus === 'waived') {
return waiverExpired.value
? '豁免已到期;风险控制已经恢复。'
: `豁免仅在 ${formatDateTime(currentDisposition.value.waiverExpiresAt)} 前有效。`
}
if (currentDisposition.value.adjudication === 'false_positive') {
return '该风险已标记为误报;如需更正,请通过审计流程处理。'
}
if (currentDisposition.value.lifecycleStatus === 'resolved') {
return '该风险已解决,处置事件会保留在追加式审计链中。'
}
return '当前没有服务端授权的可执行动作。'
})
const scoreItems = computed(() => { const scoreItems = computed(() => {
const scores = mainObservation.value?.contributionScores || {} const scores = mainObservation.value?.contributionScores || {}
return Object.entries(scores).map(([key, value]) => { return Object.entries(scores).map(([key, value]) => {
@@ -367,11 +420,22 @@ const feedbackRows = computed(() =>
watch( watch(
() => props.claimId, () => props.claimId,
() => { () => {
waiverDialogAction.value = ''
dispositionError.value = null
dispositionRequest.value = { key: '', requestId: '' }
void loadObservations() void loadObservations()
}, },
{ immediate: true } { immediate: true }
) )
watch(
() => currentDisposition.value.waiverExpiresAt,
(expiresAt) => scheduleWaiverExpiryRefresh(expiresAt),
{ immediate: true }
)
onBeforeUnmount(clearWaiverExpiryTimer)
async function loadObservations() { async function loadObservations() {
const claimId = String(props.claimId || '').trim() const claimId = String(props.claimId || '').trim()
const sequence = ++loadSequence const sequence = ++loadSequence
@@ -404,6 +468,31 @@ async function loadObservations() {
} }
} }
function clearWaiverExpiryTimer() {
if (waiverExpiryTimer) {
globalThis.clearTimeout(waiverExpiryTimer)
waiverExpiryTimer = 0
}
}
function scheduleWaiverExpiryRefresh(value) {
clearWaiverExpiryTimer()
waiverClock.value = Date.now()
const expiresAt = new Date(value).getTime()
if (!Number.isFinite(expiresAt) || expiresAt <= waiverClock.value) return
const maximumDelay = 2_147_000_000
const delay = Math.min(expiresAt - waiverClock.value + 50, maximumDelay)
waiverExpiryTimer = globalThis.setTimeout(async () => {
waiverExpiryTimer = 0
waiverClock.value = Date.now()
if (waiverClock.value >= expiresAt) {
await loadObservations()
return
}
scheduleWaiverExpiryRefresh(value)
}, delay)
}
function observationIdentity(item, index = -1) { function observationIdentity(item, index = -1) {
const explicitKey = String(item?.observationKey || item?.id || '').trim() const explicitKey = String(item?.observationKey || item?.id || '').trim()
if (explicitKey) { if (explicitKey) {
@@ -426,22 +515,69 @@ function selectObservation(item, index = -1) {
if (key) { if (key) {
activeObservationKey.value = key activeObservationKey.value = key
dispositionNote.value = '' dispositionNote.value = ''
dispositionError.value = '' dispositionError.value = null
dispositionRequest.value = { key: '', requestId: '' } dispositionRequest.value = { key: '', requestId: '' }
waiverDialogAction.value = ''
} }
} }
async function submitDispositionAction(action) { function handleDispositionAction(action) {
if (!isActionAvailable(action) || dispositionBusy.value) return
dispositionError.value = null
if (waiverActions.has(action)) {
waiverDialogAction.value = action
return
}
void submitDispositionAction(action)
}
function closeWaiverDialog() {
if (!dispositionBusy.value) waiverDialogAction.value = ''
}
async function confirmWaiverAction(payload) {
const succeeded = await submitDispositionAction(payload?.action, payload)
if (succeeded) waiverDialogAction.value = ''
}
function isActionAvailable(action) {
return (currentDisposition.value.availableActions || []).includes(action)
}
async function submitDispositionAction(action, actionPayload = {}) {
const observationId = String(mainObservation.value?.id || '').trim() const observationId = String(mainObservation.value?.id || '').trim()
if (!observationId || dispositionBusy.value) { if (!observationId || dispositionBusy.value) {
return return false
}
if (!isActionAvailable(action)) {
dispositionError.value = {
code: 'RISK_DISPOSITION_ACTION_UNAVAILABLE',
title: '操作权限已经变化',
message: currentDisposition.value.readOnlyReason || '服务端未授权当前风险处置动作。'
}
waiverDialogAction.value = ''
return false
} }
const note = dispositionNote.value.trim() const note = dispositionNote.value.trim()
if (['false_positive', 'request_supplement', 'request_waiver', 'resolve'].includes(action) && !note) { if (['false_positive', 'request_supplement', 'resolve'].includes(action) && !note) {
dispositionError.value = '该处置动作需要填写复核依据或处理说明。' dispositionError.value = {
return code: 'RISK_DISPOSITION_NOTE_REQUIRED',
title: '需要填写处理说明',
message: '该处置动作需要填写复核依据或处理说明。'
} }
const requestKey = [observationId, action, currentDisposition.value.version, note].join(':') return false
}
const mutationPayload = {
...actionPayload,
comment: String(actionPayload.comment || note).trim(),
resolution: action === 'resolve' ? note : ''
}
const requestKey = [
observationId,
action,
currentDisposition.value.version,
JSON.stringify(mutationPayload)
].join(':')
if (dispositionRequest.value.key !== requestKey) { if (dispositionRequest.value.key !== requestKey) {
dispositionRequest.value = { dispositionRequest.value = {
key: requestKey, key: requestKey,
@@ -449,24 +585,29 @@ async function submitDispositionAction(action) {
} }
} }
dispositionBusyAction.value = action dispositionBusyAction.value = action
dispositionError.value = '' dispositionError.value = null
try { try {
await executeRiskDispositionAction(observationId, { await executeRiskDispositionAction(observationId, {
...mutationPayload,
action, action,
expectedVersion: currentDisposition.value.version, expectedVersion: currentDisposition.value.version,
requestId: dispositionRequest.value.requestId, requestId: dispositionRequest.value.requestId
comment: note,
resolution: action === 'resolve' ? note : ''
}) })
dispositionNote.value = '' dispositionNote.value = ''
dispositionRequest.value = { key: '', requestId: '' } dispositionRequest.value = { key: '', requestId: '' }
await loadObservations() await loadObservations()
return true
} catch (error) { } catch (error) {
dispositionError.value = error?.message || '风险处置失败,请刷新状态后重试。' const normalizedError = normalizeRiskDispositionError(error)
if (error?.code === 'RISK_DISPOSITION_VERSION_CONFLICT') { dispositionError.value = normalizedError
if (normalizedError.shouldRefresh) {
if (normalizedError.code !== 'REQUEST_TIMEOUT') {
dispositionRequest.value = { key: '', requestId: '' } dispositionRequest.value = { key: '', requestId: '' }
await loadObservations()
} }
await loadObservations()
if (!isActionAvailable(action)) waiverDialogAction.value = ''
}
return false
} finally { } finally {
dispositionBusyAction.value = '' dispositionBusyAction.value = ''
} }
@@ -559,6 +700,19 @@ function formatFeedbackStatus(value) {
return labels[String(value || '').trim()] || '未复核' return labels[String(value || '').trim()] || '未复核'
} }
function formatDateTime(value) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '未记录'
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
}).format(date)
}
function formatAdjudication(value) { function formatAdjudication(value) {
const labels = { const labels = {
unreviewed: '待裁决', unreviewed: '待裁决',
@@ -574,6 +728,8 @@ function formatLifecycleStatus(value) {
supplement_requested: '待补材料', supplement_requested: '待补材料',
remediation_in_progress: '整改中', remediation_in_progress: '整改中',
waiver_requested: '豁免申请中', waiver_requested: '豁免申请中',
waived: '限时豁免',
waiver_rejected: '豁免已拒绝',
resolved: '已解决' resolved: '已解决'
} }
return labels[String(value || '').trim()] || '待处置' return labels[String(value || '').trim()] || '待处置'

View File

@@ -0,0 +1,264 @@
<template>
<Teleport to="body">
<div
v-if="open"
class="risk-waiver-dialog-backdrop"
@click.self="cancel"
@keydown.esc.prevent="cancel"
>
<section
class="risk-waiver-dialog"
role="dialog"
aria-modal="true"
:aria-labelledby="titleId"
>
<header>
<div>
<span>风险豁免</span>
<h3 :id="titleId">{{ dialogTitle }}</h3>
</div>
<button type="button" :disabled="busy" aria-label="关闭" @click="cancel">
<i class="mdi mdi-close"></i>
</button>
</header>
<p class="risk-waiver-dialog-notice" :class="dialogTone">
{{ dialogNotice }}
</p>
<dl v-if="!isRequest" class="risk-waiver-dialog-summary">
<div>
<dt>申请人</dt>
<dd>{{ disposition.waiverRequesterName || disposition.waiverRequesterId || '未记录' }}</dd>
</div>
<div>
<dt>申请理由</dt>
<dd>{{ disposition.waiverReason || '未记录' }}</dd>
</div>
<div>
<dt>豁免范围</dt>
<dd>{{ disposition.waiverScope || '未记录' }}</dd>
</div>
<div>
<dt>有效期至</dt>
<dd>{{ formatDateTime(disposition.waiverExpiresAt) }}</dd>
</div>
<div v-if="disposition.waiverConditions?.length">
<dt>补偿条件</dt>
<dd>{{ disposition.waiverConditions.join('') }}</dd>
</div>
</dl>
<form @submit.prevent="confirm">
<template v-if="isRequest">
<label>
<span>申请理由 <em>必填</em></span>
<textarea
v-model="form.reason"
:disabled="busy"
maxlength="2000"
placeholder="说明为什么需要例外处理,以及无法按常规流程解决的原因"
></textarea>
</label>
<label>
<span>豁免范围 <em>必填</em></span>
<textarea
v-model="form.scope"
:disabled="busy"
maxlength="1000"
placeholder="限定到本次单据、具体材料或风险项,避免扩大豁免范围"
></textarea>
</label>
<label>
<span>有效期 <em>必填</em></span>
<input
v-model="form.expiresAt"
type="datetime-local"
:min="minimumExpiry"
:disabled="busy"
/>
</label>
<label>
<span>补偿条件 <small>选填每行一项</small></span>
<textarea
v-model="form.conditions"
:disabled="busy"
maxlength="4000"
placeholder="例如:三日内补交原件&#10;到期前由财务复核"
></textarea>
</label>
</template>
<label v-else>
<span>{{ decisionReasonLabel }} <em>必填</em></span>
<textarea
v-model="form.decisionReason"
:disabled="busy"
maxlength="1000"
:placeholder="decisionReasonPlaceholder"
></textarea>
</label>
<p v-if="validationMessage" class="risk-waiver-dialog-validation" role="alert">
{{ validationMessage }}
</p>
<footer>
<button type="button" :disabled="busy" @click="cancel">取消</button>
<button
type="submit"
class="primary"
:class="dialogTone"
:disabled="busy"
>
<i v-if="busy" class="mdi mdi-loading mdi-spin"></i>
{{ busy ? '正在提交' : confirmLabel }}
</button>
</footer>
</form>
</section>
</div>
</Teleport>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
const props = defineProps({
open: { type: Boolean, default: false },
action: { type: String, default: '' },
busy: { type: Boolean, default: false },
disposition: { type: Object, default: () => ({}) }
})
const emit = defineEmits(['cancel', 'confirm'])
const titleId = 'risk-waiver-action-dialog-title'
const validationMessage = ref('')
const form = reactive({
reason: '',
scope: '',
expiresAt: '',
conditions: '',
decisionReason: ''
})
const isRequest = computed(() => props.action === 'request_waiver')
const isRejection = computed(() => props.action === 'reject_waiver')
const dialogTitle = computed(() => (
isRequest.value ? '申请风险豁免' : isRejection.value ? '拒绝风险豁免' : '批准风险豁免'
))
const dialogTone = computed(() => (isRejection.value ? 'danger' : 'warning'))
const dialogNotice = computed(() => {
if (isRequest.value) {
return '提交后仍会保持风险阻断,必须由另一位有权决定人批准后才会在有效期内解除。'
}
if (isRejection.value) {
return '拒绝后该风险仍会阻断审批;拒绝理由将写入审计记录且不可省略。'
}
return '批准后仅在申请有效期内解除风险阻断,到期后系统会自动恢复风险控制。'
})
const confirmLabel = computed(() => (
isRequest.value
? '确认提交豁免申请'
: isRejection.value
? '确认拒绝豁免'
: '确认批准豁免'
))
const decisionReasonLabel = computed(() => (isRejection.value ? '拒绝理由' : '批准理由'))
const decisionReasonPlaceholder = computed(() => (
isRejection.value
? '说明拒绝原因和仍需补充的控制措施'
: '说明批准依据和已确认的补偿控制'
))
const minimumExpiry = computed(() => toLocalDateTime(new Date(Date.now() + 60 * 1000)))
watch(
() => [props.open, props.action],
([open]) => {
if (!open) return
validationMessage.value = ''
form.reason = ''
form.scope = ''
form.conditions = ''
form.decisionReason = ''
form.expiresAt = isRequest.value
? toLocalDateTime(new Date(Date.now() + 7 * 24 * 60 * 60 * 1000))
: ''
}
)
function cancel() {
if (!props.busy) emit('cancel')
}
function confirm() {
validationMessage.value = ''
if (props.busy) return
if (isRequest.value) {
const reason = form.reason.trim()
const scope = form.scope.trim()
const expiry = new Date(form.expiresAt)
if (!reason || !scope || !form.expiresAt) {
validationMessage.value = '请完整填写申请理由、豁免范围和有效期。'
return
}
if (Number.isNaN(expiry.getTime()) || expiry.getTime() <= Date.now()) {
validationMessage.value = '有效期必须晚于当前时间。'
return
}
const conditions = parseConditions(form.conditions)
if (conditions.length > 20) {
validationMessage.value = '补偿条件最多填写 20 项。'
return
}
if (conditions.some((item) => item.length > 500)) {
validationMessage.value = '单项补偿条件不能超过 500 个字符。'
return
}
emit('confirm', {
action: props.action,
waiverReason: reason,
waiverScope: scope,
waiverExpiresAt: expiry.toISOString(),
waiverConditions: conditions
})
return
}
const comment = form.decisionReason.trim()
if (!comment) {
validationMessage.value = isRejection.value ? '请填写拒绝理由。' : '请填写批准理由。'
return
}
emit('confirm', { action: props.action, comment })
}
function parseConditions(value) {
return [...new Set(
String(value || '')
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean)
)]
}
function toLocalDateTime(value) {
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) return ''
const offset = date.getTimezoneOffset() * 60 * 1000
return new Date(date.getTime() - offset).toISOString().slice(0, 16)
}
function formatDateTime(value) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '未记录'
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
}).format(date)
}
</script>
<style scoped src="../../assets/styles/components/risk-waiver-action-dialog.css"></style>

View File

@@ -0,0 +1,161 @@
<template>
<div v-if="visible" class="risk-waiver-record">
<div class="risk-waiver-record-head">
<strong>豁免申请与决定</strong>
<em :class="disposition.waiverDecision || 'pending'">{{ decisionLabel }}</em>
</div>
<dl>
<div>
<dt>申请人</dt>
<dd>
{{ disposition.waiverRequesterName || disposition.waiverRequesterId || '未记录' }}
<small>{{ formatDateTime(disposition.waiverRequestedAt) }}</small>
</dd>
</div>
<div>
<dt>申请理由</dt>
<dd>{{ disposition.waiverReason || '未记录' }}</dd>
</div>
<div>
<dt>豁免范围</dt>
<dd>{{ disposition.waiverScope || '未记录' }}</dd>
</div>
<div>
<dt>有效期至</dt>
<dd :class="{ expired }">{{ formatDateTime(disposition.waiverExpiresAt) }}</dd>
</div>
<div class="wide">
<dt>补偿条件</dt>
<dd>
<span v-if="!disposition.waiverConditions?.length">无附加条件</span>
<ul v-else>
<li v-for="condition in disposition.waiverConditions" :key="condition">
{{ condition }}
</li>
</ul>
</dd>
</div>
<div v-if="disposition.waiverDecision" class="wide">
<dt>决定记录</dt>
<dd>
{{ disposition.waiverDeciderName || disposition.waiverDeciderId || '未记录决定人' }}
<small>{{ formatDateTime(disposition.waiverDecidedAt) }}</small>
<span>{{ disposition.waiverDecisionReason || '未记录决定理由' }}</span>
</dd>
</div>
</dl>
<section v-if="waiverEvents.length" class="risk-waiver-history">
<h4>豁免审计轨迹</h4>
<ol>
<li v-for="event in waiverEvents" :key="event.key">
<div>
<strong>{{ event.label }}</strong>
<em>v{{ event.version }}</em>
</div>
<p>{{ event.actor }} · {{ formatDateTime(event.createdAt) }}</p>
<p v-if="event.comment">{{ event.comment }}</p>
<span v-for="detail in event.details" :key="detail">{{ detail }}</span>
<code v-if="event.requestId">请求 ID{{ event.requestId }}</code>
</li>
</ol>
</section>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
disposition: { type: Object, default: () => ({}) },
expired: { type: Boolean, default: false }
})
const visible = computed(() => Boolean(
props.disposition.waiverRequesterId
|| props.disposition.waiverRequesterName
|| props.disposition.waiverRequestedAt
))
const decisionLabel = computed(() => {
if (props.disposition.waiverDecision === 'approved') {
return props.expired ? '已批准 · 已到期' : '已批准'
}
if (props.disposition.waiverDecision === 'rejected') return '已拒绝'
return '等待决定'
})
const waiverEvents = computed(() => (
(props.disposition.events || [])
.filter((event) => ['request_waiver', 'approve_waiver', 'reject_waiver'].includes(event.action))
.map((event, index) => {
const state = event.afterState || {}
const details = event.action === 'request_waiver'
? [
formatAuditDetail('原因', stateValue(state, 'waiver_reason', 'waiverReason')),
formatAuditDetail('范围', stateValue(state, 'waiver_scope', 'waiverScope')),
formatAuditDetail(
'有效期至',
formatDateTime(stateValue(state, 'waiver_expires_at', 'waiverExpiresAt'))
),
formatAuditConditions(
stateValue(state, 'waiver_conditions_json', 'waiverConditions')
)
].filter(Boolean)
: []
return {
key: event.id || `${event.action}:${event.version}:${index}`,
label: formatWaiverAction(event.action),
version: event.version,
actor: formatActor(event),
createdAt: event.createdAt,
comment: event.comment,
requestId: event.requestId,
details
}
})
))
function stateValue(state, snakeKey, camelKey) {
return state?.[snakeKey] ?? state?.[camelKey]
}
function formatAuditDetail(label, value) {
const text = String(value || '').trim()
return text && text !== '未记录' ? `${label}${text}` : ''
}
function formatAuditConditions(value) {
const conditions = Array.isArray(value)
? value.map((item) => String(item || '').trim()).filter(Boolean)
: []
return conditions.length ? `条件:${conditions.join('')}` : ''
}
function formatActor(event) {
const name = String(event.actorName || '').trim()
const id = String(event.actorId || '').trim()
if (name && id && name !== id) return `${name}${id}`
return name || id || '未记录处理人'
}
function formatWaiverAction(action) {
return {
request_waiver: '提交豁免申请',
approve_waiver: '批准豁免',
reject_waiver: '拒绝豁免'
}[action] || action
}
function formatDateTime(value) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '未记录'
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
}).format(date)
}
</script>
<style scoped src="../../assets/styles/components/risk-waiver-record.css"></style>

View File

@@ -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
}
}

View File

@@ -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 }
}

View File

@@ -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)
}

View File

@@ -13,6 +13,14 @@ function toObject(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? 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) { export function createRiskDispositionRequestId(action, observationId) {
const normalizedAction = String(action || 'action').trim() || 'action' const normalizedAction = String(action || 'action').trim() || 'action'
const normalizedObservationId = String(observationId || 'risk').trim() || 'risk' const normalizedObservationId = String(observationId || 'risk').trim() || 'risk'
@@ -35,14 +43,48 @@ export function normalizeRiskDisposition(item = {}) {
assignee: String(item.assignee || '').trim(), assignee: String(item.assignee || '').trim(),
dueAt: String(item.due_at || item.dueAt || '').trim(), dueAt: String(item.due_at || item.dueAt || '').trim(),
resolution: String(item.resolution || '').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(), createdAt: String(item.created_at || item.createdAt || '').trim(),
updatedAt: String(item.updated_at || item.updatedAt || '').trim(), updatedAt: String(item.updated_at || item.updatedAt || '').trim(),
events: toArray(item.events).map((event) => ({ events: toArray(item.events).map((event) => ({
id: String(event?.id || '').trim(), id: String(event?.id || '').trim(),
action: String(event?.action || '').trim(), action: String(event?.action || '').trim(),
version: Math.max(0, toNumber(event?.version)), version: Math.max(0, toNumber(event?.version)),
actorId: String(event?.actor_id || event?.actorId || '').trim(),
actorName: String(event?.actor_name || event?.actorName || '').trim(), actorName: String(event?.actor_name || event?.actorName || '').trim(),
requestId: String(event?.request_id || event?.requestId || '').trim(),
comment: String(event?.comment || '').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() createdAt: String(event?.created_at || event?.createdAt || '').trim()
})) }))
} }
@@ -91,6 +133,8 @@ export function normalizeRiskObservation(item = {}) {
), ),
feedbackItems: toArray(item.feedback_items || item.feedbackItems), feedbackItems: toArray(item.feedback_items || item.feedbackItems),
disposition: normalizeRiskDisposition(item.disposition), 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(), createdAt: String(item.created_at || item.createdAt || '').trim(),
updatedAt: String(item.updated_at || item.updatedAt || '').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 resolution = String(payload.resolution || '').trim()
const assignee = String(payload.assignee || '').trim() const assignee = String(payload.assignee || '').trim()
const dueAt = String(payload.dueAt || payload.due_at || '').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 (comment) body.comment = comment
if (resolution) body.resolution = resolution if (resolution) body.resolution = resolution
if (assignee) body.assignee = assignee if (assignee) body.assignee = assignee
if (dueAt) body.due_at = dueAt 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( const result = await apiRequest(
`/risk-observations/${encodeURIComponent(normalizedObservationId)}/disposition/actions`, `/risk-observations/${encodeURIComponent(normalizedObservationId)}/disposition/actions`,
@@ -232,3 +289,55 @@ export async function executeRiskDispositionAction(observationId, payload = {})
requestId 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
}
}

View File

@@ -44,7 +44,12 @@ export const DOCUMENT_CENTER_QUERY_KEYS = new Set([
'dc_scene', 'dc_scene',
'dc_q', 'dc_q',
'dc_start', '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 riskLevelTabs = ['全部', '高风险', '中风险', '低风险', '无风险']
export const RISK_TONE_META = { export const RISK_TONE_META = {

View File

@@ -96,7 +96,6 @@
@update:active-range="activeRange = $event" @update:active-range="activeRange = $event"
@update:custom-range="customRange = $event" @update:custom-range="customRange = $event"
@update:overview-dashboard="overviewDashboard = $event" @update:overview-dashboard="overviewDashboard = $event"
@batch-approve="toast('已批量通过 23 条审批任务')"
@new-application="openExpenseApplicationCreate" @new-application="openExpenseApplicationCreate"
@open-document="openWorkbenchDocument" @open-document="openWorkbenchDocument"
@navigate="handleNavigate" @navigate="handleNavigate"
@@ -183,6 +182,7 @@
@create-request="openTravelCreate" @create-request="openTravelCreate"
@create-application="openExpenseApplicationCreate" @create-application="openExpenseApplicationCreate"
@reload="reloadRequests" @reload="reloadRequests"
@request-updated="handleRequestUpdated"
@summary-change="documentSummary = $event" @summary-change="documentSummary = $event"
/> />

View File

@@ -11,13 +11,26 @@
> >
<span class="scope-tab-label"> <span class="scope-tab-label">
{{ tab.label }} {{ tab.label }}
<span v-if="tab.badgeCount > 0" class="scope-tab-badge" aria-label="新增单据数"> <span v-if="tab.badgeCount > 0" class="scope-tab-badge" :aria-label="tab.badgeLabel">
{{ tab.badgeCount > 99 ? '99+' : tab.badgeCount }} {{ tab.badgeCount > 99 ? '99+' : tab.badgeCount }}
</span> </span>
</span> </span>
</button> </button>
</nav> </nav>
<ApprovalTaskWorkspace
v-show="activeScopeTab === DOCUMENT_SCOPE_REVIEW"
:initial-filters="approvalTaskRouteState.filters"
:initial-page="approvalTaskRouteState.page"
:initial-page-size="approvalTaskRouteState.pageSize"
:refresh-token="refreshToken"
@open-document="emit('open-document', $event)"
@request-updated="emit('request-updated', $event)"
@loaded="handleApprovalTasksLoaded"
@state-change="handleApprovalTaskStateChange"
/>
<template v-if="activeScopeTab !== DOCUMENT_SCOPE_REVIEW">
<div class="document-toolbar"> <div class="document-toolbar">
<div class="filter-set"> <div class="filter-set">
<div class="list-search"> <div class="list-search">
@@ -247,6 +260,7 @@
@page-size-change="changePageSize" @page-size-change="changePageSize"
@update:current-page="currentPage = $event" @update:current-page="currentPage = $event"
/> />
</template>
</article> </article>
</section> </section>
</template> </template>
@@ -254,19 +268,19 @@
<script setup> <script setup>
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import ApprovalTaskWorkspace from '../components/approval/ApprovalTaskWorkspace.vue'
import EnterprisePagination from '../components/shared/EnterprisePagination.vue' import EnterprisePagination from '../components/shared/EnterprisePagination.vue'
import TableEmptyState from '../components/shared/TableEmptyState.vue' import TableEmptyState from '../components/shared/TableEmptyState.vue'
import TableLoadingState from '../components/shared/TableLoadingState.vue' import TableLoadingState from '../components/shared/TableLoadingState.vue'
import { useDocumentCenterArchiveRows } from '../composables/useDocumentCenterArchiveRows.js'
import { useMinimumVisibleState } from '../composables/useMinimumVisibleState.js' import { useMinimumVisibleState } from '../composables/useMinimumVisibleState.js'
import { useSystemState } from '../composables/useSystemState.js' import { useSystemState } from '../composables/useSystemState.js'
import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
import {
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
extractExpenseClaimItems,
fetchApprovalExpenseClaims,
fetchArchivedExpenseClaims
} from '../services/reimbursements.js'
import { fetchNotificationStates, patchNotificationStates } from '../services/notificationStates.js' import { fetchNotificationStates, patchNotificationStates } from '../services/notificationStates.js'
import {
appendApprovalTaskRouteQuery,
normalizeApprovalTaskRouteState,
readApprovalTaskRouteState
} from './scripts/approvalTaskRouteState.js'
import { import {
buildDocumentViewedStatePatch, buildDocumentViewedStatePatch,
buildDocumentsViewedStatePatches, buildDocumentsViewedStatePatches,
@@ -303,6 +317,7 @@ const emit = defineEmits([
'create-request', 'create-request',
'create-application', 'create-application',
'reload', 'reload',
'request-updated',
'summary-change' 'summary-change'
]) ])
@@ -353,9 +368,12 @@ function buildDocumentCenterRouteQuery() {
} }
}) })
if (activeScopeTab.value !== DOCUMENT_SCOPE_ALL) nextQuery.dc_scope = activeScopeTab.value
if (activeScopeTab.value === DOCUMENT_SCOPE_REVIEW) {
return appendApprovalTaskRouteQuery(nextQuery, approvalTaskRouteState.value)
}
if (currentPage.value > 1) nextQuery.dc_page = String(currentPage.value) if (currentPage.value > 1) nextQuery.dc_page = String(currentPage.value)
if (pageSize.value !== 20) nextQuery.dc_page_size = String(pageSize.value) if (pageSize.value !== 20) nextQuery.dc_page_size = String(pageSize.value)
if (activeScopeTab.value !== DOCUMENT_SCOPE_ALL) nextQuery.dc_scope = activeScopeTab.value
if (activeStatusTab.value !== '全部') nextQuery.dc_status = activeStatusTab.value if (activeStatusTab.value !== '全部') nextQuery.dc_status = activeStatusTab.value
if (showDocumentTypeFilter.value && activeDocumentType.value !== DOCUMENT_TYPE_ALL) { if (showDocumentTypeFilter.value && activeDocumentType.value !== DOCUMENT_TYPE_ALL) {
nextQuery.dc_doc_type = activeDocumentType.value nextQuery.dc_doc_type = activeDocumentType.value
@@ -369,6 +387,7 @@ function buildDocumentCenterRouteQuery() {
} }
const initialScopeTab = resolveInitialScopeTab() const initialScopeTab = resolveInitialScopeTab()
const approvalTaskRouteState = ref(readApprovalTaskRouteState(route.query))
const initialAppliedStart = readDocumentCenterQueryText('dc_start') const initialAppliedStart = readDocumentCenterQueryText('dc_start')
const initialAppliedEnd = readDocumentCenterQueryText('dc_end') const initialAppliedEnd = readDocumentCenterQueryText('dc_end')
const activeScopeTab = ref(initialScopeTab) const activeScopeTab = ref(initialScopeTab)
@@ -384,11 +403,14 @@ const appliedStart = ref(initialAppliedStart)
const appliedEnd = ref(initialAppliedEnd) const appliedEnd = ref(initialAppliedEnd)
const currentPage = ref(readDocumentCenterQueryNumber('dc_page', 1)) const currentPage = ref(readDocumentCenterQueryNumber('dc_page', 1))
const pageSize = ref(resolveInitialPageSize()) const pageSize = ref(resolveInitialPageSize())
const archiveRows = ref([]) const approvalTaskTotal = ref(0)
const approvalRows = ref([])
const supportingLoading = ref(false)
const supportingError = ref('')
const viewedDocumentKeys = ref(readViewedDocumentKeys()) const viewedDocumentKeys = ref(readViewedDocumentKeys())
const {
archiveRows,
supportingLoading,
supportingError,
loadSupportingRows
} = useDocumentCenterArchiveRows({ currentUser, viewedDocumentKeys })
const activeFilterConfig = computed(() => const activeFilterConfig = computed(() =>
FILTER_CONFIG_BY_SCOPE[activeScopeTab.value] || FILTER_CONFIG_BY_SCOPE[DOCUMENT_SCOPE_APPLICATION] FILTER_CONFIG_BY_SCOPE[activeScopeTab.value] || FILTER_CONFIG_BY_SCOPE[DOCUMENT_SCOPE_APPLICATION]
) )
@@ -423,14 +445,14 @@ const ownedRows = computed(() =>
) )
) )
const nonArchivedRows = computed(() => mergeDocumentRows([...ownedRows.value, ...approvalRows.value])) const nonArchivedRows = computed(() => mergeDocumentRows(ownedRows.value))
const applicationScopeRows = computed(() => prepareApplicationScopeRows(ownedRows.value)) const applicationScopeRows = computed(() => prepareApplicationScopeRows(ownedRows.value))
const scopeNewCountMap = computed(() => ({ const scopeNewCountMap = computed(() => ({
[DOCUMENT_SCOPE_ALL]: countNewDocuments(nonArchivedRows.value, viewedDocumentKeys.value), [DOCUMENT_SCOPE_ALL]: countNewDocuments(nonArchivedRows.value, viewedDocumentKeys.value),
[DOCUMENT_SCOPE_APPLICATION]: countNewDocuments(filterApplicationScopeNewRows(applicationScopeRows.value), viewedDocumentKeys.value), [DOCUMENT_SCOPE_APPLICATION]: countNewDocuments(filterApplicationScopeNewRows(applicationScopeRows.value), viewedDocumentKeys.value),
[DOCUMENT_SCOPE_REIMBURSEMENT]: countNewDocuments(ownedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT), viewedDocumentKeys.value), [DOCUMENT_SCOPE_REIMBURSEMENT]: countNewDocuments(ownedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT), viewedDocumentKeys.value),
[DOCUMENT_SCOPE_REVIEW]: countNewDocuments(approvalRows.value, viewedDocumentKeys.value), [DOCUMENT_SCOPE_REVIEW]: approvalTaskTotal.value,
[DOCUMENT_SCOPE_ARCHIVE]: countNewDocuments(archiveRows.value, viewedDocumentKeys.value) [DOCUMENT_SCOPE_ARCHIVE]: countNewDocuments(archiveRows.value, viewedDocumentKeys.value)
})) }))
@@ -438,14 +460,14 @@ const scopeTabItems = computed(() =>
scopeTabs.map((tab) => ({ scopeTabs.map((tab) => ({
value: tab, value: tab,
label: tab, label: tab,
badgeCount: scopeNewCountMap.value[tab] || 0 badgeCount: scopeNewCountMap.value[tab] || 0,
badgeLabel: tab === DOCUMENT_SCOPE_REVIEW ? '待处理审批任务数' : '新增单据数'
})) }))
) )
const allReadableDocumentRows = computed(() => [ const allReadableDocumentRows = computed(() => [
...nonArchivedRows.value, ...nonArchivedRows.value,
...filterApplicationScopeNewRows(applicationScopeRows.value), ...filterApplicationScopeNewRows(applicationScopeRows.value),
...ownedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT), ...ownedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT)
...approvalRows.value
]) ])
const totalNewDocumentCount = computed(() => countNewDocuments(allReadableDocumentRows.value, viewedDocumentKeys.value)) const totalNewDocumentCount = computed(() => countNewDocuments(allReadableDocumentRows.value, viewedDocumentKeys.value))
const showCreateDocumentActions = computed(() => const showCreateDocumentActions = computed(() =>
@@ -464,10 +486,6 @@ const activeScopeRows = computed(() => {
return ownedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT) return ownedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT)
} }
if (activeScopeTab.value === DOCUMENT_SCOPE_REVIEW) {
return approvalRows.value
}
if (activeScopeTab.value === DOCUMENT_SCOPE_ARCHIVE) { if (activeScopeTab.value === DOCUMENT_SCOPE_ARCHIVE) {
return archiveRows.value return archiveRows.value
} }
@@ -529,16 +547,14 @@ const showError = computed(() => Boolean(props.error) && !visibleRows.value.leng
const errorMessage = computed(() => props.error || supportingError.value || '单据中心加载失败。') const errorMessage = computed(() => props.error || supportingError.value || '单据中心加载失败。')
const showEmpty = computed(() => !showLoading.value && !showError.value && visibleRows.value.length === 0) const showEmpty = computed(() => !showLoading.value && !showError.value && visibleRows.value.length === 0)
const showTable = computed(() => !showLoading.value && !showError.value && visibleRows.value.length > 0) const showTable = computed(() => !showLoading.value && !showError.value && visibleRows.value.length > 0)
const showStayTimeColumn = computed(() => const showStayTimeColumn = computed(() => activeScopeTab.value === DOCUMENT_SCOPE_APPLICATION)
[DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REVIEW].includes(activeScopeTab.value)
)
const documentSummary = computed(() => { const documentSummary = computed(() => {
const rows = nonArchivedRows.value const rows = nonArchivedRows.value
return { return {
total: rows.length, total: rows.length,
toSubmit: rows.filter((row) => ['draft', 'pending_submit'].includes(row.statusGroup)).length, toSubmit: rows.filter((row) => ['draft', 'pending_submit'].includes(row.statusGroup)).length,
toProcess: approvalRows.value.length, toProcess: approvalTaskTotal.value,
archived: archiveRows.value.length archived: archiveRows.value.length
} }
}) })
@@ -680,48 +696,12 @@ function markAllDocumentsRead() {
void syncDocumentViewedPatches(viewedPatches) void syncDocumentViewedPatches(viewedPatches)
} }
async function loadSupportingRows() { function handleApprovalTasksLoaded(result = {}) {
supportingLoading.value = true approvalTaskTotal.value = Math.max(0, Number(result.total) || 0)
supportingError.value = '' }
const [approvalResult, archiveResult] = await Promise.allSettled([ function handleApprovalTaskStateChange(state = {}) {
fetchApprovalExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS), approvalTaskRouteState.value = normalizeApprovalTaskRouteState(state)
fetchArchivedExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
])
if (approvalResult.status === 'fulfilled') {
approvalRows.value = excludeArchivedDocumentRows(
extractExpenseClaimItems(approvalResult.value)
.map((item) => mapExpenseClaimToRequest(item))
.map((item) => buildDocumentRow(item, {
source: 'approval',
currentUser: currentUser.value,
viewedDocumentKeys: viewedDocumentKeys.value
}))
.filter(Boolean)
)
} else {
approvalRows.value = []
}
if (archiveResult.status === 'fulfilled') {
archiveRows.value = extractExpenseClaimItems(archiveResult.value)
.map((item) => mapExpenseClaimToRequest(item))
.map((item) => buildDocumentRow(item, {
source: 'archive',
archived: true,
currentUser: currentUser.value,
viewedDocumentKeys: viewedDocumentKeys.value
}))
.filter(Boolean)
} else {
archiveRows.value = []
supportingError.value = archiveResult.reason instanceof Error
? archiveResult.reason.message
: '归档数据加载失败。'
}
supportingLoading.value = false
} }
function reloadAll() { function reloadAll() {
@@ -738,7 +718,7 @@ watch(
) )
watch( watch(
[currentPage, pageSize, activeScopeTab, activeStatusTab, activeDocumentType, activeScene, listKeyword, appliedStart, appliedEnd], [currentPage, pageSize, activeScopeTab, activeStatusTab, activeDocumentType, activeScene, listKeyword, appliedStart, appliedEnd, approvalTaskRouteState],
() => { () => {
if (route.name !== 'app-documents') { if (route.name !== 'app-documents') {
return return
@@ -752,6 +732,7 @@ watch(
) )
watch(activeFilterConfig, () => { watch(activeFilterConfig, () => {
writeDocumentScope(activeScopeTab.value, scopeTabs)
openFilterKey.value = '' openFilterKey.value = ''
datePopover.value = false datePopover.value = false

View File

@@ -0,0 +1,191 @@
function toArray(value) {
if (value instanceof Set) {
return [...value]
}
return Array.isArray(value) ? value : []
}
function toText(value) {
return String(value ?? '').trim()
}
function uniqueTaskIds(value) {
return [...new Set(toArray(value).map(toText).filter(Boolean))]
}
function unwrapTask(item = {}) {
return item?.task && typeof item.task === 'object' ? item.task : item
}
export function resolveApprovalTaskId(item = {}) {
return toText(unwrapTask(item)?.id ?? item?.task_id ?? item?.taskId)
}
export function resolveApprovalTaskSelectionMeta(item = {}) {
const task = unwrapTask(item) || {}
const taskId = resolveApprovalTaskId(item)
const readOnlyReason = toText(task.readOnlyReason ?? task.read_only_reason)
const batchBlockReasons = toArray(
task.batchBlockReasons ?? task.batch_block_reasons_json
).map(toText).filter(Boolean)
const availableActions = new Set(
toArray(task.availableActions ?? task.available_actions).map(toText).filter(Boolean)
)
if (!taskId) {
return { taskId: '', selectable: false, reason: '审批任务缺少任务标识。' }
}
if ((task.canAct ?? task.can_act) !== true) {
return {
taskId,
selectable: false,
reason: readOnlyReason || '当前用户没有处理该审批任务的权限。'
}
}
if (toText(task.status).toLowerCase() !== 'pending') {
return { taskId, selectable: false, reason: '该审批任务已不在待处理状态。' }
}
if (!availableActions.has('approve')) {
return {
taskId,
selectable: false,
reason: readOnlyReason || '当前任务不允许执行审批通过。'
}
}
if ((task.batchEligible ?? task.batch_eligible) !== true) {
return {
taskId,
selectable: false,
reason: batchBlockReasons.join('') || '该任务需要单独核对,不能批量审批。'
}
}
if (Number(task.version) < 1) {
return { taskId, selectable: false, reason: '任务版本无效,请刷新后重试。' }
}
return { taskId, selectable: true, reason: '' }
}
export function isApprovalTaskSelectable(item = {}) {
return resolveApprovalTaskSelectionMeta(item).selectable
}
export function resolveCurrentPageApprovalSelection(items = [], selectedTaskIds = []) {
const eligibleIds = toArray(items)
.map(resolveApprovalTaskSelectionMeta)
.filter((meta) => meta.selectable)
.map((meta) => meta.taskId)
const selectedSet = new Set(uniqueTaskIds(selectedTaskIds))
const selectedEligibleIds = eligibleIds.filter((taskId) => selectedSet.has(taskId))
const allSelected = eligibleIds.length > 0 && selectedEligibleIds.length === eligibleIds.length
const someSelected = selectedEligibleIds.length > 0
return {
eligibleIds,
selectedEligibleIds,
eligibleCount: eligibleIds.length,
selectedCount: selectedEligibleIds.length,
allSelected,
someSelected,
indeterminate: someSelected && !allSelected
}
}
export function toggleApprovalTaskSelection(selectedTaskIds, item, selected) {
const current = uniqueTaskIds(selectedTaskIds)
const currentSet = new Set(current)
const meta = resolveApprovalTaskSelectionMeta(item)
if (!meta.taskId) {
return current
}
const shouldSelect = typeof selected === 'boolean' ? selected : !currentSet.has(meta.taskId)
if (!shouldSelect) {
currentSet.delete(meta.taskId)
return [...currentSet]
}
if (!meta.selectable) {
return current
}
currentSet.add(meta.taskId)
return [...currentSet]
}
export function toggleCurrentPageApprovalSelection(selectedTaskIds, items = [], selected) {
const current = uniqueTaskIds(selectedTaskIds)
const currentSet = new Set(current)
const pageState = resolveCurrentPageApprovalSelection(items, current)
const pageTaskIds = new Set(toArray(items).map(resolveApprovalTaskId).filter(Boolean))
const shouldSelect = typeof selected === 'boolean' ? selected : !pageState.allSelected
if (shouldSelect) {
pageState.eligibleIds.forEach((taskId) => currentSet.add(taskId))
} else {
pageTaskIds.forEach((taskId) => currentSet.delete(taskId))
}
return [...currentSet]
}
export function reconcileApprovalTaskSelectionWithPage(selectedTaskIds, items = []) {
const current = uniqueTaskIds(selectedTaskIds)
const eligibleIds = new Set(
toArray(items)
.map(resolveApprovalTaskSelectionMeta)
.filter((meta) => meta.selectable)
.map((meta) => meta.taskId)
)
const pageIds = new Set(toArray(items).map(resolveApprovalTaskId).filter(Boolean))
return current.filter((taskId) => !pageIds.has(taskId) || eligibleIds.has(taskId))
}
function stableFilterValue(value) {
if (Array.isArray(value)) {
return value.map(stableFilterValue).sort((left, right) => String(left).localeCompare(String(right)))
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.keys(value)
.sort()
.map((key) => [key, stableFilterValue(value[key])])
)
}
return value ?? null
}
export function buildApprovalTaskFilterSignature(filters = {}) {
const ignoredKeys = new Set(['page', 'pageSize', 'page_size'])
const normalized = Object.fromEntries(
Object.entries(filters || {})
.filter(([key]) => !ignoredKeys.has(key))
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, value]) => [key, stableFilterValue(value)])
)
return JSON.stringify(normalized)
}
export function createApprovalTaskSelectionState(options = {}) {
return {
selectedTaskIds: uniqueTaskIds(options.selectedTaskIds),
filterSignature: buildApprovalTaskFilterSignature(options.filters)
}
}
export function syncApprovalTaskSelectionFilters(state = {}, filters = {}) {
const nextSignature = buildApprovalTaskFilterSignature(filters)
const currentSignature = toText(state.filterSignature)
return {
...state,
selectedTaskIds: currentSignature === nextSignature
? uniqueTaskIds(state.selectedTaskIds)
: [],
filterSignature: nextSignature
}
}
export function reconcileApprovalTaskSelectionAfterBatch(selectedTaskIds, batchResult = {}) {
const completedTaskIds = new Set(
toArray(batchResult.items)
.filter((item) => ['succeeded', 'replayed'].includes(toText(item?.status).toLowerCase()))
.map((item) => toText(item?.taskId ?? item?.task_id))
.filter(Boolean)
)
return uniqueTaskIds(selectedTaskIds).filter((taskId) => !completedTaskIds.has(taskId))
}

View File

@@ -0,0 +1,181 @@
const RISK_LABELS = {
critical: '重大风险',
high: '高风险',
medium: '中风险',
low: '低风险'
}
const ACTION_METADATA = {
approve: { label: '审批通过', tone: 'primary', icon: 'mdi mdi-check-circle-outline' },
return: { label: '退回', tone: 'danger', icon: 'mdi mdi-undo' },
delegate: { label: '委托', tone: 'neutral', icon: 'mdi mdi-account-arrow-right-outline' },
delegation_revoke: { label: '撤销委托', tone: 'neutral', icon: 'mdi mdi-account-cancel-outline' },
transfer: { label: '转交', tone: 'warning', icon: 'mdi mdi-swap-horizontal' },
add_sign: { label: '加签', tone: 'neutral', icon: 'mdi mdi-account-plus-outline' },
countersign: { label: '会签', tone: 'neutral', icon: 'mdi mdi-account-group-outline' },
sla_escalate: { label: '立即升级', tone: 'warning', icon: 'mdi mdi-arrow-up-bold-circle-outline' }
}
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
}
export function unwrapApprovalTaskQueueItem(item = {}) {
return item?.task && typeof item.task === 'object' ? item.task : item
}
export function resolveApprovalTaskClaim(item = {}) {
return item?.claim && typeof item.claim === 'object' ? item.claim : {}
}
export function resolveApprovalTaskReadOnlyReason(item = {}) {
const task = unwrapApprovalTaskQueueItem(item)
const explicit = toText(task.readOnlyReason ?? task.read_only_reason)
if (explicit) {
return explicit
}
const blockers = toArray(task.batchBlockReasons ?? task.batch_block_reasons_json)
.map(toText)
.filter(Boolean)
return blockers.join('')
}
export function resolveApprovalTaskActionItems(item = {}) {
const task = unwrapApprovalTaskQueueItem(item)
const canDecide = (task.canAct ?? task.can_act) === true
return toArray(task.availableActions ?? task.available_actions)
.map(toText)
.filter((action, index, values) => action && values.indexOf(action) === index)
.filter((action) => canDecide || !['approve', 'return'].includes(action))
.map((action) => ACTION_METADATA[action] ? { action, ...ACTION_METADATA[action] } : null)
.filter(Boolean)
}
export function resolveApprovalTaskSlaMeta(item = {}, now = Date.now()) {
const task = unwrapApprovalTaskQueueItem(item)
const dueAtText = toText(task.dueAt ?? task.due_at)
const dueAt = dueAtText ? new Date(dueAtText) : null
const dueTime = dueAt && !Number.isNaN(dueAt.getTime()) ? dueAt.getTime() : null
const escalationLevel = Math.max(0, Math.trunc(toNumber(
task.escalationLevel ?? task.escalation_level
)))
const nextEscalationAt = toText(task.nextEscalationAt ?? task.next_escalation_at)
if (dueTime === null) {
return {
label: '未配置',
tone: 'neutral',
overdue: false,
nearDue: false,
escalationLevel,
escalationLabel: escalationLevel ? `L${escalationLevel}` : '',
title: '当前节点未返回 SLA 截止时间。',
dueAt: '',
nextEscalationAt
}
}
const diffMs = dueTime - Number(now)
const overdue = diffMs <= 0
const absoluteMinutes = Math.max(1, Math.ceil(Math.abs(diffMs) / 60000))
const hours = absoluteMinutes / 60
const durationLabel = hours >= 1
? `${hours >= 10 ? Math.round(hours) : hours.toFixed(1)}h`
: `${absoluteMinutes}m`
const nearDue = !overdue && diffMs <= 2 * 60 * 60 * 1000
const label = overdue ? `超时 ${durationLabel}` : `剩余 ${durationLabel}`
const tone = overdue ? 'danger' : nearDue ? 'warning' : 'safe'
const escalationLabel = escalationLevel ? `L${escalationLevel}` : ''
const titleParts = [
`节点截止时间:${dueAtText}`,
escalationLabel ? `当前升级等级:${escalationLabel}` : '',
nextEscalationAt ? `下次升级:${nextEscalationAt}` : ''
].filter(Boolean)
return {
label,
tone,
overdue,
nearDue,
escalationLevel,
escalationLabel,
title: titleParts.join(''),
dueAt: dueAtText,
nextEscalationAt
}
}
export function resolveApprovalTaskRow(item = {}, now = Date.now()) {
const task = unwrapApprovalTaskQueueItem(item)
const claim = resolveApprovalTaskClaim(item)
const amount = toNumber(claim.amount)
const riskLevel = toText(task.riskLevel ?? task.risk_level ?? 'low') || 'low'
const evidenceCompleteness = Math.max(0, Math.min(
1,
toNumber(task.evidenceCompleteness ?? task.evidence_completeness)
))
return {
id: toText(task.id),
claimId: toText(task.claimId ?? task.claim_id),
claimNo: toText(claim.claim_no ?? claim.claimNo ?? task.claimId ?? task.claim_id),
applicant: toText(claim.employee_name ?? claim.employeeName ?? claim.person ?? '未记录'),
department: toText(claim.department_name ?? claim.departmentName ?? claim.dept ?? '未记录'),
amountLabel: new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: Number.isInteger(amount) ? 0 : 2
}).format(amount),
nodeLabel: toText(task.nodeLabel ?? task.node_label ?? claim.approval_stage ?? claim.approvalStage),
priorityScore: Math.max(0, Math.min(100, toNumber(task.priorityScore ?? task.priority_score))),
priorityTier: toText(task.priorityTier ?? task.priority_tier ?? 'normal') || 'normal',
riskLevel,
riskLabel: RISK_LABELS[riskLevel] || RISK_LABELS.low,
openRiskCount: Math.max(0, Math.trunc(toNumber(task.openRiskCount ?? task.open_risk_count))),
evidenceLabel: `${Math.round(evidenceCompleteness * 100)}%`,
assigneeName: toText(task.assigneeName ?? task.assignee_name ?? '未分配'),
taskKind: toText(task.taskKind ?? task.task_kind ?? 'root') || 'root',
coordinationMode: toText(task.coordinationMode ?? task.coordination_mode ?? 'single') || 'single',
canAct: (task.canAct ?? task.can_act) === true,
readOnlyReason: resolveApprovalTaskReadOnlyReason(item),
actions: resolveApprovalTaskActionItems(item),
sla: resolveApprovalTaskSlaMeta(item, now),
source: item
}
}
export function isApprovalTaskKeyboardInputTarget(target) {
if (!target || typeof target !== 'object') {
return false
}
const tagName = toText(target.tagName).toLowerCase()
if (['input', 'textarea', 'select', 'button', 'a'].includes(tagName)) {
return true
}
if (target.isContentEditable) {
return true
}
return typeof target.closest === 'function'
&& Boolean(target.closest('input, textarea, select, button, a, [contenteditable="true"]'))
}
export function resolveApprovalTaskKeyboardCommand(event = {}) {
if (isApprovalTaskKeyboardInputTarget(event.target)) {
return ''
}
const rawKey = String(event.key ?? '')
if (rawKey === ' ') return 'toggle'
const key = rawKey.trim().toLowerCase()
if (key === 'enter') return 'open'
if (key === 'spacebar') return 'toggle'
if (key === 'j' || key === 'arrowdown') return 'next'
if (key === 'k' || key === 'arrowup') return 'previous'
return ''
}

View File

@@ -0,0 +1,27 @@
import { createApprovalTaskRequestId } from '../../services/approvalTasks.js'
function normalizeFingerprintValue(value) {
if (Array.isArray(value)) return value.map(normalizeFingerprintValue)
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.keys(value).sort().map((key) => [key, normalizeFingerprintValue(value[key])])
)
}
return value ?? null
}
export function buildApprovalTaskRetryFingerprint(payload = {}) {
return JSON.stringify(normalizeFingerprintValue(payload))
}
export function resolveApprovalTaskRetryKey(options = {}) {
const fingerprint = buildApprovalTaskRetryFingerprint(options.payload)
const previousFingerprint = String(options.previousFingerprint || '')
const previousRequestId = String(options.previousRequestId || '')
return {
fingerprint,
requestId: previousRequestId && previousFingerprint === fingerprint
? previousRequestId
: createApprovalTaskRequestId(options.action, options.scopeId)
}
}

View File

@@ -0,0 +1,59 @@
export const APPROVAL_TASK_ROUTE_QUERY_KEYS = Object.freeze([
'dc_review_page',
'dc_review_page_size',
'dc_review_q',
'dc_review_risk',
'dc_review_sla'
])
const ALLOWED_PAGE_SIZES = new Set([10, 20])
const ALLOWED_RISK_LEVELS = new Set(['critical', 'high', 'medium', 'low'])
const ALLOWED_SLA_STATES = new Set(['due_soon', 'overdue', 'escalated'])
function readText(source, key) {
const value = source?.[key]
return String(Array.isArray(value) ? value[0] || '' : value || '').trim()
}
function positiveInteger(value, fallback) {
const number = Math.trunc(Number(value))
return Number.isFinite(number) && number > 0 ? number : fallback
}
export function normalizeApprovalTaskRouteState(state = {}) {
const filters = state.filters || {}
const riskLevel = String(filters.riskLevel || filters.risk_level || '').trim()
const slaState = String(filters.slaState || filters.sla_state || '').trim()
const pageSize = positiveInteger(state.pageSize || state.page_size, 20)
return {
page: positiveInteger(state.page, 1),
pageSize: ALLOWED_PAGE_SIZES.has(pageSize) ? pageSize : 20,
filters: {
keyword: String(filters.keyword || '').trim(),
riskLevel: ALLOWED_RISK_LEVELS.has(riskLevel) ? riskLevel : '',
slaState: ALLOWED_SLA_STATES.has(slaState) ? slaState : ''
}
}
}
export function readApprovalTaskRouteState(query = {}) {
return normalizeApprovalTaskRouteState({
page: readText(query, 'dc_review_page'),
pageSize: readText(query, 'dc_review_page_size'),
filters: {
keyword: readText(query, 'dc_review_q'),
riskLevel: readText(query, 'dc_review_risk'),
slaState: readText(query, 'dc_review_sla')
}
})
}
export function appendApprovalTaskRouteQuery(query = {}, state = {}) {
const normalized = normalizeApprovalTaskRouteState(state)
if (normalized.page > 1) query.dc_review_page = String(normalized.page)
if (normalized.pageSize !== 20) query.dc_review_page_size = String(normalized.pageSize)
if (normalized.filters.keyword) query.dc_review_q = normalized.filters.keyword
if (normalized.filters.riskLevel) query.dc_review_risk = normalized.filters.riskLevel
if (normalized.filters.slaState) query.dc_review_sla = normalized.filters.slaState
return query
}

View File

@@ -0,0 +1,48 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
compileScript,
compileStyle,
compileTemplate,
parse
} from '@vue/compiler-sfc'
const componentFiles = [
'../src/components/approval/ApprovalTaskQueue.vue',
'../src/components/approval/ApprovalTaskWorkspace.vue',
'../src/components/approval/ApprovalAssignmentDialog.vue',
'../src/components/approval/ApprovalParticipantsDialog.vue',
'../src/components/approval/ApprovalBatchResultPanel.vue'
]
for (const relativeFile of componentFiles) {
test(`${relativeFile} 可以独立编译`, () => {
const filename = fileURLToPath(new URL(relativeFile, import.meta.url))
const source = readFileSync(filename, 'utf8')
const parsed = parse(source, { filename })
assert.deepEqual(parsed.errors, [])
const id = `approval-${relativeFile.replace(/\W+/g, '-')}`
const script = compileScript(parsed.descriptor, { id })
const template = compileTemplate({
source: parsed.descriptor.template?.content || '',
filename,
id,
compilerOptions: { bindingMetadata: script.bindings }
})
assert.deepEqual(template.errors, [])
for (const style of parsed.descriptor.styles) {
const compiledStyle = compileStyle({
source: style.content,
filename,
id,
scoped: style.scoped
})
assert.deepEqual(compiledStyle.errors, [])
}
})
}

View File

@@ -0,0 +1,201 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { useApprovalTaskQueue } from '../src/composables/useApprovalTaskQueue.js'
function queueItem(id, overrides = {}) {
return {
task: {
id,
claimId: `claim-${id}`,
status: 'pending',
version: 1,
canAct: true,
availableActions: ['approve'],
batchEligible: true,
claimStatusSnapshot: 'submitted',
claimStageSnapshot: '直属领导审批',
...overrides
},
claim: { id: `claim-${id}`, claim_no: `BX-${id}` }
}
}
function listResult(items, page = 1) {
return {
items,
total: items.length,
page,
pageSize: 20,
totalPages: items.length ? 1 : 0,
generatedAt: '2026-07-16T10:00:00Z'
}
}
function deferred() {
let resolve
let reject
const promise = new Promise((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, reject, resolve }
}
test('列表请求代次阻止慢请求覆盖新页结果', async () => {
const requests = []
const queue = useApprovalTaskQueue({
services: {
fetchApprovalTasks(params) {
const request = deferred()
requests.push({ params, request })
return request.promise
}
}
})
const firstLoad = queue.loadQueue()
await queue.setPage(2, { reload: false })
const secondLoad = queue.loadQueue()
requests[1].request.resolve(listResult([queueItem('task-new')], 2))
await secondLoad
requests[0].request.resolve(listResult([queueItem('task-stale')], 1))
const staleResult = await firstLoad
assert.equal(requests[0].params.page, 1)
assert.equal(requests[1].params.page, 2)
assert.equal(staleResult.ignored, true)
assert.equal(queue.items.value[0].task.id, 'task-new')
assert.equal(queue.page.value, 2)
})
test('筛选变化清空选择并回到第一页', async () => {
const queue = useApprovalTaskQueue({
services: {
async fetchApprovalTasks() {
return listResult([queueItem('task-001')])
}
}
})
await queue.loadQueue()
queue.toggleTask(queue.items.value[0], true)
await queue.setPage(3, { reload: false })
assert.deepEqual(queue.selectedTaskIds.value, ['task-001'])
await queue.setFilters({ riskLevel: 'high' }, { reload: false })
assert.equal(queue.page.value, 1)
assert.equal(queue.filters.value.riskLevel, 'high')
assert.deepEqual(queue.selectedTaskIds.value, [])
})
test('批量部分成功保留失败选择并使用逐项任务快照', async () => {
const batchCalls = []
let listCallCount = 0
const rows = [queueItem('task-001'), queueItem('task-002')]
const queue = useApprovalTaskQueue({
services: {
async fetchApprovalTasks() {
listCallCount += 1
return listResult(rows)
},
async batchApproveApprovalTasks(payload) {
batchCalls.push(payload)
return {
batchRequestId: payload.batchRequestId,
status: 'partial',
succeededCount: 1,
replayedCount: 0,
conflictCount: 0,
blockedCount: 1,
forbiddenCount: 0,
failedCount: 0,
items: [
{ taskId: 'task-001', status: 'succeeded' },
{ taskId: 'task-002', status: 'blocked', message: '存在开放高风险' }
]
}
}
}
})
await queue.loadQueue()
queue.toggleCurrentPage(true)
const result = await queue.batchApprove({ opinion: '批量核对完成' })
assert.equal(listCallCount, 2)
assert.equal(batchCalls.length, 1)
assert.equal(batchCalls[0].items[0].task.id, 'task-001')
assert.equal(batchCalls[0].items[0].opinion, '批量核对完成')
assert.equal(result.status, 'partial')
assert.deepEqual(queue.selectedTaskIds.value, ['task-002'])
assert.equal(queue.lastBatchResult.value.items[1].status, 'blocked')
assert.equal(queue.pendingBatchRequestId.value, '')
})
test('单项刷新采用服务端最新权限并移除失效选择', async () => {
const queue = useApprovalTaskQueue({
services: {
async fetchApprovalTasks() {
return listResult([queueItem('task-001')])
},
async fetchApprovalTaskDetail() {
return queueItem('task-001', {
version: 2,
canAct: false,
availableActions: [],
batchEligible: false,
readOnlyReason: '任务已转交给其他审批人'
})
}
}
})
await queue.loadQueue()
queue.toggleTask(queue.items.value[0], true)
await queue.refreshTask('task-001')
assert.equal(queue.items.value[0].task.version, 2)
assert.equal(queue.items.value[0].task.readOnlyReason, '任务已转交给其他审批人')
assert.deepEqual(queue.selectedTaskIds.value, [])
})
test('批量选择在前端基础层限制为服务端允许的 20 项', async () => {
const rows = Array.from({ length: 21 }, (_, index) => queueItem(`task-${index + 1}`))
const queue = useApprovalTaskQueue({
services: {
async fetchApprovalTasks() {
return listResult(rows)
}
}
})
await queue.loadQueue()
queue.toggleCurrentPage(true)
assert.equal(queue.selectedTaskIds.value.length, 20)
assert.match(queue.errorMessage.value, /最多选择 20 项/)
})
test('批量失败按相同负载复用幂等键,编辑意见后生成新键', async () => {
const requestIds = []
const queue = useApprovalTaskQueue({
services: {
async fetchApprovalTasks() {
return listResult([queueItem('task-retry')])
},
async batchApproveApprovalTasks(payload) {
requestIds.push(payload.batchRequestId)
throw new Error('模拟网络中断')
}
}
})
await queue.loadQueue()
queue.toggleCurrentPage(true)
await assert.rejects(queue.batchApprove({ opinion: '核对通过' }), /模拟网络中断/)
await assert.rejects(queue.batchApprove({ opinion: '核对通过' }), /模拟网络中断/)
await assert.rejects(queue.batchApprove({ opinion: '补充核对后通过' }), /模拟网络中断/)
assert.equal(requestIds[1], requestIds[0])
assert.notEqual(requestIds[2], requestIds[0])
})

View File

@@ -0,0 +1,127 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
createApprovalTaskSelectionState,
reconcileApprovalTaskSelectionAfterBatch,
reconcileApprovalTaskSelectionWithPage,
resolveApprovalTaskSelectionMeta,
resolveCurrentPageApprovalSelection,
syncApprovalTaskSelectionFilters,
toggleApprovalTaskSelection,
toggleCurrentPageApprovalSelection
} from '../src/views/scripts/approvalTaskQueueState.js'
function task(id, overrides = {}) {
return {
id,
status: 'pending',
version: 1,
canAct: true,
availableActions: ['approve'],
batchEligible: true,
...overrides
}
}
test('任务可选性优先使用服务端权限和批量阻断原因', () => {
assert.deepEqual(resolveApprovalTaskSelectionMeta(task('task-001')), {
taskId: 'task-001',
selectable: true,
reason: ''
})
assert.equal(
resolveApprovalTaskSelectionMeta(task('task-002', {
canAct: false,
readOnlyReason: '该任务已委托给李审批'
})).reason,
'该任务已委托给李审批'
)
assert.equal(
resolveApprovalTaskSelectionMeta(task('task-003', {
batchEligible: false,
batchBlockReasons: ['存在开放高风险', '审批意见必须单独填写']
})).reason,
'存在开放高风险;审批意见必须单独填写'
)
assert.equal(
resolveApprovalTaskSelectionMeta(task('task-004', {
availableActions: ['delegate']
})).selectable,
false
)
})
test('当前页全选和半选只处理服务端允许批量审批的任务', () => {
const rows = [
task('task-001'),
task('task-002'),
task('task-003', { batchEligible: false, batchBlockReasons: ['需单独核对'] })
]
let selected = toggleApprovalTaskSelection([], rows[0], true)
let pageState = resolveCurrentPageApprovalSelection(rows, selected)
assert.deepEqual(selected, ['task-001'])
assert.equal(pageState.indeterminate, true)
assert.equal(pageState.allSelected, false)
selected = toggleCurrentPageApprovalSelection(selected, rows, true)
pageState = resolveCurrentPageApprovalSelection(rows, selected)
assert.deepEqual(selected, ['task-001', 'task-002'])
assert.equal(pageState.allSelected, true)
assert.equal(pageState.eligibleCount, 2)
selected = toggleCurrentPageApprovalSelection(selected, rows, false)
assert.deepEqual(selected, [])
})
test('刷新后任务失去权限时从当前页选择中移除', () => {
const selected = ['off-page-task', 'task-001', 'task-002']
const rows = [
task('task-001'),
task('task-002', { canAct: false, readOnlyReason: '任务已转交' })
]
assert.deepEqual(
reconcileApprovalTaskSelectionWithPage(selected, rows),
['off-page-task', 'task-001']
)
})
test('筛选变化清空选择但翻页和每页条数变化不清空', () => {
let state = createApprovalTaskSelectionState({
selectedTaskIds: ['task-001', 'task-002'],
filters: { status: 'pending', riskLevel: 'high', page: 1, pageSize: 20 }
})
state = syncApprovalTaskSelectionFilters(state, {
status: 'pending',
riskLevel: 'high',
page: 2,
pageSize: 50
})
assert.deepEqual(state.selectedTaskIds, ['task-001', 'task-002'])
state = syncApprovalTaskSelectionFilters(state, {
status: 'pending',
riskLevel: 'medium',
page: 1,
pageSize: 20
})
assert.deepEqual(state.selectedTaskIds, [])
})
test('批量部分成功只移除成功和幂等重放项,失败项继续保留', () => {
const selected = ['task-001', 'task-002', 'task-003', 'task-004', 'task-005', 'task-unreported']
const result = {
status: 'partial',
items: [
{ taskId: 'task-001', status: 'succeeded' },
{ taskId: 'task-002', status: 'replayed' },
{ taskId: 'task-003', status: 'blocked' },
{ taskId: 'task-004', status: 'conflict' },
{ taskId: 'task-005', status: 'forbidden' }
]
}
assert.deepEqual(
reconcileApprovalTaskSelectionAfterBatch(selected, result),
['task-003', 'task-004', 'task-005', 'task-unreported']
)
})

View File

@@ -0,0 +1,89 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
resolveApprovalTaskActionItems,
resolveApprovalTaskKeyboardCommand,
resolveApprovalTaskRow,
resolveApprovalTaskSlaMeta
} from '../src/views/scripts/approvalTaskQueueViewModel.js'
function task(overrides = {}) {
return {
id: 'task-001',
claimId: 'claim-001',
nodeLabel: '直属领导审批',
canAct: true,
availableActions: ['approve', 'delegate', 'unknown_action'],
riskLevel: 'high',
openRiskCount: 2,
evidenceCompleteness: 0.75,
dueAt: '2026-07-16T12:00:00Z',
escalationLevel: 1,
nextEscalationAt: '2026-07-16T13:00:00Z',
...overrides
}
}
test('动作列表只展示服务端授权且前端认识的动作', () => {
assert.deepEqual(
resolveApprovalTaskActionItems(task()).map((item) => item.action),
['approve', 'delegate']
)
assert.deepEqual(
resolveApprovalTaskActionItems(task({
canAct: false,
availableActions: ['approve', 'return', 'transfer', 'sla_escalate'],
readOnlyReason: '管理员只能管理任务'
})).map((item) => item.action),
['transfer', 'sla_escalate']
)
})
test('SLA 展示使用服务端截止时间和升级等级', () => {
const nearDue = resolveApprovalTaskSlaMeta(task(), Date.parse('2026-07-16T11:00:00Z'))
assert.equal(nearDue.label, '剩余 1.0h')
assert.equal(nearDue.tone, 'warning')
assert.equal(nearDue.escalationLabel, 'L1')
assert.match(nearDue.title, /下次升级2026-07-16T13:00:00Z/)
const overdue = resolveApprovalTaskSlaMeta(task(), Date.parse('2026-07-16T14:00:00Z'))
assert.equal(overdue.label, '超时 2.0h')
assert.equal(overdue.overdue, true)
assert.equal(overdue.tone, 'danger')
})
test('任务行组合风险、证据、单据和只读原因', () => {
const row = resolveApprovalTaskRow({
task: task({
canAct: false,
availableActions: [],
readOnlyReason: '仅原审批人可操作'
}),
claim: {
claim_no: 'BX-2026-001',
employee_name: '张三',
department_name: '销售部',
amount: 1280.5
}
}, Date.parse('2026-07-16T11:00:00Z'))
assert.equal(row.claimNo, 'BX-2026-001')
assert.equal(row.applicant, '张三')
assert.equal(row.riskLabel, '高风险')
assert.equal(row.evidenceLabel, '75%')
assert.equal(row.readOnlyReason, '仅原审批人可操作')
assert.deepEqual(row.actions, [])
})
test('键盘模型支持 Enter、Space、J/K并在输入控件聚焦时禁用', () => {
const rowTarget = { tagName: 'TR', isContentEditable: false }
assert.equal(resolveApprovalTaskKeyboardCommand({ key: 'Enter', target: rowTarget }), 'open')
assert.equal(resolveApprovalTaskKeyboardCommand({ key: ' ', target: rowTarget }), 'toggle')
assert.equal(resolveApprovalTaskKeyboardCommand({ key: 'j', target: rowTarget }), 'next')
assert.equal(resolveApprovalTaskKeyboardCommand({ key: 'K', target: rowTarget }), 'previous')
assert.equal(
resolveApprovalTaskKeyboardCommand({ key: 'j', target: { tagName: 'INPUT' } }),
''
)
})

View File

@@ -0,0 +1,67 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
resolveApprovalTaskRetryKey
} from '../src/views/scripts/approvalTaskRetry.js'
import {
appendApprovalTaskRouteQuery,
readApprovalTaskRouteState
} from '../src/views/scripts/approvalTaskRouteState.js'
test('approval retry key is stable only while the idempotent payload stays identical', () => {
const first = resolveApprovalTaskRetryKey({
action: 'approve',
scopeId: 'task-1',
payload: { reason: '核对通过', expectedTaskVersion: 3 }
})
const samePayload = resolveApprovalTaskRetryKey({
action: 'approve',
scopeId: 'task-1',
payload: { expectedTaskVersion: 3, reason: '核对通过' },
previousFingerprint: first.fingerprint,
previousRequestId: first.requestId
})
const editedPayload = resolveApprovalTaskRetryKey({
action: 'approve',
scopeId: 'task-1',
payload: { expectedTaskVersion: 3, reason: '补充核对后通过' },
previousFingerprint: first.fingerprint,
previousRequestId: first.requestId
})
assert.equal(samePayload.requestId, first.requestId)
assert.notEqual(editedPayload.requestId, first.requestId)
})
test('approval task route state restores only supported filters and compact defaults', () => {
const state = readApprovalTaskRouteState({
dc_review_page: '3',
dc_review_page_size: '10',
dc_review_q: ' REIM-100 ',
dc_review_risk: 'high',
dc_review_sla: 'overdue'
})
assert.deepEqual(state, {
page: 3,
pageSize: 10,
filters: { keyword: 'REIM-100', riskLevel: 'high', slaState: 'overdue' }
})
assert.deepEqual(appendApprovalTaskRouteQuery({}, state), {
dc_review_page: '3',
dc_review_page_size: '10',
dc_review_q: 'REIM-100',
dc_review_risk: 'high',
dc_review_sla: 'overdue'
})
assert.deepEqual(readApprovalTaskRouteState({
dc_review_page: '-1',
dc_review_page_size: '200',
dc_review_risk: 'unknown',
dc_review_sla: 'near_due'
}), {
page: 1,
pageSize: 20,
filters: { keyword: '', riskLevel: '', slaState: '' }
})
})

View File

@@ -0,0 +1,44 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readSourceFile } from './helpers/sourceSurface.mjs'
const workspace = readSourceFile('components/approval/ApprovalTaskWorkspace.vue')
const documentsCenter = readSourceFile('views/DocumentsCenterView.vue')
const appShell = readSourceFile('views/AppShellRouteView.vue')
test('approval task workspace wires every server-authorized action to a real mutation', () => {
assert.match(workspace, /approveApprovalTask/)
assert.match(workspace, /returnApprovalTask/)
assert.match(workspace, /escalateApprovalTask/)
assert.match(workspace, /<ApprovalAssignmentDialog/)
assert.match(workspace, /<ApprovalParticipantsDialog/)
assert.match(workspace, /<ReturnReasonDialog/)
assert.doesNotMatch(workspace, /已批量通过 23 条|mock/i)
})
test('approval task decisions keep one request id across retries and enforce task version', () => {
assert.match(workspace, /resolveApprovalTaskRetryKey/)
assert.match(workspace, /previousFingerprint: requestFingerprint\.value/)
assert.match(workspace, /previousRequestId: requestId\.value/)
assert.match(workspace, /payload\.requestId = retryKey\.requestId/)
assert.match(workspace, /expectedTaskVersion: Number\(activeTask\.value\.version\)/)
assert.match(workspace, /mutation\?\.replayed[\s\S]*结果已安全重放/)
assert.match(workspace, /await queueRef\.value\?\.reload\?\.\(\)/)
})
test('documents review tab owns the task workspace and propagates updates to the app shell', () => {
assert.match(documentsCenter, /<ApprovalTaskWorkspace[\s\S]*v-show="activeScopeTab === DOCUMENT_SCOPE_REVIEW"/)
assert.match(documentsCenter, /@request-updated="emit\('request-updated', \$event\)"/)
assert.match(documentsCenter, /@loaded="handleApprovalTasksLoaded"/)
assert.doesNotMatch(documentsCenter, /fetchApprovalExpenseClaims|approvalRows/)
assert.match(appShell, /<DocumentsCenterView[\s\S]*@request-updated="handleRequestUpdated"/)
})
test('workspace separates the unfiltered pending summary from queue filters and refreshes batches', () => {
assert.match(workspace, /service\(\{ status: 'pending', page: 1, pageSize: 1 \}\)/)
assert.match(workspace, /@loaded="handleQueueLoaded"/)
assert.match(workspace, /void loadTaskSummary\(\)[\s\S]*emit\('batch-result', result\)/)
assert.match(workspace, /emit\('request-updated', \{ claim: claims\[0\], claimId: claimIds\[0\], claimIds \}\)/)
assert.match(workspace, /const workspaceError = computed\(\(\) => queueError\.value \|\| summaryError\.value\)/)
})

View File

@@ -0,0 +1,233 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
addSignApprovalTask,
approveApprovalTask,
batchApproveApprovalTasks,
countersignApprovalTask,
delegateApprovalTask,
escalateApprovalTask,
fetchApprovalTaskCandidates,
fetchApprovalTaskDetail,
fetchApprovalTasks,
returnApprovalTask,
revokeApprovalTaskDelegation,
transferApprovalTask
} from '../src/services/approvalTasks.js'
function jsonResponse(payload) {
return {
ok: true,
status: 200,
async json() {
return payload
}
}
}
function pendingTask(overrides = {}) {
return {
id: 'task-001',
claim_id: 'claim-001',
status: 'pending',
version: 3,
can_act: true,
available_actions: ['approve', 'delegate'],
batch_eligible: true,
claim_status_snapshot: 'submitted',
claim_stage_snapshot: '直属领导审批',
priority_reasons_json: [{ code: 'sla_near_due', label: '即将超时', weight: 16 }],
...overrides
}
}
test('审批任务列表发送服务端分页筛选参数并规范化任务', async () => {
let capturedUrl = ''
global.fetch = async (url) => {
capturedUrl = String(url)
return jsonResponse({
items: [{ task: pendingTask(), claim: { id: 'claim-001', claim_no: 'BX-001' } }],
total: 41,
page: 2,
page_size: 25,
total_pages: 2,
generated_at: '2026-07-16T10:00:00Z'
})
}
const result = await fetchApprovalTasks({
page: 2,
pageSize: 25,
status: 'pending',
riskLevel: 'high',
slaState: 'overdue',
nodeKey: 'direct_manager',
keyword: '张 三',
batchEligible: true,
sort: 'priority_desc'
})
const url = new URL(capturedUrl, 'http://local.test')
assert.equal(url.pathname, '/api/v1/approval-tasks')
assert.equal(url.searchParams.get('page'), '2')
assert.equal(url.searchParams.get('page_size'), '25')
assert.equal(url.searchParams.get('status'), 'pending')
assert.equal(url.searchParams.get('risk_level'), 'high')
assert.equal(url.searchParams.get('sla_state'), 'overdue')
assert.equal(url.searchParams.get('node_key'), 'direct_manager')
assert.equal(url.searchParams.get('keyword'), '张 三')
assert.equal(url.searchParams.get('batch_eligible'), 'true')
assert.equal(url.searchParams.get('sort'), 'priority_desc')
assert.equal(result.total, 41)
assert.equal(result.pageSize, 25)
assert.equal(result.items[0].task.claimId, 'claim-001')
assert.equal(result.items[0].task.priorityReasons[0].code, 'sla_near_due')
assert.equal(result.items[0].claim.claim_no, 'BX-001')
})
test('审批任务详情和候选人使用任务作用域接口', async () => {
const calls = []
global.fetch = async (url) => {
calls.push(String(url))
if (String(url).includes('/candidates?')) {
return jsonResponse({
items: [{
employee_id: 'employee-002',
employee_no: 'E002',
name: '李审批',
email: 'li@example.com',
qualified: false,
reason: '不属于当前部门'
}],
total: 1
})
}
return jsonResponse({ task: pendingTask(), claim: { id: 'claim-001' } })
}
const detail = await fetchApprovalTaskDetail('task/001')
const candidates = await fetchApprovalTaskCandidates('task/001', {
action: 'delegate',
keyword: '李 审批',
limit: 30
})
assert.equal(calls[0], '/api/v1/approval-tasks/task%2F001')
const candidateUrl = new URL(calls[1], 'http://local.test')
assert.equal(candidateUrl.pathname, '/api/v1/approval-tasks/task%2F001/candidates')
assert.equal(candidateUrl.searchParams.get('action'), 'delegate')
assert.equal(candidateUrl.searchParams.get('keyword'), '李 审批')
assert.equal(candidateUrl.searchParams.get('limit'), '30')
assert.equal(detail.task.id, 'task-001')
assert.equal(candidates.items[0].qualified, false)
assert.equal(candidates.items[0].reason, '不属于当前部门')
})
test('所有审批动作携带请求幂等键和任务版本', async () => {
const calls = []
global.fetch = async (url, options) => {
calls.push({ url: String(url), body: JSON.parse(options.body) })
return jsonResponse({ task: pendingTask({ version: 4 }), related_tasks: [], replayed: false })
}
const base = {
requestId: 'request-action-001',
expectedTaskVersion: 3,
reason: '业务审批处理'
}
await delegateApprovalTask('task-001', {
...base,
targetEmployeeId: 'employee-002',
expiresAt: '2026-07-18T10:00:00Z'
})
await revokeApprovalTaskDelegation('task-001', base)
await transferApprovalTask('task-001', { ...base, targetEmployeeId: 'employee-003' })
await addSignApprovalTask('task-001', {
...base,
participantEmployeeIds: ['employee-004', 'employee-005', 'employee-004']
})
await countersignApprovalTask('task-001', {
...base,
participantEmployeeIds: ['employee-006', 'employee-007']
})
await escalateApprovalTask('task-001', base)
await approveApprovalTask('task-001', { ...base, opinion: '同意通过' })
await returnApprovalTask('task-001', {
...base,
reasonCodes: ['missing_invoice', 'missing_invoice', 'amount_mismatch']
})
assert.deepEqual(calls.map((call) => call.url), [
'/api/v1/approval-tasks/task-001/delegate',
'/api/v1/approval-tasks/task-001/delegation/revoke',
'/api/v1/approval-tasks/task-001/transfer',
'/api/v1/approval-tasks/task-001/add-sign',
'/api/v1/approval-tasks/task-001/countersign',
'/api/v1/approval-tasks/task-001/escalate',
'/api/v1/approval-tasks/task-001/approve',
'/api/v1/approval-tasks/task-001/return'
])
calls.forEach((call) => {
assert.equal(call.body.request_id, 'request-action-001')
assert.equal(call.body.expected_task_version, 3)
assert.equal(call.body.reason, '业务审批处理')
})
assert.equal(calls[0].body.target_employee_id, 'employee-002')
assert.equal(calls[0].body.expires_at, '2026-07-18T10:00:00Z')
assert.deepEqual(calls[3].body.participant_employee_ids, ['employee-004', 'employee-005'])
assert.equal(calls[6].body.opinion, '同意通过')
assert.deepEqual(calls[7].body.reason_codes, ['missing_invoice', 'amount_mismatch'])
})
test('批量审批发送逐项快照并规范化部分成功结果', async () => {
let capturedUrl = ''
let capturedBody = null
global.fetch = async (url, options) => {
capturedUrl = String(url)
capturedBody = JSON.parse(options.body)
return jsonResponse({
batch_request_id: 'batch-request-001',
status: 'partial',
succeeded_count: 1,
blocked_count: 1,
conflict_count: 1,
items: [
{ task_id: 'task-001', claim_id: 'claim-001', status: 'succeeded', code: 'OK', message: '已通过' },
{ task_id: 'task-002', claim_id: 'claim-002', status: 'blocked', code: 'OPEN_RISK', message: '存在开放高风险' },
{ task_id: 'task-003', claim_id: 'claim-003', status: 'conflict', code: 'STALE_TASK', message: '任务已更新' }
]
})
}
const result = await batchApproveApprovalTasks({
batchRequestId: 'batch-request-001',
items: [
pendingTask({ id: 'task-001' }),
pendingTask({ id: 'task-002', version: 5 }),
{
taskId: 'task-003',
expectedTaskVersion: 2,
expectedStatus: 'submitted',
expectedApprovalStage: '财务审批',
opinion: '票据已核对'
}
]
})
assert.equal(capturedUrl, '/api/v1/approval-tasks/batch-approve')
assert.equal(capturedBody.batch_request_id, 'batch-request-001')
assert.deepEqual(capturedBody.items[0], {
task_id: 'task-001',
expected_task_version: 3,
expected_status: 'submitted',
expected_approval_stage: '直属领导审批'
})
assert.equal(capturedBody.items[1].expected_task_version, 5)
assert.equal(capturedBody.items[2].opinion, '票据已核对')
assert.equal(result.status, 'partial')
assert.equal(result.succeededCount, 1)
assert.equal(result.blockedCount, 1)
assert.equal(result.conflictCount, 1)
assert.equal(result.items[1].code, 'OPEN_RISK')
})

View File

@@ -14,6 +14,9 @@ const documentListSharedStyles = readSourceFile('assets/styles/components/docume
const tableLoadingState = readSourceFile('components/shared/TableLoadingState.vue') const tableLoadingState = readSourceFile('components/shared/TableLoadingState.vue')
const reimbursementService = readSourceFile('services/reimbursements.js') const reimbursementService = readSourceFile('services/reimbursements.js')
const requestsComposable = readSourceFile('composables/useRequests.js') const requestsComposable = readSourceFile('composables/useRequests.js')
const approvalTaskQueue = readSourceFile('components/approval/ApprovalTaskQueue.vue')
const approvalTaskService = readSourceFile('services/approvalTasks.js')
const archiveRowsComposable = readSourceFile('composables/useDocumentCenterArchiveRows.js')
test('documents center keeps only the top scope tabs and renders risk level as a dropdown filter', () => { test('documents center keeps only the top scope tabs and renders risk level as a dropdown filter', () => {
assert.match(documentsCenterView, /<nav class="status-tabs document-scope-tabs"/) assert.match(documentsCenterView, /<nav class="status-tabs document-scope-tabs"/)
@@ -69,14 +72,17 @@ test('documents center persists pagination and filters in route query for detail
) )
assert.match( assert.match(
documentsCenterView, documentsCenterView,
/watch\(\s*\[currentPage, pageSize, activeScopeTab, activeStatusTab, activeDocumentType, activeScene, listKeyword, appliedStart, appliedEnd\],[\s\S]*router\.replace\(\{ name: 'app-documents', query: nextQuery \}\)/ /watch\(\s*\[currentPage, pageSize, activeScopeTab, activeStatusTab, activeDocumentType, activeScene, listKeyword, appliedStart, appliedEnd, approvalTaskRouteState\],[\s\S]*router\.replace\(\{ name: 'app-documents', query: nextQuery \}\)/
) )
assert.match(documentsCenterView, /readApprovalTaskRouteState\(route\.query\)/)
assert.match(documentsCenterView, /appendApprovalTaskRouteQuery\(nextQuery, approvalTaskRouteState\.value\)/)
}) })
test('documents center category tabs map to the intended row sources', () => { test('documents center keeps owned rows separate and delegates review scope to approval tasks', () => {
assert.match(documentsCenterView, /excludeArchivedDocumentRows/) assert.match(documentsCenterView, /excludeArchivedDocumentRows/)
assert.match(documentsCenterView, /approvalRows\.value = excludeArchivedDocumentRows/) assert.match(documentsCenterView, /const nonArchivedRows = computed\(\(\) => mergeDocumentRows\(ownedRows\.value\)\)/)
assert.match(documentsCenterView, /const nonArchivedRows = computed\(\(\) => mergeDocumentRows\(\[\.\.\.ownedRows\.value, \.\.\.approvalRows\.value\]\)\)/) assert.match(documentsCenterView, /<ApprovalTaskWorkspace[\s\S]*v-show="activeScopeTab === DOCUMENT_SCOPE_REVIEW"/)
assert.doesNotMatch(documentsCenterView, /fetchApprovalExpenseClaims|approvalRows/)
assert.match(documentsCenterLogic, /import \{ sortDocumentRowsByLatestTime \} from '\.\/documentCenterSort\.js'/) assert.match(documentsCenterLogic, /import \{ sortDocumentRowsByLatestTime \} from '\.\/documentCenterSort\.js'/)
assert.match(documentsCenterLogic, /activeScopeTab !== DOCUMENT_SCOPE_ARCHIVE && isArchivedDocumentRow\(row\)/) assert.match(documentsCenterLogic, /activeScopeTab !== DOCUMENT_SCOPE_ARCHIVE && isArchivedDocumentRow\(row\)/)
assert.match( assert.match(
@@ -91,10 +97,6 @@ test('documents center category tabs map to the intended row sources', () => {
documentsCenterView, documentsCenterView,
/activeScopeTab\.value === DOCUMENT_SCOPE_REIMBURSEMENT[\s\S]*ownedRows\.value\.filter/ /activeScopeTab\.value === DOCUMENT_SCOPE_REIMBURSEMENT[\s\S]*ownedRows\.value\.filter/
) )
assert.match(
documentsCenterView,
/activeScopeTab\.value === DOCUMENT_SCOPE_REVIEW[\s\S]*return approvalRows\.value/
)
assert.match( assert.match(
documentsCenterView, documentsCenterView,
/activeScopeTab\.value === DOCUMENT_SCOPE_ARCHIVE[\s\S]*return archiveRows\.value/ /activeScopeTab\.value === DOCUMENT_SCOPE_ARCHIVE[\s\S]*return archiveRows\.value/
@@ -134,8 +136,9 @@ test('documents center preserves application document type from mapped requests'
) )
}) })
test('documents center refresh token reloads supporting approval and archive rows', () => { test('documents center refresh token reloads the task workspace and archive rows', () => {
assert.match(documentsCenterView, /refreshToken:\s*\{\s*type:\s*Number,\s*default:\s*0\s*\}/) assert.match(documentsCenterView, /refreshToken:\s*\{\s*type:\s*Number,\s*default:\s*0\s*\}/)
assert.match(documentsCenterView, /<ApprovalTaskWorkspace[\s\S]*:refresh-token="refreshToken"/)
assert.match( assert.match(
documentsCenterView, documentsCenterView,
/watch\(\s*\(\) => props\.refreshToken,[\s\S]*if \(token && token !== previousToken\) \{[\s\S]*void loadSupportingRows\(\)/ /watch\(\s*\(\) => props\.refreshToken,[\s\S]*if \(token && token !== previousToken\) \{[\s\S]*void loadSupportingRows\(\)/
@@ -143,15 +146,16 @@ test('documents center refresh token reloads supporting approval and archive row
assert.match(documentsCenterView, /function reloadAll\(\) \{[\s\S]*emit\('reload'\)[\s\S]*void loadSupportingRows\(\)/) assert.match(documentsCenterView, /function reloadAll\(\) \{[\s\S]*emit\('reload'\)[\s\S]*void loadSupportingRows\(\)/)
}) })
test('documents center fetches every paginated claim page for admin-scale lists', () => { test('documents center review scope uses server pagination while archive stays a bounded preview', () => {
assert.match(reimbursementService, /export function fetchAllExpenseClaims/) assert.match(reimbursementService, /export function fetchAllExpenseClaims/)
assert.match(reimbursementService, /async function fetchAllExpenseClaimPages/) assert.match(reimbursementService, /async function fetchAllExpenseClaimPages/)
assert.match(reimbursementService, /payload\.has_next/) assert.match(reimbursementService, /payload\.has_next/)
assert.match(requestsComposable, /import \{ fetchAllExpenseClaims \} from '\.\.\/services\/reimbursements\.js'/) assert.match(approvalTaskService, /search\.set\('page'/)
assert.match(requestsComposable, /const payload = await fetchAllExpenseClaims\(\)/) assert.match(approvalTaskService, /search\.set\([\s\S]*'page_size'/)
assert.match(documentsCenterView, /fetchAllApprovalExpenseClaims/) assert.match(approvalTaskQueue, /<EnterprisePagination/)
assert.match(documentsCenterView, /fetchAllArchivedExpenseClaims/) assert.match(archiveRowsComposable, /fetchArchivedExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.doesNotMatch(documentsCenterView, /REIMBURSEMENT_LIST_PREVIEW_PARAMS/) assert.doesNotMatch(documentsCenterView, /fetchApprovalExpenseClaims/)
assert.match(requestsComposable, /fetchExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
}) })
test('documents center list shows created time and conditional stay time columns', () => { test('documents center list shows created time and conditional stay time columns', () => {
@@ -164,10 +168,7 @@ test('documents center list shows created time and conditional stay time columns
assert.match(documentsCenterView, /<td data-label="创建时间">\{\{ row\.createdAtDisplay \}\}<\/td>/) assert.match(documentsCenterView, /<td data-label="创建时间">\{\{ row\.createdAtDisplay \}\}<\/td>/)
assert.match(documentsCenterView, /<td v-if="showStayTimeColumn" data-label="停留时间">\{\{ row\.stayTimeDisplay \}\}<\/td>/) assert.match(documentsCenterView, /<td v-if="showStayTimeColumn" data-label="停留时间">\{\{ row\.stayTimeDisplay \}\}<\/td>/)
assert.match(documentsCenterView, /<td data-label="发起人">\{\{ row\.initiatorName \}\}<\/td>/) assert.match(documentsCenterView, /<td data-label="发起人">\{\{ row\.initiatorName \}\}<\/td>/)
assert.match( assert.match(documentsCenterView, /const showStayTimeColumn = computed\(\(\) => activeScopeTab\.value === DOCUMENT_SCOPE_APPLICATION\)/)
documentsCenterView,
/const showStayTimeColumn = computed\(\(\) =>[\s\S]*DOCUMENT_SCOPE_APPLICATION[\s\S]*DOCUMENT_SCOPE_REVIEW/
)
assert.match(documentsCenterLogic, /createdAtDisplay: formatDocumentListTime\(createdAtSource\)/) assert.match(documentsCenterLogic, /createdAtDisplay: formatDocumentListTime\(createdAtSource\)/)
assert.match(documentsCenterLogic, /stayTimeDisplay: resolveDocumentStayTimeDisplay\(normalized\)/) assert.match(documentsCenterLogic, /stayTimeDisplay: resolveDocumentStayTimeDisplay\(normalized\)/)
assert.match(documentsCenterLogic, /initiatorName,/) assert.match(documentsCenterLogic, /initiatorName,/)
@@ -212,7 +213,8 @@ test('documents center category tabs render bubble counts for new documents', ()
documentsCenterView, documentsCenterView,
/\[DOCUMENT_SCOPE_REIMBURSEMENT\]: countNewDocuments\(ownedRows\.value\.filter\(\(row\) => row\.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT\), viewedDocumentKeys\.value\)/ /\[DOCUMENT_SCOPE_REIMBURSEMENT\]: countNewDocuments\(ownedRows\.value\.filter\(\(row\) => row\.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT\), viewedDocumentKeys\.value\)/
) )
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_REVIEW\]: countNewDocuments\(approvalRows\.value, viewedDocumentKeys\.value\)/) assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_REVIEW\]: approvalTaskTotal\.value/)
assert.match(documentsCenterView, /badgeLabel: tab === DOCUMENT_SCOPE_REVIEW \? '待处理审批任务数' : '新增单据数'/)
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_ARCHIVE\]: countNewDocuments\(archiveRows\.value, viewedDocumentKeys\.value\)/) assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_ARCHIVE\]: countNewDocuments\(archiveRows\.value, viewedDocumentKeys\.value\)/)
assert.match( assert.match(
documentsCenterView, documentsCenterView,
@@ -233,8 +235,9 @@ test('documents center can mark all unread documents as read from toolbar', () =
assert.match(documentsCenterView, /mergeNotificationStatesIntoViewedDocumentKeys/) assert.match(documentsCenterView, /mergeNotificationStatesIntoViewedDocumentKeys/)
assert.match( assert.match(
documentsCenterView, documentsCenterView,
/const allReadableDocumentRows = computed\(\(\) => \[[\s\S]*nonArchivedRows\.value[\s\S]*filterApplicationScopeNewRows\(applicationScopeRows\.value\)[\s\S]*approvalRows\.value/ /const allReadableDocumentRows = computed\(\(\) => \[[\s\S]*nonArchivedRows\.value[\s\S]*filterApplicationScopeNewRows\(applicationScopeRows\.value\)[\s\S]*DOCUMENT_TYPE_REIMBURSEMENT/
) )
assert.doesNotMatch(documentsCenterView, /approvalRows/)
assert.match(documentsCenterView, /const totalNewDocumentCount = computed\(\(\) => countNewDocuments\(allReadableDocumentRows\.value, viewedDocumentKeys\.value\)\)/) assert.match(documentsCenterView, /const totalNewDocumentCount = computed\(\(\) => countNewDocuments\(allReadableDocumentRows\.value, viewedDocumentKeys\.value\)\)/)
assert.match(documentsCenterView, /const showToolbarActions = computed\(\(\) => showCreateDocumentActions\.value \|\| totalNewDocumentCount\.value > 0\)/) assert.match(documentsCenterView, /const showToolbarActions = computed\(\(\) => showCreateDocumentActions\.value \|\| totalNewDocumentCount\.value > 0\)/)
assert.match( assert.match(

View File

@@ -1,18 +1,17 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import test from 'node:test' import test from 'node:test'
import { fileURLToPath } from 'node:url'
const root = process.cwd()
function readProjectFile(path) { function readProjectFile(path) {
return readFileSync(join(root, path), 'utf8') return readFileSync(fileURLToPath(new URL(`../${path.replace(/^web\//, '')}`, import.meta.url)), 'utf8')
} }
test('workbench and document list refreshes use preview pagination', () => { test('claim previews stay bounded while document review uses the approval task workspace', () => {
const useRequests = readProjectFile('web/src/composables/useRequests.js') const useRequests = readProjectFile('web/src/composables/useRequests.js')
const useAppShell = readProjectFile('web/src/composables/useAppShell.js') const useAppShell = readProjectFile('web/src/composables/useAppShell.js')
const documentsCenter = readProjectFile('web/src/views/DocumentsCenterView.vue') const documentsCenter = readProjectFile('web/src/views/DocumentsCenterView.vue')
const archiveRowsComposable = readProjectFile('web/src/composables/useDocumentCenterArchiveRows.js')
const approvalCenter = readProjectFile('web/src/views/scripts/ApprovalCenterView.js') const approvalCenter = readProjectFile('web/src/views/scripts/ApprovalCenterView.js')
const archiveCenter = readProjectFile('web/src/views/scripts/ArchiveCenterView.js') const archiveCenter = readProjectFile('web/src/views/scripts/ArchiveCenterView.js')
@@ -24,8 +23,9 @@ test('workbench and document list refreshes use preview pagination', () => {
assert.match(useAppShell, /fetchApprovalExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/) assert.match(useAppShell, /fetchApprovalExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.doesNotMatch(useAppShell, /fetchAllApprovalExpenseClaims\(\)/) assert.doesNotMatch(useAppShell, /fetchAllApprovalExpenseClaims\(\)/)
assert.match(documentsCenter, /fetchApprovalExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/) assert.match(archiveRowsComposable, /fetchArchivedExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.match(documentsCenter, /fetchArchivedExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/) assert.match(documentsCenter, /<ApprovalTaskWorkspace/)
assert.doesNotMatch(documentsCenter, /fetchApprovalExpenseClaims/)
assert.doesNotMatch(documentsCenter, /fetchAllApprovalExpenseClaims\(\)/) assert.doesNotMatch(documentsCenter, /fetchAllApprovalExpenseClaims\(\)/)
assert.doesNotMatch(documentsCenter, /fetchAllArchivedExpenseClaims\(\)/) assert.doesNotMatch(documentsCenter, /fetchAllArchivedExpenseClaims\(\)/)

View File

@@ -28,6 +28,8 @@ test('risk observation normalizes persisted json evidence and typed disposition'
adjudication: 'confirmed', adjudication: 'confirmed',
lifecycle_status: 'remediation_in_progress', lifecycle_status: 'remediation_in_progress',
version: 2, version: 2,
available_actions: ['request_waiver'],
read_only_reason: null,
events: [{ id: 'event-1', action: 'confirm', version: 1 }] events: [{ id: 'event-1', action: 'confirm', version: 1 }]
} }
}) })
@@ -37,6 +39,8 @@ test('risk observation normalizes persisted json evidence and typed disposition'
assert.deepEqual(observation.graphNodeKeys, ['claim:1']) assert.deepEqual(observation.graphNodeKeys, ['claim:1'])
assert.equal(observation.decisionTrace.action, 'manual_review') assert.equal(observation.decisionTrace.action, 'manual_review')
assert.equal(observation.disposition.lifecycleStatus, 'remediation_in_progress') assert.equal(observation.disposition.lifecycleStatus, 'remediation_in_progress')
assert.deepEqual(observation.disposition.availableActions, ['request_waiver'])
assert.equal(observation.disposition.readOnlyReason, '')
assert.equal(observation.disposition.events[0].action, 'confirm') assert.equal(observation.disposition.events[0].action, 'confirm')
}) })
@@ -56,8 +60,9 @@ test('risk evidence card exposes typed actions and never offers direct approval'
assert.match(evidenceCard, /确认已解决/) assert.match(evidenceCard, /确认已解决/)
assert.match(evidenceCard, /expectedVersion: currentDisposition\.value\.version/) assert.match(evidenceCard, /expectedVersion: currentDisposition\.value\.version/)
assert.doesNotMatch(evidenceCard, /自动审批|直接通过|batchApprove/) assert.doesNotMatch(evidenceCard, /自动审批|直接通过|batchApprove/)
assert.match(evidenceCard, /RISK_DISPOSITION_VERSION_CONFLICT/) assert.match(evidenceCard, /normalizeRiskDispositionError/)
assert.match(evidenceCard, /lifecycle !== 'supplement_requested'/) assert.match(evidenceCard, /currentDisposition\.value\.availableActions/)
assert.doesNotMatch(evidenceCard, /roleCodes|role_codes|isAdmin|is_admin/)
}) })
test('risk action request ids bind action and observation', () => { test('risk action request ids bind action and observation', () => {

View File

@@ -0,0 +1,215 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
executeRiskDispositionAction,
normalizeRiskDisposition,
normalizeRiskDispositionError,
normalizeRiskObservation
} from '../src/services/riskObservations.js'
const evidenceCard = readFileSync(
fileURLToPath(new URL('../src/components/travel/RiskObservationEvidenceCard.vue', import.meta.url)),
'utf8'
)
const waiverDialog = readFileSync(
fileURLToPath(new URL('../src/components/travel/RiskWaiverActionDialog.vue', import.meta.url)),
'utf8'
)
const waiverRecord = readFileSync(
fileURLToPath(new URL('../src/components/travel/RiskWaiverRecord.vue', import.meta.url)),
'utf8'
)
function jsonResponse(payload) {
return {
ok: true,
status: 200,
async json() {
return payload
}
}
}
test('risk disposition normalizes server-authorized actions and complete waiver audit fields', () => {
const disposition = normalizeRiskDisposition({
id: 'disposition-1',
observation_id: 'risk-1',
lifecycle_status: 'waived',
version: 3,
available_actions: ['approve_waiver', 'reject_waiver', 'approve_waiver'],
read_only_reason: null,
waiver_requester_id: 'employee-requester',
waiver_requester_name: '申请人甲',
waiver_requested_at: '2026-07-16T08:00:00Z',
waiver_reason: '客户现场暂时无法取得原件',
waiver_scope: '仅限本次单据的行程确认材料',
waiver_expires_at: '2026-07-20T08:00:00Z',
waiver_conditions_json: ['三日内补交原件', '到期前复核', '三日内补交原件'],
waiver_decision: 'approved',
waiver_decider_id: 'employee-decider',
waiver_decider_name: '决定人乙',
waiver_decided_at: '2026-07-16T09:00:00Z',
waiver_decision_reason: '补偿控制充分,同意限时豁免',
events: [{
id: 'waiver-event-1',
action: 'request_waiver',
version: 2,
actor_id: 'employee-requester',
actor_name: '申请人甲',
request_id: 'waiver-request-001',
comment: '客户现场暂时无法取得原件',
after_json: { waiver_scope: '仅限本次单据的行程确认材料' },
created_at: '2026-07-16T08:00:00Z'
}]
})
assert.deepEqual(disposition.availableActions, ['approve_waiver', 'reject_waiver'])
assert.equal(disposition.readOnlyReason, '')
assert.equal(disposition.waiverRequesterName, '申请人甲')
assert.equal(disposition.waiverReason, '客户现场暂时无法取得原件')
assert.equal(disposition.waiverScope, '仅限本次单据的行程确认材料')
assert.deepEqual(disposition.waiverConditions, ['三日内补交原件', '到期前复核'])
assert.equal(disposition.waiverDeciderName, '决定人乙')
assert.equal(disposition.waiverDecisionReason, '补偿控制充分,同意限时豁免')
assert.equal(disposition.events[0].actorId, 'employee-requester')
assert.equal(disposition.events[0].requestId, 'waiver-request-001')
assert.equal(
disposition.events[0].afterState.waiver_scope,
'仅限本次单据的行程确认材料'
)
})
test('observation keeps server actions when the first disposition has not been created', () => {
const observation = normalizeRiskDisposition(null)
assert.equal(observation, null)
const normalized = normalizeRiskObservation({
id: 'risk-first-action',
disposition: null,
available_actions: ['confirm', 'false_positive'],
read_only_reason: ''
})
assert.equal(normalized.disposition, null)
assert.deepEqual(normalized.availableActions, ['confirm', 'false_positive'])
assert.equal(normalized.readOnlyReason, '')
})
test('waiver request and decision payloads carry idempotency key and optimistic version', async (t) => {
const originalFetch = globalThis.fetch
const calls = []
t.after(() => {
globalThis.fetch = originalFetch
})
globalThis.fetch = async (url, options) => {
calls.push({ url: String(url), body: JSON.parse(options.body) })
return jsonResponse({
disposition: {
id: 'disposition-1',
observation_id: 'risk/1',
version: calls.length,
available_actions: []
},
event: { id: `event-${calls.length}` },
replayed: false
})
}
await executeRiskDispositionAction('risk/1', {
action: 'request_waiver',
expectedVersion: 2,
requestId: 'risk-request-waiver-001',
waiverReason: '材料暂缺',
waiverScope: '仅限当前单据',
waiverExpiresAt: '2026-07-20T08:00:00.000Z',
waiverConditions: ['三日内补件', '三日内补件', '', '财务复核']
})
await executeRiskDispositionAction('risk/1', {
action: 'approve_waiver',
expectedVersion: 3,
requestId: 'risk-approve-waiver-001',
comment: '补偿控制充分,同意限时豁免'
})
await executeRiskDispositionAction('risk/1', {
action: 'reject_waiver',
expectedVersion: 3,
requestId: 'risk-reject-waiver-001',
comment: '补偿控制不足,拒绝豁免'
})
assert.equal(calls[0].url, '/api/v1/risk-observations/risk%2F1/disposition/actions')
assert.deepEqual(calls[0].body, {
action: 'request_waiver',
expected_version: 2,
request_id: 'risk-request-waiver-001',
waiver_reason: '材料暂缺',
waiver_scope: '仅限当前单据',
waiver_expires_at: '2026-07-20T08:00:00.000Z',
waiver_conditions: ['三日内补件', '财务复核']
})
assert.deepEqual(calls[1].body, {
action: 'approve_waiver',
expected_version: 3,
request_id: 'risk-approve-waiver-001',
comment: '补偿控制充分,同意限时豁免'
})
assert.deepEqual(calls[2].body, {
action: 'reject_waiver',
expected_version: 3,
request_id: 'risk-reject-waiver-001',
comment: '补偿控制不足,拒绝豁免'
})
})
test('waiver errors are presented by conflict, permission, validation and timeout type', () => {
assert.deepEqual(
normalizeRiskDispositionError({
code: 'RISK_DISPOSITION_VERSION_CONFLICT',
message: '状态已变化'
}),
{
code: 'RISK_DISPOSITION_VERSION_CONFLICT',
title: '风险状态已更新',
message: '状态已变化',
shouldRefresh: true
}
)
assert.equal(
normalizeRiskDispositionError({ code: 'RISK_WAIVER_DECISION_FORBIDDEN' }).title,
'当前账号不能执行此操作'
)
assert.equal(normalizeRiskDispositionError({ status: 422 }).code, 'RISK_DISPOSITION_VALIDATION_FAILED')
assert.equal(normalizeRiskDispositionError({ code: 'REQUEST_TIMEOUT' }).shouldRefresh, true)
})
test('waiver UI is server-action driven and makes all three confirmations explicit', () => {
assert.match(evidenceCard, /currentDisposition\.value\.availableActions/)
assert.match(evidenceCard, /currentDisposition\.readOnlyReason/)
assert.match(evidenceCard, /批准豁免/)
assert.match(evidenceCard, /拒绝豁免/)
assert.match(evidenceCard, /风险豁免已拒绝,仍会阻断审批/)
assert.match(evidenceCard, /waiverExpiresAt/)
assert.match(evidenceCard, /scheduleWaiverExpiryRefresh/)
assert.match(evidenceCard, /onBeforeUnmount\(clearWaiverExpiryTimer\)/)
assert.doesNotMatch(evidenceCard, /roleCodes|role_codes|isAdmin|is_admin/)
assert.match(waiverDialog, /确认提交豁免申请/)
assert.match(waiverDialog, /确认批准豁免/)
assert.match(waiverDialog, /确认拒绝豁免/)
assert.match(waiverDialog, /请填写拒绝理由。/)
assert.match(waiverDialog, /补偿条件最多填写 20 项。/)
assert.match(waiverDialog, /拒绝后该风险仍会阻断审批/)
assert.match(waiverDialog, /:disabled="busy"/)
assert.match(waiverRecord, /申请人/)
assert.match(waiverRecord, /申请理由/)
assert.match(waiverRecord, /豁免范围/)
assert.match(waiverRecord, /有效期至/)
assert.match(waiverRecord, /补偿条件/)
assert.match(waiverRecord, /决定记录/)
assert.match(waiverRecord, /waiverDecisionReason/)
assert.match(waiverRecord, /豁免审计轨迹/)
assert.match(waiverRecord, /event\.requestId/)
})