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

@@ -52,6 +52,7 @@ class ExpenseClaimAttachmentAnalysisRead(BaseModel):
headline: str
summary: str
points: list[str] = Field(default_factory=list)
rule_basis: list[str] = Field(default_factory=list)
suggestion: str = ""
@@ -195,6 +196,10 @@ class ExpenseClaimAttachmentActionResponse(BaseModel):
claim_id: str
item_id: str
invoice_id: str | None = None
item_date: date | None = None
item_type: str | None = None
item_reason: str | None = None
item_location: str | None = None
item_amount: Decimal | None = None
claim_amount: Decimal | None = None
attachment: ExpenseClaimAttachmentRead | None = None

View File

@@ -0,0 +1,206 @@
from __future__ import annotations
import re
from decimal import Decimal, InvalidOperation
from typing import Any
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_AMOUNT_FIELD_KEYS = {
"amount",
"totalamount",
"paymentamount",
"paidamount",
"actualamount",
}
DOCUMENT_AMOUNT_LABEL_TOKENS = (
"金额",
"价税合计",
"合计",
"总额",
"总计",
"票价",
"支付金额",
"实付金额",
"实收金额",
)
DOCUMENT_TEXT_AMOUNT_PATTERNS = (
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*元",
)
def resolve_document_item_amount(document: dict[str, Any]) -> Decimal | None:
text = " ".join(
[
str(document.get("summary") or "").strip(),
str(document.get("text") or "").strip(),
]
).strip()
field_amount = resolve_document_field_amount(document)
text_amount = resolve_document_text_amount(text)
if field_amount is not None:
if is_date_like_amount_candidate(field_amount, text):
return text_amount
return field_amount
return text_amount
def resolve_document_field_amount(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 DOCUMENT_AMOUNT_FIELD_KEYS or any(
token in label for token in DOCUMENT_AMOUNT_LABEL_TOKENS
)
if not is_amount_field:
continue
raw_value = str(field.get("value") or "")
value = parse_document_amount_value(raw_value) or parse_plain_document_amount_value(
raw_value
)
if value is not None:
return value
return None
def resolve_document_text_amount(text: str) -> Decimal | None:
candidates = [
candidate
for candidate in extract_amount_candidates(text)
if not is_date_like_amount_candidate(candidate, text)
]
if not candidates:
return None
return max(candidates)
def parse_document_amount_value(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
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
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")
)
def is_date_like_amount_candidate(amount: Decimal | None, text: str) -> bool:
if not 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 "")))
def format_decimal_amount(amount: Decimal | None) -> str:
if amount is None:
return ""
normalized = Decimal(amount).quantize(Decimal("0.01"))
return format(normalized, "f")
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 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 DOCUMENT_TEXT_AMOUNT_PATTERNS:
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
def is_amount_match_date_fragment(
amount: Decimal,
text: str,
start: int,
end: int,
) -> bool:
if start < 0 or end < 0 or not 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

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:

View File

@@ -664,10 +664,10 @@ class OrchestratorService:
),
"draft_only": True,
}
fallback_factory = lambda exc: {
"message": f"草稿生成暂时不可用,请稍后再试:{exc}",
"degraded": True,
}
fallback_factory = lambda exc: {
"message": f"内容整理暂时不可用,请稍后再试:{exc}",
"degraded": True,
}
if ontology.scenario == "expense" or self._is_expense_review_action(context_json):
is_persistence_action = self._is_expense_persistence_action(context_json)

View File

