from __future__ import annotations import re from datetime import UTC, datetime from decimal import Decimal, InvalidOperation from sqlalchemy import or_, select from app.api.deps import CurrentUserContext from app.models.financial_record import ExpenseClaim from app.schemas.user_agent import ( UserAgentDraftPayload, UserAgentRequest, UserAgentResponse, UserAgentSuggestedAction, ) from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy from app.services.expense_claim_risk_stage import with_risk_business_stage from app.services.document_numbering import ( build_document_number, generate_unique_expense_claim_no, ) from app.services.user_agent_application_dates import ( expand_application_time_with_days, resolve_application_days_from_time_range, ) from app.services.user_agent_application_locations import normalize_application_location from app.services.application_system_estimate import apply_application_system_estimate_to_facts APPLICATION_CONTEXT_VALUES = { "application", "documents_application", "expense_application", "pre_approval", "preapproval", } APPLICATION_BASE_FIELDS = ("time", "location", "reason") APPLICATION_TIME_LABELS = ("行程时间", "招待时间", "申请时间", "发生时间", "业务发生时间", "时间") APPLICATION_FIELD_LABELS = ( "申请类型", "费用类型", "姓名", "申请人", "部门", "岗位", "职级", "直属领导", *APPLICATION_TIME_LABELS, "地点", "业务地点", "发生地点", "目的地", "事由", "申请事由", "出差事由", "原因", "用途", "天数", "出差天数", "申请天数", "出行方式", "交通方式", "交通工具", "出行工具", "用户预估费用", "预估费用", "预计总费用", "预计费用", "预计金额", "申请金额", "预算", "金额", "费用", ) APPLICATION_TRANSPORT_OPTIONS = ("飞机", "火车", "轮船") APPLICATION_TRANSPORT_KEYWORDS = { "飞机": ("飞机", "机票", "航班", "乘机", "坐飞机"), "火车": ("火车", "高铁", "动车", "铁路", "列车"), "轮船": ("轮船", "船", "客轮", "邮轮", "坐船"), } APPLICATION_REASON_VERBS = ( "支撑", "支持", "部署", "上线", "实施", "驻场", "拜访", "验收", "会议", "采购", "培训", "协助", "处理", "办理", "参加", "进行", ) APPLICATION_SUBMIT_KEYWORDS = ( "确认提交", "确认申请", "提交审核", "确认无误提交", "直接提交", ) APPLICATION_SHORT_CONFIRMATIONS = {"提交", "确认", "是", "好的", "可以", "没问题"} APPLICATION_MISSING_VALUES = {"", "待补充", "待确认", "未知", "暂无", "无", "null", "none"} APPLICATION_DUPLICATE_IGNORED_STATUSES = { "cancelled", "canceled", "void", "voided", "deleted", "已取消", "已作废", "作废", "已删除", } class UserAgentApplicationMixin: @staticmethod def _is_expense_application_request(payload: UserAgentRequest) -> bool: context_json = payload.context_json or {} context_values = { str(context_json.get("session_type") or "").strip(), str(context_json.get("entry_source") or "").strip(), str(context_json.get("document_type") or "").strip(), str(context_json.get("application_stage") or "").strip(), } conversation_state = context_json.get("conversation_state") if isinstance(conversation_state, dict): context_values.update( { str(conversation_state.get("session_type") or "").strip(), str(conversation_state.get("entry_source") or "").strip(), str(conversation_state.get("document_type") or "").strip(), str(conversation_state.get("application_stage") or "").strip(), } ) if context_values & APPLICATION_CONTEXT_VALUES: return True history = context_json.get("conversation_history") if not isinstance(history, list): return False compact_message = re.sub(r"\s+", "", str(payload.message or "")) looks_like_submit = ( any(keyword in compact_message for keyword in APPLICATION_SUBMIT_KEYWORDS) or compact_message in APPLICATION_SHORT_CONFIRMATIONS ) if not looks_like_submit: return False return any( isinstance(item, dict) and str(item.get("role") or "").strip() == "assistant" and ( "#application-submit" in str(item.get("content") or "") or ("费用申请" in str(item.get("content") or "") and "确认" in str(item.get("content") or "")) ) for item in history[-6:] ) def _build_expense_application_response( self, payload: UserAgentRequest, *, risk_flags: list[str], ) -> UserAgentResponse: facts = self._resolve_expense_application_facts(payload) step = self._resolve_expense_application_step(payload, facts) application_claim = None if step == "submitted": application_claim = self._find_duplicate_expense_application_record(payload, facts) if application_claim is not None: step = "duplicate" facts["duplicate_application_stage"] = str(application_claim.approval_stage or "").strip() else: application_claim = self._create_expense_application_record(payload, facts) facts["application_no"] = application_claim.claim_no facts["application_claim_id"] = application_claim.id facts["manager_name"] = self._resolve_application_manager_name(payload, application_claim) return UserAgentResponse( answer=self._build_expense_application_answer(payload, facts=facts, step=step), citations=[], suggested_actions=self._build_expense_application_actions(step, facts), query_payload=None, draft_payload=( self._build_submitted_application_payload(application_claim, facts) if step == "submitted" else None ), review_payload=None, risk_flags=risk_flags, requires_confirmation=step == "preview", ) def _build_expense_application_answer( self, payload: UserAgentRequest, *, facts: dict[str, str], step: str, ) -> str: recognized_table = self._build_application_summary_table(facts, include_empty=False) if step == "ask_missing": missing_fields = self._resolve_application_missing_fields(facts) missing_text = "、".join( self._display_application_slot_label(item) for item in missing_fields ) return "\n\n".join( [ "我已按「费用申请 / 事前审批」来处理这条内容。", "已识别信息:\n" + recognized_table, f"当前还需要补充:{missing_text}。", "请一次性补齐上述字段,我会继续生成申请核对结果并让你确认是否提交。", ] ) if step == "submitted": application_no = str(facts.get("application_no") or "").strip() or self._build_application_claim_no(payload, facts) manager_name = str(facts.get("manager_name") or "").strip() or "直属领导" return "\n\n".join( [ "申请单据已生成,并已进入审批流程。", f"系统已推送给 {manager_name} 审核,当前节点:{manager_name}审核中。", f"申请单号:{application_no}", "下方是简要单据信息。需要查看完整详情时,请点击快捷方式进入单据详情。", ] ) if step == "duplicate": application_no = str(facts.get("application_no") or "").strip() stage = str(facts.get("duplicate_application_stage") or "").strip() or "处理中" time_label = self._resolve_application_time_label(facts) return "\n\n".join( [ f"检测到同一申请人、同一申请类型、同一{time_label}已存在申请单,系统没有重复创建。", f"已有申请单号:{application_no}", f"当前节点:{stage}", "如需继续处理,请在单据中心查看该申请;如果本次业务时间不同,请先调整时间后再提交。", ] ) return "\n\n".join( [ "这是费用申请核对结果,请核对:", self._build_application_summary_table(facts), "请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。", ] ) def _resolve_expense_application_facts(self, payload: UserAgentRequest) -> dict[str, str]: facts = { "time": "", "location": "", "reason": "", "days": "", "transport_mode": "", "amount": "", "application_type": "", "applicant": "", "grade": "", "department": "", "position": "", "manager_name": "", "lodging_daily_cap": "", "subsidy_daily_cap": "", "transport_policy": "", "policy_estimate": "", "matched_city": "", "rule_name": "", "rule_version": "", "hotel_amount": "", "allowance_amount": "", "transport_estimated_amount": "", "transport_estimate_source": "", "transport_estimate_confidence": "", "policy_total_amount": "", } for message, is_current in self._iter_application_user_messages(payload): partial = { "time": self._resolve_application_time(payload, message=message) if is_current else self._resolve_application_time_from_text(message), "location": self._resolve_application_location(payload, message=message, use_entities=is_current), "reason": self._resolve_application_reason(message), "days": self._resolve_application_days(message), "transport_mode": self._resolve_application_transport_mode(message), "amount": self._resolve_application_amount(payload, message=message) if is_current else self._resolve_application_amount_from_text(message), "application_type": self._resolve_application_type_from_text(message), } for key, value in partial.items(): if value: facts[key] = value for key, value in self._resolve_application_preview_facts(payload.context_json or {}).items(): if value: facts[key] = value context_json = payload.context_json or {} context_time = self._resolve_application_time_from_context(context_json) if context_time and self._should_prefer_context_application_time(facts.get("time", ""), context_time): facts["time"] = context_time current_user = self._build_application_current_user(payload) employee = ExpenseClaimAccessPolicy(self.db).resolve_current_employee(current_user) if not facts["applicant"]: facts["applicant"] = str( context_json.get("name") or context_json.get("user_name") or context_json.get("applicant") or (employee.name if employee is not None else "") or current_user.name or "" ).strip() if not facts["grade"]: facts["grade"] = str( context_json.get("grade") or context_json.get("employee_grade") or context_json.get("employeeGrade") or current_user.grade or (employee.grade if employee is not None else "") or "" ).strip() if not facts["department"]: facts["department"] = str( context_json.get("department") or context_json.get("department_name") or context_json.get("departmentName") or current_user.department_name or ( employee.organization_unit.name if employee is not None and employee.organization_unit is not None else "" ) or "" ).strip() if not facts["position"]: facts["position"] = str( context_json.get("position") or context_json.get("employee_position") or context_json.get("employeePosition") or current_user.position or (employee.position if employee is not None else "") or "" ).strip() if not facts["manager_name"]: facts["manager_name"] = str( context_json.get("manager_name") or context_json.get("managerName") or context_json.get("direct_manager_name") or context_json.get("directManagerName") or current_user.manager_name or ( employee.manager.name if employee is not None and employee.manager is not None else "" ) or ( employee.organization_unit.manager_name if employee is not None and employee.organization_unit is not None else "" ) or "" ).strip() if not facts["application_type"]: facts["application_type"] = self._infer_application_type(facts) facts["time"] = self._expand_application_time_with_days( facts.get("time", ""), facts.get("days", ""), payload.context_json or {}, ) if self._is_application_missing_value(facts.get("days", "")): range_days = resolve_application_days_from_time_range(facts.get("time", "")) if range_days: facts["days"] = f"{range_days}天" apply_application_system_estimate_to_facts(facts) return facts @staticmethod def _resolve_application_preview_facts(context_json: dict[str, object]) -> dict[str, str]: preview = context_json.get("application_preview") if not isinstance(preview, dict): return {} fields = preview.get("fields") if not isinstance(fields, dict): return {} def pick(*keys: str) -> str: for key in keys: value = str(fields.get(key) or "").strip() if value: return value return "" reason = UserAgentApplicationMixin._cleanup_application_reason_candidate(pick("reason")) return { "application_type": pick("applicationType", "application_type"), "time": pick("time", "timeRange", "time_range"), "location": pick("location"), "reason": reason, "days": pick("days"), "transport_mode": pick("transportMode", "transport_mode"), "amount": pick("amount"), "applicant": pick("applicant", "name", "userName", "user_name"), "grade": pick("grade"), "department": pick("department", "departmentName", "department_name"), "position": pick("position", "employeePosition", "employee_position"), "manager_name": pick("managerName", "manager_name", "directManagerName", "direct_manager_name"), "lodging_daily_cap": pick("lodgingDailyCap", "lodging_daily_cap"), "subsidy_daily_cap": pick("subsidyDailyCap", "subsidy_daily_cap"), "transport_policy": pick("transportPolicy", "transport_policy"), "policy_estimate": pick("policyEstimate", "policy_estimate"), "matched_city": pick("matchedCity", "matched_city"), "rule_name": pick("ruleName", "rule_name"), "rule_version": pick("ruleVersion", "rule_version"), "hotel_amount": pick("hotelAmount", "hotel_amount"), "allowance_amount": pick("allowanceAmount", "allowance_amount"), "transport_estimated_amount": pick("transportEstimatedAmount", "transport_estimated_amount"), "transport_estimate_source": pick("transportEstimateSource", "transport_estimate_source"), "transport_estimate_confidence": pick("transportEstimateConfidence", "transport_estimate_confidence"), "policy_total_amount": pick("policyTotalAmount", "policy_total_amount"), } @staticmethod def _is_application_missing_value(value: object) -> bool: return str(value or "").strip().lower() in APPLICATION_MISSING_VALUES def _resolve_expense_application_step( self, payload: UserAgentRequest, facts: dict[str, str], ) -> str: if self._resolve_application_missing_base_fields(facts): return "ask_missing" if self._resolve_application_missing_followup_fields(facts): return "ask_missing" if self._is_application_submit_confirmation(payload): return "submitted" return "preview" @staticmethod def _iter_application_user_messages(payload: UserAgentRequest) -> list[tuple[str, bool]]: messages: list[tuple[str, bool]] = [] history = (payload.context_json or {}).get("conversation_history") if isinstance(history, list): for item in history: if not isinstance(item, dict): continue if str(item.get("role") or "").strip() != "user": continue content = str(item.get("content") or "").strip() if content: messages.append((content, False)) current_message = str(payload.message or "").strip() if current_message: messages.append((current_message, True)) return messages @staticmethod def _resolve_application_missing_base_fields(facts: dict[str, str]) -> list[str]: return [field for field in APPLICATION_BASE_FIELDS if not str(facts.get(field) or "").strip()] @staticmethod def _resolve_application_missing_followup_fields(facts: dict[str, str]) -> list[str]: return [ field for field in ("transport_mode",) if not str(facts.get(field) or "").strip() ] def _resolve_application_missing_fields(self, facts: dict[str, str]) -> list[str]: return [ *self._resolve_application_missing_base_fields(facts), *self._resolve_application_missing_followup_fields(facts), ] @staticmethod def _resolve_application_time(payload: UserAgentRequest, *, message: str | None = None) -> str: if message and UserAgentApplicationMixin._resolve_application_time_from_text(message): return UserAgentApplicationMixin._resolve_application_time_from_text(message) context_time = UserAgentApplicationMixin._resolve_application_time_from_context(payload.context_json or {}) if context_time: return context_time time_range = payload.ontology.time_range if time_range.start_date and time_range.end_date: return ( time_range.start_date if time_range.start_date == time_range.end_date else f"{time_range.start_date} 至 {time_range.end_date}" ) return str(time_range.raw or "").strip() @staticmethod def _resolve_application_time_from_text(message: str) -> str: labeled = UserAgentApplicationMixin._resolve_application_labeled_value( message, APPLICATION_TIME_LABELS, ) if labeled: return labeled range_match = re.search( r"(?P20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)\s*(?:至|到|~|—|–|--)\s*(?P20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)", str(message or ""), ) if range_match: return f"{range_match.group('start').rstrip('日')} 至 {range_match.group('end').rstrip('日')}" match = re.search( r"(?P20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)", str(message or ""), ) return match.group("date").rstrip("日") if match else "" @staticmethod def _resolve_application_time_from_context(context_json: dict[str, object]) -> str: business_time_context = context_json.get("business_time_context") if not isinstance(business_time_context, dict): return "" start_date = str(business_time_context.get("start_date") or "").strip() end_date = str(business_time_context.get("end_date") or start_date).strip() display_value = str(business_time_context.get("display_value") or "").strip() if start_date and end_date: return start_date if start_date == end_date else f"{start_date} 至 {end_date}" return display_value @staticmethod def _should_prefer_context_application_time(current_time: str, context_time: str) -> bool: current = str(current_time or "").strip() context = str(context_time or "").strip() if not context: return False if not current: return True if "至" not in context: return False current_dates = re.findall(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?", current) context_dates = re.findall(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?", context) return len(current_dates) <= 1 and len(context_dates) >= 2 and current_dates[:1] == context_dates[:1] @staticmethod def _resolve_application_labeled_value(message: str, labels: tuple[str, ...]) -> str: label_pattern = "|".join(re.escape(label) for label in labels) next_label_pattern = "|".join(re.escape(label) for label in APPLICATION_FIELD_LABELS) match = re.search( rf"(?:{label_pattern})[::]\s*(?P[\s\S]*?)(?=\s*(?:{next_label_pattern})[::]|[\n,。;;]|$)", str(message or ""), ) return match.group("value").strip() if match else "" def _resolve_application_location( self, payload: UserAgentRequest, *, message: str, use_entities: bool, ) -> str: labeled = self._resolve_application_labeled_value(message, ("地点", "业务地点", "发生地点")) if labeled: return normalize_application_location(labeled) if use_entities: entity_value = next( ( str(item.normalized_value or item.value or "").strip() for item in payload.ontology.entities if item.type == "location" and str(item.normalized_value or item.value or "").strip() ), "", ) if entity_value: return normalize_application_location(entity_value) return self._resolve_application_location_from_text(message) @staticmethod def _resolve_application_location_from_text(message: str) -> str: compact = re.sub(r"\s+", "", str(message or "")) if not compact: return "" for pattern in ( r"(?:出差|去|到|赴|前往)(?P[\u4e00-\u9fa5]{1,24})", r"(?P[\u4e00-\u9fa5]{1,12})(?:出差|驻场)", ): match = re.search(pattern, compact) if not match: continue target = str(match.group("target") or "").strip() location = normalize_application_location(target) if location: return location return "" @staticmethod def _resolve_application_days(message: str) -> str: labeled = UserAgentApplicationMixin._resolve_application_labeled_value( message, ("天数", "出差天数", "申请天数"), ) if labeled: return labeled if labeled.endswith("天") else f"{labeled}天" match = re.search(r"(?P\d+|[一二两三四五六七八九十]{1,3})\s*天", str(message or "")) return f"{match.group('days')}天" if match else "" @staticmethod def _resolve_application_reason(message: str) -> str: labeled = UserAgentApplicationMixin._resolve_application_labeled_value( message, ("事由", "申请事由", "出差事由", "原因", "用途"), ) if labeled: return UserAgentApplicationMixin._cleanup_application_reason_candidate(labeled) text = str(message or "").strip() if not text: return "" candidates: list[str] = [] for segment in re.split(r"[\n,。;;]+", text): candidate = UserAgentApplicationMixin._cleanup_application_reason_candidate(segment) if candidate: candidates.append(candidate) if not candidates: return "" business_candidate = next( ( candidate for candidate in candidates if any(keyword in candidate for keyword in APPLICATION_REASON_VERBS) ), "", ) return business_candidate or max(candidates, key=len) @staticmethod def _cleanup_application_reason_candidate(segment: str) -> str: text = str(segment or "").strip() if not text: return "" text = re.sub( r"^(?:行程时间|招待时间|发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|目的地|天数|出差天数|申请天数|出行方式|交通方式|交通工具|出行工具|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额|费用)[::]\s*", "", text, ) if re.fullmatch(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?\s*(?:至|到|~|—|–|--)\s*20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?", text): return "" if re.fullmatch(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?", text): return "" if re.fullmatch(r"(?P\d+|[一二两三四五六七八九十]{1,3})\s*天", text): return "" if re.fullmatch(r"(?:¥|¥)?\s*\d+(?:\.\d+)?\s*(?:元|块|万元)?", text): return "" if "时间" in text and re.search(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}", text): return "" if re.fullmatch(r"(?:去|到|前往)?[\u4e00-\u9fa5]{1,8}出差(?P\d+|[一二两三四五六七八九十]{1,3})?天?", text): return "" text = re.sub(r"^.*?(?:出差|前往|去|到|赴)[\u4e00-\u9fa5]{1,8}(?:出差)?(?P\d+|[一二两三四五六七八九十]{1,3})?天?[,,\s]*", "", text) text = re.sub(r"^(?:出差|申请|费用申请|业务|本次|去|到|前往)\s*", "", text) text = text.strip(" ::,,。;;") if not text: return "" if re.fullmatch(r"[\u4e00-\u9fa5]{1,8}", text) and not any(keyword in text for keyword in APPLICATION_REASON_VERBS): return "" return text @staticmethod def _expand_application_time_with_days( time_text: str, days_text: str, context_json: dict[str, object] | None = None, ) -> str: return expand_application_time_with_days( time_text, days_text, context_json=context_json or {}, ) def _resolve_application_amount( self, payload: UserAgentRequest, *, message: str | None = None, ) -> str: entity_amount = next( ( str(item.normalized_value or item.value or "").strip() for item in payload.ontology.entities if item.type == "amount" and str(item.normalized_value or item.value or "").strip() ), "", ) if entity_amount: return entity_amount if entity_amount.endswith("元") else f"{entity_amount}元" return self._resolve_application_amount_from_text(message or payload.message) @staticmethod def _resolve_application_amount_from_text(message: str) -> str: labeled = UserAgentApplicationMixin._resolve_application_labeled_value( message, ("用户预估费用", "预估费用", "预计总费用", "预计费用", "预计金额", "申请金额", "预算", "费用", "金额"), ) if labeled: return UserAgentApplicationMixin._normalize_application_amount(labeled) match = re.search( r"(?P\d+(?:\.\d+)?\s*万?\s*(?:元|块|人民币))", str(message or ""), ) return UserAgentApplicationMixin._normalize_application_amount(match.group("amount")) if match else "" @staticmethod def _normalize_application_amount(value: str) -> str: normalized = str(value or "").strip() if not normalized: return "" normalized = re.sub(r"\s+", "", normalized) if normalized.endswith(("元", "块")) or "人民币" in normalized: return normalized.replace("块", "元").replace("人民币", "") return f"{normalized}元" @staticmethod def _resolve_application_transport_mode(message: str) -> str: compact_message = re.sub(r"\s+", "", str(message or "")) for transport, keywords in APPLICATION_TRANSPORT_KEYWORDS.items(): if any(keyword in compact_message for keyword in keywords): return transport labeled = UserAgentApplicationMixin._resolve_application_labeled_value( message, ("出行方式", "交通方式", "交通工具", "出行工具"), ) if labeled: for transport, keywords in APPLICATION_TRANSPORT_KEYWORDS.items(): if transport in labeled or any(keyword in labeled for keyword in keywords): return transport return labeled return "" @staticmethod def _resolve_application_type_from_text(message: str) -> str: return UserAgentApplicationMixin._resolve_application_labeled_value( message, ("申请类型", "费用类型"), ) @staticmethod def _resolve_application_missing_slots(payload: UserAgentRequest) -> list[str]: return [ str(item or "").strip() for item in payload.ontology.missing_slots if str(item or "").strip() ] @staticmethod def _display_application_slot_label(slot: str) -> str: return { "expense_type": "申请类型", "amount": "系统预估费用", "time_range": "申请时间", "time": "申请时间", "location": "地点", "reason": "申请事由", "days": "天数", "transport_mode": "出行方式", "attachments": "申请材料/附件", "customer_name": "业务对象", "participants": "参与人员", }.get(str(slot or "").strip(), str(slot or "").strip()) def _build_expense_application_actions( self, step: str, facts: dict[str, str], ) -> list[UserAgentSuggestedAction]: if step == "ask_missing": missing_fields = self._resolve_application_missing_fields(facts) return [ UserAgentSuggestedAction( label="一次性补充申请信息", action_type="prefill_composer", description="在输入框预填所有待补充字段,填写后一次提交。", payload={ "application_fields": missing_fields, "prompt_prefill": self._build_application_prefill_template(missing_fields), "missing_fields": missing_fields, }, ) ] if step == "preview": return [] if step == "submitted": return [] return [] @staticmethod def _resolve_application_prefill_config(field: str) -> tuple[str, str]: config = { "time": ("补充申请时间", "申请时间段:"), "location": ("补充地点", "地点:"), "reason": ("补充申请事由", "事由:"), "days": ("补充天数", "天数:"), "transport_mode": ("补充出行方式", "出行方式:"), "amount": ("补充系统预估费用", "系统预估费用:"), } return config.get(field, ("补充申请信息", "")) @classmethod def _build_application_prefill_template(cls, fields: list[str]) -> str: lines = [ prefill for field in fields for _, prefill in [cls._resolve_application_prefill_config(field)] if prefill ] return "\n".join(lines) @classmethod def _build_application_prefill_action(cls, field: str) -> UserAgentSuggestedAction: label, prefill = cls._resolve_application_prefill_config(field) return UserAgentSuggestedAction( label=label, action_type="prefill_composer", description=f"在输入框预填“{prefill}”,用户补充后再提交。", payload={ "application_field": field, "prompt_prefill": prefill, "missing_fields": [field], }, ) @staticmethod def _infer_application_type(facts: dict[str, str]) -> str: text = " ".join(str(facts.get(key) or "") for key in ("reason", "transport_mode", "days")) if "采购" in text: return "采购费用申请" if "会议" in text or "会务" in text: return "会务费用申请" return "差旅费用申请" @staticmethod def _resolve_application_time_label(facts: dict[str, str]) -> str: application_type = str(facts.get("application_type") or "").strip() if "差旅" in application_type or "出差" in application_type: return "行程时间" if "招待" in application_type or "宴请" in application_type or "餐饮" in application_type: return "招待时间" return "申请时间" @classmethod def _build_application_summary(cls, facts: dict[str, str]) -> str: time_label = cls._resolve_application_time_label(facts) return "\n".join( f"{label}:{value or '待补充'}" for label, value in ( ("申请类型", facts.get("application_type", "")), ("姓名", facts.get("applicant", "")), ("部门", facts.get("department", "")), ("岗位", facts.get("position", "")), ("职级", facts.get("grade", "")), ("直属领导", facts.get("manager_name", "")), (time_label, facts.get("time", "")), ("地点", facts.get("location", "")), ("事由", facts.get("reason", "")), ("天数", facts.get("days", "")), ("出行方式", facts.get("transport_mode", "")), ("住宿上限/天", facts.get("lodging_daily_cap", "")), ("补贴标准/天", facts.get("subsidy_daily_cap", "")), ("交通费用口径", facts.get("transport_policy", "")), ("规则测算参考", facts.get("policy_estimate", "")), ("系统预估费用", facts.get("amount", "")), ) ) @classmethod def _build_application_summary_table( cls, facts: dict[str, str], *, include_empty: bool = True, ) -> str: time_label = cls._resolve_application_time_label(facts) rows = [ ("申请类型", facts.get("application_type", "")), ("姓名", facts.get("applicant", "")), ("部门", facts.get("department", "")), ("岗位", facts.get("position", "")), ("职级", facts.get("grade", "")), ("直属领导", facts.get("manager_name", "")), (time_label, facts.get("time", "")), ("地点", facts.get("location", "")), ("事由", facts.get("reason", "")), ("天数", facts.get("days", "")), ("出行方式", facts.get("transport_mode", "")), ("住宿上限/天", facts.get("lodging_daily_cap", "")), ("补贴标准/天", facts.get("subsidy_daily_cap", "")), ("交通费用口径", facts.get("transport_policy", "")), ("规则测算参考", facts.get("policy_estimate", "")), ("系统预估费用", facts.get("amount", "")), ] visible_rows = rows if include_empty else [(label, value) for label, value in rows if str(value or "").strip()] if not visible_rows: visible_rows = [("申请描述", "已收到,正在按费用申请上下文继续整理")] lines = ["| 字段 | 内容 |", "| --- | --- |"] lines.extend(f"| {label} | {value or '待补充'} |" for label, value in visible_rows) return "\n".join(lines) def _create_expense_application_record( self, payload: UserAgentRequest, facts: dict[str, str], ) -> ExpenseClaim: claim_no = self._build_application_claim_no(payload, facts) existing = self.db.scalar( select(ExpenseClaim) .where(ExpenseClaim.claim_no == claim_no) .limit(1) ) if existing is not None: return existing current_user = self._build_application_current_user(payload) access_policy = ExpenseClaimAccessPolicy(self.db) employee = access_policy.resolve_current_employee(current_user) department_name = str(current_user.department_name or "").strip() or "待补充" department_id = None employee_id = None employee_name = str(current_user.username or current_user.name or payload.user_id or "anonymous").strip() if employee is not None: employee_id = employee.id employee_name = str(employee.name or employee.employee_no or employee.email or employee_name).strip() department_id = employee.organization_unit_id if employee.organization_unit is not None and employee.organization_unit.name: department_name = str(employee.organization_unit.name).strip() claim = ExpenseClaim( claim_no=claim_no, employee_id=employee_id, employee_name=employee_name, department_id=department_id, department_name=department_name, project_code=None, expense_type=self._resolve_application_expense_type_code(facts), reason=str(facts.get("reason") or "费用申请").strip() or "费用申请", location=str(facts.get("location") or "待补充").strip() or "待补充", amount=self._parse_application_amount_to_decimal(facts.get("amount", "")), currency="CNY", invoice_count=0, occurred_at=self._parse_application_occurred_at(facts.get("time", "")), submitted_at=datetime.now(UTC), status="submitted", approval_stage="直属领导审批", risk_flags_json=[self._build_application_detail_flag(facts)], ) self.db.add(claim) self.db.flush() from app.services.expense_claims import ExpenseClaimService platform_review = ExpenseClaimService(self.db).evaluate_platform_risk_rules( claim, business_stage="expense_application", ) platform_flags = list(platform_review.get("flags") or []) if platform_flags: claim.risk_flags_json = [*list(claim.risk_flags_json or []), *platform_flags] self.db.commit() self.db.refresh(claim) return claim def _find_duplicate_expense_application_record( self, payload: UserAgentRequest, facts: dict[str, str], ) -> ExpenseClaim | None: current_user = self._build_application_current_user(payload) access_policy = ExpenseClaimAccessPolicy(self.db) employee = access_policy.resolve_current_employee(current_user) employee_id = employee.id if employee is not None else None employee_name = str(current_user.username or current_user.name or payload.user_id or "anonymous").strip() if employee is not None: employee_name = str(employee.name or employee.employee_no or employee.email or employee_name).strip() employee_filter = ExpenseClaim.employee_name == employee_name if employee_id is not None: employee_filter = or_(ExpenseClaim.employee_id == employee_id, employee_filter) stmt = ( select(ExpenseClaim) .where( ExpenseClaim.expense_type == self._resolve_application_expense_type_code(facts), employee_filter, ) .order_by(ExpenseClaim.id.desc()) .limit(100) ) occurred_at = self._parse_application_occurred_at(facts.get("time", "")) for claim in self.db.scalars(stmt).all(): if self._is_ignored_application_duplicate_status(claim.status): continue if self._matches_application_business_time(claim, facts, occurred_at): return claim return None @staticmethod def _is_ignored_application_duplicate_status(status: str | None) -> bool: return str(status or "").strip().lower() in APPLICATION_DUPLICATE_IGNORED_STATUSES @classmethod def _matches_application_business_time( cls, claim: ExpenseClaim, facts: dict[str, str], occurred_at: datetime, ) -> bool: current_time = cls._normalize_application_time_identity(facts.get("time")) existing_detail = cls._extract_application_detail_from_claim(claim) existing_time = cls._normalize_application_time_identity(existing_detail.get("time")) if current_time and existing_time: return current_time == existing_time if claim.occurred_at is None: return False return claim.occurred_at.date() == occurred_at.date() @staticmethod def _normalize_application_time_identity(value: object) -> str: normalized = str(value or "").strip() if not normalized: return "" normalized = ( normalized.replace("到", "至") .replace("~", "至") .replace("—", "至") .replace("–", "至") .replace("-", "至") .replace("/", "-") ) return re.sub(r"\s+", "", normalized) @staticmethod def _extract_application_detail_from_claim(claim: ExpenseClaim) -> dict[str, object]: flags = claim.risk_flags_json if isinstance(flags, dict): flags = [flags] if not isinstance(flags, list): return {} for item in flags: if not isinstance(item, dict): continue detail = item.get("application_detail") if isinstance(detail, dict): return detail return {} @staticmethod def _build_application_detail_flag(facts: dict[str, str]) -> dict[str, object]: return with_risk_business_stage( { "source": "application_detail", "severity": "info", "label": "申请详情", "application_detail": { "application_type": str(facts.get("application_type") or "").strip(), "time": str(facts.get("time") or "").strip(), "location": str(facts.get("location") or "").strip(), "reason": str(facts.get("reason") or "").strip(), "days": str(facts.get("days") or "").strip(), "transport_mode": str(facts.get("transport_mode") or "").strip(), "amount": str(facts.get("amount") or "").strip(), "grade": str(facts.get("grade") or "").strip(), "lodging_daily_cap": str(facts.get("lodging_daily_cap") or "").strip(), "subsidy_daily_cap": str(facts.get("subsidy_daily_cap") or "").strip(), "transport_policy": str(facts.get("transport_policy") or "").strip(), "policy_estimate": str(facts.get("policy_estimate") or "").strip(), "matched_city": str(facts.get("matched_city") or "").strip(), "rule_name": str(facts.get("rule_name") or "").strip(), "rule_version": str(facts.get("rule_version") or "").strip(), "hotel_amount": str(facts.get("hotel_amount") or "").strip(), "allowance_amount": str(facts.get("allowance_amount") or "").strip(), "transport_estimated_amount": str(facts.get("transport_estimated_amount") or "").strip(), "transport_estimate_source": str(facts.get("transport_estimate_source") or "").strip(), "transport_estimate_confidence": str(facts.get("transport_estimate_confidence") or "").strip(), "policy_total_amount": str(facts.get("policy_total_amount") or "").strip(), }, }, "expense_application", ) def _resolve_application_manager_name( self, payload: UserAgentRequest, claim: ExpenseClaim | None = None, ) -> str: if claim is not None: manager_name = ExpenseClaimAccessPolicy.resolve_claim_manager_name(claim) if manager_name and not ExpenseClaimAccessPolicy.is_missing_value(manager_name): return manager_name context_json = payload.context_json or {} for key in ("manager_name", "managerName", "direct_manager_name", "directManagerName"): value = str(context_json.get(key) or "").strip() if value and not ExpenseClaimAccessPolicy.is_missing_value(value): return value return "" @staticmethod def _build_application_current_user(payload: UserAgentRequest) -> CurrentUserContext: context_json = payload.context_json or {} raw_role_codes = context_json.get("role_codes") if isinstance(raw_role_codes, list): role_codes = [str(item).strip() for item in raw_role_codes if str(item).strip()] else: role_codes = [item.strip() for item in str(raw_role_codes or "").split(",") if item.strip()] username = str( payload.user_id or context_json.get("username") or context_json.get("user_id") or context_json.get("employee_no") or context_json.get("name") or "anonymous" ).strip() name = str(context_json.get("name") or context_json.get("user_name") or username).strip() return CurrentUserContext( username=username or name or "anonymous", name=name or username or "anonymous", role_codes=role_codes, is_admin=bool(context_json.get("is_admin")), department_name=str( context_json.get("department_name") or context_json.get("department") or context_json.get("departmentName") or "" ).strip(), cost_center=str(context_json.get("cost_center") or context_json.get("costCenter") or "").strip(), position=str( context_json.get("position") or context_json.get("employee_position") or context_json.get("employeePosition") or "" ).strip(), grade=str( context_json.get("grade") or context_json.get("employee_grade") or context_json.get("employeeGrade") or "" ).strip(), employee_no=str( context_json.get("employee_no") or context_json.get("employeeNo") or "" ).strip(), manager_name=str( context_json.get("manager_name") or context_json.get("managerName") or context_json.get("direct_manager_name") or context_json.get("directManagerName") or "" ).strip(), ) @staticmethod def _resolve_application_expense_type_code(facts: dict[str, str]) -> str: application_type = str(facts.get("application_type") or "").strip() if "差旅" in application_type: return "travel_application" if "采购" in application_type: return "purchase_application" if "会务" in application_type or "会议" in application_type: return "meeting_application" return "expense_application" @staticmethod def _parse_application_amount_to_decimal(amount_text: str) -> Decimal: normalized = str(amount_text or "").replace(",", "").replace(",", "").strip() match = re.search(r"\d+(?:\.\d+)?", normalized) if not match: return Decimal("0.00") try: return Decimal(match.group(0)).quantize(Decimal("0.01")) except (InvalidOperation, ValueError): return Decimal("0.00") @staticmethod def _parse_application_occurred_at(time_text: str) -> datetime: normalized = str(time_text or "") match = re.search(r"(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})", normalized) if match: year, month, day = (int(part) for part in match.groups()) return datetime(year, month, day, tzinfo=UTC) return datetime.now(UTC) def _build_submitted_application_payload( self, claim: ExpenseClaim | None, facts: dict[str, str], ) -> UserAgentDraftPayload | None: if claim is None: return None return UserAgentDraftPayload( draft_type="expense_application", title=str(facts.get("application_type") or "费用申请").strip() or "费用申请", body=self._build_application_summary(facts), confirmation_required=False, claim_id=claim.id, claim_no=claim.claim_no, status=claim.status, approval_stage=claim.approval_stage, ) def _is_application_submit_confirmation(self, payload: UserAgentRequest) -> bool: compact_message = re.sub(r"\s+", "", str(payload.message or "")) if any(keyword in compact_message for keyword in APPLICATION_SUBMIT_KEYWORDS): return True if compact_message not in APPLICATION_SHORT_CONFIRMATIONS: return False history = (payload.context_json or {}).get("conversation_history") if not isinstance(history, list): return False return any( isinstance(item, dict) and str(item.get("role") or "").strip() == "assistant" and ( "是否确认提交" in str(item.get("content") or "") or "当前状态:待确认提交" in str(item.get("content") or "") or "#application-submit" in str(item.get("content") or "") or "确认无误后" in str(item.get("content") or "") ) for item in history[-4:] ) def _build_simulated_application_no( self, payload: UserAgentRequest, facts: dict[str, str], ) -> str: return self._build_simulated_application_no_from_facts( facts, fallback_seed=str(payload.run_id or ""), ) @staticmethod def _build_simulated_application_no_from_facts( facts: dict[str, str], *, fallback_seed: str = "", ) -> str: return build_document_number("application", timestamp=datetime.now(UTC)) def _build_application_claim_no( self, payload: UserAgentRequest, facts: dict[str, str], ) -> str: return generate_unique_expense_claim_no( self.db, "application", timestamp=datetime.now(UTC), )