feat: 增加差旅报销标准测算和财务终审流程

新增差旅报销测算接口及 Spreadsheet 规则解析,审批流程拆分
直属领导审批与财务终审两阶段并细分权限,修复 PDF 文本层
缺失时自动回退 OCR,提交后清理关联会话,前端适配审批流
交互并补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-21 09:28:33 +08:00
parent 002bf4f756
commit 8f65661809
43 changed files with 4366 additions and 410 deletions

View File

@@ -34,6 +34,7 @@ from app.schemas.user_agent import (
from app.services.agent_assets import AgentAssetService
from app.services.agent_foundation import AgentFoundationService
from app.services.expense_claims import ExpenseClaimService
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService, RuntimeTravelPolicy, resolve_document_type_label
from app.services.risk_ontology_bridge import resolve_rule_codes_for_risk_check
from app.services.runtime_chat import RuntimeChatService
@@ -185,6 +186,7 @@ DOCUMENT_AMOUNT_PATTERN = re.compile(
r"[:\s¥¥人民币]*([0-9]+(?:[.,][0-9]{1,2})?)"
)
DOCUMENT_CURRENCY_AMOUNT_PATTERN = re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)")
TRAVEL_REVIEW_HOTEL_NIGHT_PATTERN = re.compile(r"(\d+)\s*(?:晚|间夜)")
SOURCE_LABELS = {
"user_text": "用户描述",
@@ -197,6 +199,8 @@ SOURCE_LABELS = {
"system": "系统判断",
}
DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = ("历史报销画像", "用户画像", "制度注意事项", "制度注意")
SCENE_REQUIRED_SLOT_KEYS = {
"hotel": {"merchant_name"},
"meeting": {"location"},
@@ -2193,8 +2197,8 @@ class UserAgentService:
for reason in self._resolve_submission_blocked_reasons(payload):
briefs.append(
UserAgentReviewRiskBrief(
title="AI预审未通过",
level="high",
title="提交风险提示",
level=self._resolve_submission_blocked_risk_level(reason),
content=reason,
detail=(
"该项属于提交审批前的阻断条件。系统会先要求补齐基础字段、附件或业务说明,"
@@ -2204,6 +2208,14 @@ class UserAgentService:
)
)
briefs.extend(
self._build_travel_policy_precheck_briefs(
payload,
document_cards=document_cards,
claim_groups=claim_groups,
)
)
employee = self._resolve_employee_profile(payload)
employee_name = (
str(employee.name).strip()
@@ -2211,7 +2223,10 @@ class UserAgentService:
else self._collect_entity_values(payload).get("employee_name")
or str(payload.context_json.get("name") or "").strip()
)
if employee_name:
current_amount = self._resolve_amount_value(payload) or sum(
self._extract_amount_from_card(card) for card in document_cards
)
if employee_name and current_amount > 0:
since = datetime.now(UTC) - timedelta(days=90)
claim_identity_conditions = [ExpenseClaim.employee_name == employee_name]
if employee is not None:
@@ -2228,57 +2243,27 @@ class UserAgentService:
stmt = select(ExpenseClaim).where(or_(*claim_identity_conditions), ExpenseClaim.occurred_at >= since)
recent_claims = list(self.db.scalars(stmt).all())
if recent_claims:
risky_count = sum(1 for item in recent_claims if item.risk_flags_json)
draft_count = sum(1 for item in recent_claims if item.status == "draft")
briefs.append(
UserAgentReviewRiskBrief(
title="历史报销画像",
level="info",
content=(
f"{employee_name} 最近 90 天共有 {len(recent_claims)} 笔报销,"
f"其中 {risky_count} 笔带风险标记,{draft_count} 笔仍处于草稿态。"
),
detail=(
"该画像来自员工近 90 天报销记录,用于辅助判断是否存在频繁草稿、"
"历史风险或异常重复报销倾向,不会单独阻断审批。"
),
suggestion="如历史记录中存在风险标记,本次提交时建议主动补充业务背景和票据说明。",
)
duplicate_count = sum(
1
for item in recent_claims
if abs(float(item.amount) - current_amount) < 0.01
)
current_amount = self._resolve_amount_value(payload)
if current_amount > 0:
duplicate_count = sum(
1
for item in recent_claims
if abs(float(item.amount) - current_amount) < 0.01
)
if duplicate_count:
briefs.append(
UserAgentReviewRiskBrief(
title="金额重复预警",
level="warning",
content=(
f"近 90 天发现 {duplicate_count} 笔金额相同的报销记录,"
"提交前建议核对是否为重复报销或拆分不当。"
),
detail=(
"系统将当前金额与近 90 天历史报销金额进行比对。金额完全一致不一定违规,"
"但在交通、餐饮、办公采购等场景中可能提示重复票据或拆分报销。"
),
suggestion="核对历史单据与当前票据是否对应同一业务;如不是重复,请在事由中说明差异。",
)
if duplicate_count:
briefs.append(
UserAgentReviewRiskBrief(
title="金额重复预警",
level="warning",
content=(
f"近 90 天发现 {duplicate_count} 笔金额相同的报销记录,"
"提交前建议核对是否为重复报销或拆分不当。"
),
detail=(
"系统将当前金额与近 90 天历史报销金额进行比对。金额完全一致不一定违规,"
"但在交通、餐饮、办公采购等场景中可能提示重复票据或拆分报销。"
),
suggestion="核对历史单据与当前票据是否对应同一业务;如不是重复,请在事由中说明差异。",
)
if citations:
briefs.append(
UserAgentReviewRiskBrief(
title="制度注意事项",
level="info",
content=citations[0].excerpt or f"请先核对 {citations[0].title} 的制度要求。",
detail=f"本条来自规则或知识库引用:{citations[0].title}。提交前应确认当前单据符合该条口径。",
suggestion="如当前场景与制度口径存在差异,请补充审批说明或选择更准确的报销分类。",
)
)
)
warning_count = sum(len(item.warnings) for item in document_cards)
if warning_count:
@@ -2296,14 +2281,635 @@ class UserAgentService:
briefs.append(
UserAgentReviewRiskBrief(
title="建议拆单",
level="high",
level="warning",
content=f"系统检测到 {len(claim_groups)} 类费用场景,建议拆成多张报销单后再提交。",
detail="同一批附件中包含多类费用场景时,混在一张报销单里会影响规则匹配、附件核验和审批归口。",
suggestion="按费用场景拆成多张报销单,分别确认金额、事由和附件归属。",
)
)
return briefs[:4]
return self._filter_deprecated_review_risk_briefs(briefs)
@staticmethod
def _resolve_submission_blocked_risk_level(reason: str) -> str:
normalized = re.sub(r"\s+", "", str(reason or ""))
amount_keywords = ("金额", "超标", "费用", "价款", "票面金额", "单价", "合计")
return "high" if any(keyword in normalized for keyword in amount_keywords) else "warning"
@staticmethod
def _filter_deprecated_review_risk_briefs(
briefs: list[UserAgentReviewRiskBrief],
) -> list[UserAgentReviewRiskBrief]:
filtered: list[UserAgentReviewRiskBrief] = []
for brief in briefs:
title = str(brief.title or "").strip()
if any(keyword in title for keyword in DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS):
continue
filtered.append(brief)
return filtered
def _build_travel_policy_precheck_briefs(
self,
payload: UserAgentRequest,
*,
document_cards: list[UserAgentReviewDocumentCard],
claim_groups: list[UserAgentReviewClaimGroup],
) -> list[UserAgentReviewRiskBrief]:
if not document_cards or not self._is_travel_review_context(payload, document_cards, claim_groups):
return []
rule_catalog = ExpenseRuleRuntimeService(self.db).load_catalog()
policy = rule_catalog.travel_policy
if policy is None:
return []
employee = self._resolve_employee_profile(payload)
grade = self._resolve_review_employee_grade(payload, employee=employee)
grade_band = ExpenseClaimService._resolve_travel_policy_band(grade)
band_label = policy.band_labels.get(grade_band or "", grade or "当前职级")
declared_city = self._resolve_declared_travel_city(payload, policy)
reason_corpus = self._build_review_reason_corpus(payload)
has_exception_note = self._text_contains_any(reason_corpus, policy.standard_exception_keywords)
standard_rule_name = str(getattr(policy, "standard_rule_name", "") or policy.rule_name)
standard_rule_version = str(getattr(policy, "standard_rule_version", "") or policy.rule_version)
briefs: list[UserAgentReviewRiskBrief] = []
amount_measurement_lines: list[str] = []
seen_keys: set[str] = set()
def append_once(key: str, brief: UserAgentReviewRiskBrief) -> None:
if key in seen_keys:
return
seen_keys.add(key)
briefs.append(brief)
for card in document_cards:
document_type = str(card.document_type or "").strip().lower()
suggested_type = str(card.suggested_expense_type or "").strip().lower()
card_text = self._build_review_document_card_text(card)
document_type_label = resolve_document_type_label(document_type)
amount = self._extract_amount_decimal_from_card(card)
if self._is_review_hotel_card(card):
hotel_city = self._extract_policy_city_from_text(card_text, policy) or declared_city
city_tier = policy.city_tiers.get(hotel_city, "tier_3")
city_tier_label = self._format_travel_city_tier(city_tier)
if amount is None:
amount_measurement_lines.append(
f"{card.filename}:识别为{document_type_label},但未识别到可核算金额,无法完成住宿差标测算。"
)
append_once(
f"hotel-amount-missing-{card.index}",
UserAgentReviewRiskBrief(
title="住宿金额待补充",
level="warning",
content=f"{card.filename} 已识别为{document_type_label},但未识别到可核算的住宿金额。",
detail=(
f"依据《{standard_rule_name}》({standard_rule_version}),住宿票据需要按员工职级、城市级别和每晚金额进行差标核算。"
"当前票据缺少金额,系统无法判断是否超出差旅标准。"
),
suggestion="请在票据识别结果中补充或更正住宿金额,再继续核对报销单。",
),
)
continue
if grade_band is None:
amount_measurement_lines.append(
f"{card.filename}:识别住宿金额 {amount:.2f} 元,但缺少员工职级,无法匹配住宿标准。"
)
append_once(
f"hotel-grade-missing-{card.index}",
UserAgentReviewRiskBrief(
title="职级信息待确认",
level="warning",
content=f"{card.filename} 已识别住宿金额 {amount:.2f} 元,但当前员工职级缺失,无法匹配住宿标准。",
detail=(
f"依据《{standard_rule_name}》({standard_rule_version}),住宿标准按职级档位和城市级别配置。"
"当前未能识别员工职级,因此无法完成创建前差标核算。"
),
suggestion="请确认员工档案或页面上下文中的职级信息,再重新进行差旅规则预检。",
),
)
continue
cap = self._resolve_review_hotel_cap(
policy,
grade_band=grade_band,
city=hotel_city,
city_tier=city_tier,
)
if cap <= Decimal("0.00"):
continue
night_count = self._extract_review_hotel_night_count(card)
nightly_amount = (amount / Decimal(max(night_count, 1))).quantize(Decimal("0.01"))
amount_measurement_lines.append(
f"{card.filename}:识别为{document_type_label},金额 {amount:.2f} 元,"
f"{night_count} 晚折算 {nightly_amount:.2f} 元/晚;"
f"适用标准为 {band_label}{city_tier_label} {cap:.2f} 元/晚,"
f"{'超出标准' if nightly_amount > cap else '测算通过'}"
)
if nightly_amount <= cap:
continue
basis = (
f"依据《{standard_rule_name}》({standard_rule_version}{band_label}{city_tier_label}"
f"住宿标准为 {cap:.2f} 元/晚;{card.filename} 识别为{document_type_label}"
f"金额 {amount:.2f} 元,按 {night_count} 晚折算约 {nightly_amount:.2f} 元/晚。"
)
append_once(
f"hotel-over-limit-{card.index}",
UserAgentReviewRiskBrief(
title="住宿超标待说明" if not has_exception_note else "住宿超标提醒",
level="high",
content=(
f"{card.filename} 住宿金额约 {nightly_amount:.2f} 元/晚,"
f"超过 {band_label} {city_tier_label}标准 {cap:.2f} 元/晚。"
),
detail=(
basis
+ (
"当前未识别到超标说明,创建单据前需要先补充原因。"
if not has_exception_note
else "当前已识别到例外说明,后续仍需审批人重点复核。"
)
),
suggestion="补充超标说明、协议酒店满房/会议高峰等原因,或调整住宿金额后再继续。",
),
)
continue
if document_type == "meal_receipt":
allowance = self._resolve_review_travel_allowance_standard(
policy,
declared_city=declared_city,
card_text=card_text,
)
if allowance is not None:
region_label, standard_amount = allowance
if amount is None:
amount_measurement_lines.append(
f"{card.filename}:识别为{document_type_label},但未识别到可核算金额,无法按{region_label}伙食补助标准测算。"
)
append_once(
f"travel-meal-amount-missing-{card.index}",
UserAgentReviewRiskBrief(
title="差旅餐饮金额待补充",
level="high",
content=f"{card.filename} 已识别为{document_type_label},但未识别到可核算金额。",
detail=(
f"依据《{standard_rule_name}》({standard_rule_version}),差旅餐饮票据优先按出差补助标准中的伙食补助进行测算。"
f"当前匹配区域为{region_label},但票据缺少金额,系统无法判断是否超出补助标准。"
),
suggestion="请在票据识别结果中补充或更正餐饮金额,再继续创建报销单。",
),
)
continue
amount_measurement_lines.append(
f"{card.filename}:识别为{document_type_label},金额 {amount:.2f} 元;"
f"适用《{standard_rule_name}{region_label}伙食补助标准 {standard_amount:.2f} 元/天,"
f"{'超出标准' if amount > standard_amount else '测算通过'}"
)
if amount > standard_amount:
append_once(
f"travel-meal-allowance-over-limit-{card.index}",
UserAgentReviewRiskBrief(
title="差旅餐饮金额超出伙食补助标准",
level="high",
content=(
f"{card.filename} 识别金额 {amount:.2f} 元,"
f"超过{region_label}伙食补助标准 {standard_amount:.2f} 元/天。"
),
detail=(
f"依据《{standard_rule_name}》({standard_rule_version})的出差补助标准,"
f"{region_label}伙食补助为 {standard_amount:.2f} 元/天;"
f"当前票据类型识别为{document_type_label},识别金额 {amount:.2f} 元。"
"首轮上传阶段按单张票据先行测算,后续可结合出差天数和实际餐补口径复核。"
),
suggestion="如该票据属于差旅餐补,请调整金额或补充超标/拆分说明;如属于业务招待或普通餐费,请改为对应费用类型后再提交。",
),
)
continue
scene_code = self._resolve_review_amount_scene_code(card, payload)
scene_policy = rule_catalog.get_scene_policy(scene_code)
scene_limit = self._resolve_review_scene_amount_limit(scene_policy)
if scene_policy is not None and scene_limit is not None:
metric_label = str(getattr(scene_limit, "metric_label", "") or scene_policy.label or "金额").strip()
standard_amount = self._resolve_scene_standard_amount(scene_limit)
if amount is None:
amount_measurement_lines.append(
f"{card.filename}:识别为{document_type_label},但未识别到可核算金额,无法按{metric_label}测算。"
)
append_once(
f"{scene_code}-amount-missing-{card.index}",
UserAgentReviewRiskBrief(
title=f"{scene_policy.label}金额待补充",
level="warning",
content=f"{card.filename} 已识别为{document_type_label},但未识别到可核算金额。",
detail=(
f"依据《{scene_policy.rule_name}》({scene_policy.rule_version}"
f"{scene_policy.label}需要按{metric_label}进行金额审核。当前票据缺少金额,系统无法判断是否合规。"
),
suggestion="请在票据识别结果中补充或更正金额,再继续核对报销单。",
),
)
continue
if standard_amount is not None:
amount_measurement_lines.append(
f"{card.filename}:识别为{document_type_label},金额 {amount:.2f} 元;"
f"适用《{scene_policy.rule_name}{metric_label}标准 {standard_amount:.2f} 元,"
f"{'超出标准' if amount > standard_amount else '测算通过'}"
)
amount_risk = self._evaluate_review_scene_amount(
amount=amount,
limit_config=scene_limit,
reason_text=reason_corpus,
)
if amount_risk is not None:
severity, threshold = amount_risk
append_once(
f"{scene_code}-amount-over-limit-{card.index}",
UserAgentReviewRiskBrief(
title=f"{scene_policy.label}金额超标待说明",
level="high" if severity == "high" else "warning",
content=(
f"{card.filename} 识别金额 {amount:.2f} 元,"
f"超过{metric_label}标准 {threshold:.2f} 元。"
),
detail=(
f"依据《{scene_policy.rule_name}》({scene_policy.rule_version}"
f"{scene_policy.label}{metric_label}审核,当前票据类型识别为{document_type_label}"
f"识别金额 {amount:.2f} 元,标准阈值 {threshold:.2f} 元。"
),
suggestion="请补充超标原因或拆分到更准确的费用类型;如属于例外场景,请在事由中写明业务背景。",
),
)
continue
transport_class = self._detect_review_transport_class(card, policy)
if transport_class and grade_band is not None:
transport_kind, class_label, class_level = transport_class
allowed_level = policy.transport_limits.get(grade_band, {}).get(transport_kind)
if allowed_level is not None and class_level > allowed_level:
append_once(
f"transport-class-over-limit-{card.index}-{class_label}",
UserAgentReviewRiskBrief(
title="交通舱位超标待说明" if not has_exception_note else "交通舱位超标提醒",
level="warning",
content=f"{card.filename} 识别为 {class_label}{band_label} 当前默认不可报销该舱位/席别。",
detail=(
f"依据《{standard_rule_name}》({standard_rule_version}{band_label} 的交通席别标准"
f"未覆盖 {class_label};票据类型识别为{document_type_label}"
+ (
"当前未识别到例外说明,创建单据前需要补充原因。"
if not has_exception_note
else "当前已识别到例外说明,后续仍需审批人重点复核。"
)
),
suggestion="补充无直达、临时改签、行程变更等例外说明,或更换为符合标准的票据。",
),
)
continue
if document_type == "meal_receipt" and self._is_travel_review_context(payload, document_cards, claim_groups):
if amount is not None:
amount_measurement_lines.append(
f"{card.filename}:识别为{document_type_label},金额 {amount:.2f} 元;需确认按餐补、餐费或业务招待口径归口。"
)
append_once(
f"travel-meal-card-{card.index}",
UserAgentReviewRiskBrief(
title="差旅餐饮票据待归口",
level="warning",
content=f"{card.filename} 已识别为餐饮票据,当前差旅报销单需要确认是否允许并入差旅费用。",
detail=(
f"依据《{standard_rule_name}》({standard_rule_version})的差旅票据预检口径,系统优先核算交通、住宿等差旅核心票据。"
"餐饮票据可能需要按餐费或业务招待场景拆分,并补充同行人员或客户信息。"
),
suggestion="如属于差旅餐补,请补充制度允许口径;如属于招待或普通餐费,建议拆成对应费用类型单据。",
),
)
continue
if suggested_type in {"travel", "hotel", "transport"} and document_type in {"other", "travel_ticket"}:
append_once(
f"travel-type-uncertain-{card.index}",
UserAgentReviewRiskBrief(
title="差旅票据类型待确认",
level="warning",
content=f"{card.filename} 归入差旅场景,但票据类型仍需确认。",
detail=(
f"依据《{standard_rule_name}》({standard_rule_version}),差旅预检需要先明确票据是机票、火车票、住宿票据、打车票等,"
"再匹配对应的金额或舱位规则。当前类型识别不够稳定。"
),
suggestion="请在附件识别结果中更正票据类型,或重新上传更清晰的附件后再继续。",
),
)
if amount_measurement_lines:
briefs.insert(
0,
UserAgentReviewRiskBrief(
title="附件金额测算结果",
level="info",
content="系统已根据首轮上传附件识别金额,并匹配当前可执行的报销标准进行测算。",
detail="".join(dict.fromkeys(amount_measurement_lines)),
suggestion="如测算结果超标,请补充超标说明、调整金额或更正票据类型后再继续。",
),
)
return briefs
def _is_travel_review_context(
self,
payload: UserAgentRequest,
document_cards: list[UserAgentReviewDocumentCard],
claim_groups: list[UserAgentReviewClaimGroup],
) -> bool:
entity_expense_type = self._collect_entity_values(payload).get("expense_type_code", "")
review_form_values = self._resolve_review_form_values(payload)
form_expense_type = str(review_form_values.get("expense_type") or "").strip()
message_context = " ".join(
[
str(payload.message or ""),
str(payload.context_json.get("user_input_text") or ""),
str(payload.context_json.get("expense_type") or ""),
form_expense_type,
]
)
if entity_expense_type in {"travel", "hotel", "transport"}:
return True
if any(group.group_code == "travel" or group.expense_type in {"travel", "hotel", "transport"} for group in claim_groups):
return True
if any(card.suggested_expense_type in {"travel", "hotel", "transport"} for card in document_cards):
return True
return any(keyword in message_context for keyword in ("差旅", "出差", "机票", "火车", "高铁", "酒店", "住宿"))
def _resolve_review_travel_allowance_standard(
self,
policy: RuntimeTravelPolicy,
*,
declared_city: str,
card_text: str,
) -> tuple[str, Decimal] | None:
meal_limits = getattr(policy, "allowance_limits", {}).get("meal", {})
if not meal_limits:
return None
region_label = self._resolve_review_travel_allowance_region(
" ".join([declared_city or "", card_text or ""])
)
amount = meal_limits.get(region_label)
if amount is None and region_label != "其他地区":
amount = meal_limits.get("其他地区")
region_label = "其他地区"
if amount is None:
return None
return region_label, Decimal(amount).quantize(Decimal("0.01"))
@staticmethod
def _resolve_review_travel_allowance_region(text: str) -> str:
normalized = re.sub(r"\s+", "", str(text or ""))
if not normalized:
return "其他地区"
if any(keyword in normalized for keyword in ("境外", "国外", "海外")):
return "国外"
if any(keyword in normalized for keyword in ("香港", "澳门", "台湾", "港澳台")):
return "港澳台"
if "乌鲁木齐" in normalized:
return "新疆-乌鲁木齐"
if "新疆" in normalized:
return "新疆-其他"
if any(keyword in normalized for keyword in ("西藏", "拉萨")):
return "西藏"
if any(keyword in normalized for keyword in ("北京", "上海", "天津", "重庆", "深圳", "珠海", "汕头", "厦门")):
return "直辖市/特区"
return "其他地区"
def _resolve_review_amount_scene_code(
self,
card: UserAgentReviewDocumentCard,
payload: UserAgentRequest,
) -> str:
document_type = str(card.document_type or "").strip().lower()
suggested_type = str(card.suggested_expense_type or "").strip().lower()
if document_type in {"taxi_receipt", "parking_toll_receipt", "transport_receipt"}:
return "transport"
if document_type == "meal_receipt":
entity_values = self._collect_entity_values(payload)
if suggested_type == "entertainment" or entity_values.get("expense_type_code") == "entertainment":
return "entertainment"
return "meal"
if document_type == "hotel_invoice" or suggested_type == "hotel":
return "hotel"
if suggested_type in {
"travel",
"transport",
"meal",
"entertainment",
"office",
"meeting",
"training",
"communication",
"welfare",
"other",
}:
return suggested_type
return self._collect_entity_values(payload).get("expense_type_code") or "other"
@staticmethod
def _resolve_review_scene_amount_limit(scene_policy: Any | None) -> Any | None:
if scene_policy is None:
return None
return getattr(scene_policy, "item_amount_limit", None) or getattr(scene_policy, "claim_amount_limit", None)
@staticmethod
def _resolve_scene_standard_amount(limit_config: Any | None) -> Decimal | None:
if limit_config is None:
return None
warn_amount = getattr(limit_config, "warn_amount", None)
block_amount = getattr(limit_config, "block_amount", None)
amount = warn_amount if warn_amount is not None else block_amount
if amount is None:
return None
try:
return Decimal(amount).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return None
@staticmethod
def _evaluate_review_scene_amount(
*,
amount: Decimal,
limit_config: Any,
reason_text: str,
) -> tuple[str, Decimal] | None:
block_amount = getattr(limit_config, "block_amount", None)
warn_amount = getattr(limit_config, "warn_amount", None)
exception_keywords = list(getattr(limit_config, "exception_keywords", []) or [])
has_exception = UserAgentService._text_contains_any(reason_text, exception_keywords)
if block_amount is not None and amount > Decimal(block_amount):
return ("high", Decimal(block_amount).quantize(Decimal("0.01")))
if warn_amount is not None and amount > Decimal(warn_amount):
return ("high", Decimal(warn_amount).quantize(Decimal("0.01")))
return None
def _resolve_review_employee_grade(self, payload: UserAgentRequest, *, employee: Employee | None) -> str:
if employee is not None and employee.grade:
return str(employee.grade).strip()
review_form_values = self._resolve_review_form_values(payload)
for source in (
review_form_values,
payload.context_json,
payload.tool_payload,
):
for key in ("employee_grade", "grade", "user_grade", "position_grade"):
value = str(source.get(key) or "").strip() if isinstance(source, dict) else ""
if value:
return value
return ""
def _build_review_reason_corpus(self, payload: UserAgentRequest) -> str:
review_form_values = self._resolve_review_form_values(payload)
parts = [
str(payload.message or ""),
str(payload.context_json.get("user_input_text") or ""),
str(review_form_values.get("reason") or ""),
str(review_form_values.get("business_reason") or ""),
str(review_form_values.get("location") or ""),
str(review_form_values.get("business_location") or ""),
]
return "\n".join(part.strip() for part in parts if part and part.strip())
def _resolve_declared_travel_city(self, payload: UserAgentRequest, policy: RuntimeTravelPolicy) -> str:
review_form_values = self._resolve_review_form_values(payload)
candidates = [
str(review_form_values.get("business_location") or ""),
str(review_form_values.get("location") or ""),
self._resolve_location_value(payload),
str(payload.message or ""),
]
for candidate in candidates:
city = self._extract_policy_city_from_text(candidate, policy)
if city:
return city
return ""
@staticmethod
def _build_review_document_card_text(card: UserAgentReviewDocumentCard) -> str:
field_text = " ".join(f"{field.label}:{field.value}" for field in card.fields)
return " ".join(
[
str(card.filename or ""),
str(card.document_type or ""),
str(card.scene_label or ""),
str(card.summary or ""),
field_text,
]
).strip()
@staticmethod
def _is_review_hotel_card(card: UserAgentReviewDocumentCard) -> bool:
document_type = str(card.document_type or "").strip().lower()
suggested_type = str(card.suggested_expense_type or "").strip().lower()
scene_label = str(card.scene_label or "").strip()
return document_type == "hotel_invoice" or suggested_type == "hotel" or "住宿" in scene_label
@staticmethod
def _extract_amount_decimal_from_card(card: UserAgentReviewDocumentCard) -> Decimal | None:
for field in card.fields:
if field.label != "金额":
continue
normalized = str(field.value or "").replace("", "").replace("", "").replace("¥", "").replace(",", "").strip()
try:
amount = Decimal(normalized).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
continue
if amount > Decimal("0.00"):
return amount
return None
@staticmethod
def _extract_review_hotel_night_count(card: UserAgentReviewDocumentCard) -> int:
text = f"{card.summary or ''} {' '.join(f'{field.label}:{field.value}' for field in card.fields)}"
match = TRAVEL_REVIEW_HOTEL_NIGHT_PATTERN.search(text)
if not match:
return 1
try:
return max(1, int(match.group(1)))
except (TypeError, ValueError):
return 1
@staticmethod
def _extract_policy_city_from_text(text: str, policy: RuntimeTravelPolicy) -> str:
normalized = str(text or "").strip()
if not normalized:
return ""
city_names = set(policy.city_tiers.keys())
city_names.update(getattr(policy, "hotel_city_limits", {}).keys())
for city in sorted(city_names, key=lambda item: len(item), reverse=True):
if city in normalized:
return city
return ""
@staticmethod
def _format_travel_city_tier(city_tier: str) -> str:
return {
"tier_1": "一线城市",
"tier_2": "重点城市",
"tier_3": "其他城市",
}.get(str(city_tier or "").strip(), "当前城市")
@staticmethod
def _resolve_review_hotel_cap(
policy: RuntimeTravelPolicy,
*,
grade_band: str,
city: str,
city_tier: str,
) -> Decimal:
normalized_city = str(city or "").strip()
if normalized_city and getattr(policy, "hotel_city_limits", None):
city_limits = policy.hotel_city_limits.get(normalized_city, {})
city_cap = city_limits.get(grade_band)
if city_cap is not None:
return Decimal(city_cap).quantize(Decimal("0.01"))
return Decimal(policy.hotel_limits.get(grade_band, {}).get(city_tier, Decimal("0.00"))).quantize(
Decimal("0.01")
)
def _detect_review_transport_class(
self,
card: UserAgentReviewDocumentCard,
policy: RuntimeTravelPolicy,
) -> tuple[str, str, int] | None:
document_type = str(card.document_type or "").strip().lower()
text = re.sub(r"\s+", "", self._build_review_document_card_text(card))
if not text:
return None
if document_type == "flight_itinerary" or any(keyword in text for keyword in ("机票", "航班", "登机牌")):
for config in policy.flight_classes:
label = str(config.keyword or "").strip()
if label and label in text:
return "flight", label, int(config.level)
if document_type == "train_ticket" or any(keyword in text for keyword in ("火车", "高铁", "动车", "铁路")):
for config in policy.train_classes:
label = str(config.keyword or "").strip()
if label and label in text:
return "train", label, int(config.level)
return None
@staticmethod
def _text_contains_any(text: str, keywords: list[str] | tuple[str, ...]) -> bool:
compact = re.sub(r"\s+", "", str(text or ""))
return bool(compact) and any(str(keyword or "").strip() and str(keyword).strip() in compact for keyword in keywords)
@staticmethod
def _resolve_submission_blocked_reasons(payload: UserAgentRequest) -> list[str]:
@@ -2543,6 +3149,14 @@ class UserAgentService:
"系统检测到你已有可用草稿,请先选择关联到现有草稿,或单独建立一张新的报销单。"
)
blocked_reasons = self._resolve_submission_blocked_reasons(payload)
if blocked_reasons:
reason_text = "".join(dict.fromkeys(reason.strip("。;;") for reason in blocked_reasons if reason))
return (
f"AI预审未通过{reason_text}"
"请先根据风险提示补充原因、调整金额或更换附件,整改后再继续提交。"
)
review_payload = UserAgentReviewPayload(
intent_summary="",
body_message="",
@@ -3460,7 +4074,18 @@ class UserAgentService:
evidence="来源于用户修改后的结构化表单。",
)
merchant_value = self._extract_document_merchant_name(ocr_documents[0]) if ocr_documents else ""
merchant_value = ""
for document in ocr_documents:
if str(document.get("document_type") or "").strip().lower() != "hotel_invoice":
continue
merchant_value = self._extract_document_merchant_name(document)
if merchant_value:
break
if not merchant_value:
for document in ocr_documents:
merchant_value = self._extract_document_merchant_name(document)
if merchant_value:
break
if merchant_value:
return self._build_slot_value(
value=merchant_value,