Files
X-Financial/server/src/app/services/expense_claim_action_protocol.py
2026-07-16 15:49:43 +08:00

135 lines
5.4 KiB
Python

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