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

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