feat(approval): add safe risk disposition workflow

This commit is contained in:
caoxiaozhu
2026-07-16 15:34:58 +08:00
parent ee88a36baf
commit 4940ebc419
64 changed files with 6602 additions and 631 deletions

View File

@@ -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 价值看板 #### 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分层组织记忆`travel_application.transport_mode` 已形成当前输入/规则 > 企业 > 部门 > 个人的解析链。租户管理员可维护 30-365 天有效的企业/部门低敏记忆;同级冲突 fail-closed低级覆盖、过期和撤销均返回可解释但脱敏的状态。创建、更新、撤销使用作用域锁、稳定幂等键和请求指纹响应丢失可安全重放请求内容变化返回冲突。
- 2026-07-16租户化历史案例风险观测、人工反馈、few-shot 关系数据和 Qdrant 向量统一绑定租户、场景、制度和规则版本Hermes 按租户分别构建图与历史,风险规则生成不再回落到默认租户。报销预审与 AI 助手接入只读 `historical_case_evidence`,旧版本显式标 stale检索故障降级为空证据。 - 2026-07-16租户化历史案例风险观测、人工反馈、few-shot 关系数据和 Qdrant 向量统一绑定租户、场景、制度和规则版本Hermes 按租户分别构建图与历史,风险规则生成不再回落到默认租户。报销预审与 AI 助手接入只读 `historical_case_evidence`,旧版本显式标 stale检索故障降级为空证据。
- 2026-07-16历史证据隐私边界公开协议仅返回“历史已确认/历史误报,仅供复核”的固定摘要,不暴露样本 ID、费用单号、人工评论和历史结论原文历史命中不改变 review ID、规则 findings、passed、blocking count、预算复核或审批路由。 - 2026-07-16历史证据隐私边界公开协议仅返回“历史已确认/历史误报,仅供复核”的固定摘要,不暴露样本 ID、费用单号、人工评论和历史结论原文历史命中不改变 review ID、规则 findings、passed、blocking count、预算复核或审批路由。

View File

