feat(approval): add safe risk disposition workflow
This commit is contained in:
120
server/src/app/services/expense_claim_action_protocol.py
Normal file
120
server/src/app/services/expense_claim_action_protocol.py
Normal file
@@ -0,0 +1,120 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user