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

@@ -1,6 +1,5 @@
from __future__ import annotations
import uuid
from collections.abc import Callable
from datetime import UTC, datetime
from decimal import Decimal
@@ -25,6 +24,7 @@ from app.services.budget_types import BudgetControlError
from app.services.document_numbering import is_application_claim_no
from app.services.expense_cases import ExpenseCaseService
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
from app.services.expense_claim_action_protocol import ExpenseClaimActionProtocolMixin
from app.services.expense_claim_application_handoff import ExpenseClaimApplicationHandoffMixin
from app.services.expense_claim_approval_flow import ExpenseClaimApprovalFlowMixin
from app.services.expense_claim_approval_routing import ExpenseClaimApprovalRoutingMixin
@@ -34,7 +34,6 @@ from app.services.expense_claim_attachment_operations import ExpenseClaimAttachm
from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin
from app.services.expense_claim_constants import RETURN_REASON_OPTIONS
from app.services.expense_claim_document_item_builder import ExpenseClaimDocumentItemBuilderMixin
from app.services.expense_claim_document_parsing import ExpenseClaimDocumentParsingMixin
from app.services.expense_claim_draft_flow import ExpenseClaimDraftFlowMixin
@@ -51,6 +50,7 @@ from app.services.expense_claim_pre_review_decision import (
pre_review_public_payload,
)
from app.services.expense_claim_read_model import ExpenseClaimReadModelMixin
from app.services.expense_claim_return_flow import ExpenseClaimReturnFlowMixin
from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin
@@ -87,7 +87,10 @@ class ExpenseClaimItemActionMixin:
if payload.item_date is not None:
item.item_date = payload.item_date
if payload.item_type is not None:
item.item_type = self._normalize_optional_text(payload.item_type, fallback=item.item_type) or item.item_type
item.item_type = (
self._normalize_optional_text(payload.item_type, fallback=item.item_type)
or item.item_type
)
if payload.item_reason is not None:
item.item_reason = (
self._normalize_optional_text(payload.item_reason, allow_empty=True) or ""
@@ -97,7 +100,9 @@ class ExpenseClaimItemActionMixin:
self._normalize_optional_text(payload.item_location, allow_empty=True) or ""
)
if payload.item_note is not None:
item.item_note = self._normalize_optional_text(payload.item_note, allow_empty=True) or ""
item.item_note = (
self._normalize_optional_text(payload.item_note, allow_empty=True) or ""
)
if payload.item_amount is not None:
amount = payload.item_amount.quantize(Decimal("0.01"))
if amount < Decimal("0.00"):
@@ -203,7 +208,9 @@ class ExpenseClaimItemActionMixin:
self._ensure_draft_claim(claim)
before_json = self._serialize_claim(claim)
item_label = str(item.item_reason or "").strip() or self._resolve_expense_type_label(item.item_type)
item_label = str(item.item_reason or "").strip() or self._resolve_expense_type_label(
item.item_type
)
self._attachment_storage.delete_item_files(item)
claim.items = [entry for entry in claim.items if entry.id != item.id]
@@ -266,8 +273,7 @@ class ExpenseClaimItemActionMixin:
raise RuntimeError("无法生成提交前预审结果。")
client_review_provided = bool(
str(pre_review_id or "").strip()
or str(pre_review_input_fingerprint or "").strip()
str(pre_review_id or "").strip() or str(pre_review_input_fingerprint or "").strip()
)
client_review_matches = pre_review_identity_matches(
pre_review_flag,
@@ -423,7 +429,9 @@ class ExpenseClaimItemActionMixin:
if not self._access_policy.has_claim_delete_access(current_user):
self._ensure_draft_claim(claim)
if not self._access_policy.is_claim_owned_by_current_user(claim, current_user):
raise ValueError("只有系统管理员或草稿、待补充、退回待提交阶段的申请人本人可以删除单据。")
raise ValueError(
"只有系统管理员或草稿、待补充、退回待提交阶段的申请人本人可以删除单据。"
)
before_json = self._serialize_claim(claim)
resource_id = claim.id
@@ -464,154 +472,31 @@ class ExpenseClaimItemActionMixin:
self.db.execute(delete(RiskObservation).where(RiskObservation.claim_id == claim_id))
self.db.execute(delete(HermesRiskReport).where(HermesRiskReport.claim_id == claim_id))
def return_claim(
self,
claim_id: str,
current_user: CurrentUserContext,
*,
reason: str | None = None,
reason_codes: list[str] | None = None,
) -> ExpenseClaim | None:
claim = self.get_claim(claim_id, current_user)
if claim is None:
return None
normalized_status = str(claim.status or "").strip().lower()
if normalized_status == "draft":
raise ValueError("草稿状态无需退回。")
if normalized_status == "returned":
raise ValueError("该单据已处于退回待提交状态,无需重复退回。")
if normalized_status in {"approved", "completed", "paid"}:
raise ValueError("已完成单据不允许退回。")
if not self._access_policy.can_return_claim(current_user, claim):
raise ValueError("只有财务人员、高级财务人员或当前审批人可以退回报销单。")
before_json = self._serialize_claim(claim)
operator = self._access_policy.resolve_current_user_display_name(current_user)
previous_status = str(claim.status or "").strip()
previous_stage = str(claim.approval_stage or "").strip() or "未标记审批环节"
previous_stage_key = self._normalize_return_stage_key(previous_stage)
is_application_claim = self._is_expense_application_claim(claim)
is_direct_manager_return = previous_stage_key == "direct_manager"
is_budget_return = previous_stage_key == "budget"
is_application_return = is_application_claim and (is_direct_manager_return or is_budget_return)
return_event_type = (
"expense_application_return"
if is_application_return
else "expense_claim_return"
)
return_label = (
"领导退回"
if is_application_claim and is_direct_manager_return
else "预算退回"
if is_application_claim and is_budget_return
else "人工退回"
)
return_reason = str(reason or "").strip()
reason_code_payload = self._normalize_return_reason_code_payload(reason_codes)
normalized_reason_codes = reason_code_payload["reason_codes"]
unknown_reason_codes = reason_code_payload["unknown_reason_codes"]
if is_application_return and not any(
code.startswith("application_") for code in normalized_reason_codes
):
raise ValueError("申请单退回必须选择至少一个退单类型。")
risk_points = [RETURN_REASON_OPTIONS[code] for code in normalized_reason_codes]
existing_return_flags = self._collect_return_flags(claim.risk_flags_json)
return_count = len(existing_return_flags) + 1
stage_return_count = (
sum(
1
for flag in existing_return_flags
if (
str(flag.get("return_stage_key") or "").strip()
or self._normalize_return_stage_key(str(flag.get("return_stage") or "").strip())
)
== previous_stage_key
)
+ 1
)
message = return_reason or self._build_default_return_message(operator=operator, risk_points=risk_points)
return_flag = {
"source": "manual_return",
"event_type": return_event_type,
"return_event_id": str(uuid.uuid4()),
"severity": "medium",
"label": return_label,
"node_key": "returned",
"node_label": "退回",
"approval_node": "退回",
"message": message,
"reason": return_reason,
"opinion": message,
"leader_opinion": message if is_application_claim and is_direct_manager_return else "",
"budget_opinion": message if is_application_claim and is_budget_return else "",
"reason_codes": normalized_reason_codes,
"risk_points": risk_points,
"operator": operator,
"operator_username": current_user.username,
"operator_role_codes": [
str(item).strip().lower()
for item in current_user.role_codes
if str(item).strip()
],
"previous_status": previous_status,
"previous_approval_stage": previous_stage,
"return_stage": previous_stage,
"return_stage_key": previous_stage_key,
"next_status": "returned",
"next_approval_stage": "待提交",
"return_count": return_count,
"stage_return_count": stage_return_count,
"created_at": datetime.now(UTC).isoformat(),
}
if unknown_reason_codes:
return_flag["unknown_reason_codes"] = unknown_reason_codes
budget_flags = self._release_budget_for_return(
claim,
current_user,
reason=message,
)
claim.status = "returned"
claim.approval_stage = "待提交"
claim.submitted_at = None
claim.risk_flags_json = self._append_budget_flags(
[*list(claim.risk_flags_json or []), return_flag],
budget_flags,
business_stage="expense_application" if is_application_claim else "reimbursement",
)
self._expense_cases.record_claim_event(
claim,
event_type=("application_returned" if is_application_claim else "claim_returned"),
actor_id=current_user.username,
tenant_id=getattr(current_user, "tenant_id", None),
idempotency_key=str(return_flag.get("return_event_id") or ""),
previous_status=previous_status,
previous_approval_stage=previous_stage,
extra_payload={
"reason": message,
"reason_codes": normalized_reason_codes,
},
)
self.db.commit()
self.db.refresh(claim)
self.audit_service.log_action(
actor=operator,
action="expense_claim.return",
resource_type="expense_claim",
resource_id=claim.id,
before_json=before_json,
after_json=self._serialize_claim(claim),
)
return claim
class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemActionMixin, ExpenseClaimPaginationMixin, ExpenseClaimApprovalFlowMixin, ExpenseClaimApprovalRoutingMixin, ExpenseClaimApplicationHandoffMixin, ExpenseClaimPreReviewMixin, ExpenseClaimBudgetFlowMixin, ExpenseClaimAttachmentOperationsMixin, ExpenseClaimReviewPreviewMixin, ExpenseClaimDraftFlowMixin, ExpenseClaimDraftPersistenceMixin, ExpenseClaimDocumentItemBuilderMixin, ExpenseClaimDocumentParsingMixin, ExpenseClaimOntologyResolverMixin, ExpenseClaimAttachmentDocumentMixin, ExpenseClaimAttachmentAnalysisMixin, ExpenseClaimReadModelMixin, ExpenseClaimRiskReviewMixin, ExpenseClaimWorkflowRepairMixin):
class ExpenseClaimService(
ExpenseClaimActionProtocolMixin,
ExpenseClaimReturnFlowMixin,
ExpenseClaimStandardAdjustmentMixin,
ExpenseClaimItemActionMixin,
ExpenseClaimPaginationMixin,
ExpenseClaimApprovalFlowMixin,
ExpenseClaimApprovalRoutingMixin,
ExpenseClaimApplicationHandoffMixin,
ExpenseClaimPreReviewMixin,
ExpenseClaimBudgetFlowMixin,
ExpenseClaimAttachmentOperationsMixin,
ExpenseClaimReviewPreviewMixin,
ExpenseClaimDraftFlowMixin,
ExpenseClaimDraftPersistenceMixin,
ExpenseClaimDocumentItemBuilderMixin,
ExpenseClaimDocumentParsingMixin,
ExpenseClaimOntologyResolverMixin,
ExpenseClaimAttachmentDocumentMixin,
ExpenseClaimAttachmentAnalysisMixin,
ExpenseClaimReadModelMixin,
ExpenseClaimRiskReviewMixin,
ExpenseClaimWorkflowRepairMixin,
):
def __init__(self, db: Session) -> None:
self.db = db
self.audit_service = AuditLogService(db)
@@ -624,11 +509,15 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
def _is_expense_application_claim(claim: ExpenseClaim) -> bool:
claim_no = str(getattr(claim, "claim_no", "") or "").strip().upper()
expense_type = str(getattr(claim, "expense_type", "") or "").strip().lower()
document_type = str(
getattr(claim, "document_type_code", "")
or getattr(claim, "document_type", "")
or ""
).strip().lower()
document_type = (
str(
getattr(claim, "document_type_code", "")
or getattr(claim, "document_type", "")
or ""
)
.strip()
.lower()
)
return (
is_application_claim_no(claim_no)
or expense_type == "application"
@@ -667,7 +556,6 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
)
stmt = self._access_policy.apply_claim_scope(stmt, current_user)
claims = list(self.db.scalars(stmt).all())
self._repair_duplicate_budget_approval_stages(claims)
return self._access_policy.attach_budget_approval_snapshots(claims)
def list_approval_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
@@ -683,7 +571,6 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
)
stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user)
claims = list(self.db.scalars(stmt).all())
self._repair_duplicate_budget_approval_stages(claims)
return self._access_policy.attach_budget_approval_snapshots(claims)
def list_archived_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
@@ -695,7 +582,11 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
)
.order_by(ExpenseClaim.updated_at.desc(), ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc())
.order_by(
ExpenseClaim.updated_at.desc(),
ExpenseClaim.submitted_at.desc(),
ExpenseClaim.created_at.desc(),
)
)
stmt = self._access_policy.apply_archived_claim_scope(stmt, current_user)
return list(self.db.scalars(stmt).all())
@@ -711,13 +602,15 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
)
.where(ExpenseClaim.id == claim_id)
)
stmt = self._access_policy.apply_claim_scope(stmt, current_user, include_approval_scope=True)
stmt = self._access_policy.apply_claim_scope(
stmt, current_user, include_approval_scope=True
)
claim = self.db.scalar(stmt)
if claim is not None:
self._repair_duplicate_budget_approval_stages([claim])
return self._access_policy.attach_approval_snapshot(claim)
def can_view_budget_analysis(self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None) -> bool:
def can_view_budget_analysis(
self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None
) -> bool:
if claim is None:
return self._access_policy.is_budget_manager_user(current_user)
if current_user.is_admin:
@@ -725,10 +618,9 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
role_codes = self._access_policy.normalize_role_codes(current_user)
if "executive" in role_codes:
return True
if (
self._access_policy.has_privileged_claim_access(current_user)
and not self._access_policy.is_claim_owned_by_current_user(claim, current_user)
):
if self._access_policy.has_privileged_claim_access(
current_user
) and not self._access_policy.is_claim_owned_by_current_user(claim, current_user):
return True
if self._access_policy.can_approve_claim(current_user, claim):
return True
@@ -751,7 +643,9 @@ class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemA
before_json = self._serialize_claim(claim)
if payload.reason is not None:
claim.reason = self._normalize_optional_text(payload.reason, allow_empty=True) or "待补充"
claim.reason = (
self._normalize_optional_text(payload.reason, allow_empty=True) or "待补充"
)
if not self._is_expense_application_claim(claim):
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)