2026-05-25 13:35:39 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import re
|
2026-05-30 15:46:51 +08:00
|
|
|
|
from datetime import UTC, datetime
|
2026-05-25 13:35:39 +08:00
|
|
|
|
from decimal import Decimal, InvalidOperation
|
|
|
|
|
|
|
2026-06-02 14:01:51 +08:00
|
|
|
|
from sqlalchemy import or_, select
|
2026-05-25 13:35:39 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-06-01 17:07:14 +08:00
|
|
|
|
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
2026-05-26 09:15:14 +08:00
|
|
|
|
from app.services.document_numbering import (
|
|
|
|
|
|
build_document_number,
|
|
|
|
|
|
generate_unique_expense_claim_no,
|
|
|
|
|
|
)
|
2026-06-02 14:01:51 +08:00
|
|
|
|
from app.services.user_agent_application_dates import (
|
|
|
|
|
|
expand_application_time_with_days,
|
|
|
|
|
|
resolve_application_days_from_time_range,
|
|
|
|
|
|
)
|
2026-05-26 09:15:14 +08:00
|
|
|
|
from app.services.user_agent_application_locations import normalize_application_location
|
2026-06-01 17:07:14 +08:00
|
|
|
|
from app.services.application_system_estimate import apply_application_system_estimate_to_facts
|
2026-05-25 13:35:39 +08:00
|
|
|
|
|
|
|
|
|
|
APPLICATION_CONTEXT_VALUES = {
|
|
|
|
|
|
"application",
|
|
|
|
|
|
"documents_application",
|
|
|
|
|
|
"expense_application",
|
|
|
|
|
|
"pre_approval",
|
|
|
|
|
|
"preapproval",
|
|
|
|
|
|
}
|
|
|
|
|
|
APPLICATION_BASE_FIELDS = ("time", "location", "reason")
|
2026-06-02 14:01:51 +08:00
|
|
|
|
APPLICATION_TIME_LABELS = ("行程时间", "招待时间", "申请时间", "发生时间", "业务发生时间", "时间")
|
|
|
|
|
|
APPLICATION_FIELD_LABELS = (
|
|
|
|
|
|
"申请类型",
|
|
|
|
|
|
"费用类型",
|
|
|
|
|
|
"姓名",
|
|
|
|
|
|
"申请人",
|
|
|
|
|
|
"部门",
|
|
|
|
|
|
"岗位",
|
|
|
|
|
|
"职级",
|
|
|
|
|
|
"直属领导",
|
|
|
|
|
|
*APPLICATION_TIME_LABELS,
|
|
|
|
|
|
"地点",
|
|
|
|
|
|
"业务地点",
|
|
|
|
|
|
"发生地点",
|
|
|
|
|
|
"目的地",
|
|
|
|
|
|
"事由",
|
|
|
|
|
|
"申请事由",
|
|
|
|
|
|
"出差事由",
|
|
|
|
|
|
"原因",
|
|
|
|
|
|
"用途",
|
|
|
|
|
|
"天数",
|
|
|
|
|
|
"出差天数",
|
|
|
|
|
|
"申请天数",
|
|
|
|
|
|
"出行方式",
|
|
|
|
|
|
"交通方式",
|
|
|
|
|
|
"交通工具",
|
|
|
|
|
|
"出行工具",
|
|
|
|
|
|
"用户预估费用",
|
|
|
|
|
|
"预估费用",
|
|
|
|
|
|
"预计总费用",
|
|
|
|
|
|
"预计费用",
|
|
|
|
|
|
"预计金额",
|
|
|
|
|
|
"申请金额",
|
|
|
|
|
|
"预算",
|
|
|
|
|
|
"金额",
|
|
|
|
|
|
"费用",
|
|
|
|
|
|
)
|
2026-05-25 13:35:39 +08:00
|
|
|
|
APPLICATION_TRANSPORT_OPTIONS = ("飞机", "火车", "轮船")
|
|
|
|
|
|
APPLICATION_TRANSPORT_KEYWORDS = {
|
|
|
|
|
|
"飞机": ("飞机", "机票", "航班", "乘机", "坐飞机"),
|
|
|
|
|
|
"火车": ("火车", "高铁", "动车", "铁路", "列车"),
|
|
|
|
|
|
"轮船": ("轮船", "船", "客轮", "邮轮", "坐船"),
|
|
|
|
|
|
}
|
|
|
|
|
|
APPLICATION_REASON_VERBS = (
|
|
|
|
|
|
"支撑",
|
|
|
|
|
|
"支持",
|
|
|
|
|
|
"部署",
|
|
|
|
|
|
"上线",
|
|
|
|
|
|
"实施",
|
|
|
|
|
|
"驻场",
|
|
|
|
|
|
"拜访",
|
|
|
|
|
|
"验收",
|
|
|
|
|
|
"会议",
|
|
|
|
|
|
"采购",
|
|
|
|
|
|
"培训",
|
|
|
|
|
|
"协助",
|
|
|
|
|
|
"处理",
|
|
|
|
|
|
"办理",
|
|
|
|
|
|
"参加",
|
|
|
|
|
|
"进行",
|
|
|
|
|
|
)
|
|
|
|
|
|
APPLICATION_SUBMIT_KEYWORDS = (
|
|
|
|
|
|
"确认提交",
|
|
|
|
|
|
"确认申请",
|
|
|
|
|
|
"提交审核",
|
|
|
|
|
|
"确认无误提交",
|
|
|
|
|
|
"直接提交",
|
|
|
|
|
|
)
|
|
|
|
|
|
APPLICATION_SHORT_CONFIRMATIONS = {"提交", "确认", "是", "好的", "可以", "没问题"}
|
2026-06-02 14:01:51 +08:00
|
|
|
|
APPLICATION_MISSING_VALUES = {"", "待补充", "待确认", "未知", "暂无", "无", "null", "none"}
|
|
|
|
|
|
APPLICATION_DUPLICATE_IGNORED_STATUSES = {
|
|
|
|
|
|
"cancelled",
|
|
|
|
|
|
"canceled",
|
|
|
|
|
|
"void",
|
|
|
|
|
|
"voided",
|
|
|
|
|
|
"deleted",
|
|
|
|
|
|
"已取消",
|
|
|
|
|
|
"已作废",
|
|
|
|
|
|
"作废",
|
|
|
|
|
|
"已删除",
|
|
|
|
|
|
}
|
2026-05-25 13:35:39 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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":
|
2026-06-02 14:01:51 +08:00
|
|
|
|
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)
|
2026-05-25 13:35:39 +08:00
|
|
|
|
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,
|
2026-06-02 14:01:51 +08:00
|
|
|
|
draft_payload=(
|
|
|
|
|
|
self._build_submitted_application_payload(application_claim, facts)
|
|
|
|
|
|
if step == "submitted"
|
|
|
|
|
|
else None
|
|
|
|
|
|
),
|
2026-05-25 13:35:39 +08:00
|
|
|
|
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}。",
|
2026-06-01 17:07:14 +08:00
|
|
|
|
"请一次性补齐上述字段,我会继续生成申请核对结果并让你确认是否提交。",
|
2026-05-25 13:35:39 +08:00
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
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(
|
|
|
|
|
|
[
|
2026-05-30 15:46:51 +08:00
|
|
|
|
"申请单据已生成,并已进入审批流程。",
|
|
|
|
|
|
f"系统已推送给 {manager_name} 审核,当前节点:{manager_name}审核中。",
|
2026-05-25 13:35:39 +08:00
|
|
|
|
f"申请单号:{application_no}",
|
2026-05-30 15:46:51 +08:00
|
|
|
|
"下方是简要单据信息。需要查看完整详情时,请点击快捷方式进入单据详情。",
|
2026-05-25 13:35:39 +08:00
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-02 14:01:51 +08:00
|
|
|
|
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}",
|
|
|
|
|
|
"如需继续处理,请在单据中心查看该申请;如果本次业务时间不同,请先调整时间后再提交。",
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-25 13:35:39 +08:00
|
|
|
|
return "\n\n".join(
|
|
|
|
|
|
[
|
2026-06-01 17:07:14 +08:00
|
|
|
|
"这是费用申请核对结果,请核对:",
|
2026-05-25 13:35:39 +08:00
|
|
|
|
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": "",
|
2026-06-01 17:07:14 +08:00
|
|
|
|
"applicant": "",
|
2026-05-26 09:15:14 +08:00
|
|
|
|
"grade": "",
|
2026-06-01 17:07:14 +08:00
|
|
|
|
"department": "",
|
|
|
|
|
|
"position": "",
|
|
|
|
|
|
"manager_name": "",
|
2026-05-26 09:15:14 +08:00
|
|
|
|
"lodging_daily_cap": "",
|
|
|
|
|
|
"subsidy_daily_cap": "",
|
|
|
|
|
|
"transport_policy": "",
|
|
|
|
|
|
"policy_estimate": "",
|
|
|
|
|
|
"matched_city": "",
|
|
|
|
|
|
"rule_name": "",
|
|
|
|
|
|
"rule_version": "",
|
2026-06-01 17:07:14 +08:00
|
|
|
|
"hotel_amount": "",
|
|
|
|
|
|
"allowance_amount": "",
|
|
|
|
|
|
"transport_estimated_amount": "",
|
|
|
|
|
|
"transport_estimate_source": "",
|
|
|
|
|
|
"transport_estimate_confidence": "",
|
|
|
|
|
|
"policy_total_amount": "",
|
2026-05-25 13:35:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-05-26 09:15:14 +08:00
|
|
|
|
for key, value in self._resolve_application_preview_facts(payload.context_json or {}).items():
|
|
|
|
|
|
if value:
|
|
|
|
|
|
facts[key] = value
|
|
|
|
|
|
|
2026-06-01 17:07:14 +08:00
|
|
|
|
context_json = payload.context_json or {}
|
2026-06-02 14:01:51 +08:00
|
|
|
|
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)
|
2026-06-01 17:07:14 +08:00
|
|
|
|
if not facts["applicant"]:
|
|
|
|
|
|
facts["applicant"] = str(
|
|
|
|
|
|
context_json.get("name")
|
|
|
|
|
|
or context_json.get("user_name")
|
|
|
|
|
|
or context_json.get("applicant")
|
2026-06-02 14:01:51 +08:00
|
|
|
|
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 "")
|
2026-06-01 17:07:14 +08:00
|
|
|
|
or ""
|
|
|
|
|
|
).strip()
|
|
|
|
|
|
if not facts["department"]:
|
|
|
|
|
|
facts["department"] = str(
|
|
|
|
|
|
context_json.get("department")
|
|
|
|
|
|
or context_json.get("department_name")
|
|
|
|
|
|
or context_json.get("departmentName")
|
2026-06-02 14:01:51 +08:00
|
|
|
|
or current_user.department_name
|
|
|
|
|
|
or (
|
|
|
|
|
|
employee.organization_unit.name
|
|
|
|
|
|
if employee is not None and employee.organization_unit is not None
|
|
|
|
|
|
else ""
|
|
|
|
|
|
)
|
2026-06-01 17:07:14 +08:00
|
|
|
|
or ""
|
|
|
|
|
|
).strip()
|
|
|
|
|
|
if not facts["position"]:
|
|
|
|
|
|
facts["position"] = str(
|
|
|
|
|
|
context_json.get("position")
|
|
|
|
|
|
or context_json.get("employee_position")
|
|
|
|
|
|
or context_json.get("employeePosition")
|
2026-06-02 14:01:51 +08:00
|
|
|
|
or current_user.position
|
|
|
|
|
|
or (employee.position if employee is not None else "")
|
2026-06-01 17:07:14 +08:00
|
|
|
|
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")
|
2026-06-02 14:01:51 +08:00
|
|
|
|
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 ""
|
|
|
|
|
|
)
|
2026-06-01 17:07:14 +08:00
|
|
|
|
or ""
|
|
|
|
|
|
).strip()
|
|
|
|
|
|
|
2026-05-25 13:35:39 +08:00
|
|
|
|
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", ""),
|
2026-05-30 15:46:51 +08:00
|
|
|
|
payload.context_json or {},
|
2026-05-25 13:35:39 +08:00
|
|
|
|
)
|
2026-06-02 14:01:51 +08:00
|
|
|
|
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}天"
|
2026-06-01 17:07:14 +08:00
|
|
|
|
apply_application_system_estimate_to_facts(facts)
|
2026-05-25 13:35:39 +08:00
|
|
|
|
return facts
|
|
|
|
|
|
|
2026-05-26 09:15:14 +08:00
|
|
|
|
@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 ""
|
|
|
|
|
|
|
2026-06-02 14:01:51 +08:00
|
|
|
|
reason = UserAgentApplicationMixin._cleanup_application_reason_candidate(pick("reason"))
|
2026-05-26 09:15:14 +08:00
|
|
|
|
return {
|
|
|
|
|
|
"application_type": pick("applicationType", "application_type"),
|
|
|
|
|
|
"time": pick("time", "timeRange", "time_range"),
|
|
|
|
|
|
"location": pick("location"),
|
2026-06-02 14:01:51 +08:00
|
|
|
|
"reason": reason,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
"days": pick("days"),
|
|
|
|
|
|
"transport_mode": pick("transportMode", "transport_mode"),
|
|
|
|
|
|
"amount": pick("amount"),
|
2026-06-01 17:07:14 +08:00
|
|
|
|
"applicant": pick("applicant", "name", "userName", "user_name"),
|
2026-05-26 09:15:14 +08:00
|
|
|
|
"grade": pick("grade"),
|
2026-06-01 17:07:14 +08:00
|
|
|
|
"department": pick("department", "departmentName", "department_name"),
|
|
|
|
|
|
"position": pick("position", "employeePosition", "employee_position"),
|
|
|
|
|
|
"manager_name": pick("managerName", "manager_name", "directManagerName", "direct_manager_name"),
|
2026-05-26 09:15:14 +08:00
|
|
|
|
"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"),
|
2026-06-01 17:07:14 +08:00
|
|
|
|
"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"),
|
2026-05-26 09:15:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-02 14:01:51 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _is_application_missing_value(value: object) -> bool:
|
|
|
|
|
|
return str(value or "").strip().lower() in APPLICATION_MISSING_VALUES
|
|
|
|
|
|
|
2026-05-25 13:35:39 +08:00
|
|
|
|
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
|
2026-06-01 17:07:14 +08:00
|
|
|
|
for field in ("transport_mode",)
|
2026-05-25 13:35:39 +08:00
|
|
|
|
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,
|
2026-06-02 14:01:51 +08:00
|
|
|
|
APPLICATION_TIME_LABELS,
|
2026-05-25 13:35:39 +08:00
|
|
|
|
)
|
|
|
|
|
|
if labeled:
|
|
|
|
|
|
return labeled
|
2026-06-02 14:01:51 +08:00
|
|
|
|
range_match = re.search(
|
|
|
|
|
|
r"(?P<start>20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)\s*(?:至|到|~|—|–|--)\s*(?P<end>20\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('日')}"
|
2026-05-25 13:35:39 +08:00
|
|
|
|
match = re.search(
|
|
|
|
|
|
r"(?P<date>20\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
|
|
|
|
|
|
|
2026-06-02 14:01:51 +08:00
|
|
|
|
@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]
|
|
|
|
|
|
|
2026-05-25 13:35:39 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _resolve_application_labeled_value(message: str, labels: tuple[str, ...]) -> str:
|
|
|
|
|
|
label_pattern = "|".join(re.escape(label) for label in labels)
|
2026-06-02 14:01:51 +08:00
|
|
|
|
next_label_pattern = "|".join(re.escape(label) for label in APPLICATION_FIELD_LABELS)
|
2026-05-25 13:35:39 +08:00
|
|
|
|
match = re.search(
|
2026-06-02 14:01:51 +08:00
|
|
|
|
rf"(?:{label_pattern})[::]\s*(?P<value>[\s\S]*?)(?=\s*(?:{next_label_pattern})[::]|[\n,。;;]|$)",
|
2026-05-25 13:35:39 +08:00
|
|
|
|
str(message or ""),
|
|
|
|
|
|
)
|
|
|
|
|
|
return match.group("value").strip() if match else ""
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_application_location(
|
|
|
|
|
|
self,
|
|
|
|
|
|
payload: UserAgentRequest,
|
|
|
|
|
|
*,
|
|
|
|
|
|
message: str,
|
|
|
|
|
|
use_entities: bool,
|
|
|
|
|
|
) -> str:
|
2026-05-26 09:15:14 +08:00
|
|
|
|
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)
|
2026-05-25 13:35:39 +08:00
|
|
|
|
|
|
|
|
|
|
@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<target>[\u4e00-\u9fa5]{1,24})",
|
|
|
|
|
|
r"(?P<target>[\u4e00-\u9fa5]{1,12})(?:出差|驻场)",
|
|
|
|
|
|
):
|
|
|
|
|
|
match = re.search(pattern, compact)
|
|
|
|
|
|
if not match:
|
|
|
|
|
|
continue
|
|
|
|
|
|
target = str(match.group("target") or "").strip()
|
2026-05-26 09:15:14 +08:00
|
|
|
|
location = normalize_application_location(target)
|
2026-05-25 13:35:39 +08:00
|
|
|
|
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<days>\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:
|
2026-06-02 14:01:51 +08:00
|
|
|
|
return UserAgentApplicationMixin._cleanup_application_reason_candidate(labeled)
|
2026-05-25 13:35:39 +08:00
|
|
|
|
|
|
|
|
|
|
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 ""
|
2026-06-02 14:01:51 +08:00
|
|
|
|
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)
|
2026-05-25 13:35:39 +08:00
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _cleanup_application_reason_candidate(segment: str) -> str:
|
|
|
|
|
|
text = str(segment or "").strip()
|
|
|
|
|
|
if not text:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
text = re.sub(
|
2026-06-02 14:01:51 +08:00
|
|
|
|
r"^(?:行程时间|招待时间|发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|目的地|天数|出差天数|申请天数|出行方式|交通方式|交通工具|出行工具|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额|费用)[::]\s*",
|
2026-05-25 13:35:39 +08:00
|
|
|
|
"",
|
|
|
|
|
|
text,
|
|
|
|
|
|
)
|
2026-06-02 14:01:51 +08:00
|
|
|
|
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 ""
|
2026-05-25 13:35:39 +08:00
|
|
|
|
if re.fullmatch(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?", text):
|
|
|
|
|
|
return ""
|
|
|
|
|
|
if re.fullmatch(r"(?P<days>\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<days>\d+|[一二两三四五六七八九十]{1,3})?天?", text):
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
text = re.sub(r"^.*?(?:出差|前往|去|到|赴)[\u4e00-\u9fa5]{1,8}(?:出差)?(?P<days>\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
|
2026-05-30 15:46:51 +08:00
|
|
|
|
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 {},
|
2026-05-25 13:35:39 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
("用户预估费用", "预估费用", "预计总费用", "预计费用", "预计金额", "申请金额", "预算", "费用", "金额"),
|
2026-05-25 13:35:39 +08:00
|
|
|
|
)
|
|
|
|
|
|
if labeled:
|
|
|
|
|
|
return UserAgentApplicationMixin._normalize_application_amount(labeled)
|
|
|
|
|
|
match = re.search(
|
|
|
|
|
|
r"(?P<amount>\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": "申请类型",
|
2026-06-01 17:07:14 +08:00
|
|
|
|
"amount": "系统预估费用",
|
2026-06-02 14:01:51 +08:00
|
|
|
|
"time_range": "申请时间",
|
|
|
|
|
|
"time": "申请时间",
|
2026-05-25 13:35:39 +08:00
|
|
|
|
"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 = {
|
2026-06-02 14:01:51 +08:00
|
|
|
|
"time": ("补充申请时间", "申请时间段:"),
|
2026-05-25 13:35:39 +08:00
|
|
|
|
"location": ("补充地点", "地点:"),
|
|
|
|
|
|
"reason": ("补充申请事由", "事由:"),
|
|
|
|
|
|
"days": ("补充天数", "天数:"),
|
|
|
|
|
|
"transport_mode": ("补充出行方式", "出行方式:"),
|
2026-06-01 17:07:14 +08:00
|
|
|
|
"amount": ("补充系统预估费用", "系统预估费用:"),
|
2026-05-25 13:35:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
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
|
2026-06-02 14:01:51 +08:00
|
|
|
|
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)
|
2026-05-25 13:35:39 +08:00
|
|
|
|
return "\n".join(
|
|
|
|
|
|
f"{label}:{value or '待补充'}"
|
|
|
|
|
|
for label, value in (
|
|
|
|
|
|
("申请类型", facts.get("application_type", "")),
|
2026-06-01 17:07:14 +08:00
|
|
|
|
("姓名", facts.get("applicant", "")),
|
|
|
|
|
|
("部门", facts.get("department", "")),
|
|
|
|
|
|
("岗位", facts.get("position", "")),
|
|
|
|
|
|
("职级", facts.get("grade", "")),
|
|
|
|
|
|
("直属领导", facts.get("manager_name", "")),
|
2026-06-02 14:01:51 +08:00
|
|
|
|
(time_label, facts.get("time", "")),
|
2026-05-25 13:35:39 +08:00
|
|
|
|
("地点", facts.get("location", "")),
|
|
|
|
|
|
("事由", facts.get("reason", "")),
|
|
|
|
|
|
("天数", facts.get("days", "")),
|
|
|
|
|
|
("出行方式", facts.get("transport_mode", "")),
|
2026-05-26 09:15:14 +08:00
|
|
|
|
("住宿上限/天", facts.get("lodging_daily_cap", "")),
|
|
|
|
|
|
("补贴标准/天", facts.get("subsidy_daily_cap", "")),
|
|
|
|
|
|
("交通费用口径", facts.get("transport_policy", "")),
|
|
|
|
|
|
("规则测算参考", facts.get("policy_estimate", "")),
|
2026-06-01 17:07:14 +08:00
|
|
|
|
("系统预估费用", facts.get("amount", "")),
|
2026-05-25 13:35:39 +08:00
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-02 14:01:51 +08:00
|
|
|
|
@classmethod
|
2026-05-25 13:35:39 +08:00
|
|
|
|
def _build_application_summary_table(
|
2026-06-02 14:01:51 +08:00
|
|
|
|
cls,
|
2026-05-25 13:35:39 +08:00
|
|
|
|
facts: dict[str, str],
|
|
|
|
|
|
*,
|
|
|
|
|
|
include_empty: bool = True,
|
|
|
|
|
|
) -> str:
|
2026-06-02 14:01:51 +08:00
|
|
|
|
time_label = cls._resolve_application_time_label(facts)
|
2026-05-25 13:35:39 +08:00
|
|
|
|
rows = [
|
|
|
|
|
|
("申请类型", facts.get("application_type", "")),
|
2026-06-01 17:07:14 +08:00
|
|
|
|
("姓名", facts.get("applicant", "")),
|
|
|
|
|
|
("部门", facts.get("department", "")),
|
|
|
|
|
|
("岗位", facts.get("position", "")),
|
|
|
|
|
|
("职级", facts.get("grade", "")),
|
|
|
|
|
|
("直属领导", facts.get("manager_name", "")),
|
2026-06-02 14:01:51 +08:00
|
|
|
|
(time_label, facts.get("time", "")),
|
2026-05-25 13:35:39 +08:00
|
|
|
|
("地点", facts.get("location", "")),
|
|
|
|
|
|
("事由", facts.get("reason", "")),
|
|
|
|
|
|
("天数", facts.get("days", "")),
|
|
|
|
|
|
("出行方式", facts.get("transport_mode", "")),
|
2026-05-26 09:15:14 +08:00
|
|
|
|
("住宿上限/天", facts.get("lodging_daily_cap", "")),
|
|
|
|
|
|
("补贴标准/天", facts.get("subsidy_daily_cap", "")),
|
|
|
|
|
|
("交通费用口径", facts.get("transport_policy", "")),
|
|
|
|
|
|
("规则测算参考", facts.get("policy_estimate", "")),
|
2026-06-01 17:07:14 +08:00
|
|
|
|
("系统预估费用", facts.get("amount", "")),
|
2026-05-25 13:35:39 +08:00
|
|
|
|
]
|
|
|
|
|
|
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="直属领导审批",
|
2026-05-26 09:15:14 +08:00
|
|
|
|
risk_flags_json=[self._build_application_detail_flag(facts)],
|
2026-05-25 13:35:39 +08:00
|
|
|
|
)
|
|
|
|
|
|
self.db.add(claim)
|
2026-06-01 17:07:14 +08:00
|
|
|
|
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]
|
2026-05-25 13:35:39 +08:00
|
|
|
|
self.db.commit()
|
|
|
|
|
|
self.db.refresh(claim)
|
|
|
|
|
|
return claim
|
|
|
|
|
|
|
2026-06-02 14:01:51 +08:00
|
|
|
|
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 {}
|
|
|
|
|
|
|
2026-05-26 09:15:14 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _build_application_detail_flag(facts: dict[str, str]) -> dict[str, object]:
|
2026-06-01 17:07:14 +08:00
|
|
|
|
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(),
|
|
|
|
|
|
},
|
2026-05-26 09:15:14 +08:00
|
|
|
|
},
|
2026-06-01 17:07:14 +08:00
|
|
|
|
"expense_application",
|
|
|
|
|
|
)
|
2026-05-26 09:15:14 +08:00
|
|
|
|
|
2026-05-25 13:35:39 +08:00
|
|
|
|
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(),
|
2026-06-02 14:01:51 +08:00
|
|
|
|
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(),
|
2026-06-01 17:07:14 +08:00
|
|
|
|
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(),
|
2026-05-25 13:35:39 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@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:
|
2026-05-26 09:15:14 +08:00
|
|
|
|
return build_document_number("application", timestamp=datetime.now(UTC))
|
2026-05-25 13:35:39 +08:00
|
|
|
|
|
|
|
|
|
|
def _build_application_claim_no(
|
|
|
|
|
|
self,
|
|
|
|
|
|
payload: UserAgentRequest,
|
|
|
|
|
|
facts: dict[str, str],
|
|
|
|
|
|
) -> str:
|
2026-05-26 09:15:14 +08:00
|
|
|
|
return generate_unique_expense_claim_no(
|
|
|
|
|
|
self.db,
|
|
|
|
|
|
"application",
|
|
|
|
|
|
timestamp=datetime.now(UTC),
|
2026-05-25 13:35:39 +08:00
|
|
|
|
)
|