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