fix: handle risk explanation standard adjustment
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user