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