fix(approval): replay immutable action responses

This commit is contained in:
caoxiaozhu
2026-07-16 15:49:43 +08:00
parent 4940ebc419
commit 28b834edd3
20 changed files with 552 additions and 60 deletions

View File

@@ -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、预算复核或审批路由。

View File

@@ -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 入账和对账仍未接入。

View File

@@ -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 → DispositionHermes 锁外计算、锁内刷新并核对状态和更新时间,旧快照被丢弃。一次性 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 项通过。

View File

@@ -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 项通过,临时数据库自动清理且持久开发库未修改。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,6 +27,7 @@ SUPPORTED_RISK_VISIBILITY_SCOPES = {
SUPPORTED_RISK_ACTIONABILITIES = {
"fixable_by_submitter",
"review_decision",
"route_review",
"budget_governance",
"finance_check",
"system_trace",

View File

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

View File

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

View File

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

View File

@@ -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": "票据城市与申报目的地不一致",
}

View File

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

View File

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

View File

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