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

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