feat(expenses): add authoritative pre-review workflow
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user