2026-07-16 15:34:58 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from collections.abc import Callable, Mapping
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
2026-07-16 15:49:43 +08:00
|
|
|
from pydantic import ValidationError
|
2026-07-16 15:34:58 +08:00
|
|
|
from sqlalchemy import select
|
|
|
|
|
|
|
|
|
|
from app.api.deps import CurrentUserContext
|
|
|
|
|
from app.models.approval_action import ApprovalActionLedger
|
|
|
|
|
from app.models.financial_record import ExpenseClaim
|
2026-07-16 15:49:43 +08:00
|
|
|
from app.schemas.reimbursement import ExpenseClaimRead
|
|
|
|
|
from app.services.approval_action_protocol import (
|
|
|
|
|
ApprovalActionConflictError,
|
|
|
|
|
ApprovalActionProtocol,
|
|
|
|
|
)
|
2026-07-16 15:34:58 +08:00
|
|
|
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,
|
2026-07-16 15:49:43 +08:00
|
|
|
) -> ExpenseClaim | ExpenseClaimRead | None:
|
2026-07-16 15:34:58 +08:00
|
|
|
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,
|
|
|
|
|
),
|
|
|
|
|
)
|
2026-07-16 15:49:43 +08:00
|
|
|
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
|
2026-07-16 15:34:58 +08:00
|
|
|
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,
|
|
|
|
|
)
|
2026-07-16 15:49:43 +08:00
|
|
|
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")
|
2026-07-16 15:34:58 +08:00
|
|
|
protocol.complete(
|
|
|
|
|
started.ledger,
|
|
|
|
|
claim,
|
2026-07-16 15:49:43 +08:00
|
|
|
response_json=response_json,
|
2026-07-16 15:34:58 +08:00
|
|
|
)
|
|
|
|
|
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
|
|
|
|
|
|
2026-07-16 15:49:43 +08:00
|
|
|
@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
|