feat(expenses): add authoritative pre-review workflow

This commit is contained in:
caoxiaozhu
2026-07-16 11:42:08 +08:00
parent ae3f02c35a
commit 6bdf65bc24
52 changed files with 4300 additions and 1069 deletions

View File

@@ -1,10 +1,9 @@
from __future__ import annotations
import re
import uuid
from collections.abc import Callable
from datetime import UTC, datetime
from decimal import Decimal, InvalidOperation
from decimal import Decimal
from typing import Any
from sqlalchemy import delete, select
@@ -19,9 +18,7 @@ from app.models.risk_observation import RiskObservation, RiskObservationFeedback
from app.schemas.reimbursement import (
ExpenseClaimItemCreate,
ExpenseClaimItemUpdate,
ExpenseClaimStandardAdjustmentPayload,
ExpenseClaimUpdate,
TravelReimbursementCalculatorRequest,
)
from app.services.audit import AuditLogService
from app.services.budget_types import BudgetControlError
@@ -37,294 +34,35 @@ 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,
STANDARD_ADJUSTMENT_RISK_SOURCE,
)
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
from app.services.expense_claim_draft_persistence import ExpenseClaimDraftPersistenceMixin
from app.services.expense_claim_errors import ExpenseClaimSubmissionBlockedError
from app.services.expense_claim_errors import (
ExpenseClaimPreReviewBlockedError,
ExpenseClaimSubmissionBlockedError,
)
from app.services.expense_claim_ontology_resolvers import ExpenseClaimOntologyResolverMixin
from app.services.expense_claim_pagination import ExpenseClaimPaginationMixin
from app.services.expense_claim_pre_review import ExpenseClaimPreReviewMixin
from app.services.expense_claim_pre_review_decision import (
pre_review_identity_matches,
pre_review_public_payload,
)
from app.services.expense_claim_read_model import ExpenseClaimReadModelMixin
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
from app.services.expense_claim_risk_stage import with_risk_business_stage
from app.services.expense_claim_standard_adjustment import (
ExpenseClaimStandardAdjustmentMixin,
)
from app.services.expense_claim_workflow_constants import DIRECT_MANAGER_APPROVAL_STAGE
from app.services.expense_claim_workflow_repair import ExpenseClaimWorkflowRepairMixin
from app.services.receipt_folder import ReceiptFolderService
class ExpenseClaimStandardAdjustmentMixin:
@staticmethod
def _normalize_standard_adjustment_amount(value: Any) -> Decimal | None:
try:
raw_value = "" if value is None else value
amount = Decimal(str(raw_value)).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return None
return amount if amount >= Decimal("0.00") else None
@staticmethod
def _format_adjustment_money(value: Decimal) -> str:
normalized = Decimal(value or Decimal("0.00")).quantize(Decimal("0.01"))
return f"{normalized:.2f}"
@staticmethod
def _normalize_standard_adjustment_days(value: Any) -> int | None:
if value is None:
return None
if isinstance(value, int):
return value if 1 <= value <= 365 else None
text = str(value or "").strip()
if not text:
return None
match = re.search(r"\d{1,3}", text)
if not match:
return None
days = int(match.group(0))
return days if 1 <= days <= 365 else None
@staticmethod
def _normalize_standard_adjustment_text(value: Any) -> str:
text = str(value or "").strip()
if not text or text in {"-", "N/A", "n/a"}:
return ""
if text in {"待补充", "未知", "暂无", "非必填"}:
return ""
return text
def _iter_standard_adjustment_application_details(self, claim: ExpenseClaim) -> list[dict[str, Any]]:
details: list[dict[str, Any]] = []
for flag in list(claim.risk_flags_json or []):
if not isinstance(flag, dict):
continue
detail = flag.get("application_detail") or flag.get("applicationDetail")
if isinstance(detail, dict):
details.append(detail)
related = flag.get("related_application") or flag.get("relatedApplication")
if isinstance(related, dict):
details.append(related)
return details
def _resolve_standard_adjustment_days(
self,
claim: ExpenseClaim,
item: ExpenseClaimItem,
entry: Any,
) -> int:
direct_days = self._normalize_standard_adjustment_days(getattr(entry, "application_days", None))
if direct_days is not None:
return direct_days
for detail in self._iter_standard_adjustment_application_details(claim):
for key in ("application_days", "applicationDays", "days"):
detail_days = self._normalize_standard_adjustment_days(detail.get(key))
if detail_days is not None:
return detail_days
candidates = [
getattr(entry, "risk", None),
getattr(entry, "title", None),
item.item_reason,
claim.reason,
]
for text in candidates:
match = re.search(r"(\d{1,3})\s*(?:天|晚|夜)", str(text or ""))
if match:
days = self._normalize_standard_adjustment_days(match.group(1))
if days is not None:
return days
return 1
def _resolve_standard_adjustment_location(
self,
claim: ExpenseClaim,
item: ExpenseClaimItem,
) -> str:
for value in (item.item_location, claim.location):
text = self._normalize_standard_adjustment_text(value)
if text:
return text
for detail in self._iter_standard_adjustment_application_details(claim):
for key in ("application_location", "applicationLocation", "location", "city"):
text = self._normalize_standard_adjustment_text(detail.get(key))
if text:
return text
return ""
def _resolve_policy_standard_reimbursable_amount(
self,
*,
claim: ExpenseClaim,
item: ExpenseClaimItem,
entry: Any,
current_user: CurrentUserContext,
) -> Decimal | None:
item_type = str(item.item_type or "").strip().lower()
if item_type not in {"hotel", "hotel_ticket"}:
return None
location = self._resolve_standard_adjustment_location(claim, item)
grade = str(claim.employee_grade or current_user.grade or "").strip()
if not location or not grade:
return None
try:
from app.services.travel_reimbursement_calculator import (
TravelReimbursementCalculatorService,
)
result = TravelReimbursementCalculatorService(self.db).calculate(
TravelReimbursementCalculatorRequest(
days=self._resolve_standard_adjustment_days(claim, item, entry),
location=location,
grade=grade,
),
current_user,
)
except Exception:
return None
return self._normalize_standard_adjustment_amount(result.hotel_amount)
def _resolve_standard_adjustment_reimbursable_amount(
self,
*,
claim: ExpenseClaim,
item: ExpenseClaimItem,
entry: Any,
original_amount: Decimal,
current_user: CurrentUserContext,
) -> Decimal:
policy_amount = self._resolve_policy_standard_reimbursable_amount(
claim=claim,
item=item,
entry=entry,
current_user=current_user,
)
if policy_amount is not None:
return min(max(policy_amount, Decimal("0.00")), original_amount)
entry_amount = self._normalize_standard_adjustment_amount(entry.reimbursable_amount)
if entry_amount is not None:
return min(max(entry_amount, Decimal("0.00")), original_amount)
return original_amount
def accept_standard_adjustment(
self,
*,
claim_id: str,
payload: ExpenseClaimStandardAdjustmentPayload,
current_user: CurrentUserContext,
) -> ExpenseClaim | None:
claim = self.get_claim(claim_id, current_user)
if claim is None:
return None
self._ensure_draft_claim(claim)
if self._is_expense_application_claim(claim):
raise ValueError("费用申请单不支持按报销标准重算。")
risk_entries = list(payload.risks or [])
if not risk_entries:
raise ValueError("请至少选择一条需要按职级标准重算的风险。")
before_json = self._serialize_claim(claim)
item_map = {str(item.id or "").strip(): item for item in list(claim.items or [])}
now_text = datetime.now(UTC).isoformat()
adjustment_flags: list[dict[str, Any]] = []
for index, entry in enumerate(risk_entries, start=1):
item_id = str(entry.item_id or "").strip()
item = item_map.get(item_id)
if item is None:
continue
original_amount = (
self._normalize_standard_adjustment_amount(entry.original_amount)
or Decimal(item.item_amount or Decimal("0.00")).quantize(Decimal("0.01"))
)
reimbursable_amount = self._resolve_standard_adjustment_reimbursable_amount(
claim=claim,
item=item,
entry=entry,
original_amount=original_amount,
current_user=current_user,
)
employee_absorbed_amount = (original_amount - reimbursable_amount).quantize(Decimal("0.01"))
item_label = (
str(item.item_reason or "").strip()
or str(entry.title or "").strip()
or f"费用明细第 {index}"
)
source_risk = str(entry.risk or entry.title or "原风险未补充异常说明").strip()
message = (
f"提交人已选择按职级最高报销标准审核:{item_label} 原票据金额 "
f"{self._format_adjustment_money(original_amount)} 元,实际报销金额 "
f"{self._format_adjustment_money(reimbursable_amount)} 元,超出 "
f"{self._format_adjustment_money(employee_absorbed_amount)} 元由员工自行承担。"
)
adjustment_flags.append(
with_risk_business_stage(
{
"source": STANDARD_ADJUSTMENT_RISK_SOURCE,
"event_type": "standard_adjustment_accepted",
"severity": "medium",
"label": "接受职级标准审核",
"title": "提交人接受职级最高报销标准",
"message": message,
"summary": "提交人未补充异常说明,已选择按职级最高报销标准重算实际报销金额。",
"suggestion": "领导和财务审批时请确认该差额由员工自行承担,并按实际报销金额入账。",
"risk_id": str(entry.risk_id or "").strip(),
"source_risk": source_risk,
"item_id": item_id,
"original_amount": self._format_adjustment_money(original_amount),
"reimbursable_amount": self._format_adjustment_money(reimbursable_amount),
"employee_absorbed_amount": self._format_adjustment_money(employee_absorbed_amount),
"risk_domain": "amount",
"actionability": "review_decision",
"visibility_scope": "leader",
"created_at": now_text,
},
"reimbursement",
)
)
if not adjustment_flags:
raise ValueError("未找到可按职级标准重算的费用明细。")
preserved_flags = [
flag
for flag in list(claim.risk_flags_json or [])
if not (
isinstance(flag, dict)
and str(flag.get("source") or "").strip() == STANDARD_ADJUSTMENT_RISK_SOURCE
)
]
claim.risk_flags_json = dedupe_claim_risk_flags([*preserved_flags, *adjustment_flags])
self._sync_claim_from_items(claim)
self.db.commit()
self.db.refresh(claim)
self.audit_service.log_action(
actor=current_user.name or current_user.username,
action="expense_claim.standard_adjustment_accept",
resource_type="expense_claim",
resource_id=claim.id,
before_json=before_json,
after_json=self._serialize_claim(claim),
)
return claim
class ExpenseClaimItemActionMixin:
def update_claim_item(
self,
@@ -497,6 +235,8 @@ class ExpenseClaimItemActionMixin:
current_user: CurrentUserContext,
*,
correlation_id: str | None = None,
pre_review_id: str = "",
pre_review_input_fingerprint: str = "",
before_commit: Callable[[BusinessEvent], None] | None = None,
) -> ExpenseClaim | None:
claim = self.get_claim(claim_id, current_user)
@@ -516,6 +256,50 @@ class ExpenseClaimItemActionMixin:
if missing_fields:
raise ExpenseClaimSubmissionBlockedError(missing_fields)
before_json = self._serialize_claim(claim)
pre_review_flag = self.refresh_claim_pre_review_state(
claim,
is_application_claim=is_application_claim,
)
if pre_review_flag is None:
raise RuntimeError("无法生成提交前预审结果。")
client_review_provided = bool(
str(pre_review_id or "").strip()
or str(pre_review_input_fingerprint or "").strip()
)
client_review_matches = pre_review_identity_matches(
pre_review_flag,
review_id=pre_review_id,
input_fingerprint=pre_review_input_fingerprint,
)
correlation_id = self._expense_cases.normalize_correlation_id(
correlation_id or str(pre_review_flag.get("review_id") or "")
)
_, pre_review_event = self._record_pre_review_event(
claim,
pre_review_flag=pre_review_flag,
current_user=current_user,
is_application_claim=is_application_claim,
correlation_id=correlation_id,
)
correlation_id = pre_review_event.correlation_id
decision = str(pre_review_flag.get("decision") or "")
review_error_code = (
"PRE_REVIEW_NEEDS_FIX"
if decision == "needs_fix"
else "PRE_REVIEW_CHANGED"
if client_review_provided and not client_review_matches
else ""
)
if review_error_code:
self.db.commit()
self.db.refresh(claim)
raise ExpenseClaimPreReviewBlockedError(
pre_review_public_payload(pre_review_flag) or {},
code=review_error_code,
)
try:
budget_flags = self._reserve_budget_for_submission(
claim,
@@ -526,7 +310,6 @@ class ExpenseClaimItemActionMixin:
if is_application_claim:
raise
budget_flags = list(exc.flags or [])
before_json = self._serialize_claim(claim)
if is_application_claim:
submitted_at = datetime.now(UTC)
preserved_flags = [
@@ -572,8 +355,6 @@ class ExpenseClaimItemActionMixin:
budget_flags,
business_stage="reimbursement",
)
if not self._has_ai_pre_review_flag(claim):
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
claim.status = "submitted"
claim.approval_stage = DIRECT_MANAGER_APPROVAL_STAGE
@@ -587,6 +368,7 @@ class ExpenseClaimItemActionMixin:
actor_id=current_user.username,
tenant_id=getattr(current_user, "tenant_id", None),
correlation_id=correlation_id,
causation_id=pre_review_event.id if pre_review_event is not None else None,
idempotency_key=(
f"submit:{claim.id}:{claim.submitted_at.isoformat()}"
if claim.submitted_at is not None
@@ -617,7 +399,7 @@ class ExpenseClaimItemActionMixin:
def delete_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
claim = self.get_claim(claim_id, current_user)
if claim is None and current_user.is_admin:
candidate_claim = self.db.scalar(
candidate_stmt = (
select(ExpenseClaim)
.options(
selectinload(ExpenseClaim.items),
@@ -626,6 +408,9 @@ class ExpenseClaimItemActionMixin:
)
.where(ExpenseClaim.id == claim_id)
)
candidate_claim = self.db.scalar(
self._access_policy.apply_tenant_scope(candidate_stmt, current_user)
)
if candidate_claim is not None:
claim = candidate_claim
if claim is None: