feat(approval): add task workflow and waiver decisions
This commit is contained in:
@@ -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 建议;风险证据卡读取真实持久字段、显示生命周期和明确动作,但不提供绕过风险门禁的直接审批入口。
|
||||||
|
|||||||
@@ -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 入账和对账仍未接入。
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
## 修复记录
|
||||||
|
|
||||||
|
- 2026-07-16 16:50:44 CST:修复 SQLAlchemy Session 关闭 autoflush 后的两处审批任务状态错误。根任务完成后先显式 flush 再生成下一节点,避免仍被查询为开放任务并误标 superseded;SLA 扫描每次升级后 flush,避免同一事务重复扫描生成相同版本事件。
|
||||||
|
- 2026-07-16 16:50:44 CST:修复审批队列前后端 SLA 与权限动作契约不一致。前端统一使用 `due_soon`,服务端正式支持 `escalated` 并拒绝未知筛选;批准/退回仍要求 `can_act`,委托撤销、管理员转交和 SLA 升级则严格按服务端 `available_actions` 展示,不再被前端错误隐藏。
|
||||||
|
- 2026-07-16 16:50:44 CST:修复任务筛选总数污染全局待办数、批量审批后摘要不刷新、队列错误与动作错误相互覆盖,以及详情返回丢失任务筛选/页码的问题。工作台独立查询未筛选 pending 摘要,批量成功聚合回流单据更新,审核路由保存专属风险/SLA/关键词/页码状态,旧响应由请求代次丢弃。
|
||||||
|
- 2026-07-16 16:50:44 CST:修复审批动作网络失败后允许编辑负载却继续复用旧幂等键的问题。单项、委托/转交、加签/会签和批量审批均对请求负载生成稳定指纹;完全相同的重试复用 request ID,理由、候选人、参与人或批量意见变化时自动生成新 ID,避免同键不同内容冲突被误解为操作失败。
|
||||||
|
- 2026-07-16 16:50:44 CST:验证全部在 `local-x-financial-linux` 容器完成。审批/风险/迁移相关后端 `122 passed, 4 skipped`,变更 Python 文件 Ruff 通过;审批、单据中心和风险前端专项 `60 passed`,Vite 生产构建通过;一次性 PostgreSQL 17 空库迁移循环 `1 passed`,审批任务与风险锁并发 `3 passed`,探针数据库均已销毁。
|
||||||
|
- 2026-07-16 16:50:44 CST:执行 `git fetch --all --prune`、工作区状态和上下游提交差异检查;上游无新增提交,本地相对 `origin/main` ahead 16,最近两个检查点为 `28b834ed` 不可变审批响应重放和 `4940ebc4` 安全风险处置。本次继续排除 7 个用户维护的财务规则工作簿及历史未跟踪开发文档。
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
## 修复记录
|
||||||
|
|
||||||
|
- 2026-07-16 16:50:44 CST:修复首次风险尚无 disposition 时前端丢弃 Observation 顶层 `available_actions/read_only_reason`、导致“确认风险”等首次处置入口永久隐藏的问题。前端只消费服务端权威投影,缺少 disposition 时构造 version 0 展示模型,不进行角色猜测或本地越权推导。
|
||||||
|
- 2026-07-16 16:50:44 CST:修复页面持续打开跨过豁免到期点后仍显示“豁免有效”的问题。证据卡按 `waiver_expires_at` 注册到期刷新,切换单据和卸载时清理定时器;服务端过期门禁继续 fail-closed,前端不再长期展示过期授权。
|
||||||
|
- 2026-07-16 16:50:44 CST:修复风险豁免界面只展示当前快照、拒绝后重新申请会让上一轮审计信息消失的问题。服务保留 actor、request ID、before/after JSON,记录组件展示申请、批准、拒绝的追加事件、版本、人员、时间、原因、申请关键字段和请求 ID。
|
||||||
|
- 2026-07-16 16:50:44 CST:风险豁免申请、批准、拒绝和到期后的任务投影刷新与风险处置同事务执行,保持 Claim → Observation → Disposition → Task 固定锁顺序;刷新风险、证据、批量资格和优先级时保留 SLA 时间窗与显式升级原因。
|
||||||
|
- 2026-07-16 16:50:44 CST:容器内风险处置、门禁与审批投影定向 `30 passed`,扩展回归 `39 passed, 3 skipped`,前端风险专项 `12 passed`,生产构建通过;一次性 PostgreSQL 并发验证包含风险重新打开与审批竞争,未发现绕过或脏写。
|
||||||
|
- 2026-07-16 16:50:44 CST:Git 拉取检查确认上游无新增提交、本地 ahead 16;本记录基于当前未提交修复,未纳入用户维护的财务规则工作簿和历史开发日志。
|
||||||
400
server/alembic/versions/20260716_0013_approval_tasks.py
Normal file
400
server/alembic/versions/20260716_0013_approval_tasks.py
Normal 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")
|
||||||
248
server/alembic/versions/20260716_0014_risk_waiver_decision.py
Normal file
248
server/alembic/versions/20260716_0014_risk_waiver_decision.py
Normal 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)
|
||||||
361
server/scripts/backfill_approval_tasks.py
Normal file
361
server/scripts/backfill_approval_tasks.py
Normal 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())
|
||||||
394
server/src/app/api/v1/endpoints/approval_tasks.py
Normal file
394
server/src/app/api/v1/endpoints/approval_tasks.py
Normal 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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
361
server/src/app/models/approval_task.py
Normal file
361
server/src/app/models/approval_task.py
Normal 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")
|
||||||
@@ -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"),
|
||||||
|
|||||||
279
server/src/app/schemas/approval_task.py
Normal file
279
server/src/app/schemas/approval_task.py
Normal 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 = ""
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
266
server/src/app/services/approval_task_access_policy.py
Normal file
266
server/src/app/services/approval_task_access_policy.py
Normal 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)
|
||||||
762
server/src/app/services/approval_task_actions.py
Normal file
762
server/src/app/services/approval_task_actions.py
Normal 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}"
|
||||||
761
server/src/app/services/approval_task_backfill.py
Normal file
761
server/src/app/services/approval_task_backfill.py
Normal 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")
|
||||||
207
server/src/app/services/approval_task_batch.py
Normal file
207
server/src/app/services/approval_task_batch.py
Normal 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}"
|
||||||
499
server/src/app/services/approval_task_lifecycle.py
Normal file
499
server/src/app/services/approval_task_lifecycle.py
Normal 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
|
||||||
566
server/src/app/services/approval_task_projection.py
Normal file
566
server/src/app/services/approval_task_projection.py
Normal 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)
|
||||||
117
server/src/app/services/approval_task_projection_refresh.py
Normal file
117
server/src/app/services/approval_task_projection_refresh.py
Normal 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)
|
||||||
223
server/src/app/services/approval_task_protocol.py
Normal file
223
server/src/app/services/approval_task_protocol.py
Normal 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
|
||||||
278
server/src/app/services/approval_task_query.py
Normal file
278
server/src/app/services/approval_task_query.py
Normal 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("不支持的审批任务排序方式。")
|
||||||
76
server/src/app/services/approval_task_scheduler.py
Normal file
76
server/src/app/services/approval_task_scheduler.py
Normal 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()
|
||||||
147
server/src/app/services/approval_task_sla.py
Normal file
147
server/src/app/services/approval_task_sla.py
Normal 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)
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
214
server/src/app/services/risk_disposition_read_projection.py
Normal file
214
server/src/app/services/risk_disposition_read_projection.py
Normal 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()
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
154
server/src/app/services/risk_waiver_decision_policy.py
Normal file
154
server/src/app/services/risk_waiver_decision_policy.py
Normal 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()
|
||||||
@@ -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",
|
||||||
[
|
[
|
||||||
|
|||||||
436
server/tests/test_approval_task_actions.py
Normal file
436
server/tests/test_approval_task_actions.py
Normal 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
|
||||||
409
server/tests/test_approval_task_backfill.py
Normal file
409
server/tests/test_approval_task_backfill.py
Normal 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)
|
||||||
237
server/tests/test_approval_task_concurrency_postgres.py
Normal file
237
server/tests/test_approval_task_concurrency_postgres.py
Normal 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
|
||||||
485
server/tests/test_approval_task_projection.py
Normal file
485
server/tests/test_approval_task_projection.py
Normal 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)
|
||||||
600
server/tests/test_approval_task_query_and_batch.py
Normal file
600
server/tests/test_approval_task_query_and_batch.py
Normal 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
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
616
server/tests/test_risk_disposition_read_projection.py
Normal file
616
server/tests/test_risk_disposition_read_projection.py
Normal 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)
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
231
web/src/assets/styles/components/risk-waiver-action-dialog.css
Normal file
231
web/src/assets/styles/components/risk-waiver-action-dialog.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
171
web/src/assets/styles/components/risk-waiver-record.css
Normal file
171
web/src/assets/styles/components/risk-waiver-record.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
409
web/src/components/approval/ApprovalAssignmentDialog.vue
Normal file
409
web/src/components/approval/ApprovalAssignmentDialog.vue
Normal 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>
|
||||||
193
web/src/components/approval/ApprovalBatchResultPanel.vue
Normal file
193
web/src/components/approval/ApprovalBatchResultPanel.vue
Normal 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>
|
||||||
497
web/src/components/approval/ApprovalParticipantsDialog.vue
Normal file
497
web/src/components/approval/ApprovalParticipantsDialog.vue
Normal 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>
|
||||||
696
web/src/components/approval/ApprovalTaskQueue.vue
Normal file
696
web/src/components/approval/ApprovalTaskQueue.vue
Normal 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>
|
||||||
390
web/src/components/approval/ApprovalTaskWorkspace.vue
Normal file
390
web/src/components/approval/ApprovalTaskWorkspace.vue
Normal 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>
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()] || '待处置'
|
||||||
|
|||||||
264
web/src/components/travel/RiskWaiverActionDialog.vue
Normal file
264
web/src/components/travel/RiskWaiverActionDialog.vue
Normal 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="例如:三日内补交原件 到期前由财务复核"
|
||||||
|
></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>
|
||||||
161
web/src/components/travel/RiskWaiverRecord.vue
Normal file
161
web/src/components/travel/RiskWaiverRecord.vue
Normal 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>
|
||||||
339
web/src/composables/useApprovalTaskQueue.js
Normal file
339
web/src/composables/useApprovalTaskQueue.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
43
web/src/composables/useDocumentCenterArchiveRows.js
Normal file
43
web/src/composables/useDocumentCenterArchiveRows.js
Normal 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 }
|
||||||
|
}
|
||||||
535
web/src/services/approvalTasks.js
Normal file
535
web/src/services/approvalTasks.js
Normal 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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
191
web/src/views/scripts/approvalTaskQueueState.js
Normal file
191
web/src/views/scripts/approvalTaskQueueState.js
Normal 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))
|
||||||
|
}
|
||||||
181
web/src/views/scripts/approvalTaskQueueViewModel.js
Normal file
181
web/src/views/scripts/approvalTaskQueueViewModel.js
Normal 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 ''
|
||||||
|
}
|
||||||
27
web/src/views/scripts/approvalTaskRetry.js
Normal file
27
web/src/views/scripts/approvalTaskRetry.js
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
59
web/src/views/scripts/approvalTaskRouteState.js
Normal file
59
web/src/views/scripts/approvalTaskRouteState.js
Normal 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
|
||||||
|
}
|
||||||
48
web/tests/approval-task-components-compile.test.mjs
Normal file
48
web/tests/approval-task-components-compile.test.mjs
Normal 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, [])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
201
web/tests/approval-task-queue-composable.test.mjs
Normal file
201
web/tests/approval-task-queue-composable.test.mjs
Normal 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])
|
||||||
|
})
|
||||||
127
web/tests/approval-task-queue-state.test.mjs
Normal file
127
web/tests/approval-task-queue-state.test.mjs
Normal 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']
|
||||||
|
)
|
||||||
|
})
|
||||||
89
web/tests/approval-task-queue-view-model.test.mjs
Normal file
89
web/tests/approval-task-queue-view-model.test.mjs
Normal 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' } }),
|
||||||
|
''
|
||||||
|
)
|
||||||
|
})
|
||||||
67
web/tests/approval-task-retry-and-route.test.mjs
Normal file
67
web/tests/approval-task-retry-and-route.test.mjs
Normal 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: '' }
|
||||||
|
})
|
||||||
|
})
|
||||||
44
web/tests/approval-task-workspace.test.mjs
Normal file
44
web/tests/approval-task-workspace.test.mjs
Normal 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\)/)
|
||||||
|
})
|
||||||
233
web/tests/approval-tasks-service.test.mjs
Normal file
233
web/tests/approval-tasks-service.test.mjs
Normal 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')
|
||||||
|
})
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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\(\)/)
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
215
web/tests/risk-waiver-flow.test.mjs
Normal file
215
web/tests/risk-waiver-flow.test.mjs
Normal 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/)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user