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"(?= 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