feat: 细化差旅票据费用明细分类并自动计算出差补贴
将差旅费用明细拆分为火车票、机票、住宿票、乘车等细分类 型,根据票据字段自动生成行程/事由描述,结合规则引擎自 动计算出差补贴金额,前端适配费用明细编辑和差旅票据审 核交互,补充单元测试覆盖。
This commit is contained in:
@@ -9,10 +9,12 @@ from typing import Any
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.core.agent_enums import AgentAssetStatus, AgentAssetType
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.schemas.agent_asset import AgentAssetListItem
|
||||
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
|
||||
from app.schemas.user_agent import (
|
||||
UserAgentCitation,
|
||||
UserAgentDraftPayload,
|
||||
@@ -37,6 +39,7 @@ from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService, RuntimeTravelPolicy, resolve_document_type_label
|
||||
from app.services.risk_ontology_bridge import resolve_rule_codes_for_risk_check
|
||||
from app.services.runtime_chat import RuntimeChatService
|
||||
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
||||
|
||||
SCENARIO_LABELS = {
|
||||
"expense": "报销",
|
||||
@@ -187,6 +190,7 @@ DOCUMENT_AMOUNT_PATTERN = re.compile(
|
||||
)
|
||||
DOCUMENT_CURRENCY_AMOUNT_PATTERN = re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)")
|
||||
TRAVEL_REVIEW_HOTEL_NIGHT_PATTERN = re.compile(r"(\d+)\s*(?:晚|间夜)")
|
||||
TRAVEL_ROUTE_PATTERN = re.compile(r"([\u4e00-\u9fa5]{2,12})\s*(?:至|→|->|-|—)\s*([\u4e00-\u9fa5]{2,12})")
|
||||
|
||||
SOURCE_LABELS = {
|
||||
"user_text": "用户描述",
|
||||
@@ -1900,6 +1904,11 @@ class UserAgentService:
|
||||
ocr_documents=ocr_documents,
|
||||
claim_groups=claim_groups,
|
||||
)
|
||||
travel_receipt_state = self._build_travel_receipt_state(
|
||||
payload,
|
||||
document_cards=document_cards,
|
||||
claim_groups=claim_groups,
|
||||
)
|
||||
missing_slot_keys = self._resolve_review_missing_slot_keys(
|
||||
payload,
|
||||
slot_cards=slot_cards,
|
||||
@@ -1911,10 +1920,11 @@ class UserAgentService:
|
||||
document_cards=document_cards,
|
||||
claim_groups=claim_groups,
|
||||
)
|
||||
risk_briefs.extend(self._build_travel_receipt_briefs(travel_receipt_state))
|
||||
association_choice_pending = self._is_review_association_choice_pending(payload)
|
||||
can_proceed = (
|
||||
False
|
||||
if association_choice_pending or submission_blocked
|
||||
if association_choice_pending or submission_blocked or travel_receipt_state.get("blocks_next_step")
|
||||
else self._can_proceed_review(
|
||||
payload,
|
||||
missing_slot_keys=missing_slot_keys,
|
||||
@@ -1943,7 +1953,15 @@ class UserAgentService:
|
||||
risk_briefs=risk_briefs,
|
||||
can_proceed=can_proceed,
|
||||
document_cards=document_cards,
|
||||
travel_receipt_state=travel_receipt_state,
|
||||
)
|
||||
missing_slot_labels = [SLOT_LABELS.get(key, key) for key in missing_slot_keys]
|
||||
missing_slot_labels.extend(
|
||||
str(item)
|
||||
for item in travel_receipt_state.get("required_missing_labels", [])
|
||||
if str(item).strip()
|
||||
)
|
||||
missing_slot_labels = list(dict.fromkeys(missing_slot_labels))
|
||||
|
||||
return UserAgentReviewPayload(
|
||||
intent_summary=intent_summary,
|
||||
@@ -1951,7 +1969,7 @@ class UserAgentService:
|
||||
scenario=payload.ontology.scenario,
|
||||
intent=payload.ontology.intent,
|
||||
can_proceed=can_proceed,
|
||||
missing_slots=[SLOT_LABELS.get(key, key) for key in missing_slot_keys],
|
||||
missing_slots=missing_slot_labels,
|
||||
risk_briefs=risk_briefs,
|
||||
slot_cards=slot_cards,
|
||||
document_cards=document_cards,
|
||||
@@ -2649,6 +2667,230 @@ class UserAgentService:
|
||||
return True
|
||||
return any(keyword in message_context for keyword in ("差旅", "出差", "机票", "火车", "高铁", "酒店", "住宿"))
|
||||
|
||||
def _build_travel_receipt_state(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
*,
|
||||
document_cards: list[UserAgentReviewDocumentCard],
|
||||
claim_groups: list[UserAgentReviewClaimGroup],
|
||||
) -> dict[str, Any]:
|
||||
empty_state: dict[str, Any] = {
|
||||
"is_travel_context": False,
|
||||
"has_long_distance_ticket": False,
|
||||
"ticket_type_label": "",
|
||||
"ticket_amount": Decimal("0.00"),
|
||||
"destination": "",
|
||||
"days": 1,
|
||||
"has_hotel_invoice": False,
|
||||
"has_local_transport": False,
|
||||
"required_missing_labels": [],
|
||||
"optional_missing_labels": [],
|
||||
"blocks_next_step": False,
|
||||
}
|
||||
if not document_cards or not self._is_travel_review_context(payload, document_cards, claim_groups):
|
||||
return empty_state
|
||||
|
||||
long_distance_cards = [card for card in document_cards if self._is_long_distance_travel_card(card)]
|
||||
if not long_distance_cards:
|
||||
return {
|
||||
**empty_state,
|
||||
"is_travel_context": True,
|
||||
}
|
||||
|
||||
has_hotel_invoice = any(self._is_review_hotel_card(card) for card in document_cards)
|
||||
has_local_transport = any(self._is_local_transport_receipt_card(card) for card in document_cards)
|
||||
required_missing_labels = [] if has_hotel_invoice else ["酒店的报销票据待上传(必须)"]
|
||||
optional_missing_labels = [] if has_local_transport else ["市内交通/乘车票据可继续上传(非必须)"]
|
||||
ticket_amount = sum(
|
||||
(self._extract_amount_decimal_from_card(card) or Decimal("0.00"))
|
||||
for card in long_distance_cards
|
||||
).quantize(Decimal("0.01"))
|
||||
|
||||
return {
|
||||
**empty_state,
|
||||
"is_travel_context": True,
|
||||
"has_long_distance_ticket": True,
|
||||
"ticket_type_label": self._resolve_travel_ticket_type_label(long_distance_cards),
|
||||
"ticket_amount": ticket_amount,
|
||||
"destination": self._resolve_travel_receipt_destination(payload, long_distance_cards),
|
||||
"days": self._resolve_travel_receipt_days(payload, long_distance_cards),
|
||||
"has_hotel_invoice": has_hotel_invoice,
|
||||
"has_local_transport": has_local_transport,
|
||||
"required_missing_labels": required_missing_labels,
|
||||
"optional_missing_labels": optional_missing_labels,
|
||||
"blocks_next_step": bool(required_missing_labels),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _is_long_distance_travel_card(card: UserAgentReviewDocumentCard) -> bool:
|
||||
document_type = str(card.document_type or "").strip().lower()
|
||||
return document_type in {"train_ticket", "flight_itinerary"}
|
||||
|
||||
@staticmethod
|
||||
def _is_local_transport_receipt_card(card: UserAgentReviewDocumentCard) -> bool:
|
||||
document_type = str(card.document_type or "").strip().lower()
|
||||
suggested_type = str(card.suggested_expense_type or "").strip().lower()
|
||||
return document_type in {"taxi_receipt", "parking_toll_receipt", "transport_receipt"} or (
|
||||
suggested_type == "transport" and document_type not in {"train_ticket", "flight_itinerary"}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_travel_ticket_type_label(cards: list[UserAgentReviewDocumentCard]) -> str:
|
||||
labels: list[str] = []
|
||||
for card in cards:
|
||||
document_type = str(card.document_type or "").strip().lower()
|
||||
if document_type == "train_ticket" and "火车" not in labels:
|
||||
labels.append("火车")
|
||||
if document_type == "flight_itinerary" and "飞机" not in labels:
|
||||
labels.append("飞机")
|
||||
return "/".join(labels) if labels else "交通"
|
||||
|
||||
def _resolve_travel_receipt_destination(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
long_distance_cards: list[UserAgentReviewDocumentCard],
|
||||
) -> str:
|
||||
for card in long_distance_cards:
|
||||
for field in card.fields:
|
||||
if str(field.label or "").strip() not in {"行程", "路线"}:
|
||||
continue
|
||||
destination = self._extract_travel_destination_from_route(field.value)
|
||||
if destination:
|
||||
return self._normalize_travel_destination(destination)
|
||||
|
||||
card_text = self._build_review_document_card_text(card)
|
||||
route_match = TRAVEL_ROUTE_PATTERN.search(card_text)
|
||||
if route_match:
|
||||
return self._normalize_travel_destination(route_match.group(2))
|
||||
|
||||
location = self._resolve_location_value(payload)
|
||||
if location:
|
||||
return self._normalize_travel_destination(location)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _extract_travel_destination_from_route(value: str) -> str:
|
||||
route_text = str(value or "").strip()
|
||||
if not route_text:
|
||||
return ""
|
||||
route_match = TRAVEL_ROUTE_PATTERN.search(route_text)
|
||||
if route_match:
|
||||
return route_match.group(2).strip()
|
||||
parts = [
|
||||
item.strip()
|
||||
for item in re.split(r"\s*(?:至|到|→|->|-|—|~|~)\s*", route_text)
|
||||
if item.strip()
|
||||
]
|
||||
return parts[-1] if len(parts) >= 2 else ""
|
||||
|
||||
def _normalize_travel_destination(self, value: str) -> str:
|
||||
candidate = re.sub(
|
||||
r"(?:火车站|高铁站|动车站|车站|站|机场|航站楼)$",
|
||||
"",
|
||||
str(value or "").strip(),
|
||||
)
|
||||
if not candidate:
|
||||
return ""
|
||||
try:
|
||||
policy = ExpenseRuleRuntimeService(self.db).load_catalog().travel_policy
|
||||
except Exception:
|
||||
policy = None
|
||||
if policy is not None:
|
||||
policy_city = self._extract_policy_city_from_text(candidate, policy)
|
||||
if policy_city:
|
||||
return policy_city
|
||||
return candidate
|
||||
|
||||
def _resolve_travel_receipt_days(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
long_distance_cards: list[UserAgentReviewDocumentCard],
|
||||
) -> int:
|
||||
dates: list[datetime] = []
|
||||
for card in long_distance_cards:
|
||||
card_text = self._build_review_document_card_text(card)
|
||||
dates.extend(self._extract_dates_from_text(card_text))
|
||||
|
||||
if dates:
|
||||
return max(1, (max(dates).date() - min(dates).date()).days + 1)
|
||||
|
||||
start_date = self._parse_date_text(payload.ontology.time_range.start_date or "")
|
||||
end_date = self._parse_date_text(payload.ontology.time_range.end_date or "")
|
||||
if start_date and end_date:
|
||||
return max(1, (end_date.date() - start_date.date()).days + 1)
|
||||
return 1
|
||||
|
||||
@staticmethod
|
||||
def _extract_dates_from_text(text: str) -> list[datetime]:
|
||||
dates: list[datetime] = []
|
||||
for match in DATE_TEXT_PATTERN.finditer(str(text or "")):
|
||||
parsed = UserAgentService._parse_date_text(match.group(1))
|
||||
if parsed is not None:
|
||||
dates.append(parsed)
|
||||
return dates
|
||||
|
||||
@staticmethod
|
||||
def _parse_date_text(value: str) -> datetime | None:
|
||||
raw_value = str(value or "").strip()
|
||||
if not raw_value:
|
||||
return None
|
||||
normalized = (
|
||||
raw_value.replace("年", "-")
|
||||
.replace("月", "-")
|
||||
.replace("/", "-")
|
||||
.replace("日", "")
|
||||
.strip()
|
||||
)
|
||||
parts = [part for part in normalized.split("-") if part]
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
try:
|
||||
year, month, day = (int(part) for part in parts)
|
||||
return datetime(year, month, day)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def _build_travel_receipt_briefs(
|
||||
self,
|
||||
travel_receipt_state: dict[str, Any],
|
||||
) -> list[UserAgentReviewRiskBrief]:
|
||||
if not travel_receipt_state.get("has_long_distance_ticket"):
|
||||
return []
|
||||
|
||||
required_labels = [
|
||||
str(item).strip()
|
||||
for item in travel_receipt_state.get("required_missing_labels", [])
|
||||
if str(item).strip()
|
||||
]
|
||||
optional_labels = [
|
||||
str(item).strip()
|
||||
for item in travel_receipt_state.get("optional_missing_labels", [])
|
||||
if str(item).strip()
|
||||
]
|
||||
if not required_labels and not optional_labels:
|
||||
return []
|
||||
|
||||
content_parts = [*required_labels, *optional_labels]
|
||||
required_text = ";".join(required_labels)
|
||||
optional_text = ";".join(optional_labels)
|
||||
return [
|
||||
UserAgentReviewRiskBrief(
|
||||
title="差旅票据待补充",
|
||||
level="warning" if required_labels else "info",
|
||||
content=";".join(content_parts),
|
||||
detail=(
|
||||
"系统已识别到长途交通票据,会按差旅报销口径核对住宿、交通等票据完整性。"
|
||||
+ (f"当前必须补充:{required_text}。" if required_text else "")
|
||||
+ (f"当前还可以补充:{optional_text}。" if optional_text else "")
|
||||
),
|
||||
suggestion=(
|
||||
"请先补充酒店住宿发票或住宿清单;在补齐前只能保存为草稿。"
|
||||
if required_labels
|
||||
else "如还有市内交通、打车、地铁或停车等乘车票据,可以继续上传;没有也可以进入下一步或保存草稿。"
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
def _resolve_review_travel_allowance_standard(
|
||||
self,
|
||||
policy: RuntimeTravelPolicy,
|
||||
@@ -3008,7 +3250,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},后续仍可继续补充。"
|
||||
|
||||
return [
|
||||
actions = [
|
||||
UserAgentReviewAction(
|
||||
label="取消",
|
||||
action_type="cancel_review",
|
||||
@@ -3021,8 +3263,18 @@ class UserAgentService:
|
||||
description="打开结构化模板,按已识别字段逐项修改。",
|
||||
emphasis="secondary",
|
||||
),
|
||||
primary_action,
|
||||
]
|
||||
if can_proceed:
|
||||
actions.append(
|
||||
UserAgentReviewAction(
|
||||
label="保存为草稿",
|
||||
action_type="save_draft",
|
||||
description="先暂存当前已识别信息,稍后仍可从个人报销继续补充或提交。",
|
||||
emphasis="secondary",
|
||||
)
|
||||
)
|
||||
actions.append(primary_action)
|
||||
return actions
|
||||
|
||||
def _build_review_intent_summary(
|
||||
self,
|
||||
@@ -3086,20 +3338,22 @@ class UserAgentService:
|
||||
return "已按您当前确认的信息保存为草稿。后续您可以继续补充缺失项,或修改识别结果后再继续提交。"
|
||||
if review_action == "link_to_existing_draft":
|
||||
document_count = self._resolve_review_document_count(payload)
|
||||
followup_copy = self._build_review_action_followup_copy(review_payload)
|
||||
if draft_payload is not None and draft_payload.claim_no:
|
||||
return (
|
||||
f"已将本次上传的 {document_count} 张票据关联到草稿 {draft_payload.claim_no}。"
|
||||
"您可以继续补充识别字段,确认无误后再提交审批。"
|
||||
f"{followup_copy or '您可以继续补充识别字段,确认无误后再提交审批。'}"
|
||||
)
|
||||
return "已将本次上传的票据关联到现有草稿。您可以继续补充识别字段,确认无误后再提交审批。"
|
||||
return f"已将本次上传的票据关联到现有草稿。{followup_copy or '您可以继续补充识别字段,确认无误后再提交审批。'}"
|
||||
if review_action == "create_new_claim_from_documents":
|
||||
document_count = self._resolve_review_document_count(payload)
|
||||
followup_copy = self._build_review_action_followup_copy(review_payload)
|
||||
if draft_payload is not None and draft_payload.claim_no:
|
||||
return (
|
||||
f"已按当前上传的 {document_count} 张票据新建报销草稿 {draft_payload.claim_no}。"
|
||||
"您可以继续补充识别字段,确认无误后再提交审批。"
|
||||
f"{followup_copy or '您可以继续补充识别字段,确认无误后再提交审批。'}"
|
||||
)
|
||||
return "已按当前上传票据新建报销草稿。您可以继续补充识别字段,确认无误后再提交审批。"
|
||||
return f"已按当前上传票据新建报销草稿。{followup_copy or '您可以继续补充识别字段,确认无误后再提交审批。'}"
|
||||
if review_action == "next_step":
|
||||
if draft_payload is not None and draft_payload.status == "submitted":
|
||||
stage_text = draft_payload.approval_stage or "审批中"
|
||||
@@ -3135,6 +3389,7 @@ class UserAgentService:
|
||||
risk_briefs: list[UserAgentReviewRiskBrief],
|
||||
can_proceed: bool,
|
||||
document_cards: list[UserAgentReviewDocumentCard],
|
||||
travel_receipt_state: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
if self._is_review_association_choice_pending(payload):
|
||||
claim_no = str(payload.tool_payload.get("association_candidate_claim_no") or "").strip()
|
||||
@@ -3157,13 +3412,30 @@ class UserAgentService:
|
||||
"请先根据风险提示补充原因、调整金额或更换附件,整改后再继续提交。"
|
||||
)
|
||||
|
||||
travel_message = self._build_travel_receipt_guidance_message(
|
||||
payload,
|
||||
travel_receipt_state=travel_receipt_state or {},
|
||||
can_proceed=can_proceed,
|
||||
)
|
||||
if travel_message:
|
||||
return travel_message
|
||||
|
||||
missing_labels = self._resolve_review_missing_slot_labels(slot_cards)
|
||||
if travel_receipt_state:
|
||||
missing_labels.extend(
|
||||
str(item)
|
||||
for item in travel_receipt_state.get("required_missing_labels", [])
|
||||
if str(item).strip()
|
||||
)
|
||||
missing_labels = list(dict.fromkeys(missing_labels))
|
||||
|
||||
review_payload = UserAgentReviewPayload(
|
||||
intent_summary="",
|
||||
body_message="",
|
||||
scenario=payload.ontology.scenario,
|
||||
intent=payload.ontology.intent,
|
||||
can_proceed=can_proceed,
|
||||
missing_slots=self._resolve_review_missing_slot_labels(slot_cards),
|
||||
missing_slots=missing_labels,
|
||||
risk_briefs=risk_briefs,
|
||||
slot_cards=slot_cards,
|
||||
document_cards=[],
|
||||
@@ -3176,6 +3448,155 @@ class UserAgentService:
|
||||
f"{self._build_review_guidance_copy(review_payload, mention_save_draft=not can_proceed)}"
|
||||
)
|
||||
|
||||
@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()]
|
||||
receipt_briefs = [
|
||||
item
|
||||
for item in review_payload.risk_briefs
|
||||
if "差旅票据待补充" in str(item.title or "")
|
||||
]
|
||||
if missing_slots:
|
||||
return f"当前仍有 {'、'.join(missing_slots)},暂时只能保存为草稿,补齐后再继续下一步。"
|
||||
if receipt_briefs:
|
||||
return "当前必需票据已具备;如还有市内交通、打车、地铁或停车等乘车票据,可以继续上传,也可以继续下一步或保存草稿。"
|
||||
if review_payload.can_proceed:
|
||||
return "当前信息已较完整,您可以继续下一步,也可以先保存为草稿。"
|
||||
return ""
|
||||
|
||||
def _build_travel_receipt_guidance_message(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
*,
|
||||
travel_receipt_state: dict[str, Any],
|
||||
can_proceed: bool,
|
||||
) -> str:
|
||||
review_action = str(payload.context_json.get("review_action") or "").strip()
|
||||
if review_action or not travel_receipt_state.get("has_long_distance_ticket"):
|
||||
return ""
|
||||
|
||||
employee = self._resolve_employee_profile(payload)
|
||||
user_name = (
|
||||
str(employee.name).strip()
|
||||
if employee is not None and employee.name
|
||||
else str(payload.context_json.get("name") or payload.user_id or "同事").strip()
|
||||
)
|
||||
destination = str(travel_receipt_state.get("destination") or "待确认").strip()
|
||||
days = max(1, int(travel_receipt_state.get("days") or 1))
|
||||
ticket_type_label = str(travel_receipt_state.get("ticket_type_label") or "交通").strip()
|
||||
ticket_amount = self._coerce_decimal_money(travel_receipt_state.get("ticket_amount"))
|
||||
|
||||
required_labels = [
|
||||
str(item).strip()
|
||||
for item in travel_receipt_state.get("required_missing_labels", [])
|
||||
if str(item).strip()
|
||||
]
|
||||
optional_labels = [
|
||||
str(item).strip()
|
||||
for item in travel_receipt_state.get("optional_missing_labels", [])
|
||||
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}. 市内交通/乘车票据(非必须,如打车、地铁、停车等)")
|
||||
if provide_items:
|
||||
lines.append("根据公司相关报销制度,您还可以继续提供:\n" + "\n".join(provide_items))
|
||||
else:
|
||||
lines.append("根据公司相关报销制度,当前核心票据已较完整,无需继续上传票据。")
|
||||
|
||||
if required_labels:
|
||||
lines.append("酒店票据仍缺失,所以暂时不能继续下一步;您可以先保存为草稿,补齐后再提交。")
|
||||
elif can_proceed and optional_labels:
|
||||
lines.append("当前必需票据已具备;如暂时没有乘车票据,也可以继续下一步,或先保存为草稿。")
|
||||
elif can_proceed:
|
||||
lines.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)
|
||||
|
||||
def _build_travel_receipt_estimate_copy(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
*,
|
||||
travel_receipt_state: dict[str, Any],
|
||||
) -> str:
|
||||
destination = str(travel_receipt_state.get("destination") or "").strip()
|
||||
days = max(1, int(travel_receipt_state.get("days") or 1))
|
||||
ticket_type_label = str(travel_receipt_state.get("ticket_type_label") or "交通").strip()
|
||||
ticket_amount = self._coerce_decimal_money(travel_receipt_state.get("ticket_amount"))
|
||||
employee = self._resolve_employee_profile(payload)
|
||||
grade = self._resolve_review_employee_grade(payload, employee=employee)
|
||||
|
||||
if not destination or not grade:
|
||||
return (
|
||||
"根据公司差旅费报销依据,"
|
||||
f"您的职级为:{grade or '待确认'},去{destination or '出差地点待确认'},"
|
||||
f"当前可确认的{ticket_type_label}票据金额为:{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 (
|
||||
"根据公司差旅费报销依据,"
|
||||
f"您的职级为:{grade},去{destination},当前可确认的{ticket_type_label}票据金额为:"
|
||||
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"))
|
||||
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)} 元。"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _coerce_decimal_money(value: Any) -> Decimal:
|
||||
try:
|
||||
return Decimal(str(value or "0")).quantize(Decimal("0.01"))
|
||||
except (InvalidOperation, ValueError):
|
||||
return Decimal("0.00")
|
||||
|
||||
@staticmethod
|
||||
def _format_decimal_money(value: Any) -> str:
|
||||
return f"{UserAgentService._coerce_decimal_money(value):.2f}"
|
||||
|
||||
@staticmethod
|
||||
def _resolve_review_missing_slot_labels(
|
||||
slot_cards: list[UserAgentReviewSlotCard],
|
||||
@@ -4076,16 +4497,11 @@ class UserAgentService:
|
||||
|
||||
merchant_value = ""
|
||||
for document in ocr_documents:
|
||||
if str(document.get("document_type") or "").strip().lower() != "hotel_invoice":
|
||||
if not self._is_hotel_document_item(document):
|
||||
continue
|
||||
merchant_value = self._extract_document_merchant_name(document)
|
||||
if merchant_value:
|
||||
break
|
||||
if not merchant_value:
|
||||
for document in ocr_documents:
|
||||
merchant_value = self._extract_document_merchant_name(document)
|
||||
if merchant_value:
|
||||
break
|
||||
if merchant_value:
|
||||
return self._build_slot_value(
|
||||
value=merchant_value,
|
||||
@@ -4407,6 +4823,8 @@ class UserAgentService:
|
||||
label=display_label,
|
||||
value=value,
|
||||
)
|
||||
if display_label == "商户/酒店" and not self._is_hotel_document_item(item):
|
||||
continue
|
||||
if display_label and normalized_value:
|
||||
normalized_fields.setdefault(display_label, normalized_value)
|
||||
|
||||
@@ -4418,7 +4836,7 @@ class UserAgentService:
|
||||
if date_match and "时间" not in normalized_fields:
|
||||
normalized_fields["时间"] = date_match.group(1)
|
||||
|
||||
merchant = self._extract_document_merchant_name_from_text(text)
|
||||
merchant = self._extract_document_merchant_name_from_text(text) if self._is_hotel_document_item(item) else ""
|
||||
if merchant and "商户/酒店" not in normalized_fields:
|
||||
normalized_fields["商户/酒店"] = merchant
|
||||
return normalized_fields
|
||||
@@ -4484,9 +4902,25 @@ class UserAgentService:
|
||||
merchant = str(fields.get("商户/酒店") or "").strip()
|
||||
if merchant:
|
||||
return merchant
|
||||
if not self._is_hotel_document_item(item):
|
||||
return ""
|
||||
text = " ".join([str(item.get("summary") or ""), str(item.get("text") or "")]).strip()
|
||||
return self._extract_document_merchant_name_from_text(text)
|
||||
|
||||
@staticmethod
|
||||
def _is_hotel_document_item(item: dict[str, object]) -> bool:
|
||||
document_type = str(item.get("document_type") or "").strip().lower()
|
||||
scene_code = str(item.get("scene_code") or "").strip().lower()
|
||||
scene_label = str(item.get("scene_label") or "").strip()
|
||||
suggested_expense_type = str(item.get("suggested_expense_type") or "").strip().lower()
|
||||
return (
|
||||
document_type == "hotel_invoice"
|
||||
or scene_code == "hotel"
|
||||
or suggested_expense_type == "hotel"
|
||||
or "住宿" in scene_label
|
||||
or "酒店" in scene_label
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_document_merchant_name_from_text(text: str) -> str:
|
||||
for keyword in ("酒店", "宾馆", "饭店", "酒楼", "餐厅", "航空", "铁路", "滴滴"):
|
||||
|
||||
Reference in New Issue
Block a user