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

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