fix: handle risk explanation standard adjustment

This commit is contained in:
caoxiaozhu
2026-06-03 17:31:40 +08:00
parent 67b81a1bd8
commit 8e2477587f
19 changed files with 976 additions and 61 deletions

View File

@@ -20,6 +20,7 @@ from app.schemas.reimbursement import (
ExpenseClaimItemUpdate,
ExpenseClaimRead,
ExpenseClaimReturnPayload,
ExpenseClaimStandardAdjustmentPayload,
ExpenseClaimUpdate,
ReimbursementCreate,
ReimbursementRead,
@@ -233,6 +234,43 @@ def update_expense_claim(
return claim
@router.post(
"/claims/{claim_id}/standard-adjustment",
response_model=ExpenseClaimRead,
summary="接受职级报销标准重算",
description="在草稿报销单存在中高风险但提交人不补充异常说明时,按职级可报销标准重算实际报销金额。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "报销单不存在。",
},
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "报销单状态不允许重算或入参不合法。",
},
},
)
def accept_expense_claim_standard_adjustment(
claim_id: str,
payload: ExpenseClaimStandardAdjustmentPayload,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseClaimRead:
service = ExpenseClaimService(db)
try:
claim = service.accept_standard_adjustment(
claim_id=claim_id,
payload=payload,
current_user=current_user,
)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return claim
@router.patch(
"/claims/{claim_id}/items/{item_id}",
response_model=ExpenseClaimRead,

View File

@@ -121,6 +121,19 @@ class ExpenseClaimUpdate(BaseModel):
reason: str | None = Field(default=None, max_length=500)
class ExpenseClaimStandardAdjustmentRisk(BaseModel):
risk_id: str | None = Field(default=None, max_length=120)
item_id: str | None = Field(default=None, max_length=120)
title: str | None = Field(default=None, max_length=120)
risk: str | None = Field(default=None, max_length=500)
original_amount: Decimal | None = None
reimbursable_amount: Decimal | None = None
class ExpenseClaimStandardAdjustmentPayload(BaseModel):
risks: list[ExpenseClaimStandardAdjustmentRisk] = Field(default_factory=list, max_length=20)
class ExpenseClaimRead(BaseModel):
model_config = ConfigDict(from_attributes=True)

View File

@@ -26,6 +26,7 @@ EXPENSE_TYPE_LABELS = {
}
MAX_DRAFT_CLAIMS_PER_USER = 3
EDITABLE_CLAIM_STATUSES = ("draft", "supplement", "returned")
STANDARD_ADJUSTMENT_RISK_SOURCE = "reimbursement_standard_adjustment"
SYSTEM_GENERATED_ITEM_TYPES = {"travel_allowance"}
OPTIONAL_ATTACHMENT_ITEM_TYPES = {"ride_ticket", "travel_allowance"}
TRAVEL_DETAIL_ITEM_TYPES = {

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import re
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from decimal import Decimal, InvalidOperation
from types import SimpleNamespace
from typing import Any
@@ -24,6 +24,7 @@ from app.services.expense_claim_constants import (
DOCUMENT_FACT_ITEM_TYPES,
LOCATION_REQUIRED_EXPENSE_TYPES,
OPTIONAL_ATTACHMENT_ITEM_TYPES,
STANDARD_ADJUSTMENT_RISK_SOURCE,
SYSTEM_GENERATED_ITEM_TYPES,
TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES,
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
@@ -329,6 +330,45 @@ class ExpenseClaimItemSyncMixin:
return destination
return ""
@staticmethod
def _parse_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
def _collect_standard_adjusted_amounts(self, claim: ExpenseClaim) -> dict[str, Decimal]:
adjusted_amounts: dict[str, Decimal] = {}
for flag in list(claim.risk_flags_json or []):
if not isinstance(flag, dict):
continue
if str(flag.get("source") or "").strip() != STANDARD_ADJUSTMENT_RISK_SOURCE:
continue
item_id = str(flag.get("item_id") or flag.get("itemId") or "").strip()
if not item_id:
continue
amount = self._parse_standard_adjustment_amount(
flag.get("reimbursable_amount") or flag.get("reimbursableAmount")
)
if amount is None:
continue
adjusted_amounts[item_id] = amount
return adjusted_amounts
def _resolve_item_amount_for_claim_total(
self,
item: ExpenseClaimItem,
adjusted_amounts: dict[str, Decimal],
) -> Decimal:
original_amount = Decimal(item.item_amount or Decimal("0.00")).quantize(Decimal("0.01"))
item_id = str(item.id or "").strip()
adjusted_amount = adjusted_amounts.get(item_id)
if adjusted_amount is None:
return original_amount
return min(max(adjusted_amount, Decimal("0.00")), original_amount)
def _sync_claim_from_items(self, claim: ExpenseClaim) -> None:
self._sync_travel_allowance_item(claim)
if not claim.items:
@@ -346,7 +386,11 @@ class ExpenseClaimItemSyncMixin:
),
)
primary_item = ordered_items[0]
total_amount = sum((item.item_amount for item in ordered_items), Decimal("0.00"))
adjusted_amounts = self._collect_standard_adjusted_amounts(claim)
total_amount = sum(
(self._resolve_item_amount_for_claim_total(item, adjusted_amounts) for item in ordered_items),
Decimal("0.00"),
)
claim.amount = total_amount.quantize(Decimal("0.01"))
claim.invoice_count = sum(1 for item in ordered_items if str(item.invoice_id or "").strip())

View File

@@ -72,7 +72,11 @@ class ExpenseClaimPolicyReviewMixin:
limit_config=item_limit,
reason_text="\n".join(
part
for part in [reason_corpus, str(item.item_reason or "").strip()]
for part in [
reason_corpus,
str(item.item_reason or "").strip(),
str(item.item_note or "").strip(),
]
if part
),
)
@@ -333,7 +337,12 @@ class ExpenseClaimPolicyReviewMixin:
f"{band_label} 职级在{standard_label}的住宿标准为 {cap} 元/晚,"
f"当前酒店识别金额约 {nightly_amount} 元/晚。"
)
item_reason = str(context["item"].item_reason or "").strip()
item_reason = " ".join(
[
str(context["item"].item_reason or "").strip(),
str(context["item"].item_note or "").strip(),
]
).strip()
item_has_exception = self._text_contains_keywords(item_reason, policy.standard_exception_keywords)
if has_standard_exception or item_has_exception:
flags.append(
@@ -368,7 +377,12 @@ class ExpenseClaimPolicyReviewMixin:
if allowed_level is None or class_level <= allowed_level:
continue
item_reason = str(context["item"].item_reason or "").strip()
item_reason = " ".join(
[
str(context["item"].item_reason or "").strip(),
str(context["item"].item_note or "").strip(),
]
).strip()
item_has_exception = self._text_contains_keywords(item_reason, policy.standard_exception_keywords)
message = f"{band_label} 职级当前默认不可报销 {class_label}"
if has_standard_exception or item_has_exception:
@@ -463,6 +477,7 @@ class ExpenseClaimPolicyReviewMixin:
parts = [str(claim.reason or "").strip(), str(claim.location or "").strip()]
for item in claim.items:
parts.append(str(item.item_reason or "").strip())
parts.append(str(item.item_note or "").strip())
parts.append(str(item.item_location or "").strip())
return "\n".join(part for part in parts if part)

View File

@@ -27,6 +27,7 @@ from app.schemas.ontology import OntologyEntity, OntologyParseResult
from app.schemas.reimbursement import (
ExpenseClaimItemCreate,
ExpenseClaimItemUpdate,
ExpenseClaimStandardAdjustmentPayload,
ExpenseClaimUpdate,
TravelReimbursementCalculatorRequest,
)
@@ -109,6 +110,7 @@ from app.services.expense_claim_constants import (
TRAVEL_POLICY_FLIGHT_CLASS_PATTERNS,
TRAVEL_POLICY_TRAIN_CLASS_PATTERNS,
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
STANDARD_ADJUSTMENT_RISK_SOURCE,
)
from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin
from app.services.expense_amounts import (
@@ -290,6 +292,126 @@ class ExpenseClaimService(
return claim
@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}"
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._normalize_standard_adjustment_amount(entry.reimbursable_amount)
or original_amount
)
reimbursable_amount = min(max(reimbursable_amount, Decimal("0.00")), original_amount)
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 = [*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
def update_claim_item(
self,
*,
@@ -758,6 +880,3 @@ class ExpenseClaimService(

View File

@@ -78,6 +78,9 @@ CANONICAL_ONTOLOGY_FIELDS = frozenset(ONTOLOGY_FIELD_ALIASES) | frozenset(
"application_policy_estimate",
"application_rule_name",
"application_rule_version",
"original_amount",
"reimbursable_amount",
"employee_absorbed_amount",
}
)