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

@@ -15,7 +15,8 @@ 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 import and_, func, or_, select
from sqlalchemy import inspect as sqlalchemy_inspect
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, selectinload
@@ -38,6 +39,18 @@ 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,14 +281,6 @@ 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])日?)")
SYSTEM_GENERATED_REASON_PREFIXES = (
"我上传了",
@@ -733,6 +738,7 @@ class ExpenseClaimService:
attachment_analysis = self._build_attachment_analysis(
document=ocr_document,
item=item,
claim=claim,
document_info=document_info,
requirement_check=requirement_check,
)
@@ -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,7 +1080,7 @@ 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()
@@ -1083,6 +1092,7 @@ class ExpenseClaimService:
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)
return resolve_document_text_amount(text)
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 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_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:
shutil.rmtree((self._get_attachment_storage_root() / claim_id).resolve(), ignore_errors=True)
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:
@@ -2971,11 +2955,36 @@ class ExpenseClaimService:
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)
self._delete_attachment_path(file_path)
self._delete_attachment_path(self._attachment_meta_path(file_path))
return
shutil.rmtree(file_path.parent, ignore_errors=True)
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,
*,
@@ -3559,38 +3665,7 @@ class ExpenseClaimService:
@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
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:
@@ -3760,6 +3826,7 @@ class ExpenseClaimService:
*,
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]:
@@ -3792,6 +3859,23 @@ 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 "其他单据"
requirement_matches = bool(requirement_check.get("matches"))
@@ -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:
@@ -3859,6 +3944,7 @@ class ExpenseClaimService:
f"附件类型要求:{requirement_check.get('message')}",
f"金额字段:已识别到与当前明细接近的金额 {item_amount} 元。",
],
"rule_basis": travel_policy_rule_basis,
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。",
}
@@ -3867,7 +3953,12 @@ class ExpenseClaimService:
headline = "AI提示附件存在轻微待核对项"
summary = "当前附件已识别出部分票据要素,但仍建议人工继续复核。"
if (
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)
@@ -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,12 +3988,16 @@ 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]
if travel_policy_high_risk:
suggestion = "请核对住宿发票金额、晚数和出差城市;如确需超标,需在附加说明中补充超标说明并提交审批重点复核。"
return {
"severity": severity,
@@ -3909,6 +4005,7 @@ class ExpenseClaimService:
"headline": headline,
"summary": summary,
"points": points,
"rule_basis": list(dict.fromkeys(travel_policy_rule_basis)),
"suggestion": suggestion,
}
@@ -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(
@@ -5194,8 +5291,14 @@ class ExpenseClaimService:
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])
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"))
@@ -5203,13 +5306,8 @@ class ExpenseClaimService:
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"{band_label} 职级在{standard_label}的住宿标准为 {cap} 元/晚,"
f"当前酒店识别金额约 {nightly_amount} 元/晚。"
)
item_reason = str(context["item"].item_reason or "").strip()
@@ -5426,12 +5524,45 @@ class ExpenseClaimService:
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_match_order = sorted(policy.city_tiers.keys(), key=lambda item: len(item), reverse=True)
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
@@ -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:
@@ -5760,6 +5901,7 @@ class ExpenseClaimService:
analysis = self._build_attachment_analysis(
document=document,
item=item,
claim=getattr(item, "claim", None),
document_info=document_info,
requirement_check=requirement_check,
)

View File

@@ -665,7 +665,7 @@ class OrchestratorService:
"draft_only": True,
}
fallback_factory = lambda exc: {
"message": f"草稿生成暂时不可用,请稍后再试:{exc}",
"message": f"内容整理暂时不可用,请稍后再试:{exc}",
"degraded": True,
}

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:
@@ -620,8 +627,6 @@ def test_user_agent_guides_implicit_expense_draft_request() -> None:
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",
]
@@ -1018,8 +1023,6 @@ def test_user_agent_draft_returns_structured_payload() -> 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.answer == response.review_payload.body_message
@@ -1158,8 +1161,6 @@ def test_user_agent_builds_review_payload_for_multi_document_expense_flow() -> N
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 any(item.scene_label == "业务招待费" for item in response.review_payload.document_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:
@@ -2067,8 +2070,6 @@ 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",
]