fix(approval): replay immutable action responses
This commit is contained in:
@@ -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