feat: 增加差旅报销标准测算和财务终审流程
新增差旅报销测算接口及 Spreadsheet 规则解析,审批流程拆分 直属领导审批与财务终审两阶段并细分权限,修复 PDF 文本层 缺失时自动回退 OCR,提交后清理关联会话,前端适配审批流 交互并补充单元测试。
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user