2026-05-22 10:42:31 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
|
from datetime import UTC, date, datetime, timedelta
|
|
|
|
|
|
from decimal import Decimal
|
|
|
|
|
|
from types import SimpleNamespace
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
from sqlalchemy import or_, select
|
|
|
|
|
|
from sqlalchemy import inspect as sqlalchemy_inspect
|
|
|
|
|
|
|
|
|
|
|
|
from app.api.deps import CurrentUserContext
|
|
|
|
|
|
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
|
|
|
|
|
|
from app.models.agent_asset import AgentAsset
|
2026-05-27 17:31:27 +08:00
|
|
|
|
from app.models.employee import Employee
|
2026-05-22 10:42:31 +08:00
|
|
|
|
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
|
|
|
|
|
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
|
|
|
|
|
|
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
|
|
|
|
|
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
|
|
|
|
|
from app.services.expense_claim_constants import (
|
|
|
|
|
|
AI_REVIEW_LOOKBACK_DAYS,
|
|
|
|
|
|
AI_REVIEW_REPEAT_RISK_BLOCK_COUNT,
|
|
|
|
|
|
AI_REVIEW_REPEAT_RISK_WARNING_COUNT,
|
|
|
|
|
|
DOCUMENT_FACT_ITEM_TYPES,
|
|
|
|
|
|
LOCATION_REQUIRED_EXPENSE_TYPES,
|
2026-06-02 14:01:51 +08:00
|
|
|
|
OPTIONAL_ATTACHMENT_ITEM_TYPES,
|
2026-05-22 10:42:31 +08:00
|
|
|
|
SYSTEM_GENERATED_ITEM_TYPES,
|
|
|
|
|
|
TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES,
|
|
|
|
|
|
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
|
|
|
|
|
|
)
|
2026-06-01 17:07:14 +08:00
|
|
|
|
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
2026-05-22 10:42:31 +08:00
|
|
|
|
from app.services.expense_rule_runtime import (
|
|
|
|
|
|
ExpenseRuleRuntimeService,
|
|
|
|
|
|
RuntimeTravelPolicy,
|
|
|
|
|
|
build_default_expense_rule_catalog,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ExpenseClaimItemSyncMixin:
|
|
|
|
|
|
def _sync_travel_allowance_item(self, claim: ExpenseClaim) -> None:
|
|
|
|
|
|
items = list(claim.items or [])
|
|
|
|
|
|
allowance_items = [
|
|
|
|
|
|
item for item in items if str(item.item_type or "").strip().lower() == "travel_allowance"
|
|
|
|
|
|
]
|
|
|
|
|
|
business_items = [
|
|
|
|
|
|
item for item in items if str(item.item_type or "").strip().lower() != "travel_allowance"
|
|
|
|
|
|
]
|
|
|
|
|
|
business_types = {str(item.item_type or "").strip().lower() for item in business_items}
|
|
|
|
|
|
is_travel_claim = str(claim.expense_type or "").strip().lower() == "travel"
|
|
|
|
|
|
has_travel_detail = bool(business_types & TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES)
|
|
|
|
|
|
if not is_travel_claim and not has_travel_detail:
|
|
|
|
|
|
for item in allowance_items:
|
|
|
|
|
|
self._discard_claim_item(claim, item)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-05-27 17:31:27 +08:00
|
|
|
|
grade = self._resolve_claim_employee_grade(claim)
|
2026-05-22 10:42:31 +08:00
|
|
|
|
if not grade:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
allowance_location = self._resolve_travel_allowance_location_from_claim(
|
|
|
|
|
|
claim=claim,
|
|
|
|
|
|
business_items=business_items,
|
|
|
|
|
|
)
|
|
|
|
|
|
if not allowance_location:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
existing_allowance = allowance_items[0] if allowance_items else None
|
|
|
|
|
|
days, start_date, end_date = self._resolve_travel_allowance_days_from_claim(
|
|
|
|
|
|
claim=claim,
|
|
|
|
|
|
business_items=business_items,
|
|
|
|
|
|
existing_allowance=existing_allowance,
|
|
|
|
|
|
)
|
|
|
|
|
|
if days < 1:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
from app.services.travel_reimbursement_calculator import (
|
|
|
|
|
|
TravelReimbursementCalculatorService,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
result = TravelReimbursementCalculatorService(self.db).calculate(
|
|
|
|
|
|
TravelReimbursementCalculatorRequest(
|
|
|
|
|
|
days=days,
|
|
|
|
|
|
location=allowance_location,
|
|
|
|
|
|
grade=grade,
|
|
|
|
|
|
),
|
|
|
|
|
|
CurrentUserContext(
|
|
|
|
|
|
username=str(claim.employee_id or claim.employee_name or "system"),
|
|
|
|
|
|
name=str(claim.employee_name or ""),
|
|
|
|
|
|
role_codes=[],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
allowance_amount = Decimal(result.allowance_amount or Decimal("0.00")).quantize(Decimal("0.01"))
|
|
|
|
|
|
allowance_rate = Decimal(result.total_allowance_rate or Decimal("0.00")).quantize(Decimal("0.01"))
|
|
|
|
|
|
if allowance_amount <= Decimal("0.00") or allowance_rate <= Decimal("0.00"):
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
item = existing_allowance
|
|
|
|
|
|
if item is None:
|
|
|
|
|
|
item = ExpenseClaimItem(claim_id=claim.id)
|
|
|
|
|
|
claim.items.append(item)
|
|
|
|
|
|
self.db.add(item)
|
|
|
|
|
|
|
|
|
|
|
|
for duplicate in allowance_items[1:]:
|
|
|
|
|
|
self._discard_claim_item(claim, duplicate)
|
|
|
|
|
|
|
|
|
|
|
|
item.item_date = end_date
|
|
|
|
|
|
item.item_type = "travel_allowance"
|
|
|
|
|
|
item.item_reason = (
|
|
|
|
|
|
f"系统自动计算出差补贴:{result.matched_city},{days}天,"
|
|
|
|
|
|
f"{allowance_rate:.2f}元/天"
|
|
|
|
|
|
)
|
|
|
|
|
|
item.item_location = str(result.allowance_region or allowance_location).strip()
|
|
|
|
|
|
item.item_amount = allowance_amount
|
|
|
|
|
|
item.invoice_id = None
|
|
|
|
|
|
|
2026-05-27 17:31:27 +08:00
|
|
|
|
def _resolve_claim_employee_grade(self, claim: ExpenseClaim) -> str:
|
|
|
|
|
|
grade = str(claim.employee_grade or "").strip()
|
|
|
|
|
|
if grade:
|
|
|
|
|
|
return grade
|
|
|
|
|
|
employee_id = str(claim.employee_id or "").strip()
|
|
|
|
|
|
if not employee_id:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
employee = self.db.get(Employee, employee_id)
|
|
|
|
|
|
return str(employee.grade if employee is not None and employee.grade else "").strip()
|
|
|
|
|
|
|
2026-05-22 10:42:31 +08:00
|
|
|
|
def _discard_claim_item(self, claim: ExpenseClaim, item: ExpenseClaimItem) -> None:
|
|
|
|
|
|
if item in claim.items:
|
|
|
|
|
|
claim.items.remove(item)
|
|
|
|
|
|
state = sqlalchemy_inspect(item)
|
|
|
|
|
|
if state.persistent:
|
|
|
|
|
|
self.db.delete(item)
|
|
|
|
|
|
elif state.pending:
|
|
|
|
|
|
self.db.expunge(item)
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_travel_allowance_days_from_claim(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
claim: ExpenseClaim,
|
|
|
|
|
|
business_items: list[ExpenseClaimItem],
|
|
|
|
|
|
existing_allowance: ExpenseClaimItem | None,
|
|
|
|
|
|
) -> tuple[int, date, date]:
|
|
|
|
|
|
dated_items = sorted(
|
|
|
|
|
|
[item.item_date for item in business_items if item.item_date is not None]
|
|
|
|
|
|
)
|
|
|
|
|
|
if dated_items:
|
|
|
|
|
|
start_date = dated_items[0]
|
|
|
|
|
|
end_date = dated_items[-1]
|
|
|
|
|
|
elif claim.occurred_at is not None:
|
|
|
|
|
|
start_date = claim.occurred_at.date()
|
|
|
|
|
|
end_date = start_date
|
|
|
|
|
|
else:
|
|
|
|
|
|
start_date = date.today()
|
|
|
|
|
|
end_date = start_date
|
|
|
|
|
|
|
|
|
|
|
|
days = (end_date - start_date).days + 1
|
|
|
|
|
|
explicit_days = max(
|
|
|
|
|
|
(self._extract_travel_day_count(item.item_reason) for item in business_items),
|
|
|
|
|
|
default=0,
|
|
|
|
|
|
)
|
|
|
|
|
|
if explicit_days > 0:
|
|
|
|
|
|
days = explicit_days
|
|
|
|
|
|
end_date = start_date + timedelta(days=days - 1)
|
|
|
|
|
|
return max(1, days), start_date, end_date
|
|
|
|
|
|
existing_days = self._extract_travel_allowance_days(existing_allowance)
|
|
|
|
|
|
unique_dates = {value for value in dated_items}
|
|
|
|
|
|
if existing_days > days and len(unique_dates) <= 1:
|
|
|
|
|
|
days = existing_days
|
|
|
|
|
|
end_date = start_date + timedelta(days=days - 1)
|
|
|
|
|
|
return max(1, days), start_date, end_date
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _extract_travel_allowance_days(item: ExpenseClaimItem | None) -> int:
|
|
|
|
|
|
if item is None:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
match = re.search(r"(\d+)\s*天", str(item.item_reason or ""))
|
|
|
|
|
|
if not match:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
try:
|
|
|
|
|
|
return max(0, int(match.group(1)))
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_travel_allowance_location_from_claim(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
claim: ExpenseClaim,
|
|
|
|
|
|
business_items: list[ExpenseClaimItem],
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
claim_location = str(claim.location or "").strip()
|
|
|
|
|
|
if claim_location and claim_location not in {"待补充", "未知", "暂无", "非必填"}:
|
|
|
|
|
|
return claim_location
|
|
|
|
|
|
|
|
|
|
|
|
sorted_items = sorted(
|
|
|
|
|
|
business_items,
|
|
|
|
|
|
key=lambda item: (item.item_date or date.max, self._normalize_sort_datetime(item.created_at)),
|
|
|
|
|
|
)
|
|
|
|
|
|
for item in sorted_items:
|
|
|
|
|
|
location = str(item.item_location or "").strip()
|
|
|
|
|
|
if location and location not in {"待补充", "未知", "暂无", "非必填"}:
|
|
|
|
|
|
return location
|
|
|
|
|
|
reason = str(item.item_reason or "").strip()
|
|
|
|
|
|
for separator in ("-", "至", "到", "→", "->"):
|
|
|
|
|
|
if separator in reason:
|
|
|
|
|
|
destination = reason.split(separator)[-1].strip()
|
|
|
|
|
|
if destination:
|
|
|
|
|
|
return destination
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
def _sync_claim_from_items(self, claim: ExpenseClaim) -> None:
|
|
|
|
|
|
self._sync_travel_allowance_item(claim)
|
|
|
|
|
|
if not claim.items:
|
|
|
|
|
|
claim.amount = Decimal("0.00")
|
|
|
|
|
|
claim.invoice_count = 0
|
|
|
|
|
|
claim.risk_flags_json = self._merge_claim_attachment_risk_flags(claim, [])
|
2026-06-01 17:07:14 +08:00
|
|
|
|
claim.risk_flags_json = self._merge_claim_platform_risk_preview_flags(claim, [])
|
2026-05-22 10:42:31 +08:00
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
ordered_items = sorted(
|
|
|
|
|
|
claim.items,
|
|
|
|
|
|
key=lambda item: (
|
|
|
|
|
|
item.item_date or date.max,
|
|
|
|
|
|
self._normalize_sort_datetime(item.created_at),
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
primary_item = ordered_items[0]
|
|
|
|
|
|
total_amount = sum((item.item_amount 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())
|
|
|
|
|
|
claim.occurred_at = datetime(
|
|
|
|
|
|
primary_item.item_date.year,
|
|
|
|
|
|
primary_item.item_date.month,
|
|
|
|
|
|
primary_item.item_date.day,
|
|
|
|
|
|
tzinfo=UTC,
|
|
|
|
|
|
)
|
|
|
|
|
|
claim.expense_type = self._resolve_claim_expense_type_from_items(
|
|
|
|
|
|
ordered_items,
|
|
|
|
|
|
fallback=str(primary_item.item_type or claim.expense_type or "other").strip() or "other",
|
|
|
|
|
|
)
|
|
|
|
|
|
primary_item_type = str(primary_item.item_type or "").strip()
|
|
|
|
|
|
if primary_item_type not in DOCUMENT_FACT_ITEM_TYPES:
|
|
|
|
|
|
claim.reason = (
|
|
|
|
|
|
self._normalize_optional_text(primary_item.item_reason, fallback=claim.reason or "待补充")
|
|
|
|
|
|
or "待补充"
|
|
|
|
|
|
)
|
|
|
|
|
|
claim.location = (
|
|
|
|
|
|
self._normalize_optional_text(primary_item.item_location, fallback=claim.location or "待补充")
|
|
|
|
|
|
or "待补充"
|
|
|
|
|
|
)
|
|
|
|
|
|
claim.risk_flags_json = self._merge_claim_attachment_risk_flags(
|
|
|
|
|
|
claim,
|
|
|
|
|
|
self._build_claim_attachment_risk_flags(ordered_items),
|
|
|
|
|
|
)
|
2026-06-01 17:07:14 +08:00
|
|
|
|
self._refresh_claim_platform_risk_preview_flags(claim)
|
2026-05-22 10:42:31 +08:00
|
|
|
|
if str(claim.status or "").strip().lower() == "draft":
|
|
|
|
|
|
claim.approval_stage = "待提交"
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _resolve_claim_expense_type_from_items(
|
|
|
|
|
|
items: list[ExpenseClaimItem],
|
|
|
|
|
|
*,
|
|
|
|
|
|
fallback: str,
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
fallback_type = str(fallback or "").strip() or "other"
|
|
|
|
|
|
item_types = {str(item.item_type or "").strip().lower() for item in items}
|
|
|
|
|
|
if item_types & (TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES | {"travel_allowance"}):
|
|
|
|
|
|
return "travel"
|
|
|
|
|
|
return fallback_type
|
|
|
|
|
|
|
|
|
|
|
|
def _refresh_item_attachment_analysis(self, item: ExpenseClaimItem) -> None:
|
|
|
|
|
|
file_path = self._attachment_storage.resolve_path(item.invoice_id)
|
|
|
|
|
|
if file_path is None or not file_path.exists():
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
metadata = self._attachment_storage.read_meta(file_path)
|
|
|
|
|
|
media_type = str(metadata.get("media_type") or self._attachment_presentation.resolve_media_type(file_path.name)).strip()
|
|
|
|
|
|
ocr_status = str(metadata.get("ocr_status") or "").strip().lower()
|
|
|
|
|
|
|
|
|
|
|
|
if ocr_status == "failed":
|
|
|
|
|
|
analysis = self._build_failed_ocr_attachment_analysis(
|
|
|
|
|
|
media_type=media_type,
|
|
|
|
|
|
error_message=str(metadata.get("ocr_error") or ""),
|
|
|
|
|
|
item=item,
|
|
|
|
|
|
)
|
|
|
|
|
|
elif ocr_status == "recognized" or any(
|
|
|
|
|
|
(
|
|
|
|
|
|
str(metadata.get("ocr_text") or "").strip(),
|
|
|
|
|
|
str(metadata.get("ocr_summary") or "").strip(),
|
|
|
|
|
|
int(metadata.get("ocr_line_count") or 0),
|
|
|
|
|
|
list(metadata.get("ocr_warnings") or []),
|
|
|
|
|
|
)
|
|
|
|
|
|
):
|
|
|
|
|
|
stored_document_info = metadata.get("document_info")
|
|
|
|
|
|
if not isinstance(stored_document_info, dict):
|
|
|
|
|
|
stored_document_info = {}
|
|
|
|
|
|
document = SimpleNamespace(
|
|
|
|
|
|
filename=str(metadata.get("file_name") or file_path.name),
|
|
|
|
|
|
text=str(metadata.get("ocr_text") or ""),
|
|
|
|
|
|
summary=str(metadata.get("ocr_summary") or ""),
|
|
|
|
|
|
avg_score=float(metadata.get("ocr_avg_score") or 0.0),
|
|
|
|
|
|
line_count=int(metadata.get("ocr_line_count") or 0),
|
|
|
|
|
|
document_type=str(stored_document_info.get("document_type") or ""),
|
|
|
|
|
|
document_type_label=str(stored_document_info.get("document_type_label") or ""),
|
|
|
|
|
|
scene_code=str(stored_document_info.get("scene_code") or ""),
|
|
|
|
|
|
scene_label=str(stored_document_info.get("scene_label") or ""),
|
|
|
|
|
|
document_fields=list(stored_document_info.get("fields") or []),
|
|
|
|
|
|
warnings=[str(value) for value in list(metadata.get("ocr_warnings") or []) if str(value).strip()],
|
|
|
|
|
|
)
|
|
|
|
|
|
document_info = self._build_attachment_document_info(document)
|
|
|
|
|
|
requirement_check = self._build_attachment_requirement_check(
|
|
|
|
|
|
item=item,
|
|
|
|
|
|
document_info=document_info,
|
|
|
|
|
|
)
|
|
|
|
|
|
analysis = self._build_attachment_analysis(
|
|
|
|
|
|
document=document,
|
|
|
|
|
|
item=item,
|
|
|
|
|
|
claim=getattr(item, "claim", None),
|
|
|
|
|
|
document_info=document_info,
|
|
|
|
|
|
requirement_check=requirement_check,
|
|
|
|
|
|
)
|
|
|
|
|
|
metadata["document_info"] = document_info
|
|
|
|
|
|
metadata["requirement_check"] = requirement_check
|
|
|
|
|
|
else:
|
|
|
|
|
|
analysis = self._build_fallback_attachment_analysis(media_type=media_type, item=item)
|
|
|
|
|
|
|
|
|
|
|
|
metadata["analysis"] = analysis
|
|
|
|
|
|
self._attachment_storage.write_meta(file_path, metadata)
|
|
|
|
|
|
|
|
|
|
|
|
def _build_claim_attachment_risk_flags(
|
|
|
|
|
|
self, ordered_items: list[ExpenseClaimItem]
|
|
|
|
|
|
) -> list[dict[str, Any]]:
|
|
|
|
|
|
derived_flags: list[dict[str, Any]] = []
|
|
|
|
|
|
for index, item in enumerate(ordered_items, start=1):
|
|
|
|
|
|
file_path = self._attachment_storage.resolve_path(item.invoice_id)
|
|
|
|
|
|
if file_path is None or not file_path.exists():
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
metadata = self._attachment_storage.read_meta(file_path)
|
|
|
|
|
|
analysis = metadata.get("analysis")
|
|
|
|
|
|
if not isinstance(analysis, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
severity = str(analysis.get("severity") or "").strip().lower()
|
|
|
|
|
|
if severity in {"", "pass", "low"}:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
summary = (
|
|
|
|
|
|
str(analysis.get("summary") or analysis.get("headline") or "").strip()
|
|
|
|
|
|
or "附件存在待核对风险。"
|
|
|
|
|
|
)
|
|
|
|
|
|
points = [
|
|
|
|
|
|
str(point or "").strip()
|
|
|
|
|
|
for point in list(analysis.get("points") or [])
|
|
|
|
|
|
if str(point or "").strip()
|
|
|
|
|
|
]
|
|
|
|
|
|
message_detail = ";".join(points[:3]) if points else summary
|
|
|
|
|
|
label = str(
|
|
|
|
|
|
analysis.get("label") or ("高风险" if severity == "high" else "中风险")
|
|
|
|
|
|
).strip()
|
|
|
|
|
|
derived_flags.append(
|
2026-06-01 17:07:14 +08:00
|
|
|
|
with_risk_business_stage(
|
|
|
|
|
|
{
|
|
|
|
|
|
"source": "attachment_analysis",
|
|
|
|
|
|
"item_id": item.id,
|
|
|
|
|
|
"severity": severity,
|
|
|
|
|
|
"label": label,
|
|
|
|
|
|
"message": f"费用明细第 {index} 条:{message_detail}",
|
|
|
|
|
|
"summary": summary,
|
|
|
|
|
|
"points": points,
|
|
|
|
|
|
},
|
|
|
|
|
|
"reimbursement",
|
|
|
|
|
|
)
|
2026-05-22 10:42:31 +08:00
|
|
|
|
)
|
|
|
|
|
|
return derived_flags
|
|
|
|
|
|
|
|
|
|
|
|
def _get_expense_rule_catalog(self) -> Any:
|
|
|
|
|
|
cached = getattr(self, "_expense_rule_catalog", None)
|
|
|
|
|
|
if cached is not None:
|
|
|
|
|
|
return cached
|
|
|
|
|
|
|
|
|
|
|
|
db = getattr(self, "db", None)
|
|
|
|
|
|
if db is None:
|
|
|
|
|
|
catalog = build_default_expense_rule_catalog()
|
|
|
|
|
|
else:
|
|
|
|
|
|
catalog = ExpenseRuleRuntimeService(db).load_catalog()
|
|
|
|
|
|
setattr(self, "_expense_rule_catalog", catalog)
|
|
|
|
|
|
return catalog
|
|
|
|
|
|
|
|
|
|
|
|
def _get_expense_scene_policy(self, expense_type: str | None) -> Any | None:
|
|
|
|
|
|
return self._get_expense_rule_catalog().get_scene_policy(expense_type)
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_min_attachment_count(self, expense_type: str | None) -> int:
|
|
|
|
|
|
policy = self._get_expense_scene_policy(expense_type)
|
|
|
|
|
|
if policy is None:
|
|
|
|
|
|
return 1
|
|
|
|
|
|
return max(0, int(policy.min_attachment_count or 0))
|
|
|
|
|
|
|
2026-06-02 14:01:51 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _is_attachment_required_item_type(item_type: str | None) -> bool:
|
|
|
|
|
|
normalized = str(item_type or "").strip().lower()
|
|
|
|
|
|
return normalized not in SYSTEM_GENERATED_ITEM_TYPES and normalized not in OPTIONAL_ATTACHMENT_ITEM_TYPES
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_claim_required_attachment_count(self, claim: ExpenseClaim) -> int:
|
|
|
|
|
|
required_items = [
|
|
|
|
|
|
item for item in list(claim.items or [])
|
|
|
|
|
|
if self._is_attachment_required_item_type(item.item_type)
|
|
|
|
|
|
]
|
|
|
|
|
|
if not required_items:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
return min(self._resolve_min_attachment_count(claim.expense_type), len(required_items))
|
|
|
|
|
|
|
2026-05-22 10:42:31 +08:00
|
|
|
|
def _build_scene_reason_corpus(self, claim: ExpenseClaim) -> str:
|
|
|
|
|
|
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_location or "").strip())
|
|
|
|
|
|
return "\n".join(part for part in parts if part)
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _merge_claim_attachment_risk_flags(
|
|
|
|
|
|
claim: ExpenseClaim,
|
|
|
|
|
|
attachment_risk_flags: list[dict[str, Any]],
|
|
|
|
|
|
) -> list[Any]:
|
|
|
|
|
|
preserved_flags = [
|
|
|
|
|
|
flag
|
|
|
|
|
|
for flag in list(claim.risk_flags_json or [])
|
|
|
|
|
|
if not (isinstance(flag, dict) and str(flag.get("source") or "").strip() == "attachment_analysis")
|
|
|
|
|
|
]
|
|
|
|
|
|
return preserved_flags + attachment_risk_flags
|
|
|
|
|
|
|
2026-06-01 17:07:14 +08:00
|
|
|
|
def _refresh_claim_platform_risk_preview_flags(self, claim: ExpenseClaim) -> None:
|
|
|
|
|
|
if str(claim.expense_type or "").strip().lower().endswith("_application"):
|
|
|
|
|
|
return
|
|
|
|
|
|
evaluator = getattr(self, "evaluate_platform_risk_rules", None)
|
|
|
|
|
|
if not callable(evaluator):
|
|
|
|
|
|
return
|
|
|
|
|
|
try:
|
|
|
|
|
|
review = evaluator(claim, business_stage="reimbursement")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return
|
|
|
|
|
|
platform_flags = list(review.get("flags") or []) if isinstance(review, dict) else []
|
|
|
|
|
|
claim.risk_flags_json = self._merge_claim_platform_risk_preview_flags(
|
|
|
|
|
|
claim,
|
|
|
|
|
|
platform_flags,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _merge_claim_platform_risk_preview_flags(
|
|
|
|
|
|
claim: ExpenseClaim,
|
|
|
|
|
|
platform_flags: list[dict[str, Any]],
|
|
|
|
|
|
) -> list[Any]:
|
|
|
|
|
|
preserved_flags = [
|
|
|
|
|
|
flag
|
|
|
|
|
|
for flag in list(claim.risk_flags_json or [])
|
|
|
|
|
|
if not (
|
|
|
|
|
|
isinstance(flag, dict)
|
|
|
|
|
|
and str(flag.get("source") or "").strip() == "submission_review"
|
|
|
|
|
|
and str(flag.get("hit_source") or "").strip() == "rule_center"
|
|
|
|
|
|
)
|
|
|
|
|
|
]
|
|
|
|
|
|
return preserved_flags + platform_flags
|
|
|
|
|
|
|
2026-05-22 10:42:31 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _format_submission_blocked_message(issues: list[str]) -> str:
|
|
|
|
|
|
normalized_issues = [str(issue or "").strip() for issue in issues if str(issue or "").strip()]
|
|
|
|
|
|
if not normalized_issues:
|
2026-06-02 14:01:51 +08:00
|
|
|
|
return "自动检测未通过,但没有返回明确原因,请刷新草稿后重试。"
|
2026-05-22 10:42:31 +08:00
|
|
|
|
|
2026-06-02 14:01:51 +08:00
|
|
|
|
return "自动检测暂未通过,原因如下:\n" + "\n".join(
|
2026-05-22 10:42:31 +08:00
|
|
|
|
f"{index}. {issue}" for index, issue in enumerate(normalized_issues, start=1)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def _validate_claim_for_submission(self, claim: ExpenseClaim) -> list[str]:
|
|
|
|
|
|
issues: list[str] = []
|
|
|
|
|
|
claim_location_required = self._is_location_required_expense_type(claim.expense_type)
|
2026-06-02 14:01:51 +08:00
|
|
|
|
claim_min_attachment_count = self._resolve_claim_required_attachment_count(claim)
|
2026-05-22 10:42:31 +08:00
|
|
|
|
|
|
|
|
|
|
if self._is_missing_value(claim.employee_name):
|
|
|
|
|
|
issues.append("申请人未完善")
|
|
|
|
|
|
if self._is_missing_value(claim.department_name):
|
|
|
|
|
|
issues.append("所属部门未完善")
|
|
|
|
|
|
if self._is_missing_value(claim.expense_type):
|
|
|
|
|
|
issues.append("报销类型未完善")
|
|
|
|
|
|
if self._is_missing_value(claim.reason):
|
|
|
|
|
|
issues.append("报销事由未完善")
|
|
|
|
|
|
if claim_location_required and self._is_missing_value(claim.location):
|
|
|
|
|
|
issues.append("业务地点未完善")
|
|
|
|
|
|
if claim.amount is None or claim.amount <= Decimal("0.00"):
|
|
|
|
|
|
issues.append("报销金额未完善")
|
|
|
|
|
|
if claim.occurred_at is None:
|
|
|
|
|
|
issues.append("发生时间未完善")
|
|
|
|
|
|
if int(claim.invoice_count or 0) < claim_min_attachment_count:
|
|
|
|
|
|
issues.append("票据附件数量不足")
|
|
|
|
|
|
if not claim.items:
|
|
|
|
|
|
issues.append("费用明细不能为空")
|
|
|
|
|
|
|
|
|
|
|
|
for index, item in enumerate(claim.items, start=1):
|
|
|
|
|
|
prefix = f"费用明细第 {index} 条"
|
|
|
|
|
|
is_system_generated = str(item.item_type or "").strip().lower() in SYSTEM_GENERATED_ITEM_TYPES
|
|
|
|
|
|
item_location_required = self._is_location_required_expense_type(item.item_type or claim.expense_type)
|
|
|
|
|
|
if item.item_date is None:
|
|
|
|
|
|
issues.append(f"{prefix}缺少日期")
|
|
|
|
|
|
if self._is_missing_value(item.item_type):
|
|
|
|
|
|
issues.append(f"{prefix}缺少费用项目")
|
|
|
|
|
|
if self._is_missing_value(item.item_reason):
|
|
|
|
|
|
issues.append(f"{prefix}缺少说明")
|
|
|
|
|
|
if item_location_required and self._is_missing_value(item.item_location):
|
|
|
|
|
|
issues.append(f"{prefix}缺少地点")
|
|
|
|
|
|
if item.item_amount is None or item.item_amount <= Decimal("0.00"):
|
|
|
|
|
|
issues.append(f"{prefix}缺少金额")
|
2026-06-02 14:01:51 +08:00
|
|
|
|
if self._is_attachment_required_item_type(item.item_type) and self._is_missing_value(item.invoice_id):
|
2026-05-22 10:42:31 +08:00
|
|
|
|
issues.append(f"{prefix}缺少票据标识")
|
|
|
|
|
|
|
|
|
|
|
|
return issues
|
|
|
|
|
|
|
|
|
|
|
|
def _is_location_required_expense_type(self, expense_type: str | None) -> bool:
|
|
|
|
|
|
policy = self._get_expense_scene_policy(expense_type)
|
|
|
|
|
|
if policy is None:
|
|
|
|
|
|
return str(expense_type or "").strip().lower() in LOCATION_REQUIRED_EXPENSE_TYPES
|
|
|
|
|
|
return bool(policy.location_required)
|