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.schemas.reimbursement import ExpenseClaimRead from app.services.approval_action_protocol import ( ApprovalActionConflictError, ApprovalActionProtocol, ) from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin ClaimActionExecutor = Callable[ [ExpenseClaim, ApprovalActionLedger, str], ExpenseClaim, ] class ExpenseClaimActionProtocolMixin: def _execute_claim_action( self, *, action: str, claim_id: str, current_user: CurrentUserContext, request_id: str | None, expected_status: str | None, expected_approval_stage: str | None, payload: Mapping[str, Any], executor: ClaimActionExecutor, ) -> 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() with protocol.serialize_request( tenant_id=tenant_id, actor_id=actor_id, request_id=request_id, ) as normalized_request_id: try: started = protocol.begin( action=action, claim_id=claim_id, current_user=current_user, request_id=normalized_request_id, expected_status=expected_status, expected_approval_stage=expected_approval_stage, payload=payload, claim_loader=lambda: self._load_claim_for_action( 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.ledger is None: # pragma: no cover - defensive invariant raise RuntimeError("审批动作账本初始化失败。") claim = executor( started.claim, 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=response_json, ) self.db.commit() self.db.refresh(claim) return self._access_policy.attach_approval_snapshot(claim) except Exception: self.db.rollback() raise def _load_claim_for_action( self, claim_id: str, current_user: CurrentUserContext, ) -> ExpenseClaim | None: stmt = select(ExpenseClaim).where(ExpenseClaim.id == claim_id) stmt = self._access_policy.apply_claim_scope( stmt, current_user, include_approval_scope=True, ) bind = self.db.get_bind() if bind is not None and bind.dialect.name == "postgresql": stmt = stmt.with_for_update() claim = self.db.scalar(stmt) if claim is not None: # 动作协议已经持有事务级请求锁和 Claim 行锁;这里只允许在同一 # 事务内修正对象,不能调用会自行 commit 的读取兼容修复入口。 self._repair_duplicate_budget_approval_stage(claim) return claim @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