From 2908dda024206aca5bf5a3073fe6b00e4eb3dffe Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Thu, 21 May 2026 23:52:34 +0800 Subject: [PATCH] fix(reimbursement): harden assistant draft and claim cleanup --- server/src/app/schemas/reimbursement.py | 5 + server/src/app/services/expense_amounts.py | 206 ++++++ server/src/app/services/expense_claims.py | 690 +++++++++++------- server/src/app/services/orchestrator.py | 8 +- server/src/app/services/user_agent.py | 296 +++++--- server/tests/test_expense_claim_service.py | 187 ++++- server/tests/test_orchestrator_review_flow.py | 2 +- server/tests/test_reimbursement_endpoints.py | 5 + server/tests/test_user_agent_service.py | 59 +- 9 files changed, 1060 insertions(+), 398 deletions(-) create mode 100644 server/src/app/services/expense_amounts.py diff --git a/server/src/app/schemas/reimbursement.py b/server/src/app/schemas/reimbursement.py index 5d4ae22..afcb395 100644 --- a/server/src/app/schemas/reimbursement.py +++ b/server/src/app/schemas/reimbursement.py @@ -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 diff --git a/server/src/app/services/expense_amounts.py b/server/src/app/services/expense_amounts.py new file mode 100644 index 0000000..e544380 --- /dev/null +++ b/server/src/app/services/expense_amounts.py @@ -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"(? 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"(? 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 diff --git a/server/src/app/services/expense_claims.py b/server/src/app/services/expense_claims.py index 0e0fbb5..df7966c 100644 --- a/server/src/app/services/expense_claims.py +++ b/server/src/app/services/expense_claims.py @@ -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"(? 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"(? 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: diff --git a/server/src/app/services/orchestrator.py b/server/src/app/services/orchestrator.py index d1e0c62..6ccea21 100644 --- a/server/src/app/services/orchestrator.py +++ b/server/src/app/services/orchestrator.py @@ -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) diff --git a/server/src/app/services/user_agent.py b/server/src/app/services/user_agent.py index 80858ce..88c1276 100644 --- a/server/src/app/services/user_agent.py +++ b/server/src/app/services/user_agent.py @@ -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"(?= 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 diff --git a/server/tests/test_expense_claim_service.py b/server/tests/test_expense_claim_service.py index 5fcf596..15766fc 100644 --- a/server/tests/test_expense_claim_service.py +++ b/server/tests/test_expense_claim_service.py @@ -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", diff --git a/server/tests/test_orchestrator_review_flow.py b/server/tests/test_orchestrator_review_flow.py index dcf576f..53e1c88 100644 --- a/server/tests/test_orchestrator_review_flow.py +++ b/server/tests/test_orchestrator_review_flow.py @@ -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 == [] diff --git a/server/tests/test_reimbursement_endpoints.py b/server/tests/test_reimbursement_endpoints.py index b5138db..af5620e 100644 --- a/server/tests/test_reimbursement_endpoints.py +++ b/server/tests/test_reimbursement_endpoints.py @@ -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", diff --git a/server/tests/test_user_agent_service.py b/server/tests/test_user_agent_service.py index c22da2a..3f75d40 100644 --- a/server/tests/test_user_agent_service.py +++ b/server/tests/test_user_agent_service.py @@ -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