@@ -166,9 +166,16 @@
## 7. P1 前端实现:审批例外与 AI 记忆 ## 7. P1 前端实现:审批例外与 AI 记忆
- [ ] [CONCEPT: 审批例外工作台] 按风险、金额、预算影响、等待时长和证据完整度排序审批事项。 - [x] [CONCEPT: 审批例外工作台] 按风险、金额、预算影响、等待时长和证据完整度排序审批事项。
- [ ] [CONCEPT: 审批例外工作台] 展示必要性、预算、政策、相似单、风险证据和 AI 意见草稿 证据:`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: 审批例外工作台] 补齐批量审批、委托、转交、加签、会签和超时升级交互。 - [ ] [CONCEPT: 审批例外工作台] 补齐批量审批、委托、转交、加签、会签和超时升级交互。
当前进度:单笔批准/退回/付款协议、优先级工作台、风险证据和处置状态已完成;批量动作、委托/转交、加签/会签、当前节点进入时间 SLA、豁免批准/拒绝及真正的数据库分页尚未实现,不以现有单笔基础冒充完成。
- [ ] [CONCEPT: 审批例外工作台] 接入真实企微/钉钉/邮件触达和处理结果回写。 - [ ] [CONCEPT: 审批例外工作台] 接入真实企微/钉钉/邮件触达和处理结果回写。
- [ ] [CONCEPT: AI 记忆与自动化设置] 新增“我的 AI 记忆”,支持来源解释、修改、忘记和关闭个性化。 - [ ] [CONCEPT: AI 记忆与自动化设置] 新增“我的 AI 记忆”,支持来源解释、修改、忘记和关闭个性化。
- [x] [CONCEPT: AI 记忆与自动化设置] 在申请核对表展示常用出行方式的记忆来源、证据数量和“忘记此偏好”,并在保存/提交后区分候选记录与已应用回执。 - [x] [CONCEPT: AI 记忆与自动化设置] 在申请核对表展示常用出行方式的记忆来源、证据数量和“忘记此偏好”,并在保存/提交后区分候选记录与已应用回执。
@@ -215,7 +222,9 @@
证据:`test_expense_case_endpoints.py` 容器内 8 项通过,覆盖跨租户、无 Case、申请与报销关联摘要以及内部字段递归过滤。 证据:`test_expense_case_endpoints.py` 容器内 8 项通过,覆盖跨租户、无 Case、申请与报销关联摘要以及内部字段递归过滤。
- [ ] [CONCEPT: 测试方案] 为 Alembic baseline、升级、旧数据迁移和回滚边界补充 Postgres 集成测试。 - [ ] [CONCEPT: 测试方案] 为 Alembic baseline、升级、旧数据迁移和回滚边界补充 Postgres 集成测试。
- [x] [CONCEPT: 测试方案] 为当前 migration-owned schema 切片补充一次性 PostgreSQL 集成测试和危险 URL 防误连门禁。 - [x] [CONCEPT: 测试方案] 为当前 migration-owned schema 切片补充一次性 PostgreSQL 集成测试和危险 URL 防误连门禁。
证据:`test_alembic_migrations.py` 默认无显式 URL 时跳过,主机和库名必须带 disposable 标记;当前 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: 测试方案] 为连接器幂等、重试、回执、失败恢复、重复付款和对账补充测试。
- [ ] [CONCEPT: 测试方案] 跑通申请 → 票据 → 报销 → 预审 → 审批 → 付款 → 入账 → 归档端到端。 - [ ] [CONCEPT: 测试方案] 跑通申请 → 票据 → 报销 → 预审 → 审批 → 付款 → 入账 → 归档端到端。
当前进度:申请批准 → 自动报销草稿 → 票据归集 → 预审 → 报销提交已在同一 Case 中跑通付款回执、ERP 入账和对账仍未接入。 当前进度:申请批准 → 自动报销草稿 → 票据归集 → 预审 → 报销提交已在同一 Case 中跑通付款回执、ERP 入账和对账仍未接入。

View File

@@ -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 CSTGit 拉取检查已执行;上游没有本地尚未包含的新提交,当前分支相对上游 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 → DispositionHermes 锁外计算、锁内刷新并核对状态和更新时间,旧快照被丢弃。一次性 PostgreSQL 并发用例证明风险重新打开与审批竞争时审批等待后读取最新风险并阻断Claim 节点和动作账本均保持无副作用。
- 2026-07-16 15:33:03 CST最终容器回归为审批/风险/费用服务 174 项、迁移/所有权 54 项、前端审批/风险 73 项通过Vite 生产构建通过;一次性 tmpfs PostgreSQL 17 上 13 项迁移和 1 项真实并发测试通过,临时数据库自动清理。再次执行 Git 拉取检查,上游仍无新增提交,本地 ahead 14。

View File

@@ -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 CSTGit 拉取检查已执行;上游没有本地尚未包含的新提交,当前分支相对上游 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 项通过。规则工作簿和历史未跟踪日志未被改动或纳入提交。

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,14 +12,12 @@ from app.schemas.budget import BudgetClaimAnalysisRead
from app.schemas.common import ErrorResponse, PaginatedResponse from app.schemas.common import ErrorResponse, PaginatedResponse
from app.schemas.reimbursement import ( from app.schemas.reimbursement import (
ExpenseClaimActionResponse, ExpenseClaimActionResponse,
ExpenseClaimApprovalPayload,
ExpenseClaimAttachmentActionResponse, ExpenseClaimAttachmentActionResponse,
ExpenseClaimAttachmentRead, ExpenseClaimAttachmentRead,
ExpenseClaimItemActionResponse, ExpenseClaimItemActionResponse,
ExpenseClaimItemCreate, ExpenseClaimItemCreate,
ExpenseClaimItemUpdate, ExpenseClaimItemUpdate,
ExpenseClaimRead, ExpenseClaimRead,
ExpenseClaimReturnPayload,
ExpenseClaimStandardAdjustmentPayload, ExpenseClaimStandardAdjustmentPayload,
ExpenseClaimSubmitPayload, ExpenseClaimSubmitPayload,
ExpenseClaimUpdate, ExpenseClaimUpdate,
@@ -33,6 +31,7 @@ from app.services.expense_claims import ExpenseClaimService
from app.services.reimbursement import ReimbursementService from app.services.reimbursement import ReimbursementService
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
from .reimbursement_approval_actions import router as approval_action_router
from .reimbursement_pre_review import ( from .reimbursement_pre_review import (
RequestIdHeader, RequestIdHeader,
expense_claim_deletion_response, expense_claim_deletion_response,
@@ -41,6 +40,7 @@ from .reimbursement_pre_review import (
) )
router = APIRouter() router = APIRouter()
router.include_router(approval_action_router)
DbSession = Annotated[Session, Depends(get_db)] DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)] 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) claim = service.get_claim(claim_id, current_user)
if claim is None: if claim is None:
if not service.can_view_budget_analysis(current_user): 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") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
if not service.can_view_budget_analysis(current_user, claim): 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) return BudgetService(db).analyze_claim_budget(claim)
@@ -418,7 +424,9 @@ async def upload_expense_claim_item_attachment(
file: Annotated[UploadFile, File(description="待上传的附件文件。")], file: Annotated[UploadFile, File(description="待上传的附件文件。")],
db: DbSession, db: DbSession,
current_user: CurrentUser, current_user: CurrentUser,
receipt_id: Annotated[str | None, Form(description="可选,来源于票据夹的持久化票据 ID。")] = None, receipt_id: Annotated[
str | None, Form(description="可选,来源于票据夹的持久化票据 ID。")
] = None,
) -> ExpenseClaimAttachmentActionResponse: ) -> ExpenseClaimAttachmentActionResponse:
service = ExpenseClaimService(db) service = ExpenseClaimService(db)
try: 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( @router.delete(
"/claims/{claim_id}", "/claims/{claim_id}",
response_model=ExpenseClaimActionResponse, 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) service = ExpenseClaimService(db)
try: try:
claim = service.delete_claim(claim_id, current_user) claim = service.delete_claim(claim_id, current_user)

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import uuid
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status 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.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.common import ErrorResponse from app.schemas.common import ErrorResponse
from app.schemas.risk_disposition import (
RiskDispositionActionCreate,
RiskDispositionMutationRead,
RiskDispositionRead,
)
from app.schemas.risk_observation import ( from app.schemas.risk_observation import (
RiskObservationDashboardRead, RiskObservationDashboardRead,
RiskObservationFeedbackCreate, RiskObservationFeedbackCreate,
@@ -14,6 +20,13 @@ from app.schemas.risk_observation import (
RiskObservationListRead, RiskObservationListRead,
RiskObservationRead, 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 from app.services.risk_observations import RiskObservationService
router = APIRouter(prefix="/risk-observations") router = APIRouter(prefix="/risk-observations")
@@ -43,6 +56,7 @@ def list_risk_observations(
limit: Annotated[int, Query(ge=1, le=200)] = 50, limit: Annotated[int, Query(ge=1, le=200)] = 50,
offset: Annotated[int, Query(ge=0)] = 0, offset: Annotated[int, Query(ge=0)] = 0,
) -> RiskObservationListRead: ) -> RiskObservationListRead:
_require_pool_access(db, current_user)
items, total = RiskObservationService(db).list_observations( items, total = RiskObservationService(db).list_observations(
tenant_id=current_user.tenant_id, tenant_id=current_user.tenant_id,
claim_id=claim_id, claim_id=claim_id,
@@ -70,6 +84,7 @@ def summarize_risk_observations(
window_days: Annotated[int, Query(ge=1, le=365)] = 30, window_days: Annotated[int, Query(ge=1, le=365)] = 30,
limit: Annotated[int, Query(ge=1, le=2000)] = 500, limit: Annotated[int, Query(ge=1, le=2000)] = 500,
) -> RiskObservationDashboardRead: ) -> RiskObservationDashboardRead:
_require_pool_access(db, current_user)
return RiskObservationService(db).summarize_dashboard( return RiskObservationService(db).summarize_dashboard(
tenant_id=current_user.tenant_id, tenant_id=current_user.tenant_id,
window_days=window_days, window_days=window_days,
@@ -88,6 +103,8 @@ def list_claim_risk_observations(
db: DbSession, db: DbSession,
current_user: CurrentUser, current_user: CurrentUser,
) -> list[RiskObservationRead]: ) -> list[RiskObservationRead]:
if not RiskObservationAccessPolicy(db).can_read_claim_risks(claim_id, current_user):
raise _not_found()
return RiskObservationService(db).list_claim_observations( return RiskObservationService(db).list_claim_observations(
claim_id, claim_id,
tenant_id=current_user.tenant_id, tenant_id=current_user.tenant_id,
@@ -105,6 +122,7 @@ def list_execution_log_risk_observations(
db: DbSession, db: DbSession,
current_user: CurrentUser, current_user: CurrentUser,
) -> list[RiskObservationRead]: ) -> list[RiskObservationRead]:
_require_pool_access(db, current_user)
return RiskObservationService(db).list_execution_log_observations( return RiskObservationService(db).list_execution_log_observations(
execution_log_id, execution_log_id,
tenant_id=current_user.tenant_id, tenant_id=current_user.tenant_id,
@@ -128,6 +146,7 @@ def get_risk_observation(
db: DbSession, db: DbSession,
current_user: CurrentUser, current_user: CurrentUser,
) -> RiskObservationRead: ) -> RiskObservationRead:
_require_pool_access(db, current_user)
observation = RiskObservationService(db).get_observation( observation = RiskObservationService(db).get_observation(
observation_key_or_id, observation_key_or_id,
tenant_id=current_user.tenant_id, tenant_id=current_user.tenant_id,
@@ -140,6 +159,53 @@ def get_risk_observation(
return 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( @router.post(
"/{observation_key_or_id}/feedback", "/{observation_key_or_id}/feedback",
response_model=RiskObservationFeedbackRead, response_model=RiskObservationFeedbackRead,
@@ -158,15 +224,103 @@ def create_risk_observation_feedback(
db: DbSession, db: DbSession,
current_user: CurrentUser, current_user: CurrentUser,
) -> RiskObservationFeedbackRead: ) -> 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: try:
return RiskObservationService(db).create_feedback( return RiskDispositionService(db).execute_action(
observation_key_or_id, observation_key_or_id,
payload, payload,
tenant_id=current_user.tenant_id, 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: except LookupError:
raise _not_found() from None
except RiskDispositionVersionConflictError as error:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_409_CONFLICT,
detail="Risk observation not found", detail={
"code": "RISK_DISPOSITION_VERSION_CONFLICT",
"current_version": error.current_version,
"message": "风险处置状态已更新,请刷新证据链后重试。",
},
) from None ) 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,
)

View File

@@ -6,6 +6,7 @@ from app.api.v1.endpoints.agent_feedback import router as agent_feedback_router
from app.api.v1.endpoints.agent_runs import router as agent_runs_router from app.api.v1.endpoints.agent_runs import router as agent_runs_router
from app.api.v1.endpoints.agent_traces import router as agent_traces_router from app.api.v1.endpoints.agent_traces import router as agent_traces_router
from app.api.v1.endpoints.analytics import router as analytics_router from app.api.v1.endpoints.analytics import router as analytics_router
from app.api.v1.endpoints.approval_workbench import router as approval_workbench_router
from app.api.v1.endpoints.attachment_association_jobs import ( from app.api.v1.endpoints.attachment_association_jobs import (
router as attachment_association_jobs_router, router as attachment_association_jobs_router,
) )
@@ -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_runs_router, tags=["agent-runs"])
router.include_router(agent_traces_router, tags=["agent-traces"]) router.include_router(agent_traces_router, tags=["agent-traces"])
router.include_router(analytics_router, tags=["analytics"]) router.include_router(analytics_router, tags=["analytics"])
router.include_router(approval_workbench_router, tags=["approval-workbench"])
router.include_router(attachment_association_jobs_router, tags=["attachment-association-jobs"]) router.include_router(attachment_association_jobs_router, tags=["attachment-association-jobs"])
router.include_router(audit_logs_router, tags=["audit-logs"]) router.include_router(audit_logs_router, tags=["audit-logs"])
router.include_router(knowledge_router, tags=["knowledge"]) router.include_router(knowledge_router, tags=["knowledge"])
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(notification_states_router, tags=["notification-states"])
router.include_router(ocr_router, tags=["ocr"]) router.include_router(ocr_router, tags=["ocr"])
router.include_router(ontology_router, tags=["ontology"]) router.include_router(ontology_router, tags=["ontology"])

View File

@@ -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_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink
from app.models.approval import ApprovalRecord from app.models.approval import ApprovalRecord
from app.models.approval_action import ApprovalActionLedger
from app.models.attachment_association_job import AttachmentAssociationJob from app.models.attachment_association_job import AttachmentAssociationJob
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
from app.models.auth_session import AuthSession from app.models.auth_session import AuthSession
@@ -34,6 +35,7 @@ from app.models.hermes_report import HermesRiskReport
from app.models.notification_state import NotificationState from app.models.notification_state import NotificationState
from app.models.organization import OrganizationUnit from app.models.organization import OrganizationUnit
from app.models.reimbursement import ReimbursementRequest 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.risk_observation import RiskObservation, RiskObservationFeedback
from app.models.role import Role from app.models.role import Role
from app.models.system_model_setting import SystemModelSetting from app.models.system_model_setting import SystemModelSetting
@@ -60,6 +62,7 @@ __all__ = [
"AIDecision", "AIDecision",
"AIDecisionFeedback", "AIDecisionFeedback",
"ApprovalRecord", "ApprovalRecord",
"ApprovalActionLedger",
"AttachmentAssociationJob", "AttachmentAssociationJob",
"AuditLog", "AuditLog",
"AuthSession", "AuthSession",
@@ -84,6 +87,8 @@ __all__ = [
"NotificationState", "NotificationState",
"OrganizationUnit", "OrganizationUnit",
"ReimbursementRequest", "ReimbursementRequest",
"RiskDisposition",
"RiskDispositionEvent",
"RiskObservation", "RiskObservation",
"RiskObservationFeedback", "RiskObservationFeedback",
"Role", "Role",

View File

@@ -130,8 +130,48 @@ MIGRATION_OWNED_TABLES_BY_REVISION: dict[str, frozenset[str]] = {
"workflow_outcomes", "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") raise RuntimeError("latest Alembic revision must own the centralized migration table set")
# 0008 之前这三张表由旧 bootstrap / 风险服务按需创建。迁移前置检查允许 # 0008 之前这三张表由旧 bootstrap / 风险服务按需创建。迁移前置检查允许
@@ -198,7 +238,13 @@ def _validate_connection(connection: Connection) -> MigrationPreflightState:
adoptable_tables = ( adoptable_tables = (
LEGACY_ADOPTABLE_HISTORICAL_CASE_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() else frozenset()
) )
missing_tables = expected_tables - owned_tables missing_tables = expected_tables - owned_tables

View File

@@ -7,6 +7,7 @@ from app.db.base import Base
MIGRATION_OWNED_TABLES: frozenset[str] = frozenset( MIGRATION_OWNED_TABLES: frozenset[str] = frozenset(
{ {
"auth_sessions", "auth_sessions",
"approval_action_ledgers",
"attachment_association_jobs", "attachment_association_jobs",
"ai_application_preview_decisions", "ai_application_preview_decisions",
"ai_decisions", "ai_decisions",
@@ -18,6 +19,8 @@ MIGRATION_OWNED_TABLES: frozenset[str] = frozenset(
"memory_evidence_links", "memory_evidence_links",
"risk_observations", "risk_observations",
"risk_observation_feedback", "risk_observation_feedback",
"risk_dispositions",
"risk_disposition_events",
"few_shot_samples", "few_shot_samples",
"workflow_outcomes", "workflow_outcomes",
} }

View File

@@ -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_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink
from app.models.approval import ApprovalRecord from app.models.approval import ApprovalRecord
from app.models.approval_action import ApprovalActionLedger
from app.models.attachment_association_job import AttachmentAssociationJob from app.models.attachment_association_job import AttachmentAssociationJob
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
from app.models.auth_session import AuthSession from app.models.auth_session import AuthSession
@@ -32,6 +33,7 @@ from app.models.hermes_report import HermesRiskReport
from app.models.notification_state import NotificationState from app.models.notification_state import NotificationState
from app.models.organization import OrganizationUnit from app.models.organization import OrganizationUnit
from app.models.reimbursement import ReimbursementRequest 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.risk_observation import RiskObservation, RiskObservationFeedback
from app.models.role import Role from app.models.role import Role
from app.models.system_model_setting import SystemModelSetting from app.models.system_model_setting import SystemModelSetting
@@ -54,6 +56,7 @@ __all__ = [
"AgentTraceEvent", "AgentTraceEvent",
"AIApplicationPreviewDecision", "AIApplicationPreviewDecision",
"ApprovalRecord", "ApprovalRecord",
"ApprovalActionLedger",
"AttachmentAssociationJob", "AttachmentAssociationJob",
"AuditLog", "AuditLog",
"AuthSession", "AuthSession",
@@ -80,6 +83,8 @@ __all__ = [
"NotificationState", "NotificationState",
"OrganizationUnit", "OrganizationUnit",
"ReimbursementRequest", "ReimbursementRequest",
"RiskDisposition",
"RiskDispositionEvent",
"RiskObservation", "RiskObservation",
"RiskObservationFeedback", "RiskObservationFeedback",
"Role", "Role",

View File

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

View File

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

View File

@@ -24,6 +24,11 @@ from app.db.base_class import Base
class RiskObservation(Base): class RiskObservation(Base):
__tablename__ = "risk_observations" __tablename__ = "risk_observations"
__table_args__ = ( __table_args__ = (
UniqueConstraint(
"tenant_id",
"id",
name="uq_risk_observations_tenant_id",
),
UniqueConstraint( UniqueConstraint(
"tenant_id", "tenant_id",
"observation_key", "observation_key",
@@ -95,6 +100,12 @@ class RiskObservation(Base):
cascade="all, delete-orphan", cascade="all, delete-orphan",
order_by="desc(RiskObservationFeedback.created_at)", order_by="desc(RiskObservationFeedback.created_at)",
) )
disposition = relationship(
"RiskDisposition",
back_populates="observation",
passive_deletes=True,
uselist=False,
)
@property @property
def sampling_strategy(self) -> dict[str, Any]: def sampling_strategy(self) -> dict[str, Any]:

View File

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

View File

@@ -276,15 +276,38 @@ class ExpenseApplicationPreviewActionResponse(BaseModel):
result: ExpenseApplicationPreviewActionResult 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: str | None = Field(default=None, max_length=500)
reason_codes: list[str] = Field(default_factory=list, max_length=10) reason_codes: list[str] = Field(default_factory=list, max_length=10)
class ExpenseClaimApprovalPayload(BaseModel): class ExpenseClaimApprovalPayload(ExpenseClaimActionProtocolPayload):
opinion: str | None = Field(default=None, max_length=500) opinion: str | None = Field(default=None, max_length=500)
class ExpenseClaimPaymentPayload(ExpenseClaimActionProtocolPayload):
pass
class TravelReimbursementCalculatorRequest(BaseModel): class TravelReimbursementCalculatorRequest(BaseModel):
days: int = Field(ge=1, le=365) days: int = Field(ge=1, le=365)
location: str = Field(min_length=1, max_length=120) location: str = Field(min_length=1, max_length=120)

View File

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

View File

@@ -5,6 +5,8 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.schemas.risk_disposition import RiskDispositionRead
RiskObservationStatus = Literal[ RiskObservationStatus = Literal[
"pending_review", "pending_review",
"confirmed", "confirmed",
@@ -89,6 +91,7 @@ class RiskObservationRead(BaseModel):
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
feedback_items: list[RiskObservationFeedbackRead] = Field(default_factory=list) feedback_items: list[RiskObservationFeedbackRead] = Field(default_factory=list)
disposition: RiskDispositionRead | None = None
class RiskObservationListRead(BaseModel): class RiskObservationListRead(BaseModel):

View File

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

View File

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

View File

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

View File

@@ -6,8 +6,14 @@ from decimal import Decimal, InvalidOperation
from typing import Any from typing import Any
from app.api.deps import CurrentUserContext from app.api.deps import CurrentUserContext
from app.models.approval_action import ApprovalActionLedger
from app.models.financial_record import ExpenseClaim from app.models.financial_record import ExpenseClaim
from app.services.budget import BudgetService 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 ( from app.services.expense_claim_workflow_constants import (
APPLICATION_LINK_STATUS_STAGE, APPLICATION_LINK_STATUS_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE, BUDGET_MANAGER_APPROVAL_STAGE,
@@ -18,10 +24,6 @@ from app.services.expense_claim_workflow_constants import (
PAYMENT_PENDING_STAGE, PAYMENT_PENDING_STAGE,
PAYMENT_PENDING_STATUS, PAYMENT_PENDING_STATUS,
) )
from app.services.expense_claim_risk_stage import (
risk_business_stage_for_claim,
with_risk_business_stage,
)
class ExpenseClaimApprovalFlowMixin: class ExpenseClaimApprovalFlowMixin:
@@ -31,10 +33,37 @@ class ExpenseClaimApprovalFlowMixin:
current_user: CurrentUserContext, current_user: CurrentUserContext,
*, *,
opinion: str | None = None, 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) normalized_opinion = str(opinion or "").strip()
if claim is None: return self._execute_claim_action(
return None 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() normalized_status = str(claim.status or "").strip().lower()
if normalized_status != "submitted": if normalized_status != "submitted":
@@ -50,8 +79,11 @@ class ExpenseClaimApprovalFlowMixin:
if previous_stage == DIRECT_MANAGER_APPROVAL_STAGE: if previous_stage == DIRECT_MANAGER_APPROVAL_STAGE:
if not self._access_policy.can_approve_claim(current_user, claim): if not self._access_policy.can_approve_claim(current_user, claim):
raise ValueError("只有当前直属领导审批人可以审批通过该单据。") raise ValueError("只有当前直属领导审批人可以审批通过该单据。")
self._ensure_claim_has_no_blocking_risk(claim, current_user)
approval_source = "manual_approval" 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 = "领导审批通过" label = "领导审批通过"
route_decision_flag = self._build_approval_route_decision( route_decision_flag = self._build_approval_route_decision(
claim, claim,
@@ -67,18 +99,29 @@ class ExpenseClaimApprovalFlowMixin:
label = "领导及预算审核通过" label = "领导及预算审核通过"
next_status = "approved" next_status = "approved"
next_stage = APPLICATION_LINK_STATUS_STAGE next_stage = APPLICATION_LINK_STATUS_STAGE
default_message = "{operator} 已完成直属领导和预算管理者审核,申请流程完成并生成报销草稿。" default_message = (
"{operator} 已完成直属领导和预算管理者审核,申请流程完成并生成报销草稿。"
)
elif requires_budget_review: 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: if next_budget_manager is None:
raise ValueError("未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。") raise ValueError(
"未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。"
)
next_status = "submitted" next_status = "submitted"
next_stage = BUDGET_MANAGER_APPROVAL_STAGE next_stage = BUDGET_MANAGER_APPROVAL_STAGE
default_message = "{operator} 已确认直属领导审核,因预算或风险关注项流转至预算管理者审批。" default_message = (
"{operator} 已确认直属领导审核,因预算或风险关注项流转至预算管理者审批。"
)
else: else:
next_status = "approved" next_status = "approved"
next_stage = APPLICATION_LINK_STATUS_STAGE next_stage = APPLICATION_LINK_STATUS_STAGE
default_message = "{operator} 已确认直属领导审核,系统判断预算充足且无风险,申请流程完成并生成报销草稿。" default_message = (
"{operator} 已确认直属领导审核,系统判断预算充足且无风险,"
"申请流程完成并生成报销草稿。"
)
else: else:
merged_budget_approval = ( merged_budget_approval = (
requires_budget_review requires_budget_review
@@ -88,21 +131,32 @@ class ExpenseClaimApprovalFlowMixin:
label = "领导及预算审核通过" label = "领导及预算审核通过"
next_status = "submitted" next_status = "submitted"
next_stage = FINANCE_APPROVAL_STAGE next_stage = FINANCE_APPROVAL_STAGE
default_message = "{operator} 已完成直属领导和预算管理者审核,流转至{next_stage}" default_message = (
"{operator} 已完成直属领导和预算管理者审核,流转至{next_stage}"
)
elif requires_budget_review: 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: if next_budget_manager is None:
raise ValueError("未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。") raise ValueError(
"未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。"
)
next_status = "submitted" next_status = "submitted"
next_stage = BUDGET_MANAGER_APPROVAL_STAGE next_stage = BUDGET_MANAGER_APPROVAL_STAGE
default_message = "{operator} 已审批通过,因预算或风险关注项流转至预算管理者审批。" default_message = (
"{operator} 已审批通过,因预算或风险关注项流转至预算管理者审批。"
)
else: else:
next_status = "submitted" next_status = "submitted"
next_stage = FINANCE_APPROVAL_STAGE next_stage = FINANCE_APPROVAL_STAGE
default_message = "{operator} 已审批通过,系统判断预算充足且无风险,流转至{next_stage}" default_message = (
"{operator} 已审批通过,系统判断预算充足且无风险,流转至{next_stage}"
)
elif previous_stage == BUDGET_MANAGER_APPROVAL_STAGE: elif previous_stage == BUDGET_MANAGER_APPROVAL_STAGE:
if not self._access_policy.can_approve_claim(current_user, claim): if not self._access_policy.can_approve_claim(current_user, claim):
raise ValueError("只有当前预算管理者可以审批通过该单据。") raise ValueError("只有当前预算管理者可以审批通过该单据。")
self._ensure_claim_has_no_blocking_risk(claim, current_user)
approval_source = "budget_approval" approval_source = "budget_approval"
event_type = ( event_type = (
"expense_application_budget_approval" "expense_application_budget_approval"
@@ -123,6 +177,7 @@ class ExpenseClaimApprovalFlowMixin:
raise ValueError("费用申请需先完成预算管理者审批。") raise ValueError("费用申请需先完成预算管理者审批。")
if not self._access_policy.can_approve_claim(current_user, claim): if not self._access_policy.can_approve_claim(current_user, claim):
raise ValueError("只有财务人员可以完成财务终审。") raise ValueError("只有财务人员可以完成财务终审。")
self._ensure_claim_has_no_blocking_risk(claim, current_user)
approval_source = "finance_approval" approval_source = "finance_approval"
event_type = "expense_claim_finance_approval" event_type = "expense_claim_finance_approval"
label = "财务审核通过" label = "财务审核通过"
@@ -146,7 +201,10 @@ class ExpenseClaimApprovalFlowMixin:
and not approval_opinion and not approval_opinion
): ):
raise ValueError("预算已超过警戒值,预算管理者需填写审批意见后才能通过。") 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 = "同意" approval_opinion = "同意"
before_json = self._serialize_claim(claim) before_json = self._serialize_claim(claim)
@@ -163,7 +221,8 @@ class ExpenseClaimApprovalFlowMixin:
"approval_event_id": str(uuid.uuid4()), "approval_event_id": str(uuid.uuid4()),
"severity": "info", "severity": "info",
"label": label, "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, "opinion": approval_opinion,
"operator": operator, "operator": operator,
"operator_username": current_user.username, "operator_username": current_user.username,
@@ -193,8 +252,10 @@ class ExpenseClaimApprovalFlowMixin:
"next_approver_name": str(next_budget_manager.name or "").strip(), "next_approver_name": str(next_budget_manager.name or "").strip(),
"next_approver_employee_id": next_budget_manager.id, "next_approver_employee_id": next_budget_manager.id,
"next_approver_grade": str(next_budget_manager.grade or "").strip(), "next_approver_grade": str(next_budget_manager.grade or "").strip(),
"next_approver_role_code": self._access_policy.resolve_budget_approval_role_code( "next_approver_role_code": (
self._access_policy.resolve_budget_approval_role_code(
next_budget_manager, next_budget_manager,
)
), ),
} }
) )
@@ -299,10 +360,6 @@ class ExpenseClaimApprovalFlowMixin:
update_case_state=False, update_case_state=False,
) )
self.db.commit()
self.db.refresh(claim)
self._access_policy.attach_budget_approval_snapshot(claim)
self.audit_service.log_action( self.audit_service.log_action(
actor=operator, actor=operator,
action="expense_claim.approve", action="expense_claim.approve",
@@ -310,18 +367,55 @@ class ExpenseClaimApprovalFlowMixin:
resource_id=claim.id, resource_id=claim.id,
before_json=before_json, before_json=before_json,
after_json=self._serialize_claim(claim), after_json=self._serialize_claim(claim),
request_id=request_id,
commit=False,
) )
return claim 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( def mark_claim_paid(
self, self,
claim_id: str, claim_id: str,
current_user: CurrentUserContext, 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) return self._execute_claim_action(
if claim is None: action="pay",
return None 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() normalized_status = str(claim.status or "").strip().lower()
if normalized_status == PAYMENT_PAID_STATUS: if normalized_status == PAYMENT_PAID_STATUS:
@@ -412,9 +506,6 @@ class ExpenseClaimApprovalFlowMixin:
update_case_state=False, update_case_state=False,
) )
self.db.commit()
self.db.refresh(claim)
self.audit_service.log_action( self.audit_service.log_action(
actor=operator, actor=operator,
action="expense_claim.mark_paid", action="expense_claim.mark_paid",
@@ -422,6 +513,8 @@ class ExpenseClaimApprovalFlowMixin:
resource_id=claim.id, resource_id=claim.id,
before_json=before_json, before_json=before_json,
after_json=self._serialize_claim(claim), after_json=self._serialize_claim(claim),
request_id=request_id,
commit=False,
) )
return claim return claim
@@ -440,7 +533,9 @@ class ExpenseClaimApprovalFlowMixin:
def _budget_approval_opinion_required(self, claim) -> bool: def _budget_approval_opinion_required(self, claim) -> bool:
budget_result = BudgetService(self.db).analyze_claim_budget(claim) 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 = ( context = (
budget_result.get("budget_context") budget_result.get("budget_context")
if isinstance(budget_result.get("budget_context"), dict) if isinstance(budget_result.get("budget_context"), dict)

View File

@@ -30,7 +30,6 @@ class ExpenseClaimPaginationMixin:
) )
stmt = self._access_policy.apply_claim_scope(stmt, current_user) stmt = self._access_policy.apply_claim_scope(stmt, current_user)
result = paginate_select(self.db, stmt, page=page, page_size=page_size) 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) self._access_policy.attach_budget_approval_snapshots(result.items)
return result return result
@@ -47,7 +46,6 @@ class ExpenseClaimPaginationMixin:
) )
stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user) stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user)
result = paginate_select(self.db, stmt, page=page, page_size=page_size) 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) self._access_policy.attach_budget_approval_snapshots(result.items)
return result return result

View File

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

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
from typing import Any from typing import Any
_SEVERITY_WEIGHT = { _SEVERITY_WEIGHT = {
"critical": 0, "critical": 0,
"high": 1, "high": 1,
@@ -12,6 +11,48 @@ _SEVERITY_WEIGHT = {
"pass": 5, "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: def _text(value: Any) -> str:
return str(value or "").strip() return str(value or "").strip()
@@ -22,6 +63,74 @@ def _severity_weight(flag: dict[str, Any]) -> int:
return _SEVERITY_WEIGHT.get(severity, 9) 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]: def _list_values(value: Any) -> list[Any]:
if isinstance(value, list): if isinstance(value, list):
return value return value
@@ -138,7 +247,9 @@ def dedupe_claim_risk_flags(flags: list[Any] | None) -> list[Any]:
continue continue
other_weight = _severity_weight(other) 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 is_shadowed = True
break break

View File

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

View File

@@ -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_item_sync import ExpenseClaimItemSyncMixin
from app.services.expense_claim_platform_risk import ExpenseClaimPlatformRiskMixin from app.services.expense_claim_platform_risk import ExpenseClaimPlatformRiskMixin
from app.services.expense_claim_policy_review import ExpenseClaimPolicyReviewMixin 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_risk_stage import with_risk_business_stage
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
from app.services.risk_observations import RiskObservationService from app.services.risk_observations import RiskObservationService
@@ -98,11 +101,15 @@ class ExpenseClaimRiskReviewMixin(
claim, claim,
platform_risk_flags, platform_risk_flags,
) )
except Exception: except Exception as error:
logger.exception( logger.exception(
"Failed to persist platform risk observations for claim_id=%s", "Failed to persist platform risk observations for claim_id=%s",
claim.id, 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] review_flags = [with_risk_business_stage(flag, "reimbursement") for flag in review_flags]
final_risk_flags = dedupe_claim_risk_flags([*preserved_flags, *review_flags]) final_risk_flags = dedupe_claim_risk_flags([*preserved_flags, *review_flags])
@@ -111,9 +118,7 @@ class ExpenseClaimRiskReviewMixin(
"status": "submitted", "status": "submitted",
"approval_stage": "直属领导审批", "approval_stage": "直属领导审批",
"risk_flags": final_risk_flags, "risk_flags": final_risk_flags,
"rule_set_fingerprint": str( "rule_set_fingerprint": str(platform_risk_review.get("rule_set_fingerprint") or ""),
platform_risk_review.get("rule_set_fingerprint") or ""
),
"message": ( "message": (
f"报销单 {claim.claim_no} 已完成自动检测," f"报销单 {claim.claim_no} 已完成自动检测,"
f"现已提交给直属领导 {manager_name or '审批人'} 审批。" f"现已提交给直属领导 {manager_name or '审批人'} 审批。"
@@ -159,4 +164,3 @@ class ExpenseClaimRiskReviewMixin(
) )
recent_claims = list(self.db.scalars(stmt).all()) recent_claims = list(self.db.scalars(stmt).all())
return sum(1 for item in recent_claims if list(item.risk_flags_json or [])) return sum(1 for item in recent_claims if list(item.risk_flags_json or []))

View File

@@ -4,7 +4,10 @@ from datetime import UTC, datetime
from typing import Any from typing import Any
from app.models.financial_record import ExpenseClaim 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 ( from app.services.expense_claim_workflow_constants import (
BUDGET_MANAGER_APPROVAL_STAGE, BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE, DIRECT_MANAGER_APPROVAL_STAGE,
@@ -13,18 +16,17 @@ from app.services.expense_claim_workflow_constants import (
class ExpenseClaimWorkflowRepairMixin: class ExpenseClaimWorkflowRepairMixin:
def _repair_duplicate_budget_approval_stages(self, claims: list[ExpenseClaim]) -> None: def _repair_duplicate_budget_approval_stages(
repaired_claims = [ self,
claims: list[ExpenseClaim],
) -> list[ExpenseClaim]:
"""只修改当前事务中的对象,不在底层辅助方法里提交事务。"""
return [
claim claim
for claim in claims for claim in claims
if claim is not None and self._repair_duplicate_budget_approval_stage(claim) 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: def _repair_duplicate_budget_approval_stage(self, claim: ExpenseClaim) -> bool:
if self._is_expense_application_claim(claim): if self._is_expense_application_claim(claim):
@@ -54,7 +56,8 @@ class ExpenseClaimWorkflowRepairMixin:
if isinstance(flag, dict) if isinstance(flag, dict)
and str(flag.get("source") or "").strip() == "manual_approval" and str(flag.get("source") or "").strip() == "manual_approval"
and str(flag.get("event_type") or "").strip() == "expense_claim_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 and str(flag.get("next_approval_stage") or "").strip() == BUDGET_MANAGER_APPROVAL_STAGE
] ]
for flag in reversed(flags): for flag in reversed(flags):
@@ -74,11 +77,14 @@ class ExpenseClaimWorkflowRepairMixin:
return any( return any(
isinstance(flag, dict) isinstance(flag, dict)
and str(flag.get("source") or "").strip() == "approval_flow_repair" 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 []) 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( return with_risk_business_stage(
{ {
"source": "approval_flow_repair", "source": "approval_flow_repair",

View File

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import uuid
from collections.abc import Callable from collections.abc import Callable
from datetime import UTC, datetime from datetime import UTC, datetime
from decimal import Decimal 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.document_numbering import is_application_claim_no
from app.services.expense_cases import ExpenseCaseService from app.services.expense_cases import ExpenseCaseService
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy 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_application_handoff import ExpenseClaimApplicationHandoffMixin
from app.services.expense_claim_approval_flow import ExpenseClaimApprovalFlowMixin from app.services.expense_claim_approval_flow import ExpenseClaimApprovalFlowMixin
from app.services.expense_claim_approval_routing import ExpenseClaimApprovalRoutingMixin 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_presentation import ExpenseClaimAttachmentPresentation
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin 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_item_builder import ExpenseClaimDocumentItemBuilderMixin
from app.services.expense_claim_document_parsing import ExpenseClaimDocumentParsingMixin from app.services.expense_claim_document_parsing import ExpenseClaimDocumentParsingMixin
from app.services.expense_claim_draft_flow import ExpenseClaimDraftFlowMixin 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, pre_review_public_payload,
) )
from app.services.expense_claim_read_model import ExpenseClaimReadModelMixin 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_review_preview import ExpenseClaimReviewPreviewMixin
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin
@@ -87,7 +87,10 @@ class ExpenseClaimItemActionMixin:
if payload.item_date is not None: if payload.item_date is not None:
item.item_date = payload.item_date item.item_date = payload.item_date
if payload.item_type is not None: 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: if payload.item_reason is not None:
item.item_reason = ( item.item_reason = (
self._normalize_optional_text(payload.item_reason, allow_empty=True) or "" 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 "" self._normalize_optional_text(payload.item_location, allow_empty=True) or ""
) )
if payload.item_note is not None: 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: if payload.item_amount is not None:
amount = payload.item_amount.quantize(Decimal("0.01")) amount = payload.item_amount.quantize(Decimal("0.01"))
if amount < Decimal("0.00"): if amount < Decimal("0.00"):
@@ -203,7 +208,9 @@ class ExpenseClaimItemActionMixin:
self._ensure_draft_claim(claim) self._ensure_draft_claim(claim)
before_json = self._serialize_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) self._attachment_storage.delete_item_files(item)
claim.items = [entry for entry in claim.items if entry.id != item.id] claim.items = [entry for entry in claim.items if entry.id != item.id]
@@ -266,8 +273,7 @@ class ExpenseClaimItemActionMixin:
raise RuntimeError("无法生成提交前预审结果。") raise RuntimeError("无法生成提交前预审结果。")
client_review_provided = bool( client_review_provided = bool(
str(pre_review_id or "").strip() str(pre_review_id or "").strip() or str(pre_review_input_fingerprint or "").strip()
or str(pre_review_input_fingerprint or "").strip()
) )
client_review_matches = pre_review_identity_matches( client_review_matches = pre_review_identity_matches(
pre_review_flag, pre_review_flag,
@@ -423,7 +429,9 @@ class ExpenseClaimItemActionMixin:
if not self._access_policy.has_claim_delete_access(current_user): if not self._access_policy.has_claim_delete_access(current_user):
self._ensure_draft_claim(claim) self._ensure_draft_claim(claim)
if not self._access_policy.is_claim_owned_by_current_user(claim, current_user): if not self._access_policy.is_claim_owned_by_current_user(claim, current_user):
raise ValueError("只有系统管理员或草稿、待补充、退回待提交阶段的申请人本人可以删除单据。") raise ValueError(
"只有系统管理员或草稿、待补充、退回待提交阶段的申请人本人可以删除单据。"
)
before_json = self._serialize_claim(claim) before_json = self._serialize_claim(claim)
resource_id = claim.id 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(RiskObservation).where(RiskObservation.claim_id == claim_id))
self.db.execute(delete(HermesRiskReport).where(HermesRiskReport.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() class ExpenseClaimService(
if normalized_status == "draft": ExpenseClaimActionProtocolMixin,
raise ValueError("草稿状态无需退回。") ExpenseClaimReturnFlowMixin,
if normalized_status == "returned": ExpenseClaimStandardAdjustmentMixin,
raise ValueError("该单据已处于退回待提交状态,无需重复退回。") ExpenseClaimItemActionMixin,
if normalized_status in {"approved", "completed", "paid"}: ExpenseClaimPaginationMixin,
raise ValueError("已完成单据不允许退回。") ExpenseClaimApprovalFlowMixin,
ExpenseClaimApprovalRoutingMixin,
if not self._access_policy.can_return_claim(current_user, claim): ExpenseClaimApplicationHandoffMixin,
raise ValueError("只有财务人员、高级财务人员或当前审批人可以退回报销单。") ExpenseClaimPreReviewMixin,
ExpenseClaimBudgetFlowMixin,
before_json = self._serialize_claim(claim) ExpenseClaimAttachmentOperationsMixin,
operator = self._access_policy.resolve_current_user_display_name(current_user) ExpenseClaimReviewPreviewMixin,
previous_status = str(claim.status or "").strip() ExpenseClaimDraftFlowMixin,
previous_stage = str(claim.approval_stage or "").strip() or "未标记审批环节" ExpenseClaimDraftPersistenceMixin,
previous_stage_key = self._normalize_return_stage_key(previous_stage) ExpenseClaimDocumentItemBuilderMixin,
is_application_claim = self._is_expense_application_claim(claim) ExpenseClaimDocumentParsingMixin,
is_direct_manager_return = previous_stage_key == "direct_manager" ExpenseClaimOntologyResolverMixin,
is_budget_return = previous_stage_key == "budget" ExpenseClaimAttachmentDocumentMixin,
is_application_return = is_application_claim and (is_direct_manager_return or is_budget_return) ExpenseClaimAttachmentAnalysisMixin,
return_event_type = ( ExpenseClaimReadModelMixin,
"expense_application_return" ExpenseClaimRiskReviewMixin,
if is_application_return ExpenseClaimWorkflowRepairMixin,
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):
def __init__(self, db: Session) -> None: def __init__(self, db: Session) -> None:
self.db = db self.db = db
self.audit_service = AuditLogService(db) self.audit_service = AuditLogService(db)
@@ -624,11 +509,15 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
def _is_expense_application_claim(claim: ExpenseClaim) -> bool: def _is_expense_application_claim(claim: ExpenseClaim) -> bool:
claim_no = str(getattr(claim, "claim_no", "") or "").strip().upper() claim_no = str(getattr(claim, "claim_no", "") or "").strip().upper()
expense_type = str(getattr(claim, "expense_type", "") or "").strip().lower() expense_type = str(getattr(claim, "expense_type", "") or "").strip().lower()
document_type = str( document_type = (
str(
getattr(claim, "document_type_code", "") getattr(claim, "document_type_code", "")
or getattr(claim, "document_type", "") or getattr(claim, "document_type", "")
or "" or ""
).strip().lower() )
.strip()
.lower()
)
return ( return (
is_application_claim_no(claim_no) is_application_claim_no(claim_no)
or expense_type == "application" or expense_type == "application"
@@ -667,7 +556,6 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
) )
stmt = self._access_policy.apply_claim_scope(stmt, current_user) stmt = self._access_policy.apply_claim_scope(stmt, current_user)
claims = list(self.db.scalars(stmt).all()) claims = list(self.db.scalars(stmt).all())
self._repair_duplicate_budget_approval_stages(claims)
return self._access_policy.attach_budget_approval_snapshots(claims) return self._access_policy.attach_budget_approval_snapshots(claims)
def list_approval_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]: 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) stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user)
claims = list(self.db.scalars(stmt).all()) claims = list(self.db.scalars(stmt).all())
self._repair_duplicate_budget_approval_stages(claims)
return self._access_policy.attach_budget_approval_snapshots(claims) return self._access_policy.attach_budget_approval_snapshots(claims)
def list_archived_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]: 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.organization_unit),
selectinload(ExpenseClaim.employee).selectinload(Employee.roles), 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) stmt = self._access_policy.apply_archived_claim_scope(stmt, current_user)
return list(self.db.scalars(stmt).all()) return list(self.db.scalars(stmt).all())
@@ -711,13 +602,15 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
) )
.where(ExpenseClaim.id == claim_id) .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) 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) 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: if claim is None:
return self._access_policy.is_budget_manager_user(current_user) return self._access_policy.is_budget_manager_user(current_user)
if current_user.is_admin: if current_user.is_admin:
@@ -725,10 +618,9 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
role_codes = self._access_policy.normalize_role_codes(current_user) role_codes = self._access_policy.normalize_role_codes(current_user)
if "executive" in role_codes: if "executive" in role_codes:
return True return True
if ( if self._access_policy.has_privileged_claim_access(
self._access_policy.has_privileged_claim_access(current_user) current_user
and not self._access_policy.is_claim_owned_by_current_user(claim, current_user) ) and not self._access_policy.is_claim_owned_by_current_user(claim, current_user):
):
return True return True
if self._access_policy.can_approve_claim(current_user, claim): if self._access_policy.can_approve_claim(current_user, claim):
return True return True
@@ -751,7 +643,9 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
before_json = self._serialize_claim(claim) before_json = self._serialize_claim(claim)
if payload.reason is not None: 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): if not self._is_expense_application_claim(claim):
self._refresh_claim_pre_review_flags(claim, is_application_claim=False) self._refresh_claim_pre_review_flags(claim, is_application_claim=False)

View File

@@ -40,39 +40,50 @@ class HermesRiskScannerService:
observation_service = RiskObservationService(self.db) observation_service = RiskObservationService(self.db)
observation_count = 0 observation_count = 0
scanned_claim_count = 0
graph_node_count = 0 graph_node_count = 0
graph_edge_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( result = evaluate_financial_risk_graph(
RiskGraphEvaluationContext( RiskGraphEvaluationContext(
claims=[ claims=[RiskGraphClaimSnapshot.from_orm(claim) for claim in tenant_claims],
RiskGraphClaimSnapshot.from_orm(claim)
for claim in tenant_claims
],
target_claim_ids={claim.id for claim in tenant_claims}, target_claim_ids={claim.id for claim in tenant_claims},
history_stats=observation_service.build_history_stats( history_stats=observation_service.build_history_stats(
tenant_id=tenant_id, tenant_id=tenant_id,
expense_types={ expense_types={str(claim.expense_type or "") for claim in tenant_claims},
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_node_count += len(result.nodes)
graph_edge_count += len(result.edges) graph_edge_count += len(result.edges)
observations_by_claim = {}
for observation in result.observations: for observation in result.observations:
claim = claims_by_id.get(observation.claim_id) observations_by_claim.setdefault(observation.claim_id, []).append(observation)
if claim is None:
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 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_service.upsert_observation(
observation, observation,
tenant_id=tenant_id, tenant_id=tenant_id,
run_id=run_id, run_id=run_id,
execution_log_id=log_id, execution_log_id=log_id,
claim_lock_acquired=True,
) )
observation_count += 1
claim.hermes_risk_flag = True claim.hermes_risk_flag = True
claim.risk_flags_json = self._append_algorithm_flag( claim.risk_flags_json = self._append_algorithm_flag(
claim, claim,
@@ -93,10 +104,8 @@ class HermesRiskScannerService:
], ],
) )
) )
now = datetime.now(timezone.utc)
for claim in claims:
claim.hermes_scanned_at = now claim.hermes_scanned_at = now
scanned_claim_count += 1
self.db.commit() self.db.commit()
logger.info( logger.info(
@@ -104,7 +113,7 @@ class HermesRiskScannerService:
observation_count, observation_count,
) )
return { return {
"scanned_claim_count": len(claims), "scanned_claim_count": scanned_claim_count,
"risk_observation_count": observation_count, "risk_observation_count": observation_count,
"graph_node_count": graph_node_count, "graph_node_count": graph_node_count,
"graph_edge_count": graph_edge_count, "graph_edge_count": graph_edge_count,
@@ -134,11 +143,16 @@ class HermesRiskScannerService:
ExpenseClaim.hermes_risk_flag.is_(False), ExpenseClaim.hermes_risk_flag.is_(False),
), ),
) )
.order_by(ExpenseClaim.id)
.limit(50) .limit(50)
) )
return list(self.db.scalars(stmt).all()) 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 @staticmethod
def _append_algorithm_flag(claim: ExpenseClaim, observation: dict) -> list: def _append_algorithm_flag(claim: ExpenseClaim, observation: dict) -> list:
existing = list(claim.risk_flags_json or []) existing = list(claim.risk_flags_json or [])
@@ -155,8 +169,7 @@ class HermesRiskScannerService:
"reimbursement", "reimbursement",
) )
if any( if any(
isinstance(item, dict) isinstance(item, dict) and item.get("observation_key") == flag["observation_key"]
and item.get("observation_key") == flag["observation_key"]
for item in existing for item in existing
): ):
return existing return existing

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ from app.schemas.risk_observation import (
RiskObservationFeedbackCreate, RiskObservationFeedbackCreate,
) )
from app.services.expense_claim_risk_stage import normalize_risk_business_stage from app.services.expense_claim_risk_stage import normalize_risk_business_stage
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
logger = get_logger("app.services.risk_observations") logger = get_logger("app.services.risk_observations")
@@ -66,6 +67,7 @@ class RiskObservationService:
tenant_id: str | None = None, tenant_id: str | None = None,
run_id: str | None = None, run_id: str | None = None,
execution_log_id: str | None = None, execution_log_id: str | None = None,
claim_lock_acquired: bool = False,
) -> RiskObservation: ) -> RiskObservation:
self.ensure_storage_ready() self.ensure_storage_ready()
payload = ( payload = (
@@ -80,6 +82,12 @@ class RiskObservationService:
tenant_id=tenant_id or _optional_text(payload.get("tenant_id")), 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")),
) )
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( item = self.db.scalar(
select(RiskObservation).where( select(RiskObservation).where(
@@ -126,6 +134,27 @@ class RiskObservationService:
self.db.flush() self.db.flush()
return item 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( def upsert_platform_risk_flags(
self, self,
claim: ExpenseClaim, claim: ExpenseClaim,
@@ -154,9 +183,7 @@ class RiskObservationService:
score = SEVERITY_SCORE.get(severity, SEVERITY_SCORE["medium"]) score = SEVERITY_SCORE.get(severity, SEVERITY_SCORE["medium"])
rule_code = _text(flag.get("rule_code")) rule_code = _text(flag.get("rule_code"))
business_stage = normalize_risk_business_stage(flag.get("business_stage")) business_stage = normalize_risk_business_stage(flag.get("business_stage"))
observation_key = ( observation_key = f"risk:{claim.id}:platform:{rule_code or signal}"
f"risk:{claim.id}:platform:{rule_code or signal}"
)
observations.append( observations.append(
self.upsert_observation( self.upsert_observation(
{ {
@@ -176,9 +203,7 @@ class RiskObservationService:
"control_stage": business_stage, "control_stage": business_stage,
"control_mode": "risk_observation", "control_mode": "risk_observation",
"automation_mode": ( "automation_mode": (
"semi_auto_review" "semi_auto_review" if severity in HIGH_LEVELS else "manual_review"
if severity in HIGH_LEVELS
else "manual_review"
), ),
"source": "rule_center", "source": "rule_center",
"algorithm_version": _text(flag.get("rule_version")) or "v1.0.0", "algorithm_version": _text(flag.get("rule_version")) or "v1.0.0",
@@ -357,6 +382,24 @@ class RiskObservationService:
observation_key_or_id, observation_key_or_id,
tenant_id=tenant_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: if observation is None:
raise LookupError("Risk observation not found.") raise LookupError("Risk observation not found.")
@@ -505,12 +548,16 @@ class RiskObservationService:
) -> str: ) -> str:
explicit_tenant_id = str(tenant_id or "").strip() explicit_tenant_id = str(tenant_id or "").strip()
normalized_claim_id = str(claim_id or "").strip() normalized_claim_id = str(claim_id or "").strip()
linked_tenant_id = self.db.scalar( linked_tenant_id = (
self.db.scalar(
select(ExpenseCaseLink.tenant_id).where( select(ExpenseCaseLink.tenant_id).where(
ExpenseCaseLink.resource_type == "expense_claim", ExpenseCaseLink.resource_type == "expense_claim",
ExpenseCaseLink.resource_id == normalized_claim_id, 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) claim_tenant_id = _normalize_tenant_id(linked_tenant_id)
if explicit_tenant_id: if explicit_tenant_id:
normalized_tenant_id = _normalize_tenant_id(explicit_tenant_id) normalized_tenant_id = _normalize_tenant_id(explicit_tenant_id)

View File

@@ -28,7 +28,7 @@ from app.models.risk_observation import RiskObservation
MIGRATION_TEST_DATABASE_URL = os.getenv("MIGRATION_TEST_DATABASE_URL", "").strip() MIGRATION_TEST_DATABASE_URL = os.getenv("MIGRATION_TEST_DATABASE_URL", "").strip()
LEGACY_PROBE_TABLE = "legacy_migration_probe_records" LEGACY_PROBE_TABLE = "legacy_migration_probe_records"
HEAD_REVISION = "20260716_0009" HEAD_REVISION = "20260716_0011"
SERVER_DIR = Path(__file__).resolve().parents[1] SERVER_DIR = Path(__file__).resolve().parents[1]
ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini" ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini"
@@ -357,12 +357,150 @@ def _assert_head_schema(engine: Engine) -> None:
"uq_risk_observations_tenant_key", "uq_risk_observations_tenant_key",
("tenant_id", "observation_key"), ("tenant_id", "observation_key"),
) )
_assert_unique_constraint(
engine,
"risk_observations",
"uq_risk_observations_tenant_id",
("tenant_id", "id"),
)
_assert_unique_constraint( _assert_unique_constraint(
engine, engine,
"few_shot_samples", "few_shot_samples",
"uq_few_shot_samples_tenant_key", "uq_few_shot_samples_tenant_key",
("tenant_id", "sample_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( _assert_check_constraint(
engine, engine,
"memory_entries", "memory_entries",
@@ -972,12 +1110,15 @@ def _create_hierarchical_memory_downgrade_probe(engine: Engine) -> None:
""" """
) )
) )
assert connection.scalar( assert (
connection.scalar(
text( text(
"SELECT COUNT(*) FROM memory_entries " "SELECT COUNT(*) FROM memory_entries "
"WHERE id = 'hierarchical-memory-downgrade-probe'" "WHERE id = 'hierarchical-memory-downgrade-probe'"
) )
) == 1 )
== 1
)
def _create_duplicate_active_organization_memory_probe(engine: Engine) -> None: 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: def _delete_enriched_few_shot_downgrade_probe(engine: Engine) -> None:
with engine.begin() as connection: with engine.begin() as connection:
connection.execute( connection.execute(
text( text("DELETE FROM few_shot_samples WHERE id = 'enriched-few-shot-downgrade-probe'")
"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: def _assert_historical_case_downgrade_probe(engine: Engine) -> None:
inspector = inspect(engine) inspector = inspect(engine)
risk_columns = { risk_columns = {str(item["name"]) for item in inspector.get_columns("risk_observations")}
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")}
}
sample_columns = {
str(item["name"]) for item in inspector.get_columns("few_shot_samples")
}
assert "tenant_id" not in risk_columns assert "tenant_id" not in risk_columns
assert {"tenant_id", "policy_ref", "rule_version"}.isdisjoint(sample_columns) assert {"tenant_id", "policy_ref", "rule_version"}.isdisjoint(sample_columns)
assert not any( 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") for item in inspector.get_foreign_keys("risk_observations")
) )
with engine.connect() as connection: with engine.connect() as connection:
assert connection.scalar( assert (
connection.scalar(
text( text(
"SELECT COUNT(*) FROM risk_observations " "SELECT COUNT(*) FROM risk_observations "
"WHERE id = 'historical-downgrade-observation'" "WHERE id = 'historical-downgrade-observation'"
) )
) == 1 )
assert connection.scalar( == 1
)
assert (
connection.scalar(
text( text(
"SELECT COUNT(*) FROM risk_observation_feedback " "SELECT COUNT(*) FROM risk_observation_feedback "
"WHERE id = 'historical-downgrade-feedback'" "WHERE id = 'historical-downgrade-feedback'"
) )
) == 1
assert connection.scalar(
text(
"SELECT COUNT(*) FROM few_shot_samples "
"WHERE id = 'historical-downgrade-sample'"
) )
) == 1 == 1
)
assert (
connection.scalar(
text(
"SELECT COUNT(*) FROM few_shot_samples WHERE id = 'historical-downgrade-sample'"
)
)
== 1
)
def _assert_legacy_sentinel(engine: Engine) -> None: 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_0008_tenant_safe_historical_cases.py", "downgrade"),
("20260716_0009_organization_memory_idempotency.py", "upgrade"), ("20260716_0009_organization_memory_idempotency.py", "upgrade"),
("20260716_0009_organization_memory_idempotency.py", "downgrade"), ("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( def test_postgresql_only_migrations_reject_other_dialects_before_mutation(

View File

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

View File

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

View File

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

View File

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

View File

@@ -38,7 +38,6 @@ from app.services.expense_claim_workflow_constants import (
APPROVAL_DONE_STAGE, APPROVAL_DONE_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE, BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE, DIRECT_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE,
) )
from app.services.expense_claims import ExpenseClaimService from app.services.expense_claims import ExpenseClaimService
from app.services.ocr import OcrService 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.add(employee)
db.flush() db.flush()
db.add(build_application_claim( db.add(
build_application_claim(
id="application-linked-1", id="application-linked-1",
claim_no="AP-202605-001", claim_no="AP-202605-001",
employee=employee, employee=employee,
)) )
)
db.commit() db.commit()
ontology = SemanticOntologyService(db).parse( ontology = SemanticOntologyService(db).parse(
OntologyParseRequest( OntologyParseRequest(
@@ -554,11 +555,13 @@ def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_ite
) )
db.add(employee) db.add(employee)
db.flush() db.flush()
db.add(build_application_claim( db.add(
build_application_claim(
id="application-linked-no-receipt", id="application-linked-no-receipt",
claim_no="AP-202606-001", claim_no="AP-202606-001",
employee=employee, employee=employee,
)) )
)
db.commit() db.commit()
ontology = SemanticOntologyService(db).parse( ontology = SemanticOntologyService(db).parse(
OntologyParseRequest( 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_claim_no"] == "AP-202606-001"
assert link_flag["application_detail"]["application_time"] == "2026-02-20 至 2026-02-23" 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_date"] == "2026-06-02T00:58:00Z"
assert link_flag["application_detail"]["application_amount"] == "3000" assert link_flag["application_detail"]["application_amount"] == "3000"
assert link_flag["application_detail"]["application_days"] == "4 天" 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.add(employee)
db.flush() db.flush()
db.add(build_application_claim( db.add(
build_application_claim(
id="application-linked-existing-placeholder", id="application-linked-existing-placeholder",
claim_no="AP-202606-002", claim_no="AP-202606-002",
employee=employee, employee=employee,
)) )
)
existing_claim = ExpenseClaim( existing_claim = ExpenseClaim(
claim_no="RE-202606020001-PLACEHOLDER", claim_no="RE-202606020001-PLACEHOLDER",
employee_id=employee.id, 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) employee = Employee(employee_no="E5108", name="Linked Employee", email=user_id)
db.add(employee) db.add(employee)
db.flush() db.flush()
db.add(build_application_claim( db.add(
build_application_claim(
id="application-returned-blocked", id="application-returned-blocked",
claim_no="AP-202606-STATUS", claim_no="AP-202606-STATUS",
employee=employee, employee=employee,
status="returned", status="returned",
)) )
)
db.commit() db.commit()
ontology = SemanticOntologyService(db).parse( 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) employee = Employee(employee_no="E5109", name="Linked Employee", email=user_id)
db.add(employee) db.add(employee)
db.flush() db.flush()
db.add(build_application_claim( db.add(
build_application_claim(
id="application-duplicate-blocked", id="application-duplicate-blocked",
claim_no="AP-202606-DUP", claim_no="AP-202606-DUP",
employee=employee, employee=employee,
)) )
)
existing_claim = ExpenseClaim( existing_claim = ExpenseClaim(
claim_no="RE-202606-DUP-DRAFT", claim_no="RE-202606-DUP-DRAFT",
employee_id=employee.id, 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: def test_resolve_expense_type_maps_office_supplies_review_value_to_office() -> None:
expense_type = ExpenseClaimService._resolve_expense_type( expense_type = ExpenseClaimService._resolve_expense_type(
[], [],
context_json={ context_json={"review_form_values": {"expense_type": "办公用品"}},
"review_form_values": {
"expense_type": "办公用品"
}
},
) )
assert expense_type == "office" 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: def test_resolve_expense_type_maps_riding_fare_review_value_to_transport() -> None:
expense_type = ExpenseClaimService._resolve_expense_type( expense_type = ExpenseClaimService._resolve_expense_type(
[], [],
context_json={ context_json={"review_form_values": {"expense_type": "乘车费用"}},
"review_form_values": {
"expense_type": "乘车费用"
}
},
) )
assert expense_type == "transport" assert expense_type == "transport"
@@ -1340,7 +1344,9 @@ def test_upsert_draft_from_ontology_supports_link_or_create_for_multi_documents(
"text": "停车费 合计 18 元", "text": "停车费 合计 18 元",
"document_type": "parking_toll_receipt", "document_type": "parking_toll_receipt",
"scene_code": "transport", "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 refreshed_meta["requirement_check"]["matches"] is False
assert any("附件类型要求" in point for point in refreshed_meta["analysis"]["points"]) assert any("附件类型要求" in point for point in refreshed_meta["analysis"]["points"])
def test_upload_attachment_refreshes_claim_pre_review(monkeypatch, tmp_path) -> None: def test_upload_attachment_refreshes_claim_pre_review(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext( current_user = CurrentUserContext(
username="emp-1", 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"] flags = payload["claim_risk_flags"]
assert any( assert any(
isinstance(flag, dict) isinstance(flag, dict) and flag.get("rule_code") == "risk.travel.high.city_mismatch"
and flag.get("rule_code") == "risk.travel.high.city_mismatch"
for flag in flags for flag in flags
) )
city_flag = next( city_flag = next(
flag flag
for flag in flags for flag in flags
if isinstance(flag, dict) if isinstance(flag, dict) and flag.get("rule_code") == "risk.travel.high.city_mismatch"
and flag.get("rule_code") == "risk.travel.high.city_mismatch"
) )
assert city_flag.get("item_ids") == [claim.items[0].id] 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"] flags = payload["claim_risk_flags"]
assert any( assert any(
isinstance(flag, dict) isinstance(flag, dict) and flag.get("rule_code") == "risk.travel.high.date_outside_trip"
and flag.get("rule_code") == "risk.travel.high.date_outside_trip"
for flag in flags 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 is not None
assert uploaded_meta["analysis"]["severity"] == "medium" assert uploaded_meta["analysis"]["severity"] == "medium"
assert any("费用核算" in point and "828.00 元" in point for point in uploaded_meta["analysis"]["points"]) assert any(
assert not any("2026.00 元与报销金额" in point for point in uploaded_meta["analysis"]["points"]) "费用核算" 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: 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( current_user = CurrentUserContext(
username="emp-hotel-risk@example.com", username="emp-hotel-risk@example.com",
name="张三", name="张三",
@@ -2964,7 +2975,8 @@ def test_delete_claim_item_attachment_removes_attachment_analysis_risk(monkeypat
assert upload_payload is not None assert upload_payload is not None
assert any( 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"] 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 is not None
assert delete_payload["invoice_id"] is None assert delete_payload["invoice_id"] is None
assert not any( 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"] for flag in delete_payload["claim_risk_flags"]
) )
assert not any( 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.invoice_count == 0
assert claim.items[0].invoice_id is None assert claim.items[0].invoice_id is None
assert not any( 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 []) 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 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( current_user = CurrentUserContext(
username="emp-1", username="emp-1",
name="张三", name="张三",
@@ -3316,7 +3332,9 @@ def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(mon
assert filename == "legacy-ticket.pdf" 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( current_user = CurrentUserContext(
username="emp-1", username="emp-1",
name="张三", 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): def fake_render_pdf_first_page(*, pdf_path, preview_path, timeout_seconds):
raise RuntimeError("Missing language pack for 'Adobe-GB1' mapping") 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, claim_id=claim.id,
item_id=claim.items[0].id, item_id=claim.items[0].id,
current_user=current_user, 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.approval_stage == "直属领导审批"
assert submitted.submitted_at is not None assert submitted.submitted_at is not None
def test_submit_claim_refreshes_legacy_pre_review_without_fingerprint(monkeypatch) -> None: def test_submit_claim_refreshes_legacy_pre_review_without_fingerprint(monkeypatch) -> None:
current_user = CurrentUserContext( current_user = CurrentUserContext(
username="emp-submit@example.com", 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 submitted.status == "submitted"
assert review_calls == 1 assert review_calls == 1
assert not any( assert not any(
flag.get("label") == "upload-time-warning" flag.get("label") == "upload-time-warning" for flag in submitted.risk_flags_json
for flag in submitted.risk_flags_json
) )
pre_review_flag = next( pre_review_flag = next(
flag flag for flag in submitted.risk_flags_json if flag.get("source") == "ai_pre_review"
for flag in submitted.risk_flags_json
if flag.get("source") == "ai_pre_review"
) )
assert pre_review_flag["review_id"] assert pre_review_flag["review_id"]
assert pre_review_flag["input_fingerprint"].startswith("sha256:") 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 blocked.submitted_at is None
assert error_info.value.review["decision"] == "needs_fix" assert error_info.value.review["decision"] == "needs_fix"
assert any( assert any(
finding["severity"] == "high" finding["severity"] == "high" and finding["disposition"] == "fix"
and finding["disposition"] == "fix"
for finding in error_info.value.review["findings"] 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"] if "多城市" in finding["message"] or "终点" in finding["message"]
] ]
assert route_findings assert route_findings
assert any( assert any("travel-item-2" in finding["item_ids"] for finding in route_findings)
"travel-item-2" in finding["item_ids"]
for finding in route_findings
)
def test_submit_claim_allows_round_trip_ticket_origin_inferred_from_route( 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 blocked.status == "draft"
assert error_info.value.review["decision"] == "needs_fix" assert error_info.value.review["decision"] == "needs_fix"
assert any( assert any(
finding.get("remediation", {}).get("alternative_action") finding.get("remediation", {}).get("alternative_action") == "accept_standard_limit"
== "accept_standard_limit"
for finding in error_info.value.review["findings"] for finding in error_info.value.review["findings"]
) )
assert any( assert any(
@@ -5360,12 +5375,15 @@ def test_admin_delete_linked_reimbursement_resets_application_link_status() -> N
sync_flag = next( sync_flag = next(
flag flag
for flag in application_claim.risk_flags_json 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["source"] == "application_link_sync"
assert sync_flag["severity"] == "info" assert sync_flag["severity"] == "info"
assert sync_flag["actionability"] == "system_trace" 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["deleted_reimbursement_claim_no"] == "RDELETE01"
assert sync_flag["next_approval_stage"] == APPLICATION_LINK_STATUS_STAGE 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() db.commit()
claim_id = claim.id 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 is not None
assert returned.status == "returned" assert returned.status == "returned"
@@ -5603,6 +5623,7 @@ def test_direct_manager_budget_monitor_routes_reimbursement_directly_to_finance(
{ {
"source": "submission_review", "source": "submission_review",
"severity": "high", "severity": "high",
"actionability": "route_review",
"label": "报销风险复核", "label": "报销风险复核",
"message": "多城市行程和住宿超标需要预算管理者二次确认。", "message": "多城市行程和住宿超标需要预算管理者二次确认。",
} }
@@ -5621,8 +5642,7 @@ def test_direct_manager_budget_monitor_routes_reimbursement_directly_to_finance(
assert approved.status == "submitted" assert approved.status == "submitted"
assert approved.approval_stage == "财务审批" assert approved.approval_stage == "财务审批"
assert not any( assert not any(
isinstance(flag, dict) isinstance(flag, dict) and flag.get("next_approval_stage") == "预算管理者审批"
and flag.get("next_approval_stage") == "预算管理者审批"
for flag in approved.risk_flags_json for flag in approved.risk_flags_json
) )
assert any( 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_status") == "submitted"
and flag.get("next_approval_stage") == "财务审批" and flag.get("next_approval_stage") == "财务审批"
and flag.get("budget_approval_merged") is True 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 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( admin_user = CurrentUserContext(
username="admin", username="admin",
name="admin", name="admin",
@@ -5706,20 +5727,25 @@ def test_duplicate_budget_stage_from_legacy_reimbursement_is_repaired_on_read()
db.add(claim) db.add(claim)
db.commit() 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 result is not None
assert repaired.approval_stage == FINANCE_APPROVAL_STAGE assert result.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE
assert any( assert not any(
isinstance(flag, dict) isinstance(flag, dict)
and flag.get("source") == "approval_flow_repair" and flag.get("source") == "approval_flow_repair"
and flag.get("event_type") == "duplicate_budget_approval_stage_repaired" and flag.get("event_type") == "duplicate_budget_approval_stage_repaired"
and flag.get("next_approval_stage") == FINANCE_APPROVAL_STAGE for flag in result.risk_flags_json
for flag in repaired.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( current_user = CurrentUserContext(
username="application-owner@example.com", username="application-owner@example.com",
name="张三", 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( manager_user = CurrentUserContext(
username="manager-application-approve@example.com", username="manager-application-approve@example.com",
name="李经理", name="李经理",
@@ -6053,9 +6081,10 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
{ {
"source": "submission_review", "source": "submission_review",
"severity": "high", "severity": "high",
"actionability": "route_review",
"label": "申请风险复核", "label": "申请风险复核",
"message": "申请金额和行程安排需要预算管理者二次确认。", "message": "申请金额和行程安排需要预算管理者二次确认。",
} },
], ],
) )
db.add(claim) 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("source") == "application_handoff"
and flag.get("event_type") == "expense_application_to_reimbursement_draft" and flag.get("event_type") == "expense_application_to_reimbursement_draft"
and flag.get("application_claim_no") == "APP-20260525-APPROVE" and flag.get("application_claim_no") == "APP-20260525-APPROVE"
and flag.get("application_detail", {}).get("application_content") == "差旅费用申请 / 上海" and flag.get("application_detail", {}).get("application_content")
and flag.get("application_detail", {}).get("application_reason") == "支撑国网服务器上线部署" == "差旅费用申请 / 上海"
and flag.get("application_detail", {}).get("application_reason")
== "支撑国网服务器上线部署"
and flag.get("application_detail", {}).get("application_days") == "3 天" 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_transport_mode") == "高铁"
and flag.get("application_detail", {}).get("application_lodging_daily_cap") == "600元/天" and flag.get("application_detail", {}).get("application_lodging_daily_cap")
and flag.get("application_detail", {}).get("application_subsidy_daily_cap") == "120元/天" == "600元/天"
and flag.get("application_detail", {}).get("application_transport_policy") == "按真实票据复核" 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") and flag.get("application_detail", {}).get("application_policy_estimate")
== "交通按真实票据 + 住宿 1,800元 + 补贴 360元" == "交通按真实票据 + 住宿 1,800元 + 补贴 360元"
and flag.get("application_detail", {}).get("application_rule_name") == "差旅标准规则" 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", "source": "submission_review",
"severity": "high", "severity": "high",
"actionability": "route_review",
"label": "Route risk", "label": "Route risk",
"message": "Application requires budget confirmation.", "message": "Application requires budget confirmation.",
} }
@@ -6306,6 +6341,7 @@ def test_direct_manager_cannot_route_application_to_missing_budget_approver() ->
{ {
"source": "submission_review", "source": "submission_review",
"severity": "high", "severity": "high",
"actionability": "route_review",
"label": "Route risk", "label": "Route risk",
"message": "Application requires budget confirmation.", "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 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( manager_user = CurrentUserContext(
username="manager-executive-merged@example.com", username="manager-executive-merged@example.com",
name="P8 Manager", name="P8 Manager",
@@ -6381,6 +6419,7 @@ def test_direct_manager_p8_executive_completes_application_without_duplicate_bud
{ {
"source": "submission_review", "source": "submission_review",
"severity": "high", "severity": "high",
"actionability": "route_review",
"label": "Route risk", "label": "Route risk",
"message": "Application requires budget confirmation.", "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_status") == "approved"
and flag.get("next_approval_stage") == APPLICATION_LINK_STATUS_STAGE and flag.get("next_approval_stage") == APPLICATION_LINK_STATUS_STAGE
and flag.get("budget_approval_merged") is True 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 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( manager_user = CurrentUserContext(
username="manager-budget-monitor-application@example.com", username="manager-budget-monitor-application@example.com",
name="李预算经理", name="李预算经理",
@@ -6469,6 +6511,7 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli
{ {
"source": "submission_review", "source": "submission_review",
"severity": "high", "severity": "high",
"actionability": "route_review",
"label": "申请风险复核", "label": "申请风险复核",
"message": "申请金额和行程安排需要预算管理者二次确认。", "message": "申请金额和行程安排需要预算管理者二次确认。",
} }
@@ -6489,8 +6532,7 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli
assert approved.approval_stage == "关联单据状态" assert approved.approval_stage == "关联单据状态"
assert reimbursement_claim_query(db).count() == 1 assert reimbursement_claim_query(db).count() == 1
assert not any( assert not any(
isinstance(flag, dict) isinstance(flag, dict) and flag.get("next_approval_stage") == "预算管理者审批"
and flag.get("next_approval_stage") == "预算管理者审批"
for flag in approved.risk_flags_json for flag in approved.risk_flags_json
) )
assert any( 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_status") == "approved"
and flag.get("next_approval_stage") == "关联单据状态" and flag.get("next_approval_stage") == "关联单据状态"
and flag.get("budget_approval_merged") is True 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 for flag in approved.risk_flags_json
) )
generated_draft = reimbursement_claim_query(db).one() generated_draft = reimbursement_claim_query(db).one()
@@ -6692,6 +6735,7 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf
{ {
"source": "platform_risk", "source": "platform_risk",
"severity": "high", "severity": "high",
"actionability": "route_review",
"label": "申请风险复核", "label": "申请风险复核",
"message": "申请金额和行程安排需要预算管理者二次确认。", "message": "申请金额和行程安排需要预算管理者二次确认。",
} }
@@ -6719,10 +6763,11 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf
assert reservation.source_type == "claim" assert reservation.source_type == "claim"
assert reservation.source_id == generated_draft.id assert reservation.source_id == generated_draft.id
assert reservation.source_no == generated_draft.claim_no assert reservation.source_no == generated_draft.claim_no
assert any(item.transaction_type == "transfer" for item in db.query(BudgetTransaction).all())
assert any( assert any(
isinstance(flag, dict) item.transaction_type == "transfer" for item in db.query(BudgetTransaction).all()
and flag.get("event_type") == "budget_reservation_transferred" )
assert any(
isinstance(flag, dict) and flag.get("event_type") == "budget_reservation_transferred"
for flag in generated_draft.risk_flags_json for flag in generated_draft.risk_flags_json
) )
@@ -6916,7 +6961,12 @@ def test_finance_approve_reimbursement_consumes_budget_reservation() -> None:
db.refresh(reservation) db.refresh(reservation)
assert reservation.source_status == "consumed" assert reservation.source_status == "consumed"
assert reservation.consumed_amount == Decimal("12000.00") 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( assert any(
isinstance(flag, dict) isinstance(flag, dict)
and flag.get("source") == "budget_control" 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]["stage_return_count"] == 1
assert return_events[0]["return_stage"] == "直属领导审批" assert return_events[0]["return_stage"] == "直属领导审批"
assert return_events[0]["reason_codes"] == ["invoice_mismatch", "business_explanation"] 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]["reason"] == "发票金额与明细金额不一致,请重新核对。"
assert return_events[0]["operator_role_codes"] == ["manager"] assert return_events[0]["operator_role_codes"] == ["manager"]
assert return_events[1]["return_count"] == 2 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", email="budget-list-market@example.com",
organization_unit=market_department, organization_unit=market_department,
) )
db.add_all([ db.add_all(
[
delivery_department, delivery_department,
market_department, market_department,
budget_manager, budget_manager,
p8_without_budget_employee, p8_without_budget_employee,
employee, employee,
market_employee, market_employee,
]) ]
)
db.flush() db.flush()
db.add_all( 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_name", "") == "赵预算"
assert getattr(claims[0], "budget_approver_grade", "") == "P8" assert getattr(claims[0], "budget_approver_grade", "") == "P8"
assert getattr(claims[0], "budget_approver_role_code", "") == "budget_monitor" 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] == [] assert [claim.claim_no for claim in claims_without_budget_role] == []

View File

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

View File

@@ -9,7 +9,7 @@ from decimal import Decimal
import pytest import pytest
from auth_helpers import install_legacy_header_auth_override from auth_helpers import install_legacy_header_auth_override
from fastapi.testclient import TestClient 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.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
@@ -17,6 +17,7 @@ from app.api.deps import get_db
from app.db.base import Base from app.db.base import Base
from app.main import create_app from app.main import create_app
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome 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.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
from app.models.employee import Employee from app.models.employee import Employee
from app.models.expense_case import BusinessEvent, ExpenseCaseLink 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 is not None
assert claim.status == "draft" assert claim.status == "draft"
assert claim.submitted_at is None assert claim.submitted_at is None
assert db.scalar( assert (
db.scalar(
select(BusinessEvent).where( select(BusinessEvent).where(
BusinessEvent.aggregate_id == claim.id, BusinessEvent.aggregate_id == claim.id,
BusinessEvent.event_type == "claim_submitted", BusinessEvent.event_type == "claim_submitted",
) )
) is None )
is None
)
def test_claim_submit_returns_changed_conflict_when_dynamic_review_changes( 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() db.commit()
headers = {"x-auth-username": "qianqi@example.com"} 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 assert response.status_code == 200
payload = response.json() payload = response.json()
assert payload["finance_owner_name"] == "Wang Finance Group" 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() meta_payload = meta_response.json()
assert meta_payload["media_type"] == "image/png" assert meta_payload["media_type"] == "image/png"
assert meta_payload["preview_kind"] == "image" 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["analysis"]["headline"]
assert meta_payload["document_info"]["fields"][0]["label"] == "金额" 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 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( def fake_recognize(
self, self,
files: list[tuple[str, bytes, str | None]], 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 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( def fake_recognize(
self, self,
files: list[tuple[str, bytes, str | None]], 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.add_all([manager, employee, claim])
db.commit() db.commit()
response = client.post( action_headers = {
"/api/v1/reimbursements/claims/claim-approve-1/approve",
json={"opinion": "情况属实,同意报销。"},
headers={
"X-Auth-Username": "manager-approve-api@example.com", "X-Auth-Username": "manager-approve-api@example.com",
"X-Auth-Name": "manager-approve-api@example.com", "X-Auth-Name": "manager-approve-api@example.com",
"X-Auth-Role-Codes": "manager", "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": "情况属实,同意报销。",
"request_id": "approve-api-claim-1",
"expected_status": "submitted",
"expected_approval_stage": "直属领导审批",
},
headers=action_headers,
) )
assert response.status_code == 200 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"] for item in payload["risk_flags_json"]
) )
approval_events = [ approval_events = [
item item for item in payload["risk_flags_json"] if item["source"] == "manual_approval"
for item in payload["risk_flags_json"]
if item["source"] == "manual_approval"
] ]
assert approval_events[0]["operator"] == "李经理" assert approval_events[0]["operator"] == "李经理"
assert "manager-approve-api@example.com" not in approval_events[0]["message"] 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: def test_approve_application_endpoint_routes_direct_manager_review_to_budget_review() -> None:
client, session_factory = build_client() client, session_factory = build_client()
@@ -769,6 +922,7 @@ def test_approve_application_endpoint_routes_direct_manager_review_to_budget_rev
{ {
"source": "submission_review", "source": "submission_review",
"severity": "high", "severity": "high",
"actionability": "route_review",
"label": "申请风险复核", "label": "申请风险复核",
"message": "申请金额和行程安排需要预算管理者二次确认。", "message": "申请金额和行程安排需要预算管理者二次确认。",
} }
@@ -779,7 +933,12 @@ def test_approve_application_endpoint_routes_direct_manager_review_to_budget_rev
response = client.post( response = client.post(
"/api/v1/reimbursements/claims/claim-application-approve-1/approve", "/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={ headers={
"X-Auth-Username": "manager-application-approve-api@example.com", "X-Auth-Username": "manager-application-approve-api@example.com",
"X-Auth-Name": "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 assert upload_response.status_code == 200
meta_payload = upload_response.json()["attachment"] meta_payload = upload_response.json()["attachment"]
assert meta_payload["preview_kind"] == "image" 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")) meta_path = next(tmp_path.rglob("invoice.pdf.meta.json"))
stored_meta = json.loads(meta_path.read_text(encoding="utf-8")) stored_meta = json.loads(meta_path.read_text(encoding="utf-8"))
assert stored_meta["preview_rendered_with"] == DocumentPreviewAssets.PDF_RENDERER_ID 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 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) monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
client, session_factory = build_client() 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.outcome_type == "application_submitted"
assert outcome.business_event_id == event.id assert outcome.business_event_id == event.id
assert any( assert any(
isinstance(flag, dict) isinstance(flag, dict) and flag.get("event_type") == "expense_application_submission"
and flag.get("event_type") == "expense_application_submission"
for flag in list(claim.risk_flags_json or []) 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()) == [] 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) monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
client, session_factory = build_client() 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.approval_stage == "待提交"
assert claim.submitted_at is None assert claim.submitted_at is None
assert claim.employee_name == "张三" assert claim.employee_name == "张三"
assert db.scalar( assert (
select(BudgetReservation).where(BudgetReservation.source_id == claim.id) db.scalar(select(BudgetReservation).where(BudgetReservation.source_id == claim.id))
) is None is None
event = db.scalar(
select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id)
) )
event = db.scalar(select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id))
assert event is not None assert event is not None
assert event.event_type == "claim_draft_created" assert event.event_type == "claim_draft_created"
assert event.payload_json["previous_status"] == "" assert event.payload_json["previous_status"] == ""
assert event.payload_json["next_status"] == "draft" assert event.payload_json["next_status"] == "draft"
link = db.scalar( link = db.scalar(select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id))
select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id)
)
assert link is not None assert link is not None
assert link.relation_type == "application" 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.reason == "其他员工原申请"
assert persisted.status == "returned" assert persisted.status == "returned"
assert persisted.approval_stage == "退回补充" assert persisted.approval_stage == "退回补充"
assert db.scalar( assert (
select(BusinessEvent).where(BusinessEvent.aggregate_id == persisted.id) db.scalar(select(BusinessEvent).where(BusinessEvent.aggregate_id == persisted.id))
) is None is None
)
def test_application_preview_action_reuses_created_draft_for_identical_retry() -> 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: with session_factory() as db:
application_claims = list( application_claims = list(
db.scalars( db.scalars(
select(ExpenseClaim).where( select(ExpenseClaim).where(ExpenseClaim.expense_type == "travel_application")
ExpenseClaim.expense_type == "travel_application"
)
).all() ).all()
) )
assert len(application_claims) == 1 assert len(application_claims) == 1
events = list( events = list(
db.scalars( db.scalars(
select(BusinessEvent).where( select(BusinessEvent).where(BusinessEvent.aggregate_id == application_claims[0].id)
BusinessEvent.aggregate_id == application_claims[0].id
)
).all() ).all()
) )
assert len(events) == 1 assert len(events) == 1

View File

@@ -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": {},
}

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Generator from collections.abc import Generator
from datetime import UTC, datetime from datetime import UTC, datetime, timedelta
from decimal import Decimal from decimal import Decimal
from types import SimpleNamespace 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.financial_record import ExpenseClaim
from app.models.risk_observation import RiskObservation from app.models.risk_observation import RiskObservation
from app.schemas.risk_observation import RiskObservationFeedbackCreate 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.hermes_risk_scanner import HermesRiskScannerService
from app.services.risk_observations import RiskObservationService 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} 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: def test_risk_observation_storage_ready_is_cached_per_bind(monkeypatch: pytest.MonkeyPatch) -> None:
with _build_session() as db: with _build_session() as db:
RiskObservationService._storage_ready_cache.clear() 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" assert updated_detail_response.json()["feedback_items"][0]["feedback_type"] == "false_positive"
with session_factory() as db: with session_factory() as db:
observation = db.query(RiskObservation).filter_by( observation = (
observation_key="risk:c1:duplicate_invoice" db.query(RiskObservation).filter_by(observation_key="risk:c1:duplicate_invoice").one()
).one() )
assert observation.status == "false_positive" assert observation.status == "false_positive"
assert observation.feedback_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 = { tenant_a_headers = {
"X-Auth-Username": "auditor-a", "X-Auth-Username": "auditor-a",
"X-Auth-Name": "Tenant A Auditor", "X-Auth-Name": "Tenant A Auditor",
"X-Auth-Role-Codes": "finance",
"X-Auth-Is-Admin": "true",
"X-Auth-Tenant-Id": "tenant-a", "X-Auth-Tenant-Id": "tenant-a",
} }
tenant_b_headers = { tenant_b_headers = {
"X-Auth-Username": "auditor-b", "X-Auth-Username": "auditor-b",
"X-Auth-Name": "Tenant B Auditor", "X-Auth-Name": "Tenant B Auditor",
"X-Auth-Role-Codes": "finance",
"X-Auth-Is-Admin": "true",
"X-Auth-Tenant-Id": "tenant-b", "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.status_code == 200
assert detail_response.json()["tenant_id"] == "tenant-a" assert detail_response.json()["tenant_id"] == "tenant-a"
assert foreign_detail_response.status_code == 404 assert foreign_detail_response.status_code == 404
assert claim_response.status_code == 200 # 单据风险入口必须先通过单据自身可见范围;不存在的历史 claim 不再旁路读取。
assert [item["tenant_id"] for item in claim_response.json()] == ["tenant-a"] assert claim_response.status_code == 404
assert execution_log_response.status_code == 200 assert execution_log_response.status_code == 200
assert [item["tenant_id"] for item in execution_log_response.json()] == ["tenant-a"] assert [item["tenant_id"] for item in execution_log_response.json()] == ["tenant-a"]
assert dashboard_response.status_code == 200 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", tenant_id="tenant-b",
) )
assert db.query(RiskObservation).filter_by( assert (
observation_key="risk:tenant-boundary" db.query(RiskObservation)
).one_or_none() is None .filter_by(observation_key="risk:tenant-boundary")
.one_or_none()
is None
)
def test_hermes_global_scan_builds_graphs_inside_each_tenant( 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 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: def test_risk_observation_feedback_pool_fields_and_replay_set_contract() -> None:
with _build_session() as db: with _build_session() as db:
service = RiskObservationService(db) service = RiskObservationService(db)

View File

@@ -19,6 +19,7 @@ def test_create_legacy_schema_never_creates_migration_owned_tables() -> None:
"ai_application_preview_decisions", "ai_application_preview_decisions",
"ai_decision_feedback", "ai_decision_feedback",
"ai_decisions", "ai_decisions",
"approval_action_ledgers",
"auth_sessions", "auth_sessions",
"attachment_association_jobs", "attachment_association_jobs",
"business_events", "business_events",
@@ -28,6 +29,8 @@ def test_create_legacy_schema_never_creates_migration_owned_tables() -> None:
"memory_evidence_links", "memory_evidence_links",
"risk_observations", "risk_observations",
"risk_observation_feedback", "risk_observation_feedback",
"risk_disposition_events",
"risk_dispositions",
"few_shot_samples", "few_shot_samples",
"workflow_outcomes", "workflow_outcomes",
} }

View File

@@ -195,6 +195,142 @@
line-height: 1.55; 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 { .risk-evidence-meta {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -181,7 +181,7 @@
font-weight: 760; 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 { th, td {
padding: 15px 12px; 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.medium { background: var(--warning-soft); color: var(--warning-active); }
.risk-tag.high { background: var(--danger-soft); color: var(--danger); } .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 { font-size: 13px; font-weight: 850; }
.sla.safe { color: var(--success); } .sla.safe { color: var(--success); }
.sla.warning { color: var(--warning-active); } .sla.warning { color: var(--warning-active); }
@@ -1473,4 +1527,3 @@ tbody tr:last-child td { border-bottom: 0; }
.opinion-wrap { .opinion-wrap {
padding: 12px 14px; padding: 12px 14px;
} }

View File

@@ -58,6 +58,50 @@
</div> </div>
</div> </div>
<section class="risk-disposition-panel" aria-label="风险处置">
<div class="risk-disposition-head">
<div>
<span>风险处置</span>
<strong>{{ dispositionStateLabel }}</strong>
</div>
<div class="risk-disposition-badges">
<em :class="currentDisposition.adjudication">
{{ formatAdjudication(currentDisposition.adjudication) }}
</em>
<em :class="currentDisposition.lifecycleStatus">
{{ formatLifecycleStatus(currentDisposition.lifecycleStatus) }}
</em>
<em>v{{ currentDisposition.version }}</em>
</div>
</div>
<p v-if="currentDisposition.resolution" class="risk-disposition-resolution">
{{ currentDisposition.resolution }}
</p>
<template v-if="dispositionActions.length">
<textarea
v-model="dispositionNote"
:disabled="dispositionBusy"
maxlength="1000"
placeholder="填写复核依据、补充要求或解决说明(误报、补材料、豁免和解决时必填)"
></textarea>
<div class="risk-disposition-actions">
<button
v-for="action in dispositionActions"
:key="action.action"
type="button"
:class="action.tone"
:disabled="dispositionBusy"
@click="submitDispositionAction(action.action)"
>
<i :class="dispositionBusyAction === action.action ? 'mdi mdi-loading mdi-spin' : action.icon"></i>
{{ action.label }}
</button>
</div>
</template>
<p v-else class="risk-disposition-complete">{{ dispositionCompleteMessage }}</p>
<p v-if="dispositionError" class="risk-disposition-error">{{ dispositionError }}</p>
</section>
<div class="risk-evidence-grid"> <div class="risk-evidence-grid">
<section class="risk-evidence-section"> <section class="risk-evidence-section">
<span class="risk-evidence-section-title">贡献分</span> <span class="risk-evidence-section-title">贡献分</span>
@@ -141,7 +185,11 @@
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { fetchClaimRiskObservations } from '../../services/riskObservations.js' import {
createRiskDispositionRequestId,
executeRiskDispositionAction,
fetchClaimRiskObservations
} from '../../services/riskObservations.js'
const props = defineProps({ const props = defineProps({
claimId: { type: String, default: '' } claimId: { type: String, default: '' }
@@ -151,6 +199,10 @@ const observations = ref([])
const loading = ref(false) const loading = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const activeObservationKey = ref('') const activeObservationKey = ref('')
const dispositionNote = ref('')
const dispositionBusyAction = ref('')
const dispositionError = ref('')
const dispositionRequest = ref({ key: '', requestId: '' })
const detailRegionId = 'risk-observation-active-detail' const detailRegionId = 'risk-observation-active-detail'
let loadSequence = 0 let loadSequence = 0
@@ -175,6 +227,92 @@ const activeObservationPosition = computed(() => {
) )
return activeIndex >= 0 ? `${activeIndex + 1} / ${observations.value.length}` : '未选择' return activeIndex >= 0 ? `${activeIndex + 1} / ${observations.value.length}` : '未选择'
}) })
const currentDisposition = computed(() => {
if (mainObservation.value?.disposition) {
return mainObservation.value.disposition
}
const feedbackStatus = String(mainObservation.value?.feedbackStatus || '').trim()
const status = String(mainObservation.value?.status || '').trim()
return {
adjudication: ['confirmed', 'false_positive'].includes(feedbackStatus)
? feedbackStatus
: 'unreviewed',
lifecycleStatus: status === 'resolved' ? 'resolved' : 'open',
version: 0,
resolution: ''
}
})
const dispositionBusy = computed(() => Boolean(dispositionBusyAction.value))
const dispositionStateLabel = computed(() => {
if (currentDisposition.value.adjudication === 'false_positive') {
return '该观察已判定为误报,不再阻断审批'
}
if (currentDisposition.value.lifecycleStatus === 'resolved') {
return '风险已完成处置,不再阻断审批'
}
if (['high', 'critical'].includes(String(mainObservation.value?.riskLevel || ''))) {
return '高风险未关闭,将阻断审批通过'
}
return '请结合证据完成人工裁决'
})
const dispositionActions = computed(() => {
const adjudication = currentDisposition.value.adjudication
const lifecycle = currentDisposition.value.lifecycleStatus
if (adjudication === 'false_positive' || lifecycle === 'resolved') {
return []
}
if (adjudication !== 'confirmed') {
const actions = [
{ action: 'confirm', label: '确认风险', icon: 'mdi mdi-shield-check', tone: 'danger' },
{ action: 'false_positive', label: '标记误报', icon: 'mdi mdi-shield-off-outline', tone: 'safe' }
]
if (lifecycle !== 'supplement_requested') {
actions.push({
action: 'request_supplement',
label: '请求补材料',
icon: 'mdi mdi-file-plus-outline',
tone: 'warning'
})
}
return actions
}
return [
{
action: 'request_supplement',
label: '请求补材料',
icon: 'mdi mdi-file-plus-outline',
tone: 'warning'
},
{
action: 'start_remediation',
label: '启动整改',
icon: 'mdi mdi-tools',
tone: 'warning'
},
{
action: 'request_waiver',
label: '申请豁免',
icon: 'mdi mdi-file-sign',
tone: 'neutral'
},
{
action: 'resolve',
label: '确认已解决',
icon: 'mdi mdi-check-decagram-outline',
tone: 'safe'
}
].filter((item) => (
item.action === 'resolve'
|| (item.action === 'request_supplement' && lifecycle !== 'supplement_requested')
|| (item.action === 'start_remediation' && lifecycle !== 'remediation_in_progress')
|| (item.action === 'request_waiver' && lifecycle !== 'waiver_requested')
))
})
const dispositionCompleteMessage = computed(() => (
currentDisposition.value.adjudication === 'false_positive'
? '该风险已标记为误报;如需更正,请由管理员通过审计流程处理。'
: '该风险已解决,处置事件会保留在追加式审计链中。'
))
const scoreItems = computed(() => { const scoreItems = computed(() => {
const scores = mainObservation.value?.contributionScores || {} const scores = mainObservation.value?.contributionScores || {}
return Object.entries(scores).map(([key, value]) => { return Object.entries(scores).map(([key, value]) => {
@@ -287,6 +425,50 @@ function selectObservation(item, index = -1) {
const key = observationIdentity(item, index) const key = observationIdentity(item, index)
if (key) { if (key) {
activeObservationKey.value = key activeObservationKey.value = key
dispositionNote.value = ''
dispositionError.value = ''
dispositionRequest.value = { key: '', requestId: '' }
}
}
async function submitDispositionAction(action) {
const observationId = String(mainObservation.value?.id || '').trim()
if (!observationId || dispositionBusy.value) {
return
}
const note = dispositionNote.value.trim()
if (['false_positive', 'request_supplement', 'request_waiver', 'resolve'].includes(action) && !note) {
dispositionError.value = '该处置动作需要填写复核依据或处理说明。'
return
}
const requestKey = [observationId, action, currentDisposition.value.version, note].join(':')
if (dispositionRequest.value.key !== requestKey) {
dispositionRequest.value = {
key: requestKey,
requestId: createRiskDispositionRequestId(action, observationId)
}
}
dispositionBusyAction.value = action
dispositionError.value = ''
try {
await executeRiskDispositionAction(observationId, {
action,
expectedVersion: currentDisposition.value.version,
requestId: dispositionRequest.value.requestId,
comment: note,
resolution: action === 'resolve' ? note : ''
})
dispositionNote.value = ''
dispositionRequest.value = { key: '', requestId: '' }
await loadObservations()
} catch (error) {
dispositionError.value = error?.message || '风险处置失败,请刷新状态后重试。'
if (error?.code === 'RISK_DISPOSITION_VERSION_CONFLICT') {
dispositionRequest.value = { key: '', requestId: '' }
await loadObservations()
}
} finally {
dispositionBusyAction.value = ''
} }
} }
@@ -376,6 +558,26 @@ function formatFeedbackStatus(value) {
} }
return labels[String(value || '').trim()] || '未复核' return labels[String(value || '').trim()] || '未复核'
} }
function formatAdjudication(value) {
const labels = {
unreviewed: '待裁决',
confirmed: '风险成立',
false_positive: '误报'
}
return labels[String(value || '').trim()] || '待裁决'
}
function formatLifecycleStatus(value) {
const labels = {
open: '待处置',
supplement_requested: '待补材料',
remediation_in_progress: '整改中',
waiver_requested: '豁免申请中',
resolved: '已解决'
}
return labels[String(value || '').trim()] || '待处置'
}
</script> </script>
<style scoped src="../../assets/styles/components/risk-observation-evidence-card.css"></style> <style scoped src="../../assets/styles/components/risk-observation-evidence-card.css"></style>

View File

@@ -0,0 +1,66 @@
import { apiRequest } from './api.js'
function toNumber(value, fallback = 0) {
const number = Number(value)
return Number.isFinite(number) ? number : fallback
}
function toArray(value) {
return Array.isArray(value) ? value : []
}
export function normalizeApprovalWorkbenchItem(item = {}) {
const evidence = item.evidence && typeof item.evidence === 'object' ? item.evidence : {}
const suggestion = item.suggestion && typeof item.suggestion === 'object' ? item.suggestion : {}
return {
taskKey: String(item.task_key || item.taskKey || '').trim(),
claim: item.claim && typeof item.claim === 'object' ? item.claim : {},
priorityScore: Math.max(0, Math.min(toNumber(item.priority_score ?? item.priorityScore), 100)),
priorityTier: String(item.priority_tier || item.priorityTier || 'normal').trim(),
priorityReasons: toArray(item.priority_reasons || item.priorityReasons).map((reason) => ({
code: String(reason?.code || '').trim(),
label: String(reason?.label || '').trim(),
weight: toNumber(reason?.weight),
tone: String(reason?.tone || 'normal').trim()
})).filter((reason) => reason.label),
riskLevel: String(item.risk_level || item.riskLevel || 'low').trim(),
openRiskCount: Math.max(0, toNumber(item.open_risk_count ?? item.openRiskCount)),
budgetUsageRate: item.budget_usage_rate == null && item.budgetUsageRate == null
? null
: toNumber(item.budget_usage_rate ?? item.budgetUsageRate),
waitingHours: Math.max(0, toNumber(item.waiting_hours ?? item.waitingHours)),
slaDueAt: String(item.sla_due_at || item.slaDueAt || '').trim(),
slaOverdue: Boolean(item.sla_overdue ?? item.slaOverdue),
evidence: {
completeness: Math.max(0, Math.min(toNumber(evidence.completeness), 1)),
presentCount: Math.max(0, toNumber(evidence.present_count ?? evidence.presentCount)),
requiredCount: Math.max(0, toNumber(evidence.required_count ?? evidence.requiredCount)),
missingLabels: toArray(evidence.missing_labels || evidence.missingLabels)
.map((value) => String(value || '').trim())
.filter(Boolean),
historicalLabels: toArray(evidence.historical_labels || evidence.historicalLabels)
.map((value) => String(value || '').trim())
.filter(Boolean)
},
suggestion: {
action: String(suggestion.action || 'manual_review').trim(),
label: String(suggestion.label || '人工复核').trim(),
reason: String(suggestion.reason || '').trim(),
advisoryOnly: suggestion.advisory_only !== false && suggestion.advisoryOnly !== false
}
}
}
export async function fetchApprovalWorkbenchItems(options = {}) {
const limit = Math.max(1, Math.min(toNumber(options.limit || 100), 200))
const payload = await apiRequest(`/approval-workbench/items?limit=${limit}`, {
timeoutMs: toNumber(options.timeoutMs || 5000),
timeoutMessage: '审批优先队列加载超时,请稍后重试。'
})
return {
items: toArray(payload?.items).map(normalizeApprovalWorkbenchItem),
total: Math.max(0, toNumber(payload?.total)),
generatedAt: String(payload?.generated_at || payload?.generatedAt || '').trim(),
scoringVersion: String(payload?.scoring_version || payload?.scoringVersion || '').trim()
}
}

View File

@@ -231,6 +231,15 @@ function createPreReviewRequestId() {
return `pre-review:${Date.now()}:${Math.random().toString(16).slice(2)}` return `pre-review:${Date.now()}:${Math.random().toString(16).slice(2)}`
} }
export function createExpenseClaimActionRequestId(action, claimId) {
const normalizedAction = String(action || 'action').trim() || 'action'
const normalizedClaimId = String(claimId || 'claim').trim() || 'claim'
if (typeof globalThis.crypto?.randomUUID === 'function') {
return `${normalizedAction}:${normalizedClaimId}:${globalThis.crypto.randomUUID()}`
}
return `${normalizedAction}:${normalizedClaimId}:${Date.now()}:${Math.random().toString(16).slice(2)}`
}
export function preReviewExpenseClaim(claimId, requestId = '') { export function preReviewExpenseClaim(claimId, requestId = '') {
const normalizedRequestId = String(requestId || '').trim() || createPreReviewRequestId() const normalizedRequestId = String(requestId || '').trim() || createPreReviewRequestId()
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/pre-review`, { return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/pre-review`, {
@@ -261,10 +270,10 @@ export function approveExpenseClaim(claimId, payload = {}) {
}) })
} }
export function payExpenseClaim(claimId) { export function payExpenseClaim(claimId, payload = {}) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/pay`, { return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/pay`, {
method: 'POST', method: 'POST',
body: JSON.stringify({}) body: JSON.stringify(payload)
}) })
} }

View File

@@ -13,6 +13,41 @@ function toObject(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? value : {} return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
} }
export function createRiskDispositionRequestId(action, observationId) {
const normalizedAction = String(action || 'action').trim() || 'action'
const normalizedObservationId = String(observationId || 'risk').trim() || 'risk'
if (typeof globalThis.crypto?.randomUUID === 'function') {
return `risk:${normalizedAction}:${normalizedObservationId}:${globalThis.crypto.randomUUID()}`
}
return `risk:${normalizedAction}:${normalizedObservationId}:${Date.now()}:${Math.random().toString(16).slice(2)}`
}
export function normalizeRiskDisposition(item = {}) {
if (!item || typeof item !== 'object' || !String(item.id || '').trim()) {
return null
}
return {
id: String(item.id || '').trim(),
observationId: String(item.observation_id || item.observationId || '').trim(),
adjudication: String(item.adjudication || 'unreviewed').trim(),
lifecycleStatus: String(item.lifecycle_status || item.lifecycleStatus || 'open').trim(),
version: Math.max(0, toNumber(item.version)),
assignee: String(item.assignee || '').trim(),
dueAt: String(item.due_at || item.dueAt || '').trim(),
resolution: String(item.resolution || '').trim(),
createdAt: String(item.created_at || item.createdAt || '').trim(),
updatedAt: String(item.updated_at || item.updatedAt || '').trim(),
events: toArray(item.events).map((event) => ({
id: String(event?.id || '').trim(),
action: String(event?.action || '').trim(),
version: Math.max(0, toNumber(event?.version)),
actorName: String(event?.actor_name || event?.actorName || '').trim(),
comment: String(event?.comment || '').trim(),
createdAt: String(event?.created_at || event?.createdAt || '').trim()
}))
}
}
export function normalizeRiskObservation(item = {}) { export function normalizeRiskObservation(item = {}) {
return { return {
id: String(item.id || '').trim(), id: String(item.id || '').trim(),
@@ -33,16 +68,29 @@ export function normalizeRiskObservation(item = {}) {
algorithmVersion: String(item.algorithm_version || item.algorithmVersion || '').trim(), algorithmVersion: String(item.algorithm_version || item.algorithmVersion || '').trim(),
status: String(item.status || '').trim(), status: String(item.status || '').trim(),
feedbackStatus: String(item.feedback_status || item.feedbackStatus || '').trim(), feedbackStatus: String(item.feedback_status || item.feedbackStatus || '').trim(),
contributionScores: toObject(item.contribution_scores || item.contributionScores), contributionScores: toObject(
baseline: toObject(item.baseline), item.contribution_scores_json || item.contribution_scores || item.contributionScores
evidence: toArray(item.evidence), ),
graphNodeKeys: toArray(item.graph_node_keys || item.graphNodeKeys), baseline: toObject(item.baseline_json || item.baseline),
graphEdgeKeys: toArray(item.graph_edge_keys || item.graphEdgeKeys), evidence: toArray(item.evidence_json || item.evidence),
policyRefs: toArray(item.policy_refs || item.policyRefs), graphNodeKeys: toArray(
similarCaseClaimIds: toArray(item.similar_case_claim_ids || item.similarCaseClaimIds), item.graph_node_keys_json || item.graph_node_keys || item.graphNodeKeys
),
graphEdgeKeys: toArray(
item.graph_edge_keys_json || item.graph_edge_keys || item.graphEdgeKeys
),
policyRefs: toArray(item.policy_refs_json || item.policy_refs || item.policyRefs),
similarCaseClaimIds: toArray(
item.similar_case_claim_ids_json
|| item.similar_case_claim_ids
|| item.similarCaseClaimIds
),
ontology: toObject(item.ontology_json || item.ontologyJson), ontology: toObject(item.ontology_json || item.ontologyJson),
decisionTrace: toObject(item.decision_trace || item.decisionTrace), decisionTrace: toObject(
item.decision_trace_json || item.decision_trace || item.decisionTrace
),
feedbackItems: toArray(item.feedback_items || item.feedbackItems), feedbackItems: toArray(item.feedback_items || item.feedbackItems),
disposition: normalizeRiskDisposition(item.disposition),
createdAt: String(item.created_at || item.createdAt || '').trim(), createdAt: String(item.created_at || item.createdAt || '').trim(),
updatedAt: String(item.updated_at || item.updatedAt || '').trim() updatedAt: String(item.updated_at || item.updatedAt || '').trim()
} }
@@ -145,3 +193,42 @@ export async function fetchRunRiskObservations(runId, options = {}) {
return toArray(payload.items || payload).map(normalizeRiskObservation) return toArray(payload.items || payload).map(normalizeRiskObservation)
} }
export async function executeRiskDispositionAction(observationId, payload = {}) {
const normalizedObservationId = String(observationId || '').trim()
const action = String(payload.action || '').trim()
if (!normalizedObservationId || !action) {
throw new Error('风险处置缺少观察标识或动作。')
}
const requestId = String(payload.requestId || payload.request_id || '').trim()
|| createRiskDispositionRequestId(action, normalizedObservationId)
const body = {
action,
expected_version: Math.max(0, toNumber(payload.expectedVersion ?? payload.expected_version)),
request_id: requestId
}
const comment = String(payload.comment || '').trim()
const resolution = String(payload.resolution || '').trim()
const assignee = String(payload.assignee || '').trim()
const dueAt = String(payload.dueAt || payload.due_at || '').trim()
if (comment) body.comment = comment
if (resolution) body.resolution = resolution
if (assignee) body.assignee = assignee
if (dueAt) body.due_at = dueAt
const result = await apiRequest(
`/risk-observations/${encodeURIComponent(normalizedObservationId)}/disposition/actions`,
{
method: 'POST',
body: JSON.stringify(body),
timeoutMs: toNumber(payload.timeoutMs || 5000),
timeoutMessage: '风险处置提交超时,请刷新状态后重试。'
}
)
return {
disposition: normalizeRiskDisposition(result?.disposition),
event: result?.event && typeof result.event === 'object' ? result.event : {},
replayed: Boolean(result?.replayed),
requestId
}
}

View File

@@ -48,18 +48,21 @@
<template #table> <template #table>
<table> <table>
<colgroup> <colgroup>
<col><col><col><col><col><col><col><col><col><col><col> <col><col><col><col><col><col><col><col><col><col><col><col><col><col>
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<th>单号</th> <th>单号</th>
<th>优先级</th>
<th>申请人</th> <th>申请人</th>
<th>申请部门</th> <th>申请部门</th>
<th>报销类型</th> <th>报销类型</th>
<th>金额</th> <th>金额</th>
<th>提交时间 <i class="mdi mdi-sort"></i></th> <th>提交时间 <i class="mdi mdi-sort"></i></th>
<th>风险等级</th> <th>风险等级</th>
<th>SLA剩余</th> <th>等待 / SLA</th>
<th>证据</th>
<th>AI建议</th>
<th>当前节点</th> <th>当前节点</th>
<th>状态</th> <th>状态</th>
<th>操作</th> <th>操作</th>
@@ -68,6 +71,11 @@
<tbody> <tbody>
<tr v-for="row in visibleRows" :key="row.id" :class="{ spotlight: row.spotlight }" @click="selectedRow = row"> <tr v-for="row in visibleRows" :key="row.id" :class="{ spotlight: row.spotlight }" @click="selectedRow = row">
<td><strong class="doc-id">{{ row.id }}</strong></td> <td><strong class="doc-id">{{ row.id }}</strong></td>
<td>
<span class="priority-tag" :class="row.priorityTier" :title="row.priorityTitle">
{{ row.priorityLabel }} {{ row.priorityScore }}
</span>
</td>
<td> <td>
<span class="person"> <span class="person">
<span class="avatar">{{ row.avatar }}</span> <span class="avatar">{{ row.avatar }}</span>
@@ -80,8 +88,18 @@
<td>{{ row.time }}</td> <td>{{ row.time }}</td>
<td><span class="risk-tag" :class="row.riskTone">{{ row.risk }}</span></td> <td><span class="risk-tag" :class="row.riskTone">{{ row.risk }}</span></td>
<td><strong class="sla" :class="row.slaTone">{{ row.sla }}</strong></td> <td><strong class="sla" :class="row.slaTone">{{ row.sla }}</strong></td>
<td>
<span class="evidence-meta" :class="row.evidenceTone" :title="row.evidenceTitle">
{{ row.evidenceLabel }}
</span>
</td>
<td>
<span class="ai-suggestion" :title="row.suggestionReason">
{{ row.suggestionLabel }}
</span>
</td>
<td>{{ row.node }}</td> <td>{{ row.node }}</td>
<td><span class="status-tag" :class="row.statusTone">{{ row.status }}</span></td> <td><span class="status-tag" :class="row.statusTone">{{ row.statusLabel }}</span></td>
<td> <td>
<button class="more-btn" type="button" aria-label="查看审批详情" @click.stop="selectedRow = row"> <button class="more-btn" type="button" aria-label="查看审批详情" @click.stop="selectedRow = row">
<i class="mdi mdi-dots-horizontal"></i> <i class="mdi mdi-dots-horizontal"></i>

View File

@@ -3,11 +3,7 @@ import { computed, ref, watch } from 'vue'
import EnterpriseListPage from '../../components/shared/EnterpriseListPage.vue' import EnterpriseListPage from '../../components/shared/EnterpriseListPage.vue'
import { useApprovalInbox } from '../../composables/useApprovalInbox.js' import { useApprovalInbox } from '../../composables/useApprovalInbox.js'
import { useSystemState } from '../../composables/useSystemState.js' import { useSystemState } from '../../composables/useSystemState.js'
import { import { fetchApprovalWorkbenchItems } from '../../services/approvalWorkbench.js'
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
extractExpenseClaimItems,
fetchApprovalExpenseClaims
} from '../../services/reimbursements.js'
import { listPendingApprovalRequests } from '../../utils/approvalInbox.js' import { listPendingApprovalRequests } from '../../utils/approvalInbox.js'
import { import {
filterActionableRiskFlags, filterActionableRiskFlags,
@@ -91,10 +87,17 @@ function resolveSlaMeta(submittedAt) {
return { label, tone: 'safe', urgent: false } return { label, tone: 'safe', urgent: false }
} }
function buildApprovalRow(request) { function buildApprovalRow(request, workbench = {}) {
const riskTone = resolveRiskTone(request.riskFlags, request.riskSummary) const resolvedRiskTone = workbench.riskLevel
|| resolveRiskTone(request.riskFlags, request.riskSummary)
const riskTone = resolvedRiskTone === 'critical' ? 'high' : resolvedRiskTone
const slaMeta = resolveSlaMeta(request.submittedAt || request.createdAt) const slaMeta = resolveSlaMeta(request.submittedAt || request.createdAt)
const statusTone = slaMeta.urgent ? 'urgent' : 'pending' const statusTone = workbench.slaOverdue || slaMeta.urgent ? 'urgent' : 'pending'
const evidence = workbench.evidence || {}
const evidencePercent = Math.round(Number(evidence.completeness || 0) * 100)
const priorityReasons = Array.isArray(workbench.priorityReasons)
? workbench.priorityReasons.map((item) => item.label).filter(Boolean)
: []
return { return {
...request, ...request,
@@ -106,12 +109,33 @@ function buildApprovalRow(request) {
time: request.applyTime, time: request.applyTime,
risk: RISK_LABELS[riskTone] || RISK_LABELS.low, risk: RISK_LABELS[riskTone] || RISK_LABELS.low,
riskTone, riskTone,
sla: slaMeta.label, priorityScore: Number(workbench.priorityScore || 0),
slaTone: slaMeta.tone, priorityTier: workbench.priorityTier || 'normal',
priorityLabel: workbench.priorityTier === 'urgent'
? '紧急'
: workbench.priorityTier === 'high'
? '优先'
: '常规',
priorityReasons,
priorityTitle: priorityReasons.join('') || '常规低风险待办',
sla: workbench.slaOverdue
? '已超时'
: workbench.waitingHours != null
? `已等待 ${Number(workbench.waitingHours || 0).toFixed(1)}h`
: slaMeta.label,
slaTone: workbench.slaOverdue ? 'danger' : slaMeta.tone,
evidenceLabel: `${evidencePercent}%`,
evidenceTone: evidencePercent < 100 ? 'warning' : 'safe',
evidenceTitle: evidence.missingLabels?.length
? `缺少:${evidence.missingLabels.join('、')}`
: '必要材料相对完整',
historicalLabels: evidence.historicalLabels || [],
suggestionLabel: workbench.suggestion?.label || '人工复核',
suggestionReason: workbench.suggestion?.reason || '',
node: request.workflowNode || '审批中', node: request.workflowNode || '审批中',
status: statusTone === 'urgent' ? '即将超时' : '待审批', statusLabel: statusTone === 'urgent' ? '即将超时' : '待审批',
statusTone, statusTone,
spotlight: riskTone === 'high' || statusTone === 'urgent' spotlight: workbench.priorityTier === 'urgent' || riskTone === 'high' || statusTone === 'urgent'
} }
} }
@@ -232,9 +256,14 @@ export default {
error.value = '' error.value = ''
try { try {
const payload = await fetchApprovalExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS) const payload = await fetchApprovalWorkbenchItems({ limit: 100 })
const pendingRequests = listPendingApprovalRequests(extractExpenseClaimItems(payload), currentUser.value) const mappedRows = payload.items.flatMap((workbenchItem) => {
const mappedRows = pendingRequests.map((item) => buildApprovalRow(item)) const pendingRequests = listPendingApprovalRequests(
[workbenchItem.claim],
currentUser.value
)
return pendingRequests.map((item) => buildApprovalRow(item, workbenchItem))
})
rows.value = mappedRows rows.value = mappedRows
syncPendingClaimIds(mappedRows.map((item) => item.claimId)) syncPendingClaimIds(mappedRows.map((item) => item.claimId))
if (!mappedRows.some((item) => item.claimId === selectedClaimId.value)) { if (!mappedRows.some((item) => item.claimId === selectedClaimId.value)) {

View File

@@ -1,6 +1,9 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { payExpenseClaim } from '../../services/reimbursements.js' import {
createExpenseClaimActionRequestId,
payExpenseClaim
} from '../../services/reimbursements.js'
import { import {
canManageExpenseClaims, canManageExpenseClaims,
isFinanceUser isFinanceUser
@@ -16,6 +19,9 @@ export function useTravelRequestPaymentFlow({
}) { }) {
const payBusy = ref(false) const payBusy = ref(false)
const payConfirmDialogOpen = ref(false) const payConfirmDialogOpen = ref(false)
const payRequestId = ref('')
const payExpectedStatus = ref('')
const payExpectedStage = ref('')
const isPendingPaymentStage = computed(() => { const isPendingPaymentStage = computed(() => {
const node = String(request.value.node || request.value.approvalStage || '').trim() const node = String(request.value.node || request.value.approvalStage || '').trim()
@@ -50,6 +56,9 @@ export function useTravelRequestPaymentFlow({
return return
} }
payRequestId.value = createExpenseClaimActionRequestId('pay', request.value.claimId)
payExpectedStatus.value = String(request.value.status || '').trim()
payExpectedStage.value = String(request.value.node || request.value.approvalStage || '').trim()
payConfirmDialogOpen.value = true payConfirmDialogOpen.value = true
} }
@@ -59,6 +68,9 @@ export function useTravelRequestPaymentFlow({
} }
payConfirmDialogOpen.value = false payConfirmDialogOpen.value = false
payRequestId.value = ''
payExpectedStatus.value = ''
payExpectedStage.value = ''
} }
async function confirmPayRequest() { async function confirmPayRequest() {
@@ -76,8 +88,15 @@ export function useTravelRequestPaymentFlow({
payBusy.value = true payBusy.value = true
try { try {
await payExpenseClaim(request.value.claimId) await payExpenseClaim(request.value.claimId, {
request_id: payRequestId.value,
expected_status: payExpectedStatus.value,
expected_approval_stage: payExpectedStage.value
})
payConfirmDialogOpen.value = false payConfirmDialogOpen.value = false
payRequestId.value = ''
payExpectedStatus.value = ''
payExpectedStage.value = ''
toast(`${request.value.id} 已确认付款。`) toast(`${request.value.id} 已确认付款。`)
emit('request-updated', { claimId: request.value.claimId }) emit('request-updated', { claimId: request.value.claimId })
} catch (error) { } catch (error) {

View File

@@ -2,6 +2,7 @@ import { computed, ref } from 'vue'
import { import {
approveExpenseClaim, approveExpenseClaim,
createExpenseClaimActionRequestId,
deleteExpenseClaim, deleteExpenseClaim,
returnExpenseClaim returnExpenseClaim
} from '../../services/reimbursements.js' } from '../../services/reimbursements.js'
@@ -26,8 +27,14 @@ export function useTravelRequestDetailApprovalFlow({
const deleteDialogOpen = ref(false) const deleteDialogOpen = ref(false)
const returnBusy = ref(false) const returnBusy = ref(false)
const returnDialogOpen = ref(false) const returnDialogOpen = ref(false)
const returnRequestId = ref('')
const returnExpectedStatus = ref('')
const returnExpectedStage = ref('')
const approveBusy = ref(false) const approveBusy = ref(false)
const approveConfirmDialogOpen = ref(false) const approveConfirmDialogOpen = ref(false)
const approveRequestId = ref('')
const approveExpectedStatus = ref('')
const approveExpectedStage = ref('')
const approvalRiskConfirmed = ref(false) const approvalRiskConfirmed = ref(false)
const leaderOpinion = ref('') const leaderOpinion = ref('')
@@ -191,6 +198,9 @@ export function useTravelRequestDetailApprovalFlow({
return return
} }
returnRequestId.value = createExpenseClaimActionRequestId('return', request.value.claimId)
returnExpectedStatus.value = String(request.value.status || '').trim()
returnExpectedStage.value = String(request.value.node || request.value.approvalStage || '').trim()
returnDialogOpen.value = true returnDialogOpen.value = true
} }
@@ -200,6 +210,9 @@ export function useTravelRequestDetailApprovalFlow({
} }
returnDialogOpen.value = false returnDialogOpen.value = false
returnRequestId.value = ''
returnExpectedStatus.value = ''
returnExpectedStage.value = ''
} }
async function confirmReturnRequest(payload) { async function confirmReturnRequest(payload) {
@@ -210,8 +223,16 @@ export function useTravelRequestDetailApprovalFlow({
returnBusy.value = true returnBusy.value = true
try { try {
await returnExpenseClaim(request.value.claimId, payload) await returnExpenseClaim(request.value.claimId, {
...payload,
request_id: returnRequestId.value,
expected_status: returnExpectedStatus.value,
expected_approval_stage: returnExpectedStage.value
})
returnDialogOpen.value = false returnDialogOpen.value = false
returnRequestId.value = ''
returnExpectedStatus.value = ''
returnExpectedStage.value = ''
toast(`${request.value.id} 已退回待提交。`) toast(`${request.value.id} 已退回待提交。`)
emit('request-updated', { claimId: request.value.claimId }) emit('request-updated', { claimId: request.value.claimId })
} catch (error) { } catch (error) {
@@ -232,6 +253,9 @@ export function useTravelRequestDetailApprovalFlow({
return return
} }
approveRequestId.value = createExpenseClaimActionRequestId('approve', request.value.claimId)
approveExpectedStatus.value = String(request.value.status || '').trim()
approveExpectedStage.value = String(request.value.node || request.value.approvalStage || '').trim()
approvalRiskConfirmed.value = !approvalRiskConfirmRequired.value approvalRiskConfirmed.value = !approvalRiskConfirmRequired.value
approveConfirmDialogOpen.value = true approveConfirmDialogOpen.value = true
} }
@@ -242,6 +266,9 @@ export function useTravelRequestDetailApprovalFlow({
} }
approveConfirmDialogOpen.value = false approveConfirmDialogOpen.value = false
approveRequestId.value = ''
approveExpectedStatus.value = ''
approveExpectedStage.value = ''
} }
function resolveApproveErrorMessage(error) { function resolveApproveErrorMessage(error) {
@@ -278,10 +305,16 @@ export function useTravelRequestDetailApprovalFlow({
approveBusy.value = true approveBusy.value = true
try { try {
const responsePayload = await approveExpenseClaim(request.value.claimId, { const responsePayload = await approveExpenseClaim(request.value.claimId, {
opinion: leaderOpinion.value.trim() || '同意' opinion: leaderOpinion.value.trim() || '同意',
request_id: approveRequestId.value,
expected_status: approveExpectedStatus.value,
expected_approval_stage: approveExpectedStage.value
}) })
const generatedDraftClaimNo = resolveGeneratedDraftClaimNo(responsePayload) const generatedDraftClaimNo = resolveGeneratedDraftClaimNo(responsePayload)
approveConfirmDialogOpen.value = false approveConfirmDialogOpen.value = false
approveRequestId.value = ''
approveExpectedStatus.value = ''
approveExpectedStage.value = ''
approvalRiskConfirmed.value = false approvalRiskConfirmed.value = false
leaderOpinion.value = '' leaderOpinion.value = ''
toast( toast(

View File

@@ -29,7 +29,7 @@ test('approval center reuses reimbursement detail view instead of its old detail
assert.doesNotMatch(approvalTemplate, /<ConfirmDialog/) assert.doesNotMatch(approvalTemplate, /<ConfirmDialog/)
assert.match(approvalScript, /import TravelRequestDetailView from '\.\.\/TravelRequestDetailView\.vue'/) assert.match(approvalScript, /import TravelRequestDetailView from '\.\.\/TravelRequestDetailView\.vue'/)
assert.match(approvalScript, /fetchApprovalExpenseClaims/) assert.match(approvalScript, /fetchApprovalWorkbenchItems/)
assert.doesNotMatch(approvalScript, /fetchExpenseClaims/) assert.doesNotMatch(approvalScript, /fetchExpenseClaims/)
assert.doesNotMatch(approvalScript, /import ConfirmDialog/) assert.doesNotMatch(approvalScript, /import ConfirmDialog/)
assert.doesNotMatch(approvalScript, /import ReturnReasonDialog/) assert.doesNotMatch(approvalScript, /import ReturnReasonDialog/)

View File

@@ -0,0 +1,66 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { normalizeApprovalWorkbenchItem } from '../src/services/approvalWorkbench.js'
const approvalTemplate = readFileSync(
fileURLToPath(new URL('../src/views/ApprovalCenterView.vue', import.meta.url)),
'utf8'
)
const approvalScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/ApprovalCenterView.js', import.meta.url)),
'utf8'
)
test('approval workbench normalizes score, evidence and advisory suggestion', () => {
const item = normalizeApprovalWorkbenchItem({
task_key: 'claim-1:manager',
claim: { id: 'claim-1', claim_no: 'RE-001' },
priority_score: 138,
priority_tier: 'urgent',
priority_reasons: [{ code: 'open_risk', label: '高风险待复核', weight: 30 }],
risk_level: 'high',
waiting_hours: 26.25,
sla_overdue: true,
evidence: {
completeness: 0.5,
present_count: 1,
required_count: 2,
missing_labels: ['发票'],
historical_labels: ['历史已确认,仅供复核']
},
suggestion: {
action: 'manual_review',
label: '人工复核风险',
reason: '存在开放风险',
advisory_only: true
}
})
assert.equal(item.priorityScore, 100)
assert.equal(item.priorityReasons[0].label, '高风险待复核')
assert.deepEqual(item.evidence.missingLabels, ['发票'])
assert.deepEqual(item.evidence.historicalLabels, ['历史已确认,仅供复核'])
assert.equal(item.suggestion.advisoryOnly, true)
})
test('approval center renders explainable priority, evidence and advisory columns', () => {
assert.match(approvalTemplate, />优先级</)
assert.match(approvalTemplate, />证据</)
assert.match(approvalTemplate, />AI建议</)
assert.match(approvalTemplate, /row\.priorityTitle/)
assert.match(approvalTemplate, /row\.evidenceTitle/)
assert.match(approvalTemplate, /row\.suggestionReason/)
assert.match(approvalScript, /fetchApprovalWorkbenchItems\(\{ limit: 100 \}\)/)
assert.match(approvalScript, /listPendingApprovalRequests/)
assert.doesNotMatch(approvalScript, /autoApprove|batchApprove|批量通过/)
})
test('approval center keeps backend status separate from display status', () => {
assert.match(approvalScript, /statusLabel: statusTone === 'urgent'/)
assert.doesNotMatch(approvalScript, /status: statusTone === 'urgent'/)
assert.match(approvalTemplate, /row\.statusLabel/)
assert.doesNotMatch(approvalTemplate, /\{\{ row\.status \}\}/)
})

View File

@@ -0,0 +1,37 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import test from 'node:test'
const service = fs.readFileSync('web/src/services/reimbursements.js', 'utf8')
const approvalFlow = fs.readFileSync(
'web/src/views/scripts/useTravelRequestDetailApprovalFlow.js',
'utf8'
)
const paymentFlow = fs.readFileSync(
'web/src/views/scripts/travelRequestDetailPaymentFlow.js',
'utf8'
)
test('approval actions keep one request id across an uncertain retry', () => {
assert.match(service, /createExpenseClaimActionRequestId/)
assert.match(approvalFlow, /const returnRequestId = ref\(''\)/)
assert.match(approvalFlow, /const approveRequestId = ref\(''\)/)
assert.match(approvalFlow, /request_id: returnRequestId\.value/)
assert.match(approvalFlow, /request_id: approveRequestId\.value/)
assert.doesNotMatch(
approvalFlow.match(/async function confirmApproveRequest\(\)[\s\S]*?\n }/)?.[0] || '',
/createExpenseClaimActionRequestId/
)
})
test('approve return and pay send optimistic status and stage preconditions', () => {
assert.match(approvalFlow, /returnExpectedStatus\.value = String\(request\.value\.status/)
assert.match(approvalFlow, /expected_status: returnExpectedStatus\.value/)
assert.match(approvalFlow, /expected_approval_stage: approveExpectedStage\.value/)
assert.match(paymentFlow, /const payRequestId = ref\(''\)/)
assert.match(paymentFlow, /payExpenseClaim\(request\.value\.claimId, \{/)
assert.match(paymentFlow, /request_id: payRequestId\.value/)
assert.match(paymentFlow, /payExpectedStatus\.value = String\(request\.value\.status/)
assert.match(paymentFlow, /expected_status: payExpectedStatus\.value/)
assert.match(paymentFlow, /expected_approval_stage: payExpectedStage\.value/)
})

View File

@@ -29,8 +29,8 @@ test('workbench and document list refreshes use preview pagination', () => {
assert.doesNotMatch(documentsCenter, /fetchAllApprovalExpenseClaims\(\)/) assert.doesNotMatch(documentsCenter, /fetchAllApprovalExpenseClaims\(\)/)
assert.doesNotMatch(documentsCenter, /fetchAllArchivedExpenseClaims\(\)/) assert.doesNotMatch(documentsCenter, /fetchAllArchivedExpenseClaims\(\)/)
assert.match(approvalCenter, /fetchApprovalExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/) assert.match(approvalCenter, /fetchApprovalWorkbenchItems\(\{ limit: 100 \}\)/)
assert.doesNotMatch(approvalCenter, /fetchApprovalExpenseClaims\(\)/) assert.doesNotMatch(approvalCenter, /fetchApprovalExpenseClaims/)
assert.match(archiveCenter, /fetchArchivedExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/) assert.match(archiveCenter, /fetchArchivedExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.doesNotMatch(archiveCenter, /fetchArchivedExpenseClaims\(\)/) assert.doesNotMatch(archiveCenter, /fetchArchivedExpenseClaims\(\)/)

View File

@@ -0,0 +1,66 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
createRiskDispositionRequestId,
normalizeRiskDisposition,
normalizeRiskObservation
} from '../src/services/riskObservations.js'
const evidenceCard = readFileSync(
fileURLToPath(new URL('../src/components/travel/RiskObservationEvidenceCard.vue', import.meta.url)),
'utf8'
)
test('risk observation normalizes persisted json evidence and typed disposition', () => {
const observation = normalizeRiskObservation({
id: 'risk-1',
contribution_scores_json: { S_rule: 90 },
evidence_json: [{ title: '重复票据' }],
graph_node_keys_json: ['claim:1'],
similar_case_claim_ids_json: ['redacted-for-reviewer-only'],
decision_trace_json: { action: 'manual_review' },
disposition: {
id: 'disposition-1',
observation_id: 'risk-1',
adjudication: 'confirmed',
lifecycle_status: 'remediation_in_progress',
version: 2,
events: [{ id: 'event-1', action: 'confirm', version: 1 }]
}
})
assert.equal(observation.contributionScores.S_rule, 90)
assert.equal(observation.evidence[0].title, '重复票据')
assert.deepEqual(observation.graphNodeKeys, ['claim:1'])
assert.equal(observation.decisionTrace.action, 'manual_review')
assert.equal(observation.disposition.lifecycleStatus, 'remediation_in_progress')
assert.equal(observation.disposition.events[0].action, 'confirm')
})
test('risk disposition normalizer rejects absent projection and keeps version', () => {
assert.equal(normalizeRiskDisposition(null), null)
assert.equal(normalizeRiskDisposition({}), null)
assert.equal(normalizeRiskDisposition({ id: 'd1', version: 4 }).version, 4)
})
test('risk evidence card exposes typed actions and never offers direct approval', () => {
assert.match(evidenceCard, /executeRiskDispositionAction/)
assert.match(evidenceCard, /确认风险/)
assert.match(evidenceCard, /标记误报/)
assert.match(evidenceCard, /请求补材料/)
assert.match(evidenceCard, /启动整改/)
assert.match(evidenceCard, /申请豁免/)
assert.match(evidenceCard, /确认已解决/)
assert.match(evidenceCard, /expectedVersion: currentDisposition\.value\.version/)
assert.doesNotMatch(evidenceCard, /自动审批|直接通过|batchApprove/)
assert.match(evidenceCard, /RISK_DISPOSITION_VERSION_CONFLICT/)
assert.match(evidenceCard, /lifecycle !== 'supplement_requested'/)
})
test('risk action request ids bind action and observation', () => {
const requestId = createRiskDispositionRequestId('confirm', 'risk-1')
assert.match(requestId, /^risk:confirm:risk-1:/)
})

View File

@@ -1928,7 +1928,9 @@ test('return reason dialog is wired into approval and detail return actions', ()
assert.match(detailViewTemplate, /<TravelRequestReturnDialog/) assert.match(detailViewTemplate, /<TravelRequestReturnDialog/)
assert.match(detailViewTemplate, /:application="isApplicationDocument"/) assert.match(detailViewTemplate, /:application="isApplicationDocument"/)
assert.doesNotMatch(approvalCenterScript, /returnExpenseClaim/) assert.doesNotMatch(approvalCenterScript, /returnExpenseClaim/)
assert.match(detailViewScript, /returnExpenseClaim\(request\.value\.claimId, payload\)/) assert.match(detailViewScript, /returnExpenseClaim\(request\.value\.claimId, \{/)
assert.match(detailViewScript, /request_id: returnRequestId\.value/)
assert.match(detailViewScript, /expected_status: returnExpectedStatus\.value/)
assert.doesNotMatch(approvalCenterScript, /审批中心退回/) assert.doesNotMatch(approvalCenterScript, /审批中心退回/)
assert.doesNotMatch(detailViewScript, /详情页退回/) assert.doesNotMatch(detailViewScript, /详情页退回/)
}) })