674 lines
30 KiB
Python
674 lines
30 KiB
Python
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
import re
|
|||
|
|
from datetime import UTC, datetime, timedelta
|
|||
|
|
from decimal import Decimal, InvalidOperation
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
from sqlalchemy import or_, select
|
|||
|
|
from sqlalchemy.orm import 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,
|
|||
|
|
UserAgentExpenseQueryRecord,
|
|||
|
|
UserAgentQueryPayload,
|
|||
|
|
UserAgentQueryStatusGroup,
|
|||
|
|
UserAgentReviewAction,
|
|||
|
|
UserAgentReviewClaimGroup,
|
|||
|
|
UserAgentReviewDocumentCard,
|
|||
|
|
UserAgentReviewDocumentField,
|
|||
|
|
UserAgentReviewEditField,
|
|||
|
|
UserAgentReviewPayload,
|
|||
|
|
UserAgentReviewRiskBrief,
|
|||
|
|
UserAgentReviewSlotCard,
|
|||
|
|
UserAgentRequest,
|
|||
|
|
UserAgentSuggestedAction,
|
|||
|
|
)
|
|||
|
|
from app.services.agent_assets import AgentAssetService
|
|||
|
|
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.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
|||
|
|
from app.services.user_agent_constants import *
|
|||
|
|
|
|||
|
|
|
|||
|
|
class UserAgentReviewMessageMixin:
|
|||
|
|
|
|||
|
|
def _build_review_confirmation_actions(
|
|||
|
|
self,
|
|||
|
|
payload: UserAgentRequest,
|
|||
|
|
*,
|
|||
|
|
can_proceed: bool,
|
|||
|
|
claim_groups: list[UserAgentReviewClaimGroup],
|
|||
|
|
draft_payload: UserAgentDraftPayload | None,
|
|||
|
|
missing_slot_keys: set[str] | None = None,
|
|||
|
|
) -> list[UserAgentReviewAction]:
|
|||
|
|
missing_slot_keys = set(missing_slot_keys or set())
|
|||
|
|
if self._is_review_association_choice_pending(payload):
|
|||
|
|
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=link_label,
|
|||
|
|
action_type="link_to_existing_draft",
|
|||
|
|
description=(
|
|||
|
|
f"把本次上传票据并入现有草稿 {claim_no}。"
|
|||
|
|
if claim_no
|
|||
|
|
else "把本次上传票据并入现有草稿。"
|
|||
|
|
),
|
|||
|
|
emphasis="primary",
|
|||
|
|
),
|
|||
|
|
UserAgentReviewAction(
|
|||
|
|
label="单独建立报销单",
|
|||
|
|
action_type="create_new_claim_from_documents",
|
|||
|
|
description="基于当前上传的多张票据,新建一张独立的报销草稿。",
|
|||
|
|
emphasis="secondary",
|
|||
|
|
),
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
review_action = str(payload.context_json.get("review_action") or "").strip()
|
|||
|
|
if "expense_type" in missing_slot_keys and not review_action:
|
|||
|
|
return [
|
|||
|
|
UserAgentReviewAction(
|
|||
|
|
label="保存为草稿",
|
|||
|
|
action_type="save_draft",
|
|||
|
|
description="先暂存当前已识别信息,稍后仍可从个人报销继续补充或提交。",
|
|||
|
|
emphasis="primary",
|
|||
|
|
),
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
primary_action = UserAgentReviewAction(
|
|||
|
|
label="继续下一步" if can_proceed else "保存为草稿",
|
|||
|
|
action_type="next_step" if can_proceed else "save_draft",
|
|||
|
|
description=(
|
|||
|
|
"当前识别信息已满足继续处理条件,确认后进入下一步。"
|
|||
|
|
if can_proceed
|
|||
|
|
else "暂存当前识别结果,后续可以继续补充或修改。"
|
|||
|
|
),
|
|||
|
|
emphasis="primary",
|
|||
|
|
)
|
|||
|
|
if len(claim_groups) > 1 and can_proceed:
|
|||
|
|
primary_action.description = f"系统建议拆分为 {len(claim_groups)} 张报销单,确认后继续下一步。"
|
|||
|
|
if draft_payload is not None and draft_payload.claim_no and not can_proceed:
|
|||
|
|
primary_action.description = f"保存后会生成草稿 {draft_payload.claim_no},后续仍可继续补充。"
|
|||
|
|
|
|||
|
|
actions = []
|
|||
|
|
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,
|
|||
|
|
payload: UserAgentRequest,
|
|||
|
|
*,
|
|||
|
|
slot_cards: list[UserAgentReviewSlotCard],
|
|||
|
|
claim_groups: list[UserAgentReviewClaimGroup],
|
|||
|
|
) -> str:
|
|||
|
|
slots = {item.key: item for item in slot_cards}
|
|||
|
|
expense_type = slots.get("expense_type")
|
|||
|
|
amount = slots.get("amount")
|
|||
|
|
time_range = slots.get("time_range")
|
|||
|
|
location = slots.get("location")
|
|||
|
|
customer = slots.get("customer_name")
|
|||
|
|
|
|||
|
|
summary = "我先根据您当前提供的信息整理出一笔报销:"
|
|||
|
|
if expense_type and expense_type.value:
|
|||
|
|
summary = f"识别到您希望报销一笔“{expense_type.value}”费用:"
|
|||
|
|
details: list[str] = []
|
|||
|
|
if customer and customer.value:
|
|||
|
|
details.append(f"客户:{customer.value}")
|
|||
|
|
if time_range and time_range.value:
|
|||
|
|
details.append(f"时间:{time_range.value}")
|
|||
|
|
if location and location.value:
|
|||
|
|
details.append(f"地点:{location.value}")
|
|||
|
|
if amount and amount.value:
|
|||
|
|
details.append(f"金额:{amount.value}")
|
|||
|
|
reason = slots.get("reason")
|
|||
|
|
if reason and reason.value:
|
|||
|
|
details.append(f"事由:{reason.value}")
|
|||
|
|
if details:
|
|||
|
|
return "\n\n".join([summary, "基础信息识别结果:", "\n".join(details)])
|
|||
|
|
return summary
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _build_review_body_answer(
|
|||
|
|
self,
|
|||
|
|
payload: UserAgentRequest,
|
|||
|
|
*,
|
|||
|
|
review_payload: UserAgentReviewPayload | None,
|
|||
|
|
draft_payload: UserAgentDraftPayload | None,
|
|||
|
|
) -> str | None:
|
|||
|
|
if review_payload is None:
|
|||
|
|
return None
|
|||
|
|
if payload.ontology.scenario != "expense":
|
|||
|
|
return None
|
|||
|
|
if payload.ontology.intent not in {"draft", "operate"}:
|
|||
|
|
return None
|
|||
|
|
if payload.tool_payload.get("draft_limit_reached"):
|
|||
|
|
return (
|
|||
|
|
str(payload.tool_payload.get("message") or "").strip()
|
|||
|
|
or "你当前已保存 3 个草稿,请先完成已保存的草稿,才能再次新建草稿。"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
review_action = str(payload.context_json.get("review_action") or "").strip()
|
|||
|
|
if payload.tool_payload.get("preview_only") and not review_action:
|
|||
|
|
return review_payload.body_message or self._build_review_intent_summary(
|
|||
|
|
payload,
|
|||
|
|
slot_cards=review_payload.slot_cards,
|
|||
|
|
claim_groups=review_payload.claim_groups,
|
|||
|
|
)
|
|||
|
|
if payload.tool_payload.get("duplicate_attachment_blocked") or payload.tool_payload.get("duplicate_invoice_blocked"):
|
|||
|
|
return (
|
|||
|
|
str(payload.tool_payload.get("message") or "").strip()
|
|||
|
|
or "检测到本次上传票据与当前单据已有票据重复,请重新上传不同的票据后再归集。"
|
|||
|
|
)
|
|||
|
|
if review_action == "save_draft":
|
|||
|
|
if draft_payload is not None and draft_payload.claim_no:
|
|||
|
|
return (
|
|||
|
|
f"已按您当前确认的信息保存为草稿 {draft_payload.claim_no}。"
|
|||
|
|
"后续您可以继续补充缺失项,或修改识别结果后再继续提交。"
|
|||
|
|
)
|
|||
|
|
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 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 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 "审批中"
|
|||
|
|
return f"报销单 {draft_payload.claim_no or ''} 已提交,当前节点为 {stage_text}。".strip()
|
|||
|
|
if payload.tool_payload.get("submission_blocked"):
|
|||
|
|
reasons = self._resolve_submission_blocked_reasons(payload)
|
|||
|
|
if reasons:
|
|||
|
|
reason_lines = "\n".join(
|
|||
|
|
f"{index}. {reason}" for index, reason in enumerate(reasons, start=1)
|
|||
|
|
)
|
|||
|
|
return (
|
|||
|
|
"AI预审暂未通过,所以还没有提交到审批人。\n"
|
|||
|
|
f"{reason_lines}\n"
|
|||
|
|
"请先处理以上项目;处理完成后再点继续下一步。"
|
|||
|
|
)
|
|||
|
|
return str(payload.tool_payload.get("message") or "").strip() or "当前报销单暂时还不能提交审批。"
|
|||
|
|
return (
|
|||
|
|
f"{self._build_review_intent_summary(payload, slot_cards=review_payload.slot_cards, claim_groups=review_payload.claim_groups)}\n\n"
|
|||
|
|
"当前关键信息已基本齐全,您确认无误后可以继续下一步。"
|
|||
|
|
)
|
|||
|
|
return review_payload.body_message or None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _build_review_body_message(
|
|||
|
|
self,
|
|||
|
|
payload: UserAgentRequest,
|
|||
|
|
*,
|
|||
|
|
slot_cards: list[UserAgentReviewSlotCard],
|
|||
|
|
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()
|
|||
|
|
document_count = len(document_cards) or self._resolve_review_document_count(payload)
|
|||
|
|
if claim_no:
|
|||
|
|
return (
|
|||
|
|
f"已识别出本次上传的 {document_count} 张票据。"
|
|||
|
|
f"系统检测到你已有草稿 {claim_no},请选择关联到该草稿,或单独建立一张新的报销单。"
|
|||
|
|
)
|
|||
|
|
return (
|
|||
|
|
f"已识别出本次上传的 {document_count} 张票据。"
|
|||
|
|
"系统检测到你已有可用草稿,请先选择关联到现有草稿,或单独建立一张新的报销单。"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
blocked_reasons = self._resolve_submission_blocked_reasons(payload)
|
|||
|
|
if blocked_reasons:
|
|||
|
|
reason_text = ";".join(dict.fromkeys(reason.strip("。;;") for reason in blocked_reasons if reason))
|
|||
|
|
return (
|
|||
|
|
f"AI预审未通过:{reason_text}。"
|
|||
|
|
"请先根据风险提示补充原因、调整金额或更换附件,整改后再继续提交。"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
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))
|
|||
|
|
|
|||
|
|
expense_type_slot = next((item for item in slot_cards if item.key == "expense_type"), None)
|
|||
|
|
if expense_type_slot is not None and not str(expense_type_slot.value or "").strip():
|
|||
|
|
return (
|
|||
|
|
f"{self._build_review_intent_summary(payload, slot_cards=slot_cards, claim_groups=[])}\n\n"
|
|||
|
|
"我已经先保留了当前识别出的时间、地点和事由,但还不能确定这张单据应该走哪类报销流程。"
|
|||
|
|
"请先点击“选择报销类型”,在差旅费、交通费、住宿费等选项中选定;"
|
|||
|
|
"选定后,后续上传的票据都会作为这张单据的补充继续核对,不会重新改判报销类型。"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
review_payload = UserAgentReviewPayload(
|
|||
|
|
intent_summary="",
|
|||
|
|
body_message="",
|
|||
|
|
scenario=payload.ontology.scenario,
|
|||
|
|
intent=payload.ontology.intent,
|
|||
|
|
can_proceed=can_proceed,
|
|||
|
|
missing_slots=missing_labels,
|
|||
|
|
risk_briefs=risk_briefs,
|
|||
|
|
slot_cards=slot_cards,
|
|||
|
|
document_cards=[],
|
|||
|
|
claim_groups=[],
|
|||
|
|
confirmation_actions=[],
|
|||
|
|
edit_fields=[],
|
|||
|
|
)
|
|||
|
|
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"职级 {calculation.grade},目的地 {destination},匹配城市 {calculation.matched_city};"
|
|||
|
|
"补齐交通、酒店等票据后,我会按真实票据金额和规则中心标准重新复核。"
|
|||
|
|
),
|
|||
|
|
"",
|
|||
|
|
"| 项目 | 测算口径 | 金额 |",
|
|||
|
|
"| --- | --- | ---: |",
|
|||
|
|
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)} 元 |",
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@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()]
|
|||
|
|
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()
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
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:
|
|||
|
|
sections.append("还需补充:\n" + "\n".join(provide_items))
|
|||
|
|
else:
|
|||
|
|
sections.append("票据完整性:当前核心票据已较完整,无需继续上传票据。")
|
|||
|
|
|
|||
|
|
if required_labels:
|
|||
|
|
sections.append(
|
|||
|
|
"处理建议:酒店票据仍缺失,暂时不能继续下一步。"
|
|||
|
|
"您可以先保存为草稿,补齐后再提交。"
|
|||
|
|
)
|
|||
|
|
elif can_proceed and optional_labels:
|
|||
|
|
sections.append(
|
|||
|
|
"处理建议:必需票据已具备。"
|
|||
|
|
"如暂时没有乘车票据,也可以继续下一步,或先保存为草稿。"
|
|||
|
|
)
|
|||
|
|
elif can_proceed:
|
|||
|
|
sections.append(
|
|||
|
|
"处理建议:当前信息已较完整,确认无误后可以继续下一步;"
|
|||
|
|
"暂时不提交时,也可以先保存为草稿。"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
estimate_copy = self._build_travel_receipt_estimate_copy(
|
|||
|
|
payload,
|
|||
|
|
travel_receipt_state=travel_receipt_state,
|
|||
|
|
)
|
|||
|
|
if estimate_copy:
|
|||
|
|
sections.append(estimate_copy)
|
|||
|
|
return "\n\n".join(section for section in sections if section)
|
|||
|
|
|
|||
|
|
|
|||
|
|
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 (
|
|||
|
|
"差旅费测算:\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(
|
|||
|
|
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"
|
|||
|
|
f"1. 职级:{grade}\n"
|
|||
|
|
f"2. 目的地:{destination}\n"
|
|||
|
|
f"3. 已提交{ticket_type_label}:{self._format_decimal_money(ticket_amount)} 元\n"
|
|||
|
|
"4. 住宿和补贴标准:暂时无法自动测算,请以规则中心最新差旅标准为准。"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
total_amount = (
|
|||
|
|
ticket_amount
|
|||
|
|
+ self._coerce_decimal_money(calculation.hotel_amount)
|
|||
|
|
+ self._coerce_decimal_money(calculation.allowance_amount)
|
|||
|
|
).quantize(Decimal("0.01"))
|
|||
|
|
return (
|
|||
|
|
"差旅费测算:\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
|
|||
|
|
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"{UserAgentReviewMessageMixin._coerce_decimal_money(value):.2f}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
def _resolve_review_missing_slot_labels(
|
|||
|
|
slot_cards: list[UserAgentReviewSlotCard],
|
|||
|
|
) -> list[str]:
|
|||
|
|
return [item.label for item in slot_cards if item.status == "missing"]
|
|||
|
|
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
def _build_review_guidance_copy(
|
|||
|
|
review_payload: UserAgentReviewPayload,
|
|||
|
|
*,
|
|||
|
|
mention_save_draft: bool,
|
|||
|
|
) -> str:
|
|||
|
|
reminder_count = len(review_payload.risk_briefs)
|
|||
|
|
|
|||
|
|
if review_payload.can_proceed:
|
|||
|
|
if reminder_count:
|
|||
|
|
return (
|
|||
|
|
f"当前关键信息已基本齐全,但还有 {reminder_count} 条提醒。"
|
|||
|
|
"请核查对话中的文字说明,确认无误后继续下一步。"
|
|||
|
|
)
|
|||
|
|
return "当前关键信息已基本齐全,您确认无误后可以继续下一步。"
|
|||
|
|
|
|||
|
|
return ""
|
|||
|
|
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
def _can_proceed_review(
|
|||
|
|
payload: UserAgentRequest,
|
|||
|
|
*,
|
|||
|
|
missing_slot_keys: list[str],
|
|||
|
|
claim_groups: list[UserAgentReviewClaimGroup],
|
|||
|
|
) -> bool:
|
|||
|
|
if payload.ontology.ambiguity:
|
|||
|
|
return False
|
|||
|
|
if missing_slot_keys:
|
|||
|
|
return False
|
|||
|
|
if not claim_groups:
|
|||
|
|
return False
|
|||
|
|
return True
|
|||
|
|
|