fix(reimbursement): harden assistant draft and claim cleanup

This commit is contained in:
caoxiaozhu
2026-05-21 23:52:34 +08:00
parent e701fa01da
commit 2908dda024
9 changed files with 1060 additions and 398 deletions

View File

@@ -2,11 +2,11 @@ from __future__ import annotations
import base64
import binascii
import json
import mimetypes
import re
import shutil
import uuid
import json
import mimetypes
import re
import shutil
import uuid
from collections import defaultdict
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal, InvalidOperation
@@ -15,29 +15,42 @@ from types import SimpleNamespace
from typing import Any
from urllib.parse import quote
from sqlalchemy import and_, func, inspect as sqlalchemy_inspect, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, selectinload
from app.api.deps import CurrentUserContext
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
from app.core.config import get_settings
from app.models.agent_asset import AgentAsset
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.organization import OrganizationUnit
from app.schemas.ontology import OntologyEntity, OntologyParseResult
from app.schemas.reimbursement import (
ExpenseClaimItemCreate,
ExpenseClaimItemUpdate,
ExpenseClaimUpdate,
TravelReimbursementCalculatorRequest,
)
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.agent_foundation import AgentFoundationService
from app.services.audit import AuditLogService
from app.services.document_intelligence import build_document_insight
from sqlalchemy import and_, func, or_, select
from sqlalchemy import inspect as sqlalchemy_inspect
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, selectinload
from app.api.deps import CurrentUserContext
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
from app.core.config import get_settings
from app.models.agent_asset import AgentAsset
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.organization import OrganizationUnit
from app.schemas.ontology import OntologyEntity, OntologyParseResult
from app.schemas.reimbursement import (
ExpenseClaimItemCreate,
ExpenseClaimItemUpdate,
ExpenseClaimUpdate,
TravelReimbursementCalculatorRequest,
)
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.agent_foundation import AgentFoundationService
from app.services.audit import AuditLogService
from app.services.document_intelligence import build_document_insight
from app.services.expense_amounts import (
extract_amount_candidates,
format_decimal_amount,
is_amount_match_date_fragment,
is_date_like_amount_candidate,
is_probable_year_amount,
parse_document_amount_value,
parse_plain_document_amount_value,
resolve_document_field_amount,
resolve_document_item_amount,
resolve_document_text_amount,
)
from app.services.expense_rule_runtime import (
DEFAULT_SCENE_RULE_ASSET_CODE,
ExpenseRuleRuntimeService,
@@ -268,15 +281,7 @@ RETURN_REASON_OPTIONS = {
"approval_question": "审批人需要补充说明",
}
MAX_CLAIM_NO_RETRY_ATTEMPTS = 3
DOCUMENT_AMOUNT_PATTERNS = (
re.compile(
r"(?:价税合计|合计金额|费用合计|总费用|费用总计|订单(?:总)?金额|支付(?:金额)?|实付(?:金额)?|实收(?:金额)?|总(?:额|计|价)|票价|金额|车费|消费金额|房费|住宿费)"
r"[:\s¥¥人民币为是]*([0-9]+(?:[.,][0-9]{1,2})?)"
),
re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)"),
re.compile(r"([0-9]+(?:[.,][0-9]{1,2})?)\s*元"),
)
DOCUMENT_DATE_PATTERN = re.compile(r"((?:20\d{2}|19\d{2})[-/年.](?:1[0-2]|0?[1-9])[-/月.](?:3[01]|[12]\d|0?[1-9])日?)")
DOCUMENT_DATE_PATTERN = re.compile(r"((?:20\d{2}|19\d{2})[-/年.](?:1[0-2]|0?[1-9])[-/月.](?:3[01]|[12]\d|0?[1-9])日?)")
SYSTEM_GENERATED_REASON_PREFIXES = (
"我上传了",
"请按当前已识别信息",
@@ -730,12 +735,13 @@ class ExpenseClaimService:
item=item,
document_info=document_info,
)
attachment_analysis = self._build_attachment_analysis(
document=ocr_document,
item=item,
document_info=document_info,
requirement_check=requirement_check,
)
attachment_analysis = self._build_attachment_analysis(
document=ocr_document,
item=item,
claim=claim,
document_info=document_info,
requirement_check=requirement_check,
)
except Exception as exc: # pragma: no cover - fallback path depends on OCR runtime
ocr_status = "failed"
ocr_error = str(exc)
@@ -935,7 +941,7 @@ class ExpenseClaimService:
after_json=self._serialize_claim(claim),
)
if str(claim.status or "").strip().lower() == "submitted":
self._delete_submitted_claim_assistant_sessions(claim.id)
self._delete_claim_assistant_sessions(claim.id)
return claim
@@ -1049,8 +1055,11 @@ class ExpenseClaimService:
attachment_count = self._resolve_attachment_count(context_json)
return {
"message": (
"我已根据当前信息整理出待核对的报销内容,但尚未保存为草稿"
"请在右侧核对信息,只有点击“保存为草稿”或“继续下一步”后才会正式写入单据。"
"我已先整理出本次报销的待核对信息"
"如果附件还没有上传,金额可以先按制度口径做参考测算:"
"差旅费按“交通票据金额 + 住宿标准 × 出差天数 + 出差补贴 × 出差天数”估算;"
"交通费、住宿费等其他费用以实际票据金额为基础,再按规则中心限额和审批口径复核。"
"后续补充票据后,我会用真实票据金额重新校验。"
),
"draft_only": True,
"preview_only": True,
@@ -1071,18 +1080,19 @@ class ExpenseClaimService:
before_json = self._serialize_claim(claim)
resource_id = claim.id
self._delete_claim_attachment_root(claim.id)
self._delete_claim_attachment_files(claim)
self.db.delete(claim)
self.db.commit()
self.db.commit()
self.audit_service.log_action(
actor=current_user.name or current_user.username,
action="expense_claim.delete",
resource_type="expense_claim",
resource_id=resource_id,
before_json=before_json,
after_json=None,
)
self.audit_service.log_action(
actor=current_user.name or current_user.username,
action="expense_claim.delete",
resource_type="expense_claim",
resource_id=resource_id,
before_json=before_json,
after_json=None,
)
self._delete_claim_assistant_sessions(resource_id)
return claim
@@ -1798,7 +1808,9 @@ class ExpenseClaimService:
return None
try:
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
from app.services.travel_reimbursement_calculator import (
TravelReimbursementCalculatorService,
)
result = TravelReimbursementCalculatorService(self.db).calculate(
TravelReimbursementCalculatorRequest(
@@ -1870,6 +1882,7 @@ class ExpenseClaimService:
) -> tuple[int, date, date]:
start_date = occurred_at.date()
end_date = start_date
explicit_days = self._extract_travel_allowance_days_from_context(context_json)
business_time_context = context_json.get("business_time_context")
if isinstance(business_time_context, dict):
@@ -1891,9 +1904,52 @@ class ExpenseClaimService:
if end_date < start_date:
end_date = start_date
if explicit_days > 0:
return explicit_days, start_date, start_date + timedelta(days=explicit_days - 1)
days = (end_date - start_date).days + 1
return max(1, days), start_date, end_date
@staticmethod
def _extract_travel_allowance_days_from_context(context_json: dict[str, Any]) -> int:
review_form_values = context_json.get("review_form_values")
text_parts: list[str] = []
if isinstance(review_form_values, dict):
text_parts.extend(
str(review_form_values.get(key) or "")
for key in (
"reason",
"business_reason",
"reason_value",
"scene_label",
"time_range",
"business_time",
)
)
text_parts.extend(
str(context_json.get(key) or "")
for key in ("user_input_text", "message", "raw_text", "ocr_summary")
)
return ExpenseClaimService._extract_travel_day_count(" ".join(text_parts))
@staticmethod
def _extract_travel_day_count(text: str) -> int:
normalized = str(text or "").replace(" ", "")
if not normalized:
return 0
patterns = (
r"(?:出差|差旅|行程|支撑|支持|部署|项目|业务)\D{0,12}?(\d{1,2})天",
r"(\d{1,2})天(?:出差|差旅|行程)",
)
for pattern in patterns:
match = re.search(pattern, normalized)
if not match:
continue
try:
return max(1, int(match.group(1)))
except ValueError:
continue
return 0
@staticmethod
def _parse_iso_date_or_default(value: Any, fallback: date) -> date:
try:
@@ -2267,110 +2323,32 @@ class ExpenseClaimService:
return ""
def _resolve_document_item_amount(self, document: dict[str, Any]) -> Decimal | None:
text = " ".join(
[
str(document.get("summary") or "").strip(),
str(document.get("text") or "").strip(),
]
).strip()
field_amount = self._resolve_document_field_amount(document)
text_amount = self._resolve_document_text_amount(text)
if field_amount is not None:
if self._is_date_like_amount_candidate(field_amount, text):
return text_amount
return field_amount
return text_amount
return resolve_document_item_amount(document)
def _resolve_document_field_amount(self, document: dict[str, Any]) -> Decimal | None:
for field in list(document.get("document_fields") or []):
if not isinstance(field, dict):
continue
key = str(field.get("key") or "").strip().lower().replace("_", "")
label = str(field.get("label") or "").replace(" ", "")
is_amount_field = key in {
"amount",
"totalamount",
"paymentamount",
"paidamount",
"actualamount",
} or any(
token in label
for token in ("金额", "价税合计", "合计", "总额", "总计", "票价", "支付金额", "实付金额", "实收金额")
)
if not is_amount_field:
continue
raw_value = str(field.get("value") or "")
value = self._parse_document_amount_value(raw_value) or self._parse_plain_document_amount_value(raw_value)
if value is not None:
return value
return None
return resolve_document_field_amount(document)
def _resolve_document_text_amount(self, text: str) -> Decimal | None:
candidates = [
candidate
for candidate in self._extract_amount_candidates(text)
if not self._is_date_like_amount_candidate(candidate, text)
]
if not candidates:
return None
return max(candidates)
def _parse_document_amount_value(self, value: str) -> Decimal | None:
raw_value = str(value or "").strip()
if not raw_value:
return None
for pattern in DOCUMENT_AMOUNT_PATTERNS:
match = pattern.search(raw_value)
if not match:
continue
numeric = str(match.group(1) or "").replace(",", ".").strip()
try:
amount = Decimal(numeric).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
continue
if amount > Decimal("0.00"):
return amount
return None
return resolve_document_text_amount(text)
def _parse_document_amount_value(self, value: str) -> Decimal | None:
return parse_document_amount_value(value)
@staticmethod
def _parse_plain_document_amount_value(value: str) -> Decimal | None:
raw_value = str(value or "").strip()
if not re.fullmatch(r"[0-9]{1,6}(?:[.,][0-9]{1,2})?", raw_value):
return None
try:
amount = Decimal(raw_value.replace(",", ".")).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return None
return amount if amount > Decimal("0.00") else None
return parse_plain_document_amount_value(value)
@staticmethod
def _is_probable_year_amount(amount: Decimal | None) -> bool:
if amount is None:
return False
try:
normalized = Decimal(amount).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return False
return normalized == normalized.to_integral_value() and Decimal("1900") <= normalized <= Decimal("2099")
return is_probable_year_amount(amount)
@classmethod
def _is_date_like_amount_candidate(cls, amount: Decimal | None, text: str) -> bool:
if not cls._is_probable_year_amount(amount):
return False
year = str(int(Decimal(amount or 0)))
pattern = re.compile(rf"(?<!\d){re.escape(year)}\s*(?:年|[-/.])\s*\d{{1,2}}")
return bool(pattern.search(str(text or "")))
return is_date_like_amount_candidate(amount, text)
@staticmethod
def _format_decimal_amount(amount: Decimal | None) -> str:
if amount is None:
return ""
normalized = Decimal(amount).quantize(Decimal("0.01"))
return format(normalized, "f")
return format_decimal_amount(amount)
def _resolve_document_item_date(self, document: dict[str, Any], *, fallback: date) -> date:
return self._resolve_document_item_date_candidate(document) or fallback
@@ -2903,8 +2881,14 @@ class ExpenseClaimService:
def _build_item_attachment_dir(self, claim_id: str, item_id: str) -> Path:
return (self._get_attachment_storage_root() / claim_id / item_id).resolve()
def _delete_claim_attachment_root(self, claim_id: str) -> None:
shutil.rmtree((self._get_attachment_storage_root() / claim_id).resolve(), ignore_errors=True)
def _delete_claim_attachment_files(self, claim: ExpenseClaim) -> None:
for item in list(claim.items or []):
self._delete_item_attachment_files(item)
self._delete_claim_attachment_root(claim.id)
def _delete_claim_attachment_root(self, claim_id: str) -> None:
claim_root = self._assert_attachment_storage_child(self._get_attachment_storage_root() / claim_id)
self._delete_attachment_path(claim_root)
@staticmethod
def _normalize_attachment_filename(filename: str | None) -> str:
@@ -2968,14 +2952,39 @@ class ExpenseClaimService:
file_path = self._resolve_item_attachment_path(item)
if file_path is None:
return
root = self._get_attachment_storage_root()
if file_path.parent == root:
file_path.unlink(missing_ok=True)
self._attachment_meta_path(file_path).unlink(missing_ok=True)
return
shutil.rmtree(file_path.parent, ignore_errors=True)
root = self._get_attachment_storage_root()
if file_path.parent == root:
self._delete_attachment_path(file_path)
self._delete_attachment_path(self._attachment_meta_path(file_path))
return
self._delete_attachment_path(file_path.parent)
def _assert_attachment_storage_child(self, path: Path) -> Path:
root = self._get_attachment_storage_root()
resolved = path.resolve()
try:
resolved.relative_to(root)
except ValueError as exc:
raise FileNotFoundError("Attachment path is invalid") from exc
return resolved
def _delete_attachment_path(self, path: Path | None) -> None:
if path is None:
return
target = self._assert_attachment_storage_child(path)
if not target.exists():
return
if target.is_dir():
shutil.rmtree(target)
else:
target.unlink()
if target.exists():
raise OSError(f"Attachment path was not deleted: {target}")
@staticmethod
def _attachment_meta_path(file_path: Path) -> Path:
@@ -3062,6 +3071,7 @@ class ExpenseClaimService:
metadata["analysis"] = self._build_attachment_analysis(
document=document,
item=item,
claim=getattr(item, "claim", None),
document_info=document_info,
requirement_check=requirement_check,
)
@@ -3452,6 +3462,102 @@ class ExpenseClaimService:
return points
def _build_attachment_travel_policy_audit(
self,
*,
document: Any,
item: ExpenseClaimItem,
document_info: dict[str, Any],
claim: ExpenseClaim | None = None,
) -> dict[str, Any]:
policy = self._get_expense_rule_catalog().travel_policy
if policy is None:
return {"points": [], "rule_basis": [], "has_high_risk": False}
item_type = str(item.item_type or "").strip().lower()
document_type = str(document_info.get("document_type") or "").strip().lower()
scene_code = str(document_info.get("scene_code") or "").strip().lower()
if not (
item_type in {"hotel", "hotel_ticket"}
or document_type == "hotel_invoice"
or scene_code == "hotel"
):
return {"points": [], "rule_basis": [], "has_high_risk": False}
item_amount = Decimal(item.item_amount or Decimal("0.00")).quantize(Decimal("0.01"))
if item_amount <= Decimal("0.00"):
return {"points": [], "rule_basis": [], "has_high_risk": False}
claim = claim or getattr(item, "claim", None)
grade_band = self._resolve_travel_policy_band(getattr(claim, "employee_grade", None))
rule_name = str(policy.standard_rule_name or policy.rule_name or "公司差旅费报销规则").strip()
rule_version = str(policy.standard_rule_version or policy.rule_version or "").strip()
version_text = f"{rule_version}" if rule_version else ""
rule_basis = [
f"依据《{rule_name}{version_text},住宿费按员工职级、出差城市和每晚金额进行差标核算。"
]
if grade_band is None:
return {
"points": ["住宿标准:当前员工职级缺失,无法匹配规则中心的住宿报销标准。"],
"rule_basis": rule_basis,
"has_high_risk": False,
}
text = " ".join(
[
str(getattr(document, "summary", "") or "").strip(),
str(getattr(document, "text", "") or "").strip(),
]
).strip()
context = {
"item": item,
"document_info": document_info,
"ocr_summary": str(getattr(document, "summary", "") or "").strip(),
"ocr_text": str(getattr(document, "text", "") or "").strip(),
}
hotel_city = self._extract_hotel_city(context, policy)
claim_city = self._extract_city_from_text(str(getattr(claim, "location", "") or ""), policy) if claim else ""
reason_city = self._extract_city_from_text(str(getattr(claim, "reason", "") or ""), policy) if claim else ""
baseline_city = hotel_city or claim_city or reason_city
if not baseline_city:
baseline_city = self._extract_city_from_text(text, policy)
if not baseline_city:
return {
"points": ["住宿标准:未能从酒店名称、出差地点或票据内容匹配到规则中心城市,无法核算住宿差标。"],
"rule_basis": rule_basis,
"has_high_risk": False,
}
standard = self._resolve_travel_policy_hotel_standard(
policy=policy,
grade_band=grade_band,
city=baseline_city,
)
if standard is None:
return {"points": [], "rule_basis": rule_basis, "has_high_risk": False}
cap, standard_label = standard
night_count = self._extract_hotel_night_count(context)
nightly_amount = (item_amount / Decimal(max(night_count, 1))).quantize(Decimal("0.01"))
if nightly_amount <= cap:
return {"points": [], "rule_basis": rule_basis, "has_high_risk": False}
band_label = policy.band_labels.get(grade_band, str(getattr(claim, "employee_grade", "") or "当前职级").strip())
over_amount = (nightly_amount - cap).quantize(Decimal("0.01"))
return {
"points": [
(
f"住宿标准:{band_label}{standard_label}的住宿标准为 "
f"{self._format_decimal_amount(cap)} 元/晚,票据识别金额 "
f"{self._format_decimal_amount(item_amount)} 元 / {night_count} 晚,"
f"{self._format_decimal_amount(nightly_amount)} 元/晚,"
f"超出 {self._format_decimal_amount(over_amount)} 元/晚。"
)
],
"rule_basis": rule_basis,
"has_high_risk": True,
}
def _backfill_item_date_from_attachment(
self,
*,
@@ -3557,40 +3663,9 @@ class ExpenseClaimService:
normalized = str(scene_code or "").strip().lower()
return DOCUMENT_SCENE_LABELS.get(normalized, "其他票据")
@staticmethod
def _extract_amount_candidates(text: str) -> list[Decimal]:
values: list[Decimal] = []
seen: set[Decimal] = set()
def append_candidate(raw: str, *, source_text: str = "", start: int = -1, end: int = -1) -> None:
compact = str(raw or "").replace(",", ".").strip()
if not compact:
return
try:
candidate = Decimal(compact).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return
if ExpenseClaimService._is_amount_match_date_fragment(candidate, source_text, start, end):
return
if candidate in seen:
return
seen.add(candidate)
values.append(candidate)
for pattern in (
r"(?:金额|价税合计|合计|小写|实收金额|支付金额|订单金额|总额|总计|总费用|费用总计|票价|房费|住宿费|餐费)[:\s¥¥人民币为是]*([0-9]{1,6}(?:[.,][0-9]{1,2})?)",
r"[¥¥]\s*([0-9]{1,6}(?:[.,][0-9]{1,2})?)",
r"([0-9]{1,6}(?:[.,][0-9]{1,2})?)\s*元",
):
for match in re.finditer(pattern, text, flags=re.IGNORECASE):
append_candidate(match.group(1), source_text=text, start=match.start(1), end=match.end(1))
if values:
return values
for match in re.finditer(r"(?<!\d)(\d{1,6}\.\d{1,2})(?!\d)", text):
append_candidate(match.group(1), source_text=text, start=match.start(1), end=match.end(1))
return values
@staticmethod
def _extract_amount_candidates(text: str) -> list[Decimal]:
return extract_amount_candidates(text)
@staticmethod
def _is_amount_match_date_fragment(
@@ -3599,16 +3674,7 @@ class ExpenseClaimService:
start: int,
end: int,
) -> bool:
if start < 0 or end < 0 or not ExpenseClaimService._is_probable_year_amount(amount):
return False
before = str(text or "")[max(0, start - 8):start]
after = str(text or "")[end:end + 10]
if re.match(r"\s*(?:年|[-/.])\s*\d{1,2}", after):
return True
if re.search(r"\d{1,2}\s*(?:年|[-/.])\s*$", before):
return True
return False
return is_amount_match_date_fragment(amount, text, start, end)
@staticmethod
def _has_date_like_text(text: str) -> bool:
@@ -3755,14 +3821,15 @@ class ExpenseClaimService:
"suggestion": "建议重新上传更清晰的票据图片,或稍后重试识别后再提交。",
}
def _build_attachment_analysis(
self,
*,
document: Any,
item: ExpenseClaimItem,
document_info: dict[str, Any] | None = None,
requirement_check: dict[str, Any] | None = None,
) -> dict[str, Any]:
def _build_attachment_analysis(
self,
*,
document: Any,
item: ExpenseClaimItem,
claim: ExpenseClaim | None = None,
document_info: dict[str, Any] | None = None,
requirement_check: dict[str, Any] | None = None,
) -> dict[str, Any]:
warnings = [str(value).strip() for value in list(getattr(document, "warnings", []) or []) if str(value).strip()]
text = " ".join(
[
@@ -3792,8 +3859,25 @@ class ExpenseClaimService:
item=item,
document_info=document_info,
)
travel_policy_audit = self._build_attachment_travel_policy_audit(
document=document,
item=item,
claim=claim,
document_info=document_info,
)
travel_policy_points = [
str(point).strip()
for point in list(travel_policy_audit.get("points") or [])
if str(point).strip()
]
travel_policy_rule_basis = [
str(point).strip()
for point in list(travel_policy_audit.get("rule_basis") or [])
if str(point).strip()
]
travel_policy_high_risk = bool(travel_policy_audit.get("has_high_risk"))
recognized_document_type = str(document_info.get("document_type") or "other").strip().lower() or "other"
recognized_document_label = str(document_info.get("document_type_label") or "其他单据").strip() or "其他单据"
recognized_document_label = str(document_info.get("document_type_label") or "其他单据").strip() or "其他单据"
requirement_matches = bool(requirement_check.get("matches"))
mismatch_severity = str(requirement_check.get("mismatch_severity") or "high").strip().lower() or "high"
@@ -3840,6 +3924,7 @@ class ExpenseClaimService:
if not requirement_matches:
points.append(f"附件类型要求:{requirement_check.get('message')}")
points.extend(expense_audit_points)
points.extend(travel_policy_points)
if purpose_mismatch_point:
points.append(purpose_mismatch_point)
if route_format_point:
@@ -3854,23 +3939,29 @@ class ExpenseClaimService:
"label": "AI提示符合条件",
"headline": "AI提示附件符合基础校验条件",
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
"points": [
f"票据类型:已识别为{recognized_document_label}",
f"附件类型要求:{requirement_check.get('message')}",
f"金额字段:已识别到与当前明细接近的金额 {item_amount} 元。",
],
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。",
}
"points": [
f"票据类型:已识别为{recognized_document_label}",
f"附件类型要求:{requirement_check.get('message')}",
f"金额字段:已识别到与当前明细接近的金额 {item_amount} 元。",
],
"rule_basis": travel_policy_rule_basis,
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。",
}
severity = "low"
label = "低风险"
headline = "AI提示附件存在轻微待核对项"
summary = "当前附件已识别出部分票据要素,但仍建议人工继续复核。"
if (
line_count == 0
or not compact_text
or (recognized_document_type == "other" and not has_ticket_keyword and issue_count >= 2)
if travel_policy_high_risk:
severity = "high"
label = "高风险"
headline = "AI提示住宿金额超出报销标准"
summary = "当前住宿票据金额超过规则中心差旅住宿标准,强行提交前需补充超标原因。"
elif (
line_count == 0
or not compact_text
or (recognized_document_type == "other" and not has_ticket_keyword and issue_count >= 2)
or (not requirement_matches and mismatch_severity == "high")
or (purpose_mismatch_point and amount_mismatch)
):
@@ -3882,6 +3973,7 @@ class ExpenseClaimService:
purpose_mismatch_point
or route_format_point
or expense_audit_points
or travel_policy_points
or amount_mismatch
or issue_count >= 2
or warnings
@@ -3896,21 +3988,26 @@ class ExpenseClaimService:
summary = "票据行程已识别,但费用明细说明未按“起始地-目的地”格式填写。"
elif expense_audit_points and issue_count == len(expense_audit_points):
summary = "OCR 金额已完成二次核算,请按票据原文总额复核。"
elif travel_policy_points and issue_count == len(travel_policy_points):
summary = "住宿票据已识别,但当前缺少职级或城市信息,无法完成差旅住宿标准核算。"
suggestion = {
"high": "建议过滤当前不匹配的票据,重新上传符合当前费用场景的清晰原件。",
"medium": "建议根据风险点补齐清晰票据,或修正金额、日期、费用说明后再提交。",
"low": "建议人工再次核对金额和业务说明,确认后可继续流转。",
}[severity]
return {
"severity": severity,
"label": label,
"headline": headline,
"summary": summary,
"points": points,
"suggestion": suggestion,
}
"high": "建议过滤当前不匹配的票据,重新上传符合当前费用场景的清晰原件。",
"medium": "建议根据风险点补齐清晰票据,或修正金额、日期、费用说明后再提交。",
"low": "建议人工再次核对金额和业务说明,确认后可继续流转。",
}[severity]
if travel_policy_high_risk:
suggestion = "请核对住宿发票金额、晚数和出差城市;如确需超标,需在附加说明中补充超标说明并提交审批重点复核。"
return {
"severity": severity,
"label": label,
"headline": headline,
"summary": summary,
"points": points,
"rule_basis": list(dict.fromkeys(travel_policy_rule_basis)),
"suggestion": suggestion,
}
@staticmethod
def _serialize_claim(claim: ExpenseClaim) -> dict[str, Any]:
@@ -4057,7 +4154,7 @@ class ExpenseClaimService:
if str(item.item_type or "").strip().lower() in SYSTEM_GENERATED_ITEM_TYPES:
raise ValueError("系统自动计算的费用明细不可手动修改。")
def _delete_submitted_claim_assistant_sessions(self, claim_id: str | None) -> None:
def _delete_claim_assistant_sessions(self, claim_id: str | None) -> None:
from app.services.agent_conversations import AgentConversationService
AgentConversationService(self.db).delete_conversations_for_draft_claim(
@@ -5193,25 +5290,26 @@ class ExpenseClaimService:
if grade_band is None:
continue
baseline_city = hotel_city or expected_destination_city
city_tier = policy.city_tiers.get(str(baseline_city or "").strip(), "tier_3")
cap = Decimal(policy.hotel_limits[grade_band][city_tier])
night_count = self._extract_hotel_night_count(context)
item_amount = Decimal(context["item"].item_amount or Decimal("0.00")).quantize(Decimal("0.01"))
nightly_amount = (item_amount / Decimal(max(night_count, 1))).quantize(Decimal("0.01"))
if nightly_amount <= cap:
continue
city_tier_label = {
"tier_1": "一线城市",
"tier_2": "重点城市",
"tier_3": "其他城市",
}.get(city_tier, "当前城市")
hotel_message = (
f"{band_label} 职级在{city_tier_label}的住宿标准为 {cap} 元/晚,"
f"当前酒店识别金额约 {nightly_amount} 元/晚"
)
baseline_city = hotel_city or expected_destination_city
standard = self._resolve_travel_policy_hotel_standard(
policy=policy,
grade_band=grade_band,
city=baseline_city,
)
if standard is None:
continue
cap, standard_label = standard
night_count = self._extract_hotel_night_count(context)
item_amount = Decimal(context["item"].item_amount or Decimal("0.00")).quantize(Decimal("0.01"))
nightly_amount = (item_amount / Decimal(max(night_count, 1))).quantize(Decimal("0.01"))
if nightly_amount <= cap:
continue
hotel_message = (
f"{band_label} 职级在{standard_label}的住宿标准为 {cap} 元/晚"
f"当前酒店识别金额约 {nightly_amount} 元/晚。"
)
item_reason = str(context["item"].item_reason or "").strip()
item_has_exception = self._text_contains_keywords(item_reason, policy.standard_exception_keywords)
if has_standard_exception or item_has_exception:
@@ -5411,9 +5509,9 @@ class ExpenseClaimService:
return None
return origin_city, destination_city
def _extract_hotel_city(self, context: dict[str, Any], policy: RuntimeTravelPolicy) -> str:
document_info = context["document_info"]
item = context["item"]
def _extract_hotel_city(self, context: dict[str, Any], policy: RuntimeTravelPolicy) -> str:
document_info = context["document_info"]
item = context["item"]
merchant_name = self._resolve_document_field_value(document_info, "merchant_name")
for candidate in (
merchant_name,
@@ -5423,18 +5521,51 @@ class ExpenseClaimService:
):
city = self._extract_city_from_text(candidate, policy)
if city:
return city
return ""
@staticmethod
def _extract_city_from_text(text: str, policy: RuntimeTravelPolicy) -> str:
normalized = str(text or "").strip()
if not normalized:
return ""
city_match_order = sorted(policy.city_tiers.keys(), key=lambda item: len(item), reverse=True)
for city in city_match_order:
if city in normalized:
return city
return city
return ""
@staticmethod
def _format_travel_policy_city_tier(city_tier: str) -> str:
return {
"tier_1": "一线城市",
"tier_2": "重点城市",
"tier_3": "其他城市",
}.get(str(city_tier or "").strip(), "当前城市")
def _resolve_travel_policy_hotel_standard(
self,
*,
policy: RuntimeTravelPolicy,
grade_band: str,
city: str,
) -> tuple[Decimal, str] | None:
normalized_city = str(city or "").strip()
city_limits = getattr(policy, "hotel_city_limits", {}) or {}
city_entry = city_limits.get(normalized_city) if normalized_city else None
if city_entry and city_entry.get(grade_band) is not None:
cap = Decimal(city_entry[grade_band]).quantize(Decimal("0.01"))
return cap, normalized_city
city_tier = (getattr(policy, "city_tiers", {}) or {}).get(normalized_city, "tier_3")
tier_entry = (getattr(policy, "hotel_limits", {}) or {}).get(grade_band, {})
tier_cap = tier_entry.get(city_tier)
if tier_cap is None:
return None
tier_label = self._format_travel_policy_city_tier(city_tier)
cap = Decimal(tier_cap).quantize(Decimal("0.01"))
return cap, tier_label
@staticmethod
def _extract_city_from_text(text: str, policy: RuntimeTravelPolicy) -> str:
normalized = str(text or "").strip()
if not normalized:
return ""
city_names = set(policy.city_tiers.keys())
city_names.update((getattr(policy, "hotel_city_limits", {}) or {}).keys())
city_match_order = sorted(city_names, key=lambda item: len(item), reverse=True)
for city in city_match_order:
if city in normalized:
return city
return ""
@staticmethod
@@ -5537,7 +5668,9 @@ class ExpenseClaimService:
return
try:
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
from app.services.travel_reimbursement_calculator import (
TravelReimbursementCalculatorService,
)
result = TravelReimbursementCalculatorService(self.db).calculate(
TravelReimbursementCalculatorRequest(
@@ -5609,6 +5742,14 @@ class ExpenseClaimService:
end_date = start_date
days = (end_date - start_date).days + 1
explicit_days = max(
(ExpenseClaimService._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 = ExpenseClaimService._extract_travel_allowance_days(existing_allowance)
unique_dates = {value for value in dated_items}
if existing_days > days and len(unique_dates) <= 1:
@@ -5757,12 +5898,13 @@ class ExpenseClaimService:
item=item,
document_info=document_info,
)
analysis = self._build_attachment_analysis(
document=document,
item=item,
document_info=document_info,
requirement_check=requirement_check,
)
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: