Files
X-Financial/server/src/app/services/user_agent_application.py
caoxiaozhu 7989f3a159 feat: 新增风险图谱算法与系统仪表盘及操作反馈体系
后端新增风险图谱算法模块、风险观察与反馈服务、规则 DSL
校验器和可解释性引擎,完善系统仪表盘和财务仪表盘统计,
优化 agent 运行和编排执行链路,清理旧开发文档,前端新增
系统趋势、负载热力图等多种仪表盘图表组件,完善操作反馈
对话框和工作台日期选择器,优化报销创建和审批详情交互,
补充单元测试覆盖。
2026-05-30 15:46:51 +08:00

913 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import re
from datetime import UTC, datetime
from decimal import Decimal, InvalidOperation
from sqlalchemy import 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.document_numbering import (
build_document_number,
generate_unique_expense_claim_no,
)
from app.services.user_agent_application_dates import expand_application_time_with_days
from app.services.user_agent_application_locations import normalize_application_location
APPLICATION_CONTEXT_VALUES = {
"application",
"documents_application",
"expense_application",
"pre_approval",
"preapproval",
}
APPLICATION_BASE_FIELDS = ("time", "location", "reason")
APPLICATION_TRANSPORT_OPTIONS = ("飞机", "火车", "轮船")
APPLICATION_TRANSPORT_KEYWORDS = {
"飞机": ("飞机", "机票", "航班", "乘机", "坐飞机"),
"火车": ("火车", "高铁", "动车", "铁路", "列车"),
"轮船": ("轮船", "", "客轮", "邮轮", "坐船"),
}
APPLICATION_REASON_VERBS = (
"支撑",
"支持",
"部署",
"上线",
"实施",
"驻场",
"拜访",
"验收",
"会议",
"采购",
"培训",
"协助",
"处理",
"办理",
"参加",
"进行",
)
APPLICATION_SUBMIT_KEYWORDS = (
"确认提交",
"确认申请",
"提交审核",
"确认无误提交",
"直接提交",
)
APPLICATION_SHORT_CONFIRMATIONS = {"提交", "确认", "", "好的", "可以", "没问题"}
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._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),
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}",
"下方是简要单据信息。需要查看完整详情时,请点击快捷方式进入单据详情。",
]
)
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": "",
"grade": "",
"lodging_daily_cap": "",
"subsidy_daily_cap": "",
"transport_policy": "",
"policy_estimate": "",
"matched_city": "",
"rule_name": "",
"rule_version": "",
}
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
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 {},
)
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 ""
return {
"application_type": pick("applicationType", "application_type"),
"time": pick("time", "timeRange", "time_range"),
"location": pick("location"),
"reason": pick("reason"),
"days": pick("days"),
"transport_mode": pick("transportMode", "transport_mode"),
"amount": pick("amount"),
"grade": pick("grade"),
"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"),
}
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", "amount")
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,
("发生时间", "业务发生时间", "申请时间", "时间"),
)
if labeled:
return labeled
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
@staticmethod
def _resolve_application_labeled_value(message: str, labels: tuple[str, ...]) -> str:
label_pattern = "|".join(re.escape(label) for label in labels)
match = re.search(
rf"(?:{label_pattern})[:]\s*(?P<value>[^\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<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()
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<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:
return 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 ""
return 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}日?", 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
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<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": "申请类型",
"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 _build_application_summary(facts: dict[str, str]) -> str:
return "\n".join(
f"{label}{value or '待补充'}"
for label, value in (
("申请类型", facts.get("application_type", "")),
("发生时间", facts.get("time", "")),
("地点", facts.get("location", "")),
("事由", facts.get("reason", "")),
("天数", facts.get("days", "")),
("出行方式", facts.get("transport_mode", "")),
("职级", facts.get("grade", "")),
("住宿上限/天", facts.get("lodging_daily_cap", "")),
("补贴标准/天", facts.get("subsidy_daily_cap", "")),
("交通费用口径", facts.get("transport_policy", "")),
("规则测算参考", facts.get("policy_estimate", "")),
("用户预估费用", facts.get("amount", "")),
)
)
@staticmethod
def _build_application_summary_table(
facts: dict[str, str],
*,
include_empty: bool = True,
) -> str:
rows = [
("申请类型", facts.get("application_type", "")),
("发生时间", facts.get("time", "")),
("地点", facts.get("location", "")),
("事由", facts.get("reason", "")),
("天数", facts.get("days", "")),
("出行方式", facts.get("transport_mode", "")),
("职级", facts.get("grade", "")),
("住宿上限/天", 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.commit()
self.db.refresh(claim)
return claim
@staticmethod
def _build_application_detail_flag(facts: dict[str, str]) -> dict[str, object]:
return {
"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(),
},
}
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(),
)
@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),
)