Files
X-Financial/server/src/app/services/user_agent_application.py
caoxiaozhu 0264a4b5b4 refactor(server): user_agent/steward/ocr 等服务重构并适配关联任务
- user_agent 拆分 application/locations/knowledge/response/review 四个子模块,接入申请位置语义与关联草稿分支
- steward planner/runtime/slot/plan_builder 决策链路重构,travel_reimbursement_calculator/orchestrator_expense_query 适配
- ocr/document_preview/document_intelligence/receipt_folder 复用预览与资产缓存,expense_claim_draft_flow/application_handoff 适配
- pyproject.toml 新增依赖,paddleocr bootstrap 脚本与 server_start.sh 调整
- 更新差旅/交通/通信等财务规则表,同步 document_intelligence/ocr/receipt_folder/user_agent 等测试
2026-06-24 10:42:24 +08:00

1563 lines
64 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 or_, select
from app.api.deps import CurrentUserContext
from app.models.financial_record import ExpenseClaim
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
from app.schemas.user_agent import (
UserAgentDraftPayload,
UserAgentRequest,
UserAgentResponse,
UserAgentSuggestedAction,
)
from app.services.application_location_semantics import (
strip_route_location_prefix_with_jieba,
validate_application_location_text,
)
from app.services.application_system_estimate import apply_application_system_estimate_to_facts
from app.services.document_numbering import (
build_document_number,
generate_unique_expense_claim_no,
)
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
from app.services.expense_claim_risk_stage import with_risk_business_stage
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
from app.services.user_agent_application_dates import (
expand_application_time_with_days,
resolve_application_date_range,
resolve_application_days_from_time_range,
)
from app.services.user_agent_application_locations import normalize_application_location
from app.services.user_agent_application_summary import (
build_application_summary,
build_application_summary_table,
resolve_application_time_label,
)
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_TYPE_DISPLAY_LABELS = {
"travel": "差旅费用申请",
"travel_application": "差旅费用申请",
"expense_application": "费用申请",
"application": "费用申请",
"transport": "交通费用申请",
"transportation": "交通费用申请",
"traffic": "交通费用申请",
"hotel": "住宿费用申请",
"accommodation": "住宿费用申请",
"meeting": "会务费用申请",
"conference": "会务费用申请",
"purchase": "采购费用申请",
"procurement": "采购费用申请",
"training": "培训费用申请",
"business_entertainment": "业务招待申请",
"entertainment": "业务招待申请",
"office": "办公费用申请",
}
APPLICATION_REASON_VERBS = (
"支撑",
"支持",
"部署",
"上线",
"实施",
"驻场",
"拜访",
"验收",
"会议",
"采购",
"培训",
"协助",
"处理",
"办理",
"参加",
"进行",
)
APPLICATION_SUBMIT_KEYWORDS = (
"确认提交",
"确认申请",
"提交审核",
"确认无误提交",
"直接提交",
)
APPLICATION_SAVE_DRAFT_KEYWORDS = (
"保存草稿",
"保存申请草稿",
"存草稿",
"先保存",
)
APPLICATION_SHORT_CONFIRMATIONS = {"提交", "确认", "", "好的", "可以", "没问题"}
APPLICATION_MISSING_VALUES = {"", "待补充", "待确认", "未知", "暂无", "", "null", "none"}
APPLICATION_DUPLICATE_IGNORED_STATUSES = {
"cancelled",
"canceled",
"void",
"voided",
"deleted",
"已取消",
"已作废",
"作废",
"已删除",
}
class UserAgentApplicationSlotMixin:
@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()
]
@staticmethod
def _resolve_application_validation_issues(facts: dict[str, str]) -> list[dict[str, str]]:
issues: list[dict[str, str]] = []
location_error = validate_application_location_text(facts.get("location", ""))
if location_error:
issues.append({
"field": "location",
"message": location_error,
})
return issues
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:
departure_time = UserAgentApplicationMixin._resolve_application_labeled_value(
message,
("出发时间", "出发日期"),
)
return_time = UserAgentApplicationMixin._resolve_application_labeled_value(
message,
("返回时间", "返回日期"),
)
if departure_time and return_time:
return departure_time if departure_time == return_time else f"{departure_time}{return_time}"
labeled = UserAgentApplicationMixin._resolve_application_labeled_value(
message,
APPLICATION_TIME_LABELS,
)
if labeled:
return labeled
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('')}"
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 _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)
table_match = re.search(
rf"\|\s*(?:{label_pattern})\s*\|\s*(?P<value>[^|\n]+?)\s*\|",
str(message or ""),
)
if table_match:
return table_match.group("value").strip()
next_label_pattern = "|".join(re.escape(label) for label in APPLICATION_FIELD_LABELS)
match = re.search(
rf"(?:{label_pattern})[:]\s*(?P<value>[\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<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 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<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 ""
tokenized = strip_route_location_prefix_with_jieba(text)
if tokenized != text:
text = tokenized
else:
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._normalize_application_type_label(
UserAgentApplicationMixin._resolve_application_labeled_value(
message,
("申请类型", "费用类型"),
)
)
@staticmethod
def _normalize_application_type_label(value: object, fallback: str = "") -> str:
raw_value = str(value or "").strip()
if not raw_value:
return str(fallback or "").strip()
normalized_key = raw_value.lower()
if normalized_key in APPLICATION_TYPE_DISPLAY_LABELS:
return APPLICATION_TYPE_DISPLAY_LABELS[normalized_key]
if re.fullmatch(r"(差旅费|差旅|出差)", raw_value):
return "差旅费用申请"
if re.fullmatch(r"(交通费|交通)", raw_value):
return "交通费用申请"
if re.fullmatch(r"(住宿费|住宿|酒店)", raw_value):
return "住宿费用申请"
if re.fullmatch(r"(会务|会议|会务费)", raw_value):
return "会务费用申请"
if re.fullmatch(r"(采购|采购费|办公用品)", raw_value):
return "采购费用申请"
if raw_value.endswith("费用申请") or raw_value.endswith("申请"):
return raw_value
if raw_value.endswith("费用"):
return f"{raw_value}申请"
if raw_value.endswith(""):
return f"{raw_value[:-1]}费用申请"
return raw_value
@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 in {"ask_missing", "ask_invalid"}:
missing_fields = (
self._resolve_application_missing_fields(facts)
if step == "ask_missing"
else [
issue.get("field", "")
for issue in self._resolve_application_validation_issues(facts)
if issue.get("field")
]
)
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 "差旅费用申请"
class UserAgentApplicationPersistenceMixin:
@staticmethod
def _resolve_application_edit_claim_id(context_json: dict[str, object]) -> str:
if not isinstance(context_json, dict):
return ""
is_edit_mode = bool(context_json.get("application_edit_mode") or context_json.get("applicationEditMode"))
claim_id = str(
context_json.get("application_edit_claim_id")
or context_json.get("applicationEditClaimId")
or ""
).strip()
return claim_id if is_edit_mode and claim_id else ""
@staticmethod
def _is_expense_application_claim_like(claim: ExpenseClaim) -> bool:
expense_type = str(claim.expense_type or "").strip().lower()
claim_no = str(claim.claim_no or "").strip().upper()
flags = claim.risk_flags_json
if isinstance(flags, dict):
flags = [flags]
if not isinstance(flags, list):
flags = []
has_application_detail = any(
isinstance(flag, dict)
and (
str(flag.get("business_stage") or "").strip() == "expense_application"
or isinstance(flag.get("application_detail"), dict)
)
for flag in flags
)
return (
expense_type in {"application", "expense_application"}
or expense_type.endswith("_application")
or claim_no.startswith("AP-")
or claim_no.startswith("APP-")
or has_application_detail
)
def _find_editable_expense_application_record(
self,
payload: UserAgentRequest,
) -> ExpenseClaim | None:
claim_id = self._resolve_application_edit_claim_id(payload.context_json or {})
if not claim_id:
return None
claim = self.db.get(ExpenseClaim, claim_id)
if claim is None:
raise ValueError("未找到要修改的申请单。")
if not self._is_expense_application_claim_like(claim):
raise ValueError("只能修改申请单。")
current_user = self._build_application_current_user(payload)
access_policy = ExpenseClaimAccessPolicy(self.db)
if not (current_user.is_admin or access_policy.is_claim_owned_by_current_user(claim, current_user)):
raise ValueError("只能修改本人被退回的申请单。")
status = str(claim.status or "").strip().lower()
if status not in {"returned", "draft", "supplement"}:
raise ValueError("当前申请单状态不支持修改。")
return claim
def _update_expense_application_record(
self,
payload: UserAgentRequest,
facts: dict[str, str],
claim: ExpenseClaim,
*,
submit: bool,
) -> ExpenseClaim:
current_user = self._build_application_current_user(payload)
flags = claim.risk_flags_json
if isinstance(flags, dict):
flags = [flags]
if not isinstance(flags, list):
flags = []
preserved_flags = [
flag
for flag in flags
if not (
isinstance(flag, dict)
and str(flag.get("source") or "").strip() == "application_detail"
)
]
claim.expense_type = self._resolve_application_expense_type_code(facts)
claim.reason = str(facts.get("reason") or "费用申请").strip() or "费用申请"
claim.location = str(facts.get("location") or "待补充").strip() or "待补充"
claim.amount = self._parse_application_amount_to_decimal(facts.get("amount", ""))
claim.occurred_at = self._parse_application_occurred_at(facts.get("time", ""))
claim.risk_flags_json = [*preserved_flags, self._build_application_detail_flag(facts)]
if not submit:
claim.status = "draft"
claim.approval_stage = "待提交"
claim.submitted_at = None
self.db.commit()
self.db.refresh(claim)
return claim
from app.services.expense_claims import ExpenseClaimService
submitted = ExpenseClaimService(self.db).submit_claim(claim.id, current_user)
if submitted is None:
raise ValueError("未找到可修改的申请单。")
return submitted
def _create_expense_application_record(
self,
payload: UserAgentRequest,
facts: dict[str, str],
*,
submit: bool,
) -> 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) if submit else None,
status="submitted" if submit else "draft",
approval_stage="直属领导审批" if submit else "待提交",
risk_flags_json=[self._build_application_detail_flag(facts)],
)
self.db.add(claim)
self.db.flush()
if submit:
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_range = resolve_application_date_range(facts.get("time", ""))
current_time = cls._normalize_application_time_identity(facts.get("time"))
existing_detail = cls._extract_application_detail_from_claim(claim)
existing_range = resolve_application_date_range(existing_detail.get("time"))
if existing_range is None and claim.occurred_at is not None:
existing_day = claim.occurred_at.date()
existing_range = (existing_day, existing_day)
if current_range is None and occurred_at is not None:
current_day = occurred_at.date()
current_range = (current_day, current_day)
if current_range is not None and existing_range is not None:
return current_range[0] <= existing_range[1] and existing_range[0] <= current_range[1]
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_persisted_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=build_application_summary(facts),
confirmation_required=False,
claim_id=claim.id,
claim_no=claim.claim_no,
status=claim.status,
approval_stage=claim.approval_stage,
)
@staticmethod
def _is_application_save_draft_action(payload: UserAgentRequest) -> bool:
context_json = payload.context_json or {}
action = str(
context_json.get("application_action")
or context_json.get("applicationAction")
or ""
).strip().lower()
if action in {"save_draft", "application_save_draft", "draft"}:
return True
if bool(context_json.get("application_save_mode") or context_json.get("applicationSaveMode")):
return True
compact_message = re.sub(r"\s+", "", str(payload.message or ""))
return any(keyword in compact_message for keyword in APPLICATION_SAVE_DRAFT_KEYWORDS)
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),
)
class UserAgentApplicationMixin(UserAgentApplicationSlotMixin, UserAgentApplicationPersistenceMixin):
@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 in {"draft", "submitted"}:
editable_claim = self._find_editable_expense_application_record(payload)
if editable_claim is not None:
application_claim = self._update_expense_application_record(
payload,
facts,
editable_claim,
submit=step == "submitted",
)
facts["application_edit_mode"] = "true"
elif 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,
submit=True,
)
else:
application_claim = self._create_expense_application_record(
payload,
facts,
submit=False,
)
if application_claim is not None:
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_persisted_application_payload(application_claim, facts)
if step in {"draft", "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 = 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 == "ask_invalid":
issue_messages = [
item["message"]
for item in self._resolve_application_validation_issues(facts)
if str(item.get("message") or "").strip()
]
return "\n\n".join(
[
"我已识别到申请信息里有需要先修正的字段。",
"已识别信息:\n" + recognized_table,
*issue_messages,
"请把地点改为真实出差地点,业务事项放在事由中;修正后我再帮您提交申请。",
]
)
if step == "draft":
application_no = str(facts.get("application_no") or "").strip()
return "\n\n".join(
[
"申请草稿已保存。",
f"草稿单号:{application_no}" if application_no else "草稿单号:待生成",
"当前节点:待提交。",
"后续可进入单据详情继续核对、补充或提交审批。",
]
)
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 "直属领导"
submitted_title = (
"申请单据已修改并重新提交,已进入审批流程。"
if str(facts.get("application_edit_mode") or "").strip().lower() == "true"
else "申请单据已生成,并已进入审批流程。"
)
return "\n\n".join(
[
submitted_title,
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 = resolve_application_time_label(facts)
return "\n\n".join(
[
f"检测到同一申请人、同一申请类型、同一{time_label}已存在申请单,系统没有重复创建。",
f"已有申请单号:{application_no}",
f"当前节点:{stage}",
"如需继续处理,请在单据中心查看该申请;如果本次业务时间不同,请先调整时间后再提交。",
]
)
return "\n\n".join(
[
"这是费用申请核对结果,请核对:",
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
facts["application_type"] = self._normalize_application_type_label(facts.get("application_type", ""))
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}"
self._apply_rule_center_travel_policy_to_application_facts(payload, facts)
apply_application_system_estimate_to_facts(facts)
return facts
def _apply_rule_center_travel_policy_to_application_facts(
self,
payload: UserAgentRequest,
facts: dict[str, str],
) -> None:
if "差旅" not in str(facts.get("application_type") or "") and "出差" not in str(facts.get("application_type") or ""):
return
location = str(facts.get("location") or "").strip()
grade = str(facts.get("grade") or "").strip()
if not location or not grade:
return
days = self._parse_application_days_count(facts.get("days", "")) or 1
try:
result = TravelReimbursementCalculatorService(self.db).calculate(
TravelReimbursementCalculatorRequest(days=days, location=location, grade=grade),
self._build_application_current_user(payload),
)
except ValueError:
return
hotel_rate = self._format_application_policy_money(result.hotel_rate)
hotel_amount = self._format_application_policy_money(result.hotel_amount)
allowance_rate = self._format_application_policy_money(result.total_allowance_rate)
allowance_amount = self._format_application_policy_money(result.allowance_amount)
if hotel_rate:
facts["lodging_daily_cap"] = f"{hotel_rate}元/天"
if hotel_amount:
facts["hotel_amount"] = f"{hotel_amount}"
if allowance_rate:
facts["subsidy_daily_cap"] = f"{allowance_rate}元/天"
if allowance_amount:
facts["allowance_amount"] = f"{allowance_amount}"
if str(result.matched_city or "").strip():
facts["matched_city"] = str(result.matched_city).strip()
if str(result.rule_name or "").strip():
facts["rule_name"] = str(result.rule_name).strip()
if str(result.rule_version or "").strip():
facts["rule_version"] = str(result.rule_version).strip()
@staticmethod
def _format_application_policy_money(value: object) -> str:
try:
amount = Decimal(str(value or "0")).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return ""
if amount == amount.to_integral():
return f"{int(amount):,}"
return f"{amount:,.2f}".rstrip("0").rstrip(".")
@staticmethod
def _parse_application_days_count(value: object) -> int:
match = re.search(r"\d+", str(value or ""))
if not match:
return 0
try:
return max(0, int(match.group(0)))
except ValueError:
return 0
@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": UserAgentApplicationMixin._normalize_application_type_label(
pick("applicationType", "application_type")
),
"time": pick("time", "timeRange", "time_range"),
"location": normalize_application_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_validation_issues(facts):
return "ask_invalid"
if self._is_application_save_draft_action(payload):
return "draft"
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"