fix(approval): replay immutable action responses
This commit is contained in:
@@ -136,9 +136,11 @@
|
||||
- 当前首个可用切片由 `/api/v1/approval-workbench/items` 返回统一投影,优先级由风险、SLA、预算、金额和证据完整度共同计算,并返回每个分项、排序原因和仅供参考的 AI 建议;前端展示状态与服务端业务状态分离,避免乐观并发前置条件被中文标签覆盖。
|
||||
- 批准、退回和付款均使用动作协议:客户端在确认时冻结 `request_id`、预期状态和预期审批节点;服务端以租户 + 操作人 + 请求 ID 唯一账本、请求指纹、PostgreSQL advisory lock、Claim 行锁和同事务业务事件确保相同请求可安全重放,不同内容或陈旧快照以 409 拒绝。
|
||||
- 高风险门禁同时读取持久化风险观察和尚未落表的原始风险标记;未处置的严重/高危可行动风险在路由、预算和状态修改前阻断,原始重大风险持久化失败时 fail-closed。仅明确标记为 `route_review` 的路由型风险进入对应审批节点而不冒充已解决。
|
||||
- 门禁只消费与当前单据业务阶段一致的显式风险:申请阶段不会被明确标记为 `reimbursement` 的风险阻断,反之亦然;缺少阶段的未知重大风险仍保守处理。`route_review` 是显式路由语义,审批路由可以继续但风险仍保留在证据和后续复核中。
|
||||
- 风险处置采用判定与生命周期双状态:支持确认风险、误报、补件、开始整改、申请豁免和完成处置;每个动作绑定请求指纹、预期版本、操作人和只追加事件。完整证据只对管理员或当前审批人开放,风险池对财务/管理/预算角色开放,处置写入由管理员或锁内重新确认的当前审批人完成。
|
||||
- 并发锁顺序固定为 Claim → RiskObservation → RiskDisposition,审批动作、人工处置和 Hermes 扫描共用 Claim 锁。扫描在锁外计算、锁内校验状态与更新时间,过期快照丢弃并等待下一轮,避免旧图结果覆盖刚完成的人工处置。
|
||||
- 尚未完成边界:工作台当前仍为服务内排序后截断;SLA 仍基于申请提交时间而不是当前节点进入时间;批量审批、委托/转交、加签/会签、豁免批准/拒绝和原始响应快照重放将在本阶段后续切片完成。
|
||||
- 所有单据动作首次完整 API 响应写入动作账本;风险处置首次完整响应在 append-only 事件 INSERT 前原子写入。相同请求重放只返回首次版本,风险旧事件无快照时也只从目标版本及以前的审计链重建,不读取当前 Claim、当前处置投影或后续事件。
|
||||
- 尚未完成边界:工作台当前仍为服务内排序后截断;SLA 仍基于申请提交时间而不是当前节点进入时间;批量审批、委托/转交、加签/会签和豁免批准/拒绝将在本阶段后续切片完成。
|
||||
|
||||
#### CFO 价值看板
|
||||
|
||||
@@ -550,6 +552,7 @@ docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv \
|
||||
- 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(严格响应重放):修复审批和风险动作只保证副作用幂等、却在重放时返回当前投影的问题。审批账本保存完整 `ExpenseClaimRead`;风险 append-only 事件通过 `20260716_0012` 保存完整处置响应。单据或风险后续推进后,旧请求只返回首次快照和当时事件,不暴露后续状态;历史无快照事件按目标版本安全重建。门禁同步增加申请/报销阶段匹配和显式 `route_review` 路由语义。容器内审批、费用 Case、路由、风险和严格重放组合 182 项通过,一次性 PostgreSQL 17 迁移与并发共 17 项通过。
|
||||
- 2026-07-16(分层组织记忆):`travel_application.transport_mode` 已形成当前输入/规则 > 企业 > 部门 > 个人的解析链。租户管理员可维护 30-365 天有效的企业/部门低敏记忆;同级冲突 fail-closed,低级覆盖、过期和撤销均返回可解释但脱敏的状态。创建、更新、撤销使用作用域锁、稳定幂等键和请求指纹,响应丢失可安全重放,请求内容变化返回冲突。
|
||||
- 2026-07-16(租户化历史案例):风险观测、人工反馈、few-shot 关系数据和 Qdrant 向量统一绑定租户、场景、制度和规则版本;Hermes 按租户分别构建图与历史,风险规则生成不再回落到默认租户。报销预审与 AI 助手接入只读 `historical_case_evidence`,旧版本显式标 stale,检索故障降级为空证据。
|
||||
- 2026-07-16(历史证据隐私边界):公开协议仅返回“历史已确认/历史误报,仅供复核”的固定摘要,不暴露样本 ID、费用单号、人工评论和历史结论原文;历史命中不改变 review ID、规则 findings、passed、blocking count、预算复核或审批路由。
|
||||
|
||||
@@ -168,14 +168,17 @@
|
||||
|
||||
- [x] [CONCEPT: 审批例外工作台] 按风险、金额、预算影响、等待时长和证据完整度排序审批事项。
|
||||
证据:`GET /api/v1/approval-workbench/items` 由 `ApprovalWorkbenchService` 统一生成可解释优先级、风险/SLA/预算/金额/证据分项和排序原因;审批中心使用该投影,不再用前端临时状态覆盖服务端乐观并发状态。
|
||||
- [x] [CONCEPT: 审批例外工作台] 展示必要性、预算、政策、相似单、风险证据和 AI 意见草稿。
|
||||
- [x] [CONCEPT: 审批例外工作台] 展示预算压力、风险证据、材料完整度、历史标签和简短 AI 复核建议。
|
||||
证据:工作台返回证据完整度、缺失证据、预算影响、风险摘要、历史确认标签及 `advisory_only` AI 建议;详情页风险卡读取持久化证据、政策依据、贡献度和决策轨迹,并明确禁止从风险卡直接审批。
|
||||
- [ ] [CONCEPT: 审批例外工作台] 补齐统一必要性摘要、完整政策/相似单证据和可编辑审批意见草稿。
|
||||
- [x] [CONCEPT: 审批例外工作台] 为批准、退回和付款建立幂等动作协议与乐观前置条件。
|
||||
证据:动作请求冻结 `request_id`、`expected_status` 和 `expected_approval_stage`;服务端以租户 + 操作人 + 请求 ID 唯一账本、请求指纹、advisory lock、Claim 行锁和同事务事件保证同请求安全重放,不同内容或过期快照返回 409。
|
||||
证据:动作请求冻结 `request_id`、`expected_status` 和 `expected_approval_stage`;服务端以租户 + 操作人 + 请求 ID 唯一账本、请求指纹、advisory lock、Claim 行锁和同事务事件保证同请求安全重放,不同内容或过期快照返回 409。首次完整 API 响应以不可变快照落账,单据后续推进后重放仍只返回首次结果,不读取或泄露后续状态。
|
||||
- [x] [CONCEPT: 审批例外工作台] 为风险处置动作建立不可变原响应快照和历史安全重放。
|
||||
证据:`20260716_0012` 为 append-only 风险事件增加 `response_json`;新事件在 INSERT 前原子写入完整响应,重放只返回目标事件版本及以前的事件。0012 前无快照事件从 `after_json` 与 `version <= target` 的审计链安全重建,不读取当前处置投影。
|
||||
- [x] [CONCEPT: 审批例外工作台] 建立高风险门禁与类型化风险处置生命周期。
|
||||
证据:严重/高危可行动风险在任何审批副作用前阻断;确认风险、误报、补件、整改、豁免申请和完成处置具有乐观版本、请求指纹、租户权限、只追加事件和 Claim 共用锁。未持久化的原始高风险仍保守阻断,重大风险持久化失败 fail-closed。
|
||||
- [ ] [CONCEPT: 审批例外工作台] 补齐批量审批、委托、转交、加签、会签和超时升级交互。
|
||||
当前进度:单笔批准/退回/付款协议、优先级工作台、风险证据和处置状态已完成;批量动作、委托/转交、加签/会签、当前节点进入时间 SLA、豁免批准/拒绝及真正的数据库分页尚未实现,不以现有单笔基础冒充完成。
|
||||
当前进度:单笔批准/退回/付款协议、原响应快照重放、优先级工作台、风险证据和处置状态已完成;批量动作、委托/转交、加签/会签、当前节点进入时间 SLA、豁免批准/拒绝及真正的数据库分页尚未实现,不以现有单笔基础冒充完成。
|
||||
- [ ] [CONCEPT: 审批例外工作台] 接入真实企微/钉钉/邮件触达和处理结果回写。
|
||||
- [ ] [CONCEPT: AI 记忆与自动化设置] 新增“我的 AI 记忆”,支持来源解释、修改、忘记和关闭个性化。
|
||||
- [x] [CONCEPT: AI 记忆与自动化设置] 在申请核对表展示常用出行方式的记忆来源、证据数量和“忘记此偏好”,并在保存/提交后区分候选记录与已应用回执。
|
||||
@@ -222,9 +225,9 @@
|
||||
证据:`test_expense_case_endpoints.py` 容器内 8 项通过,覆盖跨租户、无 Case、申请与报销关联摘要以及内部字段递归过滤。
|
||||
- [ ] [CONCEPT: 测试方案] 为 Alembic baseline、升级、旧数据迁移和回滚边界补充 Postgres 集成测试。
|
||||
- [x] [CONCEPT: 测试方案] 为当前 migration-owned schema 切片补充一次性 PostgreSQL 集成测试和危险 URL 防误连门禁。
|
||||
证据:`test_alembic_migrations.py` 默认无显式 URL 时跳过,主机和库名必须带 disposable 标记;当前 0011 Head 在 tmpfs PostgreSQL 17 中 13 项通过,覆盖空库升级、重复升级、审批动作账本、风险处置复合租户约束、只追加事件触发器、非空降级保护、组织 active 脏数据升级前拒绝、版本化 few-shot 无损降级拒绝、外键级联、base 降级、legacy 哨兵保留、漂移拒绝和再次升级;临时容器自动清理,持久化开发库未被修改。完整 legacy baseline 仍保留在上一条未完成项中。
|
||||
证据:`test_alembic_migrations.py` 默认无显式 URL 时跳过,主机和库名必须带 disposable 标记;当前 0012 Head 在 tmpfs PostgreSQL 17 中通过完整迁移验证,覆盖空库升级、重复升级、审批动作账本、风险处置复合租户约束、只追加事件触发器、不可变响应快照及有数据降级拒绝、组织 active 脏数据升级前拒绝、版本化 few-shot 无损降级拒绝、外键级联、base 降级、legacy 哨兵保留、漂移拒绝和再次升级;临时容器自动清理,持久化开发库未被修改。完整 legacy baseline 仍保留在上一条未完成项中。
|
||||
- [x] [CONCEPT: 测试方案] 验证审批动作幂等、风险门禁、风险处置权限和共同锁顺序。
|
||||
证据:容器内审批/风险/费用服务组合 174 项、迁移/所有权 54 项、前端审批/风险 73 项通过;一次性 PostgreSQL 17 上 13 项迁移和 1 项真实并发用例通过,证明风险重新打开持有 Claim 锁时审批等待,随后按最新风险阻断且不留下动作账本或审批副作用。
|
||||
证据:容器内审批、费用 Case、路由、租户、风险和严格重放组合 182 项,迁移/所有权 54 项、前端审批/风险 73 项通过;一次性 PostgreSQL 17 上迁移与真实并发共 17 项通过,证明风险重新打开持有 Claim 锁时审批等待,随后按最新风险阻断且不留下动作账本或审批副作用。
|
||||
- [ ] [CONCEPT: 测试方案] 为连接器幂等、重试、回执、失败恢复、重复付款和对账补充测试。
|
||||
- [ ] [CONCEPT: 测试方案] 跑通申请 → 票据 → 报销 → 预审 → 审批 → 付款 → 入账 → 归档端到端。
|
||||
当前进度:申请批准 → 自动报销草稿 → 票据归集 → 预审 → 报销提交已在同一 Case 中跑通;付款回执、ERP 入账和对账仍未接入。
|
||||
|
||||
@@ -8,3 +8,8 @@
|
||||
- 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。
|
||||
- 2026-07-16 15:44:39 CST:提交后交叉审查发现“副作用幂等”不等于“响应幂等”:动作账本虽然已有 `response_json`,重放却重新读取当前 Claim,后续节点变化会返回新状态。现改为首次动作在同一事务保存完整 `ExpenseClaimRead`,首次和重放均从该快照构造响应;快照缺失、单据/状态/节点身份不一致时 fail-closed 返回冲突,不再退回当前投影。
|
||||
- 2026-07-16 15:44:39 CST:新增“首次批准 → 单据继续推进并写入后续内部状态 → 旧 request_id 重放”服务和 HTTP 回归,确认响应逐字段等于首次结果、后续风险字段不泄露、数据库当前状态不被回退;审批动作与报销接口组合 29 项通过。
|
||||
- 2026-07-16 15:44:39 CST:再次完成 Git 拉取检查,上游无新增提交,本地 ahead 15,新增 ahead 为 `4940ebc4` 审批风险安全底座;用户维护的财务规则工作簿和历史日志继续保持未暂存。
|
||||
- 2026-07-16 15:48:38 CST:扩大回归范围后发现两个兼容边界:首次动作若直接返回 Pydantic 快照会破坏既有服务层 ORM 可变语义,因此首次执行继续返回已提交并刷新的 ORM,只有重放从不可变快照构造;HTTP 首次与重放仍逐字段一致。另为风险门禁增加申请/报销阶段匹配,显式错阶段风险不阻断当前流程,未知阶段重大风险仍 fail-closed。
|
||||
- 2026-07-16 15:48:38 CST:路由型重大风险统一使用显式 `route_review`,它允许当前审批进入预算/财务复核,但不会被标记为已解决;普通无 actionability 的原始重大风险继续阻断。容器内费用服务、审批路由、租户/Case、动作协议、接口和风险处置组合 182 项通过。
|
||||
|
||||
@@ -8,3 +8,6 @@
|
||||
- 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 项通过。规则工作簿和历史未跟踪日志未被改动或纳入提交。
|
||||
- 2026-07-16 15:44:39 CST:交叉审查发现风险处置重放在权限复核前读取当前 Disposition 及全部事件,旧 request_id 可能看到首次动作之后的处置状态。新增 `20260716_0012`:完整响应在 append-only Event 的 INSERT 前原子写入 `response_json`,重放只返回该不可变快照并将 `replayed=true`;不得通过 UPDATE 补写审计事件。
|
||||
- 2026-07-16 15:44:39 CST:兼容 0012 前历史事件时,不回读当前 Disposition,只使用目标 Event 的 `after_json` 和 `version <= target` 的只追加事件重建;快照身份、租户、观察、处置、请求和版本任一不一致均 fail-closed。新增后续补件/整改后重放首个裁决的服务与 HTTP 回归,确认只返回版本 1 和当时事件。
|
||||
- 2026-07-16 15:44:39 CST:容器内风险处置、迁移和前置检查 69 项通过、1 项条件跳过,审批快照与报销接口 29 项通过;一次性 tmpfs PostgreSQL 17 的 0012 Head 迁移与审批风险真实并发共 17 项通过,临时数据库自动清理且持久开发库未修改。
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""add immutable risk disposition response snapshots
|
||||
|
||||
Revision ID: 20260716_0012
|
||||
Revises: 20260716_0011
|
||||
Create Date: 2026-07-16 17:10:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260716_0012"
|
||||
down_revision: str | None = "20260716_0011"
|
||||
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_0012 only supports PostgreSQL; "
|
||||
f"refusing to mutate {dialect_name} without transactional JSON DDL"
|
||||
)
|
||||
|
||||
|
||||
def _require_empty_response_snapshots_for_downgrade() -> None:
|
||||
snapshot_count = int(
|
||||
op.get_bind().scalar(
|
||||
sa.text("SELECT COUNT(*) FROM risk_disposition_events WHERE response_json IS NOT NULL")
|
||||
)
|
||||
or 0
|
||||
)
|
||||
if snapshot_count:
|
||||
raise RuntimeError(
|
||||
"cannot downgrade risk disposition response snapshots: "
|
||||
f"risk_disposition_events contains {snapshot_count} immutable snapshot(s)"
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_require_postgresql()
|
||||
op.add_column(
|
||||
"risk_disposition_events",
|
||||
sa.Column("response_json", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_require_postgresql()
|
||||
# 已写入事件快照受 append-only 触发器保护;降级不能静默删除审计响应。
|
||||
_require_empty_response_snapshots_for_downgrade()
|
||||
op.drop_column("risk_disposition_events", "response_json")
|
||||
@@ -199,11 +199,7 @@ def execute_risk_disposition_action(
|
||||
observation_key_or_id,
|
||||
payload,
|
||||
)
|
||||
return RiskDispositionMutationRead(
|
||||
disposition=mutation.disposition,
|
||||
event=mutation.event,
|
||||
replayed=mutation.replayed,
|
||||
)
|
||||
return mutation.response
|
||||
|
||||
|
||||
@router.post(
|
||||
|
||||
@@ -170,8 +170,29 @@ MIGRATION_OWNED_TABLES_BY_REVISION: dict[str, frozenset[str]] = {
|
||||
"approval_action_ledgers",
|
||||
}
|
||||
),
|
||||
"20260716_0012": 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_0011"] != MIGRATION_OWNED_TABLES:
|
||||
if MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0012"] != MIGRATION_OWNED_TABLES:
|
||||
raise RuntimeError("latest Alembic revision must own the centralized migration table set")
|
||||
|
||||
# 0008 之前这三张表由旧 bootstrap / 风险服务按需创建。迁移前置检查允许
|
||||
@@ -244,6 +265,7 @@ def _validate_connection(connection: Connection) -> MigrationPreflightState:
|
||||
"20260716_0009",
|
||||
"20260716_0010",
|
||||
"20260716_0011",
|
||||
"20260716_0012",
|
||||
}
|
||||
else frozenset()
|
||||
)
|
||||
|
||||
@@ -165,6 +165,7 @@ class RiskDispositionEvent(Base):
|
||||
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)
|
||||
response_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
|
||||
@@ -72,7 +72,6 @@ class ApprovalActionProtocol:
|
||||
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)
|
||||
@@ -101,7 +100,7 @@ class ApprovalActionProtocol:
|
||||
if existing.completed_at is None:
|
||||
raise ApprovalActionConflictError("该审批动作仍在处理中,请稍后重试。")
|
||||
return ApprovalActionStart(
|
||||
claim=replay_claim_loader(),
|
||||
claim=None,
|
||||
ledger=existing,
|
||||
request_id=request_id,
|
||||
replayed=True,
|
||||
|
||||
@@ -3,12 +3,17 @@ from __future__ import annotations
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
from pydantic import ValidationError
|
||||
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.schemas.reimbursement import ExpenseClaimRead
|
||||
from app.services.approval_action_protocol import (
|
||||
ApprovalActionConflictError,
|
||||
ApprovalActionProtocol,
|
||||
)
|
||||
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
|
||||
|
||||
ClaimActionExecutor = Callable[
|
||||
@@ -29,7 +34,7 @@ class ExpenseClaimActionProtocolMixin:
|
||||
expected_approval_stage: str | None,
|
||||
payload: Mapping[str, Any],
|
||||
executor: ClaimActionExecutor,
|
||||
) -> ExpenseClaim | None:
|
||||
) -> ExpenseClaim | ExpenseClaimRead | None:
|
||||
protocol = ApprovalActionProtocol(self.db)
|
||||
tenant_id = ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id)
|
||||
actor_id = str(current_user.username or "").strip().casefold()
|
||||
@@ -51,18 +56,16 @@ class ExpenseClaimActionProtocolMixin:
|
||||
claim_id,
|
||||
current_user,
|
||||
),
|
||||
replay_claim_loader=lambda: self._load_claim_for_replay(
|
||||
claim_id,
|
||||
current_user,
|
||||
),
|
||||
)
|
||||
if started.replayed:
|
||||
if started.ledger is None: # pragma: no cover - defensive invariant
|
||||
raise RuntimeError("审批动作重放账本缺失。")
|
||||
response = self._response_from_ledger(started.ledger)
|
||||
self.db.commit()
|
||||
return response
|
||||
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("审批动作账本初始化失败。")
|
||||
|
||||
@@ -71,14 +74,16 @@ class ExpenseClaimActionProtocolMixin:
|
||||
started.ledger,
|
||||
normalized_request_id,
|
||||
)
|
||||
self.db.flush()
|
||||
self.db.refresh(claim)
|
||||
response = ExpenseClaimRead.model_validate(
|
||||
self._access_policy.attach_approval_snapshot(claim)
|
||||
)
|
||||
response_json = response.model_dump(mode="json")
|
||||
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(),
|
||||
},
|
||||
response_json=response_json,
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
@@ -108,13 +113,22 @@ class ExpenseClaimActionProtocolMixin:
|
||||
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)
|
||||
@staticmethod
|
||||
def _response_from_ledger(ledger: ApprovalActionLedger) -> ExpenseClaimRead:
|
||||
try:
|
||||
response = ExpenseClaimRead.model_validate(ledger.response_json)
|
||||
except (TypeError, ValueError, ValidationError) as error:
|
||||
raise ApprovalActionConflictError(
|
||||
"该审批动作缺少可验证的原始响应快照,请刷新单据后使用新的 request_id。"
|
||||
) from error
|
||||
if str(response.id) != str(ledger.claim_id):
|
||||
raise ApprovalActionConflictError("审批动作原始响应快照与单据不一致。")
|
||||
if str(response.status or "").strip().lower() != str(
|
||||
ledger.result_status or ""
|
||||
).strip().lower():
|
||||
raise ApprovalActionConflictError("审批动作原始响应快照状态校验失败。")
|
||||
if str(response.approval_stage or "").strip() != str(
|
||||
ledger.result_approval_stage or ""
|
||||
).strip():
|
||||
raise ApprovalActionConflictError("审批动作原始响应快照节点校验失败。")
|
||||
return response
|
||||
|
||||
@@ -6,6 +6,7 @@ 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.schemas.reimbursement import ExpenseClaimRead
|
||||
from app.services.expense_claim_constants import RETURN_REASON_OPTIONS
|
||||
|
||||
|
||||
@@ -20,7 +21,7 @@ class ExpenseClaimReturnFlowMixin:
|
||||
request_id: str | None = None,
|
||||
expected_status: str | None = None,
|
||||
expected_approval_stage: str | None = None,
|
||||
) -> ExpenseClaim | None:
|
||||
) -> ExpenseClaim | ExpenseClaimRead | None:
|
||||
normalized_reason = str(reason or "").strip()
|
||||
reason_code_payload = self._normalize_return_reason_code_payload(reason_codes)
|
||||
normalized_codes = [
|
||||
|
||||
@@ -8,11 +8,16 @@ 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.document_numbering import is_application_claim_no
|
||||
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_risk_stage import (
|
||||
risk_business_stage_for_claim,
|
||||
risk_flag_business_stage,
|
||||
)
|
||||
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
|
||||
|
||||
_BLOCKING_LEVELS = {"high", "critical", "danger"}
|
||||
@@ -52,6 +57,7 @@ class ExpenseClaimRiskGate:
|
||||
tenant_id: str | None,
|
||||
) -> list[BlockingRiskObservation]:
|
||||
normalized_tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(tenant_id)
|
||||
claim_business_stage = self._claim_business_stage(claim)
|
||||
rows = self.db.execute(
|
||||
select(RiskObservation, RiskDisposition)
|
||||
.outerjoin(
|
||||
@@ -75,6 +81,12 @@ class ExpenseClaimRiskGate:
|
||||
materialized_keys: set[str] = set()
|
||||
for observation, disposition in rows:
|
||||
materialized_keys.add(str(observation.observation_key or "").strip())
|
||||
observation_stage = risk_flag_business_stage(
|
||||
{"control_stage": observation.control_stage},
|
||||
default="",
|
||||
)
|
||||
if observation_stage and observation_stage != claim_business_stage:
|
||||
continue
|
||||
level = str(observation.risk_level or "").strip().lower()
|
||||
if level not in _BLOCKING_LEVELS:
|
||||
continue
|
||||
@@ -100,6 +112,9 @@ class ExpenseClaimRiskGate:
|
||||
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
|
||||
flag_stage = risk_flag_business_stage(flag, default="")
|
||||
if flag_stage and flag_stage != claim_business_stage:
|
||||
continue
|
||||
observation_key = claim_risk_flag_observation_key(flag, claim_id=claim.id)
|
||||
if observation_key and observation_key in materialized_keys:
|
||||
# 完整物化后的处置投影优先;仅对没有 Observation 的高风险兜底。
|
||||
@@ -124,6 +139,16 @@ class ExpenseClaimRiskGate:
|
||||
)
|
||||
return blockers
|
||||
|
||||
@staticmethod
|
||||
def _claim_business_stage(claim: ExpenseClaim) -> str:
|
||||
expense_type = str(claim.expense_type or "").strip().lower()
|
||||
is_application = (
|
||||
is_application_claim_no(str(claim.claim_no or "").strip())
|
||||
or expense_type == "application"
|
||||
or expense_type.endswith("_application")
|
||||
)
|
||||
return risk_business_stage_for_claim(is_application_claim=is_application)
|
||||
|
||||
def ensure_approvable(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
|
||||
@@ -27,6 +27,7 @@ SUPPORTED_RISK_VISIBILITY_SCOPES = {
|
||||
SUPPORTED_RISK_ACTIONABILITIES = {
|
||||
"fixable_by_submitter",
|
||||
"review_decision",
|
||||
"route_review",
|
||||
"budget_governance",
|
||||
"finance_check",
|
||||
"system_trace",
|
||||
|
||||
@@ -6,7 +6,9 @@ import os
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -16,7 +18,12 @@ 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.schemas.risk_disposition import (
|
||||
RiskDispositionActionCreate,
|
||||
RiskDispositionEventRead,
|
||||
RiskDispositionMutationRead,
|
||||
RiskDispositionRead,
|
||||
)
|
||||
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
|
||||
from app.services.risk_observation_access_policy import RiskObservationAccessPolicy
|
||||
|
||||
@@ -45,11 +52,21 @@ class RiskDispositionPermissionError(RuntimeError):
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RiskDispositionMutation:
|
||||
disposition: RiskDisposition
|
||||
event: RiskDispositionEvent
|
||||
replayed: bool
|
||||
response: RiskDispositionMutationRead
|
||||
legacy_feedback: RiskObservationFeedback | None = None
|
||||
|
||||
@property
|
||||
def disposition(self) -> RiskDispositionRead:
|
||||
return self.response.disposition
|
||||
|
||||
@property
|
||||
def event(self) -> RiskDispositionEventRead:
|
||||
return self.response.event
|
||||
|
||||
@property
|
||||
def replayed(self) -> bool:
|
||||
return self.response.replayed
|
||||
|
||||
|
||||
class RiskDispositionService:
|
||||
def __init__(self, db: Session) -> None:
|
||||
@@ -149,6 +166,7 @@ class RiskDispositionService:
|
||||
if current_version != payload.expected_version:
|
||||
raise RiskDispositionVersionConflictError(current_version)
|
||||
|
||||
mutation_at = datetime.now(UTC)
|
||||
if disposition is None:
|
||||
disposition = RiskDisposition(
|
||||
id=str(uuid.uuid4()),
|
||||
@@ -157,6 +175,8 @@ class RiskDispositionService:
|
||||
adjudication=_initial_adjudication(observation),
|
||||
lifecycle_status=_initial_lifecycle_status(observation),
|
||||
version=0,
|
||||
created_at=mutation_at,
|
||||
updated_at=mutation_at,
|
||||
)
|
||||
self.db.add(disposition)
|
||||
|
||||
@@ -164,7 +184,7 @@ class RiskDispositionService:
|
||||
before = _state(disposition)
|
||||
_apply_action(disposition, observation, payload)
|
||||
disposition.version = current_version + 1
|
||||
disposition.updated_at = datetime.now(UTC)
|
||||
disposition.updated_at = mutation_at
|
||||
event = RiskDispositionEvent(
|
||||
id=str(uuid.uuid4()),
|
||||
tenant_id=normalized_tenant,
|
||||
@@ -179,7 +199,16 @@ class RiskDispositionService:
|
||||
comment=payload.comment,
|
||||
before_json=before,
|
||||
after_json=_state(disposition),
|
||||
created_at=mutation_at,
|
||||
)
|
||||
response = self._build_response_snapshot(
|
||||
disposition,
|
||||
event,
|
||||
prior_version=current_version,
|
||||
)
|
||||
# PostgreSQL 会拒绝 append-only 事件的后续 UPDATE,因此完整响应
|
||||
# 必须和事件在同一条 INSERT 中原子写入。
|
||||
event.response_json = response.model_dump(mode="json")
|
||||
self.db.add(event)
|
||||
legacy_feedback = self._append_safe_feedback(
|
||||
observation,
|
||||
@@ -194,9 +223,7 @@ class RiskDispositionService:
|
||||
self.db.refresh(legacy_feedback)
|
||||
self._ingest_feedback_sample(observation, legacy_feedback)
|
||||
return RiskDispositionMutation(
|
||||
disposition=disposition,
|
||||
event=event,
|
||||
replayed=False,
|
||||
response=response,
|
||||
legacy_feedback=legacy_feedback,
|
||||
)
|
||||
except (LookupError, RiskDispositionConflictError, RiskDispositionPermissionError):
|
||||
@@ -232,6 +259,49 @@ class RiskDispositionService:
|
||||
statement = statement.with_for_update()
|
||||
return self.db.scalar(statement.execution_options(populate_existing=True))
|
||||
|
||||
def _build_response_snapshot(
|
||||
self,
|
||||
disposition: RiskDisposition,
|
||||
event: RiskDispositionEvent,
|
||||
*,
|
||||
prior_version: int,
|
||||
) -> RiskDispositionMutationRead:
|
||||
prior_events = list(
|
||||
self.db.scalars(
|
||||
select(RiskDispositionEvent)
|
||||
.where(
|
||||
RiskDispositionEvent.tenant_id == disposition.tenant_id,
|
||||
RiskDispositionEvent.disposition_id == disposition.id,
|
||||
RiskDispositionEvent.version <= prior_version,
|
||||
)
|
||||
.order_by(RiskDispositionEvent.version.asc())
|
||||
).all()
|
||||
)
|
||||
event_read = RiskDispositionEventRead.model_validate(event)
|
||||
event_history = [
|
||||
*[RiskDispositionEventRead.model_validate(item) for item in prior_events],
|
||||
event_read,
|
||||
]
|
||||
disposition_read = RiskDispositionRead(
|
||||
id=disposition.id,
|
||||
tenant_id=disposition.tenant_id,
|
||||
observation_id=disposition.observation_id,
|
||||
adjudication=disposition.adjudication,
|
||||
lifecycle_status=disposition.lifecycle_status,
|
||||
version=disposition.version,
|
||||
assignee=disposition.assignee,
|
||||
due_at=disposition.due_at,
|
||||
resolution=disposition.resolution,
|
||||
created_at=disposition.created_at,
|
||||
updated_at=disposition.updated_at,
|
||||
events=event_history,
|
||||
)
|
||||
return RiskDispositionMutationRead(
|
||||
disposition=disposition_read,
|
||||
event=event_read,
|
||||
replayed=False,
|
||||
)
|
||||
|
||||
def _find_replay(
|
||||
self,
|
||||
*,
|
||||
@@ -249,9 +319,7 @@ class RiskDispositionService:
|
||||
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.")
|
||||
response = self._event_response(event)
|
||||
feedback = self.db.scalar(
|
||||
select(RiskObservationFeedback).where(
|
||||
RiskObservationFeedback.observation_id == event.observation_id,
|
||||
@@ -259,12 +327,97 @@ class RiskDispositionService:
|
||||
)
|
||||
)
|
||||
return RiskDispositionMutation(
|
||||
disposition=disposition,
|
||||
event=event,
|
||||
replayed=True,
|
||||
response=response.model_copy(update={"replayed": True}),
|
||||
legacy_feedback=feedback,
|
||||
)
|
||||
|
||||
def _event_response(
|
||||
self,
|
||||
event: RiskDispositionEvent,
|
||||
) -> RiskDispositionMutationRead:
|
||||
if isinstance(event.response_json, dict) and event.response_json:
|
||||
try:
|
||||
response = RiskDispositionMutationRead.model_validate(event.response_json)
|
||||
except ValidationError as error:
|
||||
raise RiskDispositionConflictError(
|
||||
"Stored risk disposition response snapshot is invalid."
|
||||
) from error
|
||||
self._validate_snapshot_identity(event, response)
|
||||
return response
|
||||
return self._rebuild_legacy_event_response(event)
|
||||
|
||||
def _rebuild_legacy_event_response(
|
||||
self,
|
||||
event: RiskDispositionEvent,
|
||||
) -> RiskDispositionMutationRead:
|
||||
# 0012 之前的事件没有响应快照。只读取目标版本及以前的 append-only
|
||||
# 事件,并使用目标事件 after_json 重建;禁止读取当前 disposition。
|
||||
history = list(
|
||||
self.db.scalars(
|
||||
select(RiskDispositionEvent)
|
||||
.where(
|
||||
RiskDispositionEvent.tenant_id == event.tenant_id,
|
||||
RiskDispositionEvent.disposition_id == event.disposition_id,
|
||||
RiskDispositionEvent.version <= event.version,
|
||||
)
|
||||
.order_by(RiskDispositionEvent.version.asc())
|
||||
).all()
|
||||
)
|
||||
target = next((item for item in history if item.id == event.id), None)
|
||||
if target is None:
|
||||
raise RiskDispositionConflictError(
|
||||
"Legacy risk disposition replay event is missing from its audit chain."
|
||||
)
|
||||
state: dict[str, Any] = dict(event.after_json or {})
|
||||
event_reads = [RiskDispositionEventRead.model_validate(item) for item in history]
|
||||
target_read = RiskDispositionEventRead.model_validate(target)
|
||||
try:
|
||||
disposition = RiskDispositionRead(
|
||||
id=event.disposition_id,
|
||||
tenant_id=event.tenant_id,
|
||||
observation_id=event.observation_id,
|
||||
adjudication=state.get("adjudication"),
|
||||
lifecycle_status=state.get("lifecycle_status"),
|
||||
version=event.version,
|
||||
assignee=state.get("assignee"),
|
||||
due_at=state.get("due_at"),
|
||||
resolution=state.get("resolution"),
|
||||
created_at=history[0].created_at,
|
||||
updated_at=event.created_at,
|
||||
events=event_reads,
|
||||
)
|
||||
response = RiskDispositionMutationRead(
|
||||
disposition=disposition,
|
||||
event=target_read,
|
||||
replayed=False,
|
||||
)
|
||||
except ValidationError as error:
|
||||
raise RiskDispositionConflictError(
|
||||
"Legacy risk disposition response cannot be reconstructed safely."
|
||||
) from error
|
||||
self._validate_snapshot_identity(event, response)
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _validate_snapshot_identity(
|
||||
event: RiskDispositionEvent,
|
||||
response: RiskDispositionMutationRead,
|
||||
) -> None:
|
||||
if (
|
||||
response.event.id != event.id
|
||||
or response.event.request_id != event.request_id
|
||||
or response.event.version != event.version
|
||||
or response.disposition.id != event.disposition_id
|
||||
or response.disposition.observation_id != event.observation_id
|
||||
or response.disposition.tenant_id != event.tenant_id
|
||||
or response.disposition.version != event.version
|
||||
or not any(item.id == event.id for item in response.disposition.events)
|
||||
or any(item.version > event.version for item in response.disposition.events)
|
||||
):
|
||||
raise RiskDispositionConflictError(
|
||||
"Risk disposition response snapshot does not match its immutable event."
|
||||
)
|
||||
|
||||
def _append_safe_feedback(
|
||||
self,
|
||||
observation: RiskObservation,
|
||||
|
||||
@@ -28,7 +28,7 @@ from app.models.risk_observation import RiskObservation
|
||||
|
||||
MIGRATION_TEST_DATABASE_URL = os.getenv("MIGRATION_TEST_DATABASE_URL", "").strip()
|
||||
LEGACY_PROBE_TABLE = "legacy_migration_probe_records"
|
||||
HEAD_REVISION = "20260716_0011"
|
||||
HEAD_REVISION = "20260716_0012"
|
||||
SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||
ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini"
|
||||
|
||||
@@ -487,6 +487,14 @@ def _assert_head_schema(engine: Engine) -> None:
|
||||
("tenant_id", "observation_id"),
|
||||
"risk_observations",
|
||||
)
|
||||
risk_disposition_event_columns = {
|
||||
str(item["name"])
|
||||
for item in inspect(engine).get_columns(
|
||||
"risk_disposition_events",
|
||||
schema="public",
|
||||
)
|
||||
}
|
||||
assert "response_json" in risk_disposition_event_columns
|
||||
with engine.connect() as connection:
|
||||
append_only_trigger_count = int(
|
||||
connection.scalar(
|
||||
@@ -1325,6 +1333,8 @@ def _assert_base_schema(engine: Engine) -> None:
|
||||
("20260716_0010_approval_action_protocol.py", "downgrade"),
|
||||
("20260716_0011_risk_disposition.py", "upgrade"),
|
||||
("20260716_0011_risk_disposition.py", "downgrade"),
|
||||
("20260716_0012_risk_disposition_response_snapshot.py", "upgrade"),
|
||||
("20260716_0012_risk_disposition_response_snapshot.py", "downgrade"),
|
||||
],
|
||||
)
|
||||
def test_postgresql_only_migrations_reject_other_dialects_before_mutation(
|
||||
@@ -1341,6 +1351,18 @@ def test_postgresql_only_migrations_reject_other_dialects_before_mutation(
|
||||
assert operation_guard.mutation_calls == []
|
||||
|
||||
|
||||
def test_risk_disposition_snapshot_migration_refuses_lossy_downgrade() -> None:
|
||||
migration = _load_migration_module("20260716_0012_risk_disposition_response_snapshot.py")
|
||||
operation_guard = _UnsupportedDialectOperationGuard(dialect_name="postgresql")
|
||||
operation_guard.bind.scalar = lambda _statement: 1
|
||||
migration.op = operation_guard
|
||||
|
||||
with pytest.raises(RuntimeError, match="contains 1 immutable snapshot"):
|
||||
migration.downgrade()
|
||||
|
||||
assert operation_guard.mutation_calls == []
|
||||
|
||||
|
||||
def test_head_model_declares_soft_claim_reference_and_organization_only_active_index() -> None:
|
||||
claim_column = RiskObservation.__table__.c.claim_id
|
||||
assert not claim_column.foreign_keys
|
||||
|
||||
@@ -15,6 +15,7 @@ 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.schemas.reimbursement import ExpenseClaimRead
|
||||
from app.services.approval_action_protocol import (
|
||||
ApprovalActionConflictError,
|
||||
ApprovalActionProtocol,
|
||||
@@ -117,6 +118,9 @@ def test_approve_replay_persists_one_ledger_event_and_audit(
|
||||
)
|
||||
|
||||
assert first is not None and replay is not None
|
||||
assert ExpenseClaimRead.model_validate(replay).model_dump(mode="json") == (
|
||||
ExpenseClaimRead.model_validate(first).model_dump(mode="json")
|
||||
)
|
||||
assert replay.approval_stage == "财务审批"
|
||||
assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 1
|
||||
assert db.scalar(select(func.count()).select_from(BusinessEvent)) == 1
|
||||
@@ -130,6 +134,61 @@ def test_approve_replay_persists_one_ledger_event_and_audit(
|
||||
)
|
||||
|
||||
|
||||
def test_approve_replay_returns_original_snapshot_after_claim_moves_forward(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
claim = _seed_claim(db, claim_id="claim-action-original-response")
|
||||
service = ExpenseClaimService(db)
|
||||
first = service.approve_claim(
|
||||
claim.id,
|
||||
_manager_user(),
|
||||
opinion="同意",
|
||||
request_id="approve-original-response-1",
|
||||
expected_status="submitted",
|
||||
expected_approval_stage="直属领导审批",
|
||||
)
|
||||
assert first is not None
|
||||
first_json = ExpenseClaimRead.model_validate(first).model_dump(mode="json")
|
||||
|
||||
current = db.get(ExpenseClaim, claim.id)
|
||||
assert current is not None
|
||||
current.status = "pending_payment"
|
||||
current.approval_stage = "待付款"
|
||||
current.risk_flags_json = [
|
||||
*list(current.risk_flags_json or []),
|
||||
{"source": "future_state", "message": "首次响应之后产生的内部状态"},
|
||||
]
|
||||
db.commit()
|
||||
|
||||
replay = service.approve_claim(
|
||||
claim.id,
|
||||
_manager_user(),
|
||||
opinion="同意",
|
||||
request_id="approve-original-response-1",
|
||||
expected_status="submitted",
|
||||
expected_approval_stage="直属领导审批",
|
||||
)
|
||||
|
||||
assert replay is not None
|
||||
assert ExpenseClaimRead.model_validate(replay).model_dump(mode="json") == first_json
|
||||
assert replay.approval_stage == "财务审批"
|
||||
assert all(
|
||||
item.get("source") != "future_state" for item in replay.risk_flags_json or []
|
||||
)
|
||||
persisted = db.get(ExpenseClaim, claim.id)
|
||||
assert persisted is not None
|
||||
assert persisted.status == "pending_payment"
|
||||
assert persisted.approval_stage == "待付款"
|
||||
ledger = db.scalar(
|
||||
select(ApprovalActionLedger).where(
|
||||
ApprovalActionLedger.request_id == "approve-original-response-1"
|
||||
)
|
||||
)
|
||||
assert ledger is not None
|
||||
assert ledger.response_json == first_json
|
||||
|
||||
|
||||
def test_request_id_payload_mismatch_and_stale_preconditions_return_conflict(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
|
||||
@@ -342,6 +342,7 @@ def test_high_risk_application_under_90_percent_routes_to_budget_manager() -> No
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"actionability": "route_review",
|
||||
"label": "申请信息风险",
|
||||
"message": "申请事由需要领导关注。",
|
||||
"business_stage": "expense_application",
|
||||
@@ -477,7 +478,7 @@ def test_review_decision_high_risk_application_flows_from_pre_review_to_p8() ->
|
||||
{
|
||||
"source": "manual_risk",
|
||||
"severity": "high",
|
||||
"actionability": "review_decision",
|
||||
"actionability": "route_review",
|
||||
"label": "特殊项目风险",
|
||||
"message": "该项目需预算管理者确认。",
|
||||
"business_stage": "expense_application",
|
||||
@@ -619,6 +620,7 @@ def test_risky_reimbursement_routes_to_budget_then_finance() -> None:
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"actionability": "route_review",
|
||||
"label": "行程城市异常",
|
||||
"message": "票据城市与申报目的地不一致",
|
||||
}
|
||||
|
||||
@@ -145,6 +145,10 @@ def test_known_revision_requires_and_accepts_its_exact_owned_table_set(
|
||||
"20260716_0011",
|
||||
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0011"] - {"risk_disposition_events"},
|
||||
),
|
||||
(
|
||||
"20260716_0012",
|
||||
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0012"] - {"risk_disposition_events"},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_known_revision_with_missing_or_unexpected_owned_tables_is_rejected(
|
||||
|
||||
@@ -736,6 +736,17 @@ def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review()
|
||||
assert approval_events[0]["operator"] == "李经理"
|
||||
assert "manager-approve-api@example.com" not in approval_events[0]["message"]
|
||||
|
||||
with session_factory() as db:
|
||||
moved_forward = db.get(ExpenseClaim, "claim-approve-1")
|
||||
assert moved_forward is not None
|
||||
moved_forward.status = "pending_payment"
|
||||
moved_forward.approval_stage = "待付款"
|
||||
moved_forward.risk_flags_json = [
|
||||
*list(moved_forward.risk_flags_json or []),
|
||||
{"source": "future_state", "message": "首次响应之后的状态"},
|
||||
]
|
||||
db.commit()
|
||||
|
||||
replay_response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-approve-1/approve",
|
||||
json={
|
||||
@@ -747,7 +758,11 @@ def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review()
|
||||
headers=action_headers,
|
||||
)
|
||||
assert replay_response.status_code == 200
|
||||
assert replay_response.json()["approval_stage"] == "财务审批"
|
||||
assert replay_response.json() == payload
|
||||
assert all(
|
||||
item.get("source") != "future_state"
|
||||
for item in replay_response.json()["risk_flags_json"]
|
||||
)
|
||||
|
||||
changed_payload_response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-approve-1/approve",
|
||||
|
||||
@@ -228,6 +228,37 @@ def test_risk_disposition_idempotency_and_optimistic_version_are_enforced(
|
||||
assert replay.replayed is True
|
||||
assert replay.event.id == first.event.id
|
||||
assert db.scalar(select(func.count()).select_from(RiskDispositionEvent)) == 1
|
||||
stored_event = db.get(RiskDispositionEvent, first.event.id)
|
||||
assert stored_event is not None
|
||||
assert stored_event.response_json is not None
|
||||
assert stored_event.response_json["replayed"] is False
|
||||
assert stored_event.response_json["disposition"]["version"] == 1
|
||||
|
||||
advanced = service.execute_action(
|
||||
observation.id,
|
||||
_action(
|
||||
"start_remediation",
|
||||
version=1,
|
||||
request_id="request-remediation-after-idempotent",
|
||||
),
|
||||
tenant_id="default",
|
||||
actor_id="finance-1",
|
||||
actor_name="财务甲",
|
||||
)
|
||||
late_replay = service.execute_action(
|
||||
observation.id,
|
||||
payload,
|
||||
tenant_id="default",
|
||||
actor_id="finance-1",
|
||||
actor_name="财务甲",
|
||||
)
|
||||
|
||||
assert advanced.disposition.version == 2
|
||||
assert late_replay.replayed is True
|
||||
assert late_replay.disposition.version == 1
|
||||
assert late_replay.disposition.lifecycle_status == "open"
|
||||
assert [item.version for item in late_replay.disposition.events] == [1]
|
||||
assert db.scalar(select(func.count()).select_from(RiskDispositionEvent)) == 2
|
||||
|
||||
with pytest.raises(RiskDispositionIdempotencyConflictError):
|
||||
service.execute_action(
|
||||
@@ -268,7 +299,56 @@ def test_risk_disposition_idempotency_and_optimistic_version_are_enforced(
|
||||
actor_id="finance-1",
|
||||
actor_name="财务甲",
|
||||
)
|
||||
assert error.value.current_version == 1
|
||||
assert error.value.current_version == 2
|
||||
|
||||
|
||||
def test_legacy_event_replay_rebuilds_only_target_version_history(
|
||||
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-replay")
|
||||
)
|
||||
db.commit()
|
||||
service = RiskDispositionService(db)
|
||||
payload = _action("confirm", version=0, request_id="request-legacy-replay-001")
|
||||
first = service.execute_action(
|
||||
observation.id,
|
||||
payload,
|
||||
tenant_id="default",
|
||||
actor_id="finance-1",
|
||||
actor_name="财务甲",
|
||||
)
|
||||
legacy_event = db.get(RiskDispositionEvent, first.event.id)
|
||||
assert legacy_event is not None
|
||||
legacy_event.response_json = None
|
||||
db.commit()
|
||||
|
||||
service.execute_action(
|
||||
observation.id,
|
||||
_action(
|
||||
"start_remediation",
|
||||
version=1,
|
||||
request_id="request-legacy-replay-later",
|
||||
),
|
||||
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 replay.disposition.version == 1
|
||||
assert replay.disposition.lifecycle_status == "open"
|
||||
assert [item.version for item in replay.disposition.events] == [1]
|
||||
|
||||
|
||||
def test_risk_observation_api_enforces_pool_claim_and_typed_action_permissions(
|
||||
@@ -360,6 +440,26 @@ def test_risk_observation_api_enforces_pool_claim_and_typed_action_permissions(
|
||||
"comment": "人工复核确认",
|
||||
},
|
||||
)
|
||||
advanced = client.post(
|
||||
f"/api/v1/risk-observations/{observation_id}/disposition/actions",
|
||||
headers=finance_headers,
|
||||
json={
|
||||
"action": "request_supplement",
|
||||
"expected_version": 1,
|
||||
"request_id": "api-supplement-request-001",
|
||||
"comment": "请补充完整行程材料",
|
||||
},
|
||||
)
|
||||
late_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,
|
||||
@@ -410,6 +510,13 @@ def test_risk_observation_api_enforces_pool_claim_and_typed_action_permissions(
|
||||
assert replay.status_code == 200
|
||||
assert replay.json()["replayed"] is True
|
||||
assert replay.json()["event"]["id"] == first.json()["event"]["id"]
|
||||
assert advanced.status_code == 200
|
||||
assert advanced.json()["disposition"]["version"] == 2
|
||||
assert late_replay.status_code == 200
|
||||
assert late_replay.json()["replayed"] is True
|
||||
assert late_replay.json()["disposition"]["version"] == 1
|
||||
assert late_replay.json()["disposition"]["lifecycle_status"] == "open"
|
||||
assert [item["version"] for item in late_replay.json()["disposition"]["events"]] == [1]
|
||||
assert stale.status_code == 409
|
||||
assert stale.json()["detail"]["code"] == "RISK_DISPOSITION_VERSION_CONFLICT"
|
||||
assert stale.json()["detail"]["message"] == "风险处置状态已更新,请刷新证据链后重试。"
|
||||
@@ -423,8 +530,8 @@ def test_risk_observation_api_enforces_pool_claim_and_typed_action_permissions(
|
||||
headers=finance_headers,
|
||||
)
|
||||
assert detail.status_code == 200
|
||||
assert detail.json()["disposition"]["version"] == 1
|
||||
assert len(detail.json()["disposition"]["events"]) == 1
|
||||
assert detail.json()["disposition"]["version"] == 2
|
||||
assert len(detail.json()["disposition"]["events"]) == 2
|
||||
|
||||
|
||||
def test_current_claim_approver_can_manage_disposition_without_pool_access() -> None:
|
||||
|
||||
Reference in New Issue
Block a user