121 lines
4.5 KiB
Python
121 lines
4.5 KiB
Python
|
|
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)
|