feat(approval): add safe risk disposition workflow
This commit is contained in:
@@ -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、预算复核或审批路由。
|
||||||
|
|||||||
@@ -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 入账和对账仍未接入。
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
## 修复记录
|
||||||
|
|
||||||
|
- 2026-07-16 15:10:42 CST:修复审批、退回和付款动作缺少稳定请求标识、预期状态与预期审批节点校验的问题。新增租户 + 操作人 + 请求 ID 唯一账本、请求指纹、PostgreSQL advisory lock 与 Claim 行锁;完全相同的重试返回原结果,请求内容变化或状态已过期返回 409,不重复扣减预算、写审批记录、生成业务事件或付款结果。
|
||||||
|
- 2026-07-16 15:10:42 CST:修复高风险观察仅展示但不阻止审批的问题。审批流程在任何路由、预算和业务状态变更之前检查同租户高危/严重风险;只有已判定误报或完成处置的观察允许继续,阻断响应返回稳定错误码和观察状态,整笔动作账本随事务回滚。
|
||||||
|
- 2026-07-16 15:10:42 CST:前端确认弹窗冻结请求 ID、预期单据状态和审批节点,网络结果不确定时复用同一请求,避免用户重试造成重复业务副作用。
|
||||||
|
- 2026-07-16 15:10:42 CST:验证在 `local-x-financial-linux` 容器完成:费用服务 116 项、审批动作/风险门禁/风险处置组合 26 项、报销接口回归 22 项;一次性 tmpfs PostgreSQL 17 迁移循环 13 项通过。变更 Python 文件 Ruff F/I 与格式检查通过。
|
||||||
|
- 2026-07-16 15:10:42 CST:Git 拉取检查已执行;上游没有本地尚未包含的新提交,当前分支相对上游 ahead 14。已有本地提交包括 `ee88a36b` 分层费用学习、`6bdf65bc` 权威预审、`ae3f02c3` 零录入票据闭环等,本次修复在这些能力之上继续演进。
|
||||||
|
- 2026-07-16 15:33:03 CST:交叉审查后修复三项并发绕过:审批动作不再调用会隐式提交的读取修复逻辑,列表/分页 GET 也不再修改审批节点;审批中心保留服务端原始状态并单独生成展示标签;门禁将持久化风险观察与未匹配原始高风险合并,重大观察持久化失败时 fail-closed。
|
||||||
|
- 2026-07-16 15:33:03 CST:审批、风险处置和 Hermes 扫描统一以 Claim 行锁为公共协调点,锁顺序固定为 Claim → Observation → Disposition;Hermes 锁外计算、锁内刷新并核对状态和更新时间,旧快照被丢弃。一次性 PostgreSQL 并发用例证明风险重新打开与审批竞争时,审批等待后读取最新风险并阻断,Claim 节点和动作账本均保持无副作用。
|
||||||
|
- 2026-07-16 15:33:03 CST:最终容器回归为审批/风险/费用服务 174 项、迁移/所有权 54 项、前端审批/风险 73 项通过,Vite 生产构建通过;一次性 tmpfs PostgreSQL 17 上 13 项迁移和 1 项真实并发测试通过,临时数据库自动清理。再次执行 Git 拉取检查,上游仍无新增提交,本地 ahead 14。
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
## 修复记录
|
||||||
|
|
||||||
|
- 2026-07-16 15:10:42 CST:修复风险观察列表和完整证据链读取范围过宽的问题。风险池仅允许管理员、财务、管理层和预算监控角色访问;单据完整风险证据只允许管理员或当前审批人查看,申请人和无权主体统一以 404 隐藏资源存在性,所有查询继续绑定可信租户。
|
||||||
|
- 2026-07-16 15:10:42 CST:修复风险反馈只有自由文本状态、无法可靠表达处置过程的问题。新增类型化判定与生命周期投影,区分确认风险、误报、补件、整改、豁免申请和完成处置;使用乐观版本、请求指纹、行锁和仅追加事件表保证并发与审计一致性,已完成状态不可重新判定。
|
||||||
|
- 2026-07-16 15:10:42 CST:修复前端读取错误 JSON 字段名导致证据、政策依据、贡献度和决策轨迹可能丢失的问题;风险证据卡改用服务端持久字段并展示处置状态、版本和明确动作,高风险卡不提供直接审批入口。
|
||||||
|
- 2026-07-16 15:10:42 CST:验证在 `local-x-financial-linux` 容器完成:风险观察、风险处置、审批工作台与风险门禁组合 26 项,前端审批/风险/动作协议定向 72 项,Vite 生产构建通过;一次性 PostgreSQL 验证了复合租户外键、唯一约束、仅追加触发器和非空降级保护。
|
||||||
|
- 2026-07-16 15:10:42 CST:Git 拉取检查已执行;上游没有本地尚未包含的新提交,当前分支相对上游 ahead 14。已有 ahead 提交均保留在修复上下文中,未修改用户正在维护的财务规则工作簿和历史未跟踪日志。
|
||||||
|
- 2026-07-16 15:33:03 CST:交叉审查后收紧处置动作语义:误报、补件和豁免申请必须提供服务端校验的说明,完成处置必须提供处理结果;版本冲突返回明确消息并触发前端刷新,已完成生命周期不再展示重复动作。
|
||||||
|
- 2026-07-16 15:33:03 CST:处置服务在 Claim 公共锁内重新确认当前审批人,Observation 和 Disposition 使用租户范围行锁及固定顺序;风险观察写入同样获取 Claim 锁。完整证据、风险池和处置权限分别执行最小授权,跨租户和无权资源继续以 404 隐藏。
|
||||||
|
- 2026-07-16 15:33:03 CST:最终验证全部在 `local-x-financial-linux` 容器完成并设置 60 秒超时;相关后端 174 项、迁移/所有权 54 项、前端 73 项与生产构建通过,一次性 PostgreSQL 17 的结构和并发验证 14 项通过。规则工作簿和历史未跟踪日志未被改动或纳入提交。
|
||||||
@@ -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")
|
||||||
233
server/alembic/versions/20260716_0011_risk_disposition.py
Normal file
233
server/alembic/versions/20260716_0011_risk_disposition.py
Normal 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",
|
||||||
|
)
|
||||||
31
server/src/app/api/v1/endpoints/approval_workbench.py
Normal file
31
server/src/app/api/v1/endpoints/approval_workbench.py
Normal 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)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
64
server/src/app/models/approval_action.py
Normal file
64
server/src/app/models/approval_action.py
Normal 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(),
|
||||||
|
)
|
||||||
174
server/src/app/models/risk_disposition.py
Normal file
174
server/src/app/models/risk_disposition.py
Normal 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")
|
||||||
@@ -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]:
|
||||||
|
|||||||
58
server/src/app/schemas/approval_workbench.py
Normal file
58
server/src/app/schemas/approval_workbench.py
Normal 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"
|
||||||
@@ -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)
|
||||||
|
|||||||
96
server/src/app/schemas/risk_disposition.py
Normal file
96
server/src/app/schemas/risk_disposition.py
Normal 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
|
||||||
@@ -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):
|
||||||
|
|||||||
270
server/src/app/services/approval_action_protocol.py
Normal file
270
server/src/app/services/approval_action_protocol.py
Normal 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()}"
|
||||||
518
server/src/app/services/approval_workbench.py
Normal file
518
server/src/app/services/approval_workbench.py
Normal 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
|
||||||
120
server/src/app/services/expense_claim_action_protocol.py
Normal file
120
server/src/app/services/expense_claim_action_protocol.py
Normal 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)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
187
server/src/app/services/expense_claim_return_flow.py
Normal file
187
server/src/app/services/expense_claim_return_flow.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
148
server/src/app/services/expense_claim_risk_gate.py
Normal file
148
server/src/app/services/expense_claim_risk_gate.py
Normal 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"
|
||||||
@@ -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 []))
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
430
server/src/app/services/risk_dispositions.py
Normal file
430
server/src/app/services/risk_dispositions.py
Normal 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()
|
||||||
84
server/src/app/services/risk_observation_access_policy.py
Normal file
84
server/src/app/services/risk_observation_access_policy.py
Normal 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)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
225
server/tests/test_approval_risk_concurrency_postgres.py
Normal file
225
server/tests/test_approval_risk_concurrency_postgres.py
Normal 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
|
||||||
221
server/tests/test_approval_workbench.py
Normal file
221
server/tests/test_approval_workbench.py
Normal 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"
|
||||||
322
server/tests/test_expense_claim_action_protocol.py
Normal file
322
server/tests/test_expense_claim_action_protocol.py
Normal 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()
|
||||||
222
server/tests/test_expense_claim_risk_gate.py
Normal file
222
server/tests/test_expense_claim_risk_gate.py
Normal 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",
|
||||||
|
)
|
||||||
@@ -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] == []
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
661
server/tests/test_risk_dispositions.py
Normal file
661
server/tests/test_risk_dispositions.py
Normal 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": {},
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
66
web/src/services/approvalWorkbench.js
Normal file
66
web/src/services/approvalWorkbench.js
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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/)
|
||||||
|
|||||||
66
web/tests/approval-workbench.test.mjs
Normal file
66
web/tests/approval-workbench.test.mjs
Normal 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 \}\}/)
|
||||||
|
})
|
||||||
37
web/tests/expense-claim-action-protocol.test.mjs
Normal file
37
web/tests/expense-claim-action-protocol.test.mjs
Normal 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/)
|
||||||
|
})
|
||||||
@@ -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\(\)/)
|
||||||
|
|||||||
66
web/tests/risk-disposition-ui.test.mjs
Normal file
66
web/tests/risk-disposition-ui.test.mjs
Normal 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:/)
|
||||||
|
})
|
||||||
@@ -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, /详情页退回/)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user