from __future__ import annotations from collections.abc import Callable, Mapping from typing import Any 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.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 | 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, ), replay_claim_loader=lambda: self._load_claim_for_replay( claim_id, current_user, ), ) 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("审批动作账本初始化失败。") claim = executor( started.claim, started.ledger, normalized_request_id, ) 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(), }, ) 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 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)