@@ -241,7 +241,7 @@ SYSTEM_GENERATED_REASON_PREFIXES = (
"请基于当前上传的多张票据",
"我已核对右侧识别结果",
"请同步修正逐票据识别结果",
"我已修改识别信息",
"我已校正核对信息",
"查看报销草稿",
"请解释一下当前这笔报销的合规风险和待补充项",
)
@@ -445,7 +445,7 @@ class UserAgentService:
return (
"可以帮你发起报销。请补充费用类型、发生时间、金额、事由和相关对象,"
"或者直接上传票据附件,我再继续帮你判断能否报、缺什么材料以及生成报销草稿"
"或者直接上传票据附件,我再继续帮你判断能否报、缺什么材料,并整理待核对信息"
f"{attachment_hint}"
)
@@ -473,7 +473,7 @@ class UserAgentService:
return (
f"已识别到一笔{time_text}{expense_type}支出{amount_hint}"
"如果要继续生成报销草稿,还需要补充客户单位、参与人员、费用明细和票据附件。"
"如果要继续整理报销核对信息,还需要补充客户单位、参与人员、费用明细和票据附件。"
"你也可以继续上传发票或图片,我会把这些信息带入后续对话。"
)
@@ -3283,22 +3283,6 @@ class UserAgentService:
claim_no = str(payload.tool_payload.get("association_candidate_claim_no") or "").strip()
link_label = f"关联到草稿 {claim_no}" if claim_no else "关联到现有草稿"
return [
UserAgentReviewAction(
label="取消",
action_type="cancel_review",
description="放弃当前识别结果,并退出本次核对流程。",
emphasis="secondary",
),
UserAgentReviewAction(
label="选择报销类型" if "expense_type" in missing_slot_keys else "修改识别信息",
action_type="edit_review",
description=(
"先选择本次报销类型,后续票据会作为当前单据的补充继续核对。"
if "expense_type" in missing_slot_keys
else "打开结构化模板,按已识别字段逐项修改。"
),
emphasis="secondary",
),
UserAgentReviewAction(
label=link_label,
action_type="link_to_existing_draft",
@@ -3321,15 +3305,9 @@ class UserAgentService:
if "expense_type" in missing_slot_keys and not review_action:
return [
UserAgentReviewAction(
label="取消",
action_type="cancel_review",
description="放弃当前识别结果,并退出本次核对流程",
emphasis="secondary",
),
UserAgentReviewAction(
label="选择报销类型",
action_type="edit_review",
description="先选择本次报销类型,后续票据会作为当前单据的补充继续核对。",
label="保存为草稿",
action_type="save_draft",
description="先暂存当前识别信息,稍后仍可从个人报销继续补充或提交",
emphasis="primary",
),
]
@@ -3349,24 +3327,7 @@ class UserAgentService:
if draft_payload is not None and draft_payload.claim_no and not can_proceed:
primary_action.description = f"保存后会生成草稿 {draft_payload.claim_no},后续仍可继续补充。"
actions = [
UserAgentReviewAction(
label="取消",
action_type="cancel_review",
description="放弃当前识别结果,并退出本次核对流程。",
emphasis="secondary",
),
UserAgentReviewAction(
label="选择报销类型" if "expense_type" in missing_slot_keys else "修改识别信息",
action_type="edit_review",
description=(
"先选择本次报销类型,后续票据会作为当前单据的补充继续核对。"
if "expense_type" in missing_slot_keys
else "打开结构化模板,按已识别字段逐项修改。"
),
emphasis="secondary",
),
]
actions = []
if can_proceed:
actions.append(
UserAgentReviewAction(
@@ -3433,16 +3394,11 @@ class UserAgentService:
review_action = str(payload.context_json.get("review_action") or "").strip()
if payload.tool_payload.get("preview_only") and not review_action:
base_message = review_payload.body_message or self._build_review_intent_summary(
return review_payload.body_message or self._build_review_intent_summary(
payload,
slot_cards=review_payload.slot_cards,
claim_groups=review_payload.claim_groups,
)
return (
f"{base_message} "
"本次只是核对预览,尚未保存为草稿;需要暂存时请点击“保存为草稿”,"
"需要正式提交时再点击“继续下一步”。"
)
if review_action == "save_draft":
if draft_payload is not None and draft_payload.claim_no:
return (
@@ -3488,11 +3444,6 @@ class UserAgentService:
f"{self._build_review_intent_summary(payload, slot_cards=review_payload.slot_cards, claim_groups=review_payload.claim_groups)} "
"当前关键信息已基本齐全,您确认无误后可以继续下一步。"
)
if review_action == "edit_review":
return (
f"{self._build_review_intent_summary(payload, slot_cards=review_payload.slot_cards, claim_groups=review_payload.claim_groups)} "
f"{self._build_review_guidance_copy(review_payload, mention_save_draft=True)}"
)
return review_payload.body_message or None
def _build_review_body_message(
@@ -3566,11 +3517,157 @@ class UserAgentService:
confirmation_actions=[],
edit_fields=[],
)
return (
f"{self._build_review_intent_summary(payload, slot_cards=slot_cards, claim_groups=[])} "
f"{self._build_review_guidance_copy(review_payload, mention_save_draft=not can_proceed)}"
return "\n\n".join(
item
for item in [
self._build_review_intent_summary(payload, slot_cards=slot_cards, claim_groups=[]),
self._build_review_standard_calculation_copy(payload, slot_cards),
self._build_review_guidance_copy(review_payload, mention_save_draft=not can_proceed),
]
if item
)
def _build_review_standard_calculation_copy(
self,
payload: UserAgentRequest,
slot_cards: list[UserAgentReviewSlotCard],
) -> str:
slots = {item.key: item for item in slot_cards}
expense_type = str(slots.get("expense_type").value if slots.get("expense_type") else "").strip()
if "差旅" in expense_type:
return self._build_review_travel_calculation_table(payload, slots)
if "交通" in expense_type:
return (
"报销测算参考:交通费通常以实际票据金额为基础,结合出行地点、业务事由和票据合规性复核;"
"如果它属于差旅行程的一部分,后续也会并入差旅费测算。"
)
if "住宿" in expense_type:
return (
"报销测算参考:住宿费通常按“实际住宿金额”和“目的地住宿标准 × 住宿天数”取合规口径;"
"补齐酒店票据后再核对是否超标。"
)
return (
"报销测算参考:先以用户填写金额或票据识别金额为基础,"
"再结合费用类型、发生地点、业务事由和规则中心限额进行复核。"
)
def _build_review_travel_calculation_table(
self,
payload: UserAgentRequest,
slots: dict[str, UserAgentReviewSlotCard],
) -> str:
destination = self._resolve_slot_text(slots, "location")
days = self._resolve_review_travel_days(payload, slots)
ticket_amount = self._resolve_slot_money(slots, "amount")
employee = self._resolve_employee_profile(payload)
grade = self._resolve_review_employee_grade(payload, employee=employee)
if not destination or not grade:
return "\n".join(
[
"报销测算参考:",
"",
"| 项目 | 当前信息 | 测算说明 |",
"| --- | --- | --- |",
f"| 出差地点 | {destination or '待确认'} | 用于匹配城市住宿标准和补贴区域 |",
f"| 出差天数 | {days} 天 | 来自业务发生时间或用户描述 |",
f"| 职级 | {grade or '待确认'} | 补齐后才能匹配住宿标准和补贴档位 |",
f"| 交通票据 | {self._format_decimal_money(ticket_amount)} 元 | 上传票据后会按真实金额重新复核 |",
]
)
current_user = CurrentUserContext(
username=str(payload.user_id or payload.context_json.get("name") or "anonymous").strip() or "anonymous",
name=str(payload.context_json.get("name") or payload.user_id or "anonymous").strip() or "anonymous",
role_codes=[
str(item).strip()
for item in list(payload.context_json.get("role_codes") or [])
if str(item).strip()
],
is_admin=bool(payload.context_json.get("is_admin")),
department_name=str(payload.context_json.get("department_name") or payload.context_json.get("department") or "").strip(),
)
try:
calculation = TravelReimbursementCalculatorService(self.db).calculate(
TravelReimbursementCalculatorRequest(days=days, location=destination, grade=grade),
current_user,
)
except Exception:
return "\n".join(
[
"报销测算参考:",
"",
"| 项目 | 当前信息 | 测算说明 |",
"| --- | --- | --- |",
f"| 出差地点 | {destination} | 暂时未能匹配规则中心地点 |",
f"| 出差天数 | {days} 天 | 来自业务发生时间或用户描述 |",
f"| 职级 | {grade} | 暂时无法自动匹配差旅标准 |",
f"| 交通票据 | {self._format_decimal_money(ticket_amount)} 元 | 上传票据后会按真实金额重新复核 |",
]
)
total_amount = (
ticket_amount
+ self._coerce_decimal_money(calculation.hotel_amount)
+ self._coerce_decimal_money(calculation.allowance_amount)
).quantize(Decimal("0.01"))
ticket_basis = "当前未上传交通票据,先按 0.00 元占位" if ticket_amount <= Decimal("0.00") else "已识别或填写的交通票据金额"
return "\n".join(
[
"报销测算参考:",
"",
"| 项目 | 测算口径 | 金额 |",
"| --- | --- | ---: |",
f"| 交通票据 | {ticket_basis} | {self._format_decimal_money(ticket_amount)} 元 |",
f"| 住宿标准 | {self._format_decimal_money(calculation.hotel_rate)} 元/天 × {calculation.days} 天 | {self._format_decimal_money(calculation.hotel_amount)} 元 |",
f"| 出差补贴 | {self._format_decimal_money(calculation.total_allowance_rate)} 元/天 × {calculation.days} 天 | {self._format_decimal_money(calculation.allowance_amount)} 元 |",
f"| 参考合计 | 交通票据 + 住宿标准 + 出差补贴 | {self._format_decimal_money(total_amount)} 元 |",
"",
(
f"测算依据:职级 {calculation.grade},目的地 {destination},匹配城市 {calculation.matched_city}"
"补齐交通、酒店等票据后,我会按真实票据金额和规则中心标准重新复核。"
),
]
)
@staticmethod
def _resolve_slot_text(slots: dict[str, UserAgentReviewSlotCard], key: str) -> str:
item = slots.get(key)
return str(getattr(item, "value", "") or getattr(item, "raw_value", "") or "").strip()
def _resolve_review_travel_days(
self,
payload: UserAgentRequest,
slots: dict[str, UserAgentReviewSlotCard],
) -> int:
text = " ".join(
[
str(payload.message or ""),
str(payload.context_json.get("user_input_text") or ""),
self._resolve_slot_text(slots, "reason"),
self._resolve_slot_text(slots, "time_range"),
]
)
explicit_match = re.search(r"(?<!\d)(\d{1,2})\s*天", text)
if explicit_match:
return max(1, int(explicit_match.group(1)))
dates = self._extract_dates_from_text(self._resolve_slot_text(slots, "time_range"))
if len(dates) >= 2:
return max(1, (max(dates).date() - min(dates).date()).days)
return 1
def _resolve_slot_money(
self,
slots: dict[str, UserAgentReviewSlotCard],
key: str,
) -> Decimal:
text = self._resolve_slot_text(slots, key).replace(",", "")
match = re.search(r"([0-9]+(?:\.[0-9]{1,2})?)", text)
if not match:
return Decimal("0.00")
return self._coerce_decimal_money(match.group(1))
@staticmethod
def _build_review_action_followup_copy(review_payload: UserAgentReviewPayload) -> str:
missing_slots = [str(item).strip() for item in review_payload.missing_slots if str(item).strip()]
@@ -3620,35 +3717,53 @@ class UserAgentService:
if str(item).strip()
]
lines = [
f"您好:{user_name},根据您提交的票据信息,您可能出差的地点为 {destination},天数为:{days} 天。",
f"根据票据,您现在提交的是{ticket_type_label}票,一共金额为:{self._format_decimal_money(ticket_amount)} 元。",
]
provide_items: list[str] = []
if required_labels:
provide_items.append("1. 酒店住宿发票/住宿清单(必须,当前待上传)")
if optional_labels:
provide_items.append(f"{len(provide_items) + 1}. 市内交通/乘车票据(非必须,如打车、地铁、停车等)")
sections = [
f"您好,{user_name}。我先按票据信息做一次差旅预检。",
"\n".join(
[
"已识别信息:",
f"1. 出差地点:{destination}",
f"2. 预计天数:{days}",
f"3. 票据类型:{ticket_type_label}",
f"4. 票据金额:{self._format_decimal_money(ticket_amount)}",
]
),
]
if provide_items:
lines.append("根据公司相关报销制度,您还可以继续提供\n" + "\n".join(provide_items))
sections.append("还需补充\n" + "\n".join(provide_items))
else:
lines.append("根据公司相关报销制度,当前核心票据已较完整,无需继续上传票据。")
sections.append("票据完整性:当前核心票据已较完整,无需继续上传票据。")
if required_labels:
lines.append("酒店票据仍缺失,所以暂时不能继续下一步;您可以先保存为草稿,补齐后再提交。")
sections.append(
"处理建议:酒店票据仍缺失,暂时不能继续下一步。"
"您可以先保存为草稿,补齐后再提交。"
)
elif can_proceed and optional_labels:
lines.append("当前必需票据已具备;如暂时没有乘车票据,也可以继续下一步,或先保存为草稿。")
sections.append(
"处理建议:必需票据已具备。"
"如暂时没有乘车票据,也可以继续下一步,或先保存为草稿。"
)
elif can_proceed:
lines.append("当前信息已较完整,确认无误后可以继续下一步,也可以先保存为草稿。")
sections.append(
"处理建议:当前信息已较完整,确认无误后可以继续下一步;"
"暂时不提交时,也可以先保存为草稿。"
)
estimate_copy = self._build_travel_receipt_estimate_copy(
payload,
travel_receipt_state=travel_receipt_state,
)
if estimate_copy:
lines.append(estimate_copy)
return "\n".join(line for line in lines if line)
sections.append(estimate_copy)
return "\n\n".join(section for section in sections if section)
def _build_travel_receipt_estimate_copy(
self,
@@ -3665,10 +3780,11 @@ class UserAgentService:
if not destination or not grade:
return (
"根据公司差旅费报销依据,"
f"您的职级{grade or '待确认'},去{destination or '出差地点待确认'}"
f"当前可确认的{ticket_type_label}票据金额为:{self._format_decimal_money(ticket_amount)} 元;"
"住宿和补贴金额需补齐职级或地点后再核算。"
"差旅费测算:\n"
f"1. 职级:{grade or '待确认'}\n"
f"2. 目的地:{destination or '出差地点待确认'}\n"
f"3. 已提交{ticket_type_label}{self._format_decimal_money(ticket_amount)}\n"
"4. 住宿和补贴金额:需补齐职级或地点后再核算。"
)
current_user = CurrentUserContext(
@@ -3689,9 +3805,11 @@ class UserAgentService:
)
except Exception:
return (
"根据公司差旅费报销依据,"
f"您的职级{grade},去{destination},当前可确认的{ticket_type_label}票据金额为:"
f"{self._format_decimal_money(ticket_amount)} 元;住宿和补贴标准暂时无法自动测算,请以规则中心最新差旅标准为准。"
"差旅费测算:\n"
f"1. 职级:{grade}\n"
f"2. 目的地:{destination}\n"
f"3. 已提交{ticket_type_label}{self._format_decimal_money(ticket_amount)}\n"
"4. 住宿和补贴标准:暂时无法自动测算,请以规则中心最新差旅标准为准。"
)
total_amount = (
@@ -3700,13 +3818,13 @@ class UserAgentService:
+ self._coerce_decimal_money(calculation.allowance_amount)
).quantize(Decimal("0.01"))
return (
"根据公司差旅费报销依据,"
f"您的职级{calculation.grade},去{calculation.matched_city or destination}"
"报销费用核算约为:"
f"已提交{ticket_type_label} {self._format_decimal_money(ticket_amount)} + "
f"住宿标准 {self._format_decimal_money(calculation.hotel_rate)} 元/天 × {calculation.days} + "
f"出差补贴 {self._format_decimal_money(calculation.total_allowance_rate)} 元/天 × {calculation.days} = "
f"{self._format_decimal_money(total_amount)}"
"差旅费测算:\n"
f"1. 职级:{calculation.grade}\n"
f"2. 目的地:{calculation.matched_city or destination}\n"
f"3. 已提交{ticket_type_label}{self._format_decimal_money(ticket_amount)}\n"
f"4. 住宿标准{self._format_decimal_money(calculation.hotel_rate)} 元/天 × {calculation.days}\n"
f"5. 出差补贴{self._format_decimal_money(calculation.total_allowance_rate)} 元/天 × {calculation.days}\n"
f"6. 参考合计:{self._format_decimal_money(total_amount)}"
)
@staticmethod
@@ -3739,7 +3857,7 @@ class UserAgentService:
if reminder_count:
return (
f"当前关键信息已基本齐全,但还有 {reminder_count} 条提醒。"
"您可以展开下方卡片查看详情,确认无误后继续下一步。"
"请核查对话中的文字说明,确认无误后继续下一步。"
)
return "当前关键信息已基本齐全,您确认无误后可以继续下一步。"
@@ -3750,10 +3868,10 @@ class UserAgentService:
issue_parts.append(f"{reminder_count} 条提醒")
issue_summary = "".join(issue_parts) if issue_parts else "一些细节还需要进一步确认"
suffix = ";如果想先暂存,也可以点击下方按钮保存草稿。" if mention_save_draft else ""
suffix = ";如果想先暂存,也可以点击对话文字中的“草稿" if mention_save_draft else ""
return (
f"当前还有 {issue_summary}"
f"您可以展开下方卡片查看详情,继续补充或修改{suffix}"
f"请核查对话中的文字说明{suffix}"
)
@staticmethod

View File

@@ -136,7 +136,7 @@ def test_save_or_submit_preview_does_not_create_claim_without_explicit_action()
assert result["preview_only"] is True
assert result["status"] == "preview"
assert "尚未保存为草稿" in result["message"]
assert "差旅费按“交通票据金额 + 住宿标准 × 出差天数 + 出差补贴 × 出差天数”估算" in result["message"]
assert _count_claims(db) == before_count
@@ -684,6 +684,62 @@ def test_upsert_travel_draft_uses_ticket_item_types_and_auto_allowance() -> None
)
def test_upsert_travel_draft_uses_explicit_text_days_for_allowance() -> None:
user_id = "travel-explicit-days@example.com"
message = "业务发生时间:2026-05-20 至 2026-05-23去上海支撑上海电力服务器部署出差3天申请差旅费报销"
with build_session() as db:
employee = Employee(
employee_no="E5012",
name="文本差旅员工",
email=user_id,
grade="P4",
)
db.add(employee)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id=user_id,
context_json={"name": "文本差旅员工", "grade": "P4"},
)
)
result = ExpenseClaimService(db).upsert_draft_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message=message,
ontology=ontology,
context_json={
"name": "文本差旅员工",
"grade": "P4",
"user_input_text": message,
"review_form_values": {
"expense_type": "差旅费",
"business_location": "上海",
"reason": "去上海支撑上海电力服务器部署出差3天",
"time_range": "2026-05-20 至 2026-05-23",
"business_time": "2026-05-20 至 2026-05-23",
},
"business_time_context": {
"mode": "range",
"start_date": "2026-05-20",
"end_date": "2026-05-23",
"display_value": "2026-05-20 至 2026-05-23",
},
},
)
claim = db.get(ExpenseClaim, result["claim_id"])
assert claim is not None
assert claim.expense_type == "travel"
allowance_item = next(item for item in claim.items if item.item_type == "travel_allowance")
assert allowance_item.item_amount == Decimal("300.00")
assert "3天" in allowance_item.item_reason
assert allowance_item.item_date == date(2026, 5, 22)
assert claim.amount == Decimal("300.00")
def test_sync_travel_claim_adds_allowance_from_manual_ticket_dates() -> None:
with build_session() as db:
employee = Employee(
@@ -1288,6 +1344,94 @@ def test_upload_hotel_attachment_audits_date_like_amount(monkeypatch, tmp_path)
assert not any("2026.00 元与报销金额" in point for point in uploaded_meta["analysis"]["points"])
def test_upload_hotel_attachment_flags_amount_over_travel_policy(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext(
username="emp-hotel-risk@example.com",
name="张三",
role_codes=[],
is_admin=False,
)
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
return OcrRecognizeBatchRead(
total_file_count=1,
success_count=1,
documents=[
OcrRecognizeDocumentRead(
filename="hotel-risk.png",
media_type="image/png",
text="北京全季酒店 住宿 1晚 金额800元 2026-05-13",
summary="北京全季酒店住宿发票,住宿 1 晚,金额 800 元。",
avg_score=0.98,
line_count=1,
page_count=1,
document_type="hotel_invoice",
document_type_label="酒店住宿票据",
scene_code="hotel",
scene_label="住宿票据",
document_fields=[
{"key": "merchant_name", "label": "商户", "value": "北京全季酒店"},
{"key": "amount", "label": "金额", "value": "800元"},
{"key": "date", "label": "日期", "value": "2026-05-13"},
],
)
],
)
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
monkeypatch.setattr(ExpenseClaimService, "_get_attachment_storage_root", lambda self: tmp_path)
with build_session() as db:
employee = Employee(
employee_no="E7401",
name="张三",
email="emp-hotel-risk@example.com",
grade="P4",
)
db.add(employee)
db.flush()
claim = build_claim(expense_type="travel", location="北京")
claim.employee = employee
claim.employee_id = employee.id
claim.reason = "北京客户现场出差"
claim.amount = Decimal("0.00")
claim.invoice_count = 0
claim.items[0].item_type = "hotel"
claim.items[0].item_reason = "北京住宿"
claim.items[0].item_location = "北京"
claim.items[0].item_amount = Decimal("0.00")
claim.items[0].invoice_id = None
db.add(claim)
db.commit()
service = ExpenseClaimService(db)
updated = service.upload_claim_item_attachment(
claim_id=claim.id,
item_id=claim.items[0].id,
filename="hotel-risk.png",
content=b"fake-image-bytes",
media_type="image/png",
current_user=current_user,
)
assert updated is not None
uploaded_meta = service.get_claim_item_attachment_meta(
claim_id=claim.id,
item_id=claim.items[0].id,
current_user=current_user,
)
assert uploaded_meta is not None
analysis = uploaded_meta["analysis"]
assert analysis["severity"] == "high"
assert analysis["headline"] == "AI提示住宿金额超出报销标准"
assert any("住宿标准" in point and "800.00 元" in point for point in analysis["points"])
assert any("住宿费按员工职级" in basis for basis in analysis["rule_basis"])
def test_attachment_analysis_does_not_compare_business_purpose_with_ticket_scene() -> None:
with build_session() as db:
claim = build_claim(expense_type="travel", location="上海")
@@ -1505,6 +1649,47 @@ def test_delete_claim_item_removes_row_and_attachment_files(monkeypatch, tmp_pat
assert not attachment_root.exists()
def test_delete_claim_removes_all_claim_attachment_files(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext(
username="emp-1",
name="张三",
role_codes=[],
is_admin=False,
)
monkeypatch.setattr(ExpenseClaimService, "_get_attachment_storage_root", lambda self: tmp_path)
with build_session() as db:
claim = build_claim(expense_type="office", location="深圳南山")
attachment_dir = tmp_path / claim.id / claim.items[0].id
attachment_dir.mkdir(parents=True)
attachment_path = attachment_dir / "office-note.png"
attachment_path.write_bytes(b"fake-image-bytes")
(attachment_dir / "office-note.png.meta.json").write_text("{}", encoding="utf-8")
orphan_path = tmp_path / claim.id / "orphan-preview.png"
orphan_path.write_bytes(b"orphan-preview")
claim.items[0].invoice_id = f"{claim.id}/{claim.items[0].id}/office-note.png"
db.add(claim)
db.commit()
conversation = AgentConversationService(db).get_or_create_conversation(
conversation_id=None,
user_id=current_user.username,
source="user_message",
context_json={
"session_type": "expense",
"draft_claim_id": claim.id,
},
)
claim_id = claim.id
claim_root = tmp_path / claim.id
deleted = ExpenseClaimService(db).delete_claim(claim_id, current_user)
assert deleted is not None
assert db.get(ExpenseClaim, claim_id) is None
assert not claim_root.exists()
assert AgentConversationService(db).get_conversation(conversation.conversation_id) is None
def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext(
username="emp-1",

View File

@@ -350,7 +350,7 @@ def test_orchestrator_expense_preview_does_not_persist_claim_before_user_action(
assert response.status == "succeeded"
assert response.result.get("review_payload") is not None
assert response.result.get("draft_payload") is None
assert "尚未保存为草稿" in response.result["answer"]
assert "交通费通常以实际票据金额为基础" in response.result["answer"]
assert user_claims == []

View File

@@ -158,6 +158,11 @@ def test_claim_item_attachment_upload_preview_and_delete(monkeypatch, tmp_path)
assert upload_payload["attachment"]["document_info"]["document_type"] == "office_invoice"
assert upload_payload["attachment"]["requirement_check"]["matches"] is True
assert upload_payload["invoice_id"]
assert upload_payload["item_type"] == "office"
assert upload_payload["item_reason"] == "识别到办公用品发票,金额 88 元。"
assert upload_payload["item_location"] == "深圳南山"
assert upload_payload["item_date"] == "2026-05-13"
assert upload_payload["item_amount"] == "88.00"
meta_response = client.get(
f"/api/v1/reimbursements/claims/{claim_id}/items/{item_id}/attachment/meta",

View File

@@ -554,6 +554,7 @@ def test_user_agent_continues_identification_after_expense_type_selection() -> N
query=f"{message}\n用户选择报销场景:差旅费",
user_id="pytest-selected-type@example.com",
context_json={
"grade": "P4",
"expense_scene_selection": {
"expense_type": "travel",
"expense_type_label": "差旅费",
@@ -573,6 +574,7 @@ def test_user_agent_continues_identification_after_expense_type_selection() -> N
message=f"{message}\n用户选择报销场景:差旅费",
ontology=ontology,
context_json={
"grade": "P4",
"expense_scene_selection": {
"expense_type": "travel",
"expense_type_label": "差旅费",
@@ -593,6 +595,11 @@ def test_user_agent_continues_identification_after_expense_type_selection() -> N
assert slot_map["expense_type"].normalized_value == "travel"
assert slot_map["time_range"].value == "2026-02-20 至 2026-02-23"
assert slot_map["location"].value == "上海"
assert "报销测算参考:" in response.answer
assert "| 项目 | 测算口径 | 金额 |" in response.answer
assert "| 住宿标准 |" in response.answer
assert "| 出差补贴 |" in response.answer
assert "| 参考合计 |" in response.answer
def test_user_agent_guides_implicit_expense_draft_request() -> None:
@@ -618,12 +625,10 @@ def test_user_agent_guides_implicit_expense_draft_request() -> None:
assert response.review_payload is not None
assert response.answer == response.review_payload.body_message
assert response.review_payload.intent_summary.startswith("识别到您希望报销一笔“业务招待费”费用。")
assert response.review_payload.missing_slots == ["客户名称", "参与人员", "票据附件"]
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
"cancel_review",
"edit_review",
"save_draft",
]
assert response.review_payload.missing_slots == ["客户名称", "参与人员", "票据附件"]
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
"save_draft",
]
slot_map = {item.key: item for item in response.review_payload.slot_cards}
assert slot_map["expense_type"].value == "业务招待费"
@@ -1016,12 +1021,10 @@ def test_user_agent_draft_returns_structured_payload() -> None:
assert response.draft_payload.confirmation_required is True
assert response.review_payload is not None
assert response.review_payload.can_proceed is False
assert response.review_payload.missing_slots == ["金额", "事由说明", "票据附件"]
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
"cancel_review",
"edit_review",
"save_draft",
]
assert response.review_payload.missing_slots == ["金额", "事由说明", "票据附件"]
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
"save_draft",
]
assert response.answer == response.review_payload.body_message
@@ -1156,12 +1159,10 @@ def test_user_agent_builds_review_payload_for_multi_document_expense_flow() -> N
assert response.review_payload is not None
assert len(response.review_payload.document_cards) == 2
assert len(response.review_payload.claim_groups) == 2
assert response.review_payload.missing_slots == ["参与人员"]
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
"cancel_review",
"edit_review",
"save_draft",
]
assert response.review_payload.missing_slots == ["参与人员"]
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
"save_draft",
]
assert any(item.scene_label == "业务招待费" for item in response.review_payload.document_cards)
assert f"时间为 {yesterday}" in response.review_payload.intent_summary
slot_map = {item.key: item for item in response.review_payload.slot_cards}
@@ -1577,9 +1578,11 @@ def test_user_agent_review_payload_does_not_fill_hotel_name_from_train_ticket()
assert "酒店住宿发票/住宿清单(必须,当前待上传)" in response.answer
assert "市内交通/乘车票据(非必须" in response.answer
assert "只能保存为草稿" in response.answer or "保存为草稿" in response.answer
assert "您的职级为P4" in response.answer
assert "去北京" in response.answer
assert "已提交火车 560.00 元" in response.answer
assert "已识别信息:" in response.answer
assert "酒店住宿发票/住宿清单" in response.answer
assert "职级P4" in response.answer
assert "目的地:北京" in response.answer
assert "已提交火车560.00 元" in response.answer
field_labels = [
field.label
for card in response.review_payload.document_cards
@@ -1658,7 +1661,7 @@ def test_user_agent_review_payload_allows_next_step_when_only_optional_ride_rece
assert "save_draft" in action_types
assert "next_step" in action_types
assert "市内交通/乘车票据(非必须" in response.answer
assert "也可以继续下一步" in response.answer
assert "继续下一步" in response.answer
def test_user_agent_review_payload_allows_next_step_after_required_travel_receipts_are_complete() -> None:
@@ -2065,11 +2068,9 @@ def test_user_agent_prompts_existing_draft_association_choice_for_multi_document
)
assert response.review_payload is not None
assert response.review_payload.can_proceed is False
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
"cancel_review",
"edit_review",
"link_to_existing_draft",
"create_new_claim_from_documents",
]
assert response.review_payload.can_proceed is False
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
"link_to_existing_draft",
"create_new_claim_from_documents",
]
assert "EXP-202605-008" in response.answer