feat(approval): add safe risk disposition workflow

This commit is contained in:
caoxiaozhu
2026-07-16 15:34:58 +08:00
parent ee88a36baf
commit 4940ebc419
64 changed files with 6602 additions and 631 deletions

View 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)