fix(reimbursement): harden assistant draft and claim cleanup
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user