fix(reimbursement): harden assistant draft and claim cleanup
This commit is contained in:
@@ -241,7 +241,7 @@ SYSTEM_GENERATED_REASON_PREFIXES = (
|
||||
"请基于当前上传的多张票据",
|
||||
"我已核对右侧识别结果",
|
||||
"请同步修正逐票据识别结果",
|
||||
"我已修改识别信息",
|
||||
"我已校正核对信息",
|
||||
"查看报销草稿",
|
||||
"请解释一下当前这笔报销的合规风险和待补充项",
|
||||
)
|
||||
@@ -445,7 +445,7 @@ class UserAgentService:
|
||||
|
||||
return (
|
||||
"可以帮你发起报销。请补充费用类型、发生时间、金额、事由和相关对象,"
|
||||
"或者直接上传票据附件,我再继续帮你判断能否报、缺什么材料以及生成报销草稿。"
|
||||
"或者直接上传票据附件,我再继续帮你判断能否报、缺什么材料,并整理待核对信息。"
|
||||
f"{attachment_hint}"
|
||||
)
|
||||
|
||||
@@ -473,7 +473,7 @@ class UserAgentService:
|
||||
|
||||
return (
|
||||
f"已识别到一笔{time_text}的{expense_type}支出{amount_hint}。"
|
||||
"如果要继续生成报销草稿,还需要补充客户单位、参与人员、费用明细和票据附件。"
|
||||
"如果要继续整理报销核对信息,还需要补充客户单位、参与人员、费用明细和票据附件。"
|
||||
"你也可以继续上传发票或图片,我会把这些信息带入后续对话。"
|
||||
)
|
||||
|
||||
@@ -3283,22 +3283,6 @@ class UserAgentService:
|
||||
claim_no = str(payload.tool_payload.get("association_candidate_claim_no") or "").strip()
|
||||
link_label = f"关联到草稿 {claim_no}" if claim_no else "关联到现有草稿"
|
||||
return [
|
||||
UserAgentReviewAction(
|
||||
label="取消",
|
||||
action_type="cancel_review",
|
||||
description="放弃当前识别结果,并退出本次核对流程。",
|
||||
emphasis="secondary",
|
||||
),
|
||||
UserAgentReviewAction(
|
||||
label="选择报销类型" if "expense_type" in missing_slot_keys else "修改识别信息",
|
||||
action_type="edit_review",
|
||||
description=(
|
||||
"先选择本次报销类型,后续票据会作为当前单据的补充继续核对。"
|
||||
if "expense_type" in missing_slot_keys
|
||||
else "打开结构化模板,按已识别字段逐项修改。"
|
||||
),
|
||||
emphasis="secondary",
|
||||
),
|
||||
UserAgentReviewAction(
|
||||
label=link_label,
|
||||
action_type="link_to_existing_draft",
|
||||
@@ -3321,15 +3305,9 @@ class UserAgentService:
|
||||
if "expense_type" in missing_slot_keys and not review_action:
|
||||
return [
|
||||
UserAgentReviewAction(
|
||||
label="取消",
|
||||
action_type="cancel_review",
|
||||
description="放弃当前识别结果,并退出本次核对流程。",
|
||||
emphasis="secondary",
|
||||
),
|
||||
UserAgentReviewAction(
|
||||
label="选择报销类型",
|
||||
action_type="edit_review",
|
||||
description="先选择本次报销类型,后续票据会作为当前单据的补充继续核对。",
|
||||
label="保存为草稿",
|
||||
action_type="save_draft",
|
||||
description="先暂存当前已识别信息,稍后仍可从个人报销继续补充或提交。",
|
||||
emphasis="primary",
|
||||
),
|
||||
]
|
||||
@@ -3349,24 +3327,7 @@ class UserAgentService:
|
||||
if draft_payload is not None and draft_payload.claim_no and not can_proceed:
|
||||
primary_action.description = f"保存后会生成草稿 {draft_payload.claim_no},后续仍可继续补充。"
|
||||
|
||||
actions = [
|
||||
UserAgentReviewAction(
|
||||
label="取消",
|
||||
action_type="cancel_review",
|
||||
description="放弃当前识别结果,并退出本次核对流程。",
|
||||
emphasis="secondary",
|
||||
),
|
||||
UserAgentReviewAction(
|
||||
label="选择报销类型" if "expense_type" in missing_slot_keys else "修改识别信息",
|
||||
action_type="edit_review",
|
||||
description=(
|
||||
"先选择本次报销类型,后续票据会作为当前单据的补充继续核对。"
|
||||
if "expense_type" in missing_slot_keys
|
||||
else "打开结构化模板,按已识别字段逐项修改。"
|
||||
),
|
||||
emphasis="secondary",
|
||||
),
|
||||
]
|
||||
actions = []
|
||||
if can_proceed:
|
||||
actions.append(
|
||||
UserAgentReviewAction(
|
||||
@@ -3433,16 +3394,11 @@ class UserAgentService:
|
||||
|
||||
review_action = str(payload.context_json.get("review_action") or "").strip()
|
||||
if payload.tool_payload.get("preview_only") and not review_action:
|
||||
base_message = review_payload.body_message or self._build_review_intent_summary(
|
||||
return review_payload.body_message or self._build_review_intent_summary(
|
||||
payload,
|
||||
slot_cards=review_payload.slot_cards,
|
||||
claim_groups=review_payload.claim_groups,
|
||||
)
|
||||
return (
|
||||
f"{base_message} "
|
||||
"本次只是核对预览,尚未保存为草稿;需要暂存时请点击“保存为草稿”,"
|
||||
"需要正式提交时再点击“继续下一步”。"
|
||||
)
|
||||
if review_action == "save_draft":
|
||||
if draft_payload is not None and draft_payload.claim_no:
|
||||
return (
|
||||
@@ -3488,11 +3444,6 @@ class UserAgentService:
|
||||
f"{self._build_review_intent_summary(payload, slot_cards=review_payload.slot_cards, claim_groups=review_payload.claim_groups)} "
|
||||
"当前关键信息已基本齐全,您确认无误后可以继续下一步。"
|
||||
)
|
||||
if review_action == "edit_review":
|
||||
return (
|
||||
f"{self._build_review_intent_summary(payload, slot_cards=review_payload.slot_cards, claim_groups=review_payload.claim_groups)} "
|
||||
f"{self._build_review_guidance_copy(review_payload, mention_save_draft=True)}"
|
||||
)
|
||||
return review_payload.body_message or None
|
||||
|
||||
def _build_review_body_message(
|
||||
@@ -3566,11 +3517,157 @@ class UserAgentService:
|
||||
confirmation_actions=[],
|
||||
edit_fields=[],
|
||||
)
|
||||
return (
|
||||
f"{self._build_review_intent_summary(payload, slot_cards=slot_cards, claim_groups=[])} "
|
||||
f"{self._build_review_guidance_copy(review_payload, mention_save_draft=not can_proceed)}"
|
||||
return "\n\n".join(
|
||||
item
|
||||
for item in [
|
||||
self._build_review_intent_summary(payload, slot_cards=slot_cards, claim_groups=[]),
|
||||
self._build_review_standard_calculation_copy(payload, slot_cards),
|
||||
self._build_review_guidance_copy(review_payload, mention_save_draft=not can_proceed),
|
||||
]
|
||||
if item
|
||||
)
|
||||
|
||||
def _build_review_standard_calculation_copy(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
slot_cards: list[UserAgentReviewSlotCard],
|
||||
) -> str:
|
||||
slots = {item.key: item for item in slot_cards}
|
||||
expense_type = str(slots.get("expense_type").value if slots.get("expense_type") else "").strip()
|
||||
if "差旅" in expense_type:
|
||||
return self._build_review_travel_calculation_table(payload, slots)
|
||||
if "交通" in expense_type:
|
||||
return (
|
||||
"报销测算参考:交通费通常以实际票据金额为基础,结合出行地点、业务事由和票据合规性复核;"
|
||||
"如果它属于差旅行程的一部分,后续也会并入差旅费测算。"
|
||||
)
|
||||
if "住宿" in expense_type:
|
||||
return (
|
||||
"报销测算参考:住宿费通常按“实际住宿金额”和“目的地住宿标准 × 住宿天数”取合规口径;"
|
||||
"补齐酒店票据后再核对是否超标。"
|
||||
)
|
||||
return (
|
||||
"报销测算参考:先以用户填写金额或票据识别金额为基础,"
|
||||
"再结合费用类型、发生地点、业务事由和规则中心限额进行复核。"
|
||||
)
|
||||
|
||||
def _build_review_travel_calculation_table(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
slots: dict[str, UserAgentReviewSlotCard],
|
||||
) -> str:
|
||||
destination = self._resolve_slot_text(slots, "location")
|
||||
days = self._resolve_review_travel_days(payload, slots)
|
||||
ticket_amount = self._resolve_slot_money(slots, "amount")
|
||||
employee = self._resolve_employee_profile(payload)
|
||||
grade = self._resolve_review_employee_grade(payload, employee=employee)
|
||||
|
||||
if not destination or not grade:
|
||||
return "\n".join(
|
||||
[
|
||||
"报销测算参考:",
|
||||
"",
|
||||
"| 项目 | 当前信息 | 测算说明 |",
|
||||
"| --- | --- | --- |",
|
||||
f"| 出差地点 | {destination or '待确认'} | 用于匹配城市住宿标准和补贴区域 |",
|
||||
f"| 出差天数 | {days} 天 | 来自业务发生时间或用户描述 |",
|
||||
f"| 职级 | {grade or '待确认'} | 补齐后才能匹配住宿标准和补贴档位 |",
|
||||
f"| 交通票据 | {self._format_decimal_money(ticket_amount)} 元 | 上传票据后会按真实金额重新复核 |",
|
||||
]
|
||||
)
|
||||
|
||||
current_user = CurrentUserContext(
|
||||
username=str(payload.user_id or payload.context_json.get("name") or "anonymous").strip() or "anonymous",
|
||||
name=str(payload.context_json.get("name") or payload.user_id or "anonymous").strip() or "anonymous",
|
||||
role_codes=[
|
||||
str(item).strip()
|
||||
for item in list(payload.context_json.get("role_codes") or [])
|
||||
if str(item).strip()
|
||||
],
|
||||
is_admin=bool(payload.context_json.get("is_admin")),
|
||||
department_name=str(payload.context_json.get("department_name") or payload.context_json.get("department") or "").strip(),
|
||||
)
|
||||
try:
|
||||
calculation = TravelReimbursementCalculatorService(self.db).calculate(
|
||||
TravelReimbursementCalculatorRequest(days=days, location=destination, grade=grade),
|
||||
current_user,
|
||||
)
|
||||
except Exception:
|
||||
return "\n".join(
|
||||
[
|
||||
"报销测算参考:",
|
||||
"",
|
||||
"| 项目 | 当前信息 | 测算说明 |",
|
||||
"| --- | --- | --- |",
|
||||
f"| 出差地点 | {destination} | 暂时未能匹配规则中心地点 |",
|
||||
f"| 出差天数 | {days} 天 | 来自业务发生时间或用户描述 |",
|
||||
f"| 职级 | {grade} | 暂时无法自动匹配差旅标准 |",
|
||||
f"| 交通票据 | {self._format_decimal_money(ticket_amount)} 元 | 上传票据后会按真实金额重新复核 |",
|
||||
]
|
||||
)
|
||||
|
||||
total_amount = (
|
||||
ticket_amount
|
||||
+ self._coerce_decimal_money(calculation.hotel_amount)
|
||||
+ self._coerce_decimal_money(calculation.allowance_amount)
|
||||
).quantize(Decimal("0.01"))
|
||||
ticket_basis = "当前未上传交通票据,先按 0.00 元占位" if ticket_amount <= Decimal("0.00") else "已识别或填写的交通票据金额"
|
||||
return "\n".join(
|
||||
[
|
||||
"报销测算参考:",
|
||||
"",
|
||||
"| 项目 | 测算口径 | 金额 |",
|
||||
"| --- | --- | ---: |",
|
||||
f"| 交通票据 | {ticket_basis} | {self._format_decimal_money(ticket_amount)} 元 |",
|
||||
f"| 住宿标准 | {self._format_decimal_money(calculation.hotel_rate)} 元/天 × {calculation.days} 天 | {self._format_decimal_money(calculation.hotel_amount)} 元 |",
|
||||
f"| 出差补贴 | {self._format_decimal_money(calculation.total_allowance_rate)} 元/天 × {calculation.days} 天 | {self._format_decimal_money(calculation.allowance_amount)} 元 |",
|
||||
f"| 参考合计 | 交通票据 + 住宿标准 + 出差补贴 | {self._format_decimal_money(total_amount)} 元 |",
|
||||
"",
|
||||
(
|
||||
f"测算依据:职级 {calculation.grade},目的地 {destination},匹配城市 {calculation.matched_city};"
|
||||
"补齐交通、酒店等票据后,我会按真实票据金额和规则中心标准重新复核。"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_slot_text(slots: dict[str, UserAgentReviewSlotCard], key: str) -> str:
|
||||
item = slots.get(key)
|
||||
return str(getattr(item, "value", "") or getattr(item, "raw_value", "") or "").strip()
|
||||
|
||||
def _resolve_review_travel_days(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
slots: dict[str, UserAgentReviewSlotCard],
|
||||
) -> int:
|
||||
text = " ".join(
|
||||
[
|
||||
str(payload.message or ""),
|
||||
str(payload.context_json.get("user_input_text") or ""),
|
||||
self._resolve_slot_text(slots, "reason"),
|
||||
self._resolve_slot_text(slots, "time_range"),
|
||||
]
|
||||
)
|
||||
explicit_match = re.search(r"(?<!\d)(\d{1,2})\s*天", text)
|
||||
if explicit_match:
|
||||
return max(1, int(explicit_match.group(1)))
|
||||
|
||||
dates = self._extract_dates_from_text(self._resolve_slot_text(slots, "time_range"))
|
||||
if len(dates) >= 2:
|
||||
return max(1, (max(dates).date() - min(dates).date()).days)
|
||||
return 1
|
||||
|
||||
def _resolve_slot_money(
|
||||
self,
|
||||
slots: dict[str, UserAgentReviewSlotCard],
|
||||
key: str,
|
||||
) -> Decimal:
|
||||
text = self._resolve_slot_text(slots, key).replace(",", "")
|
||||
match = re.search(r"([0-9]+(?:\.[0-9]{1,2})?)", text)
|
||||
if not match:
|
||||
return Decimal("0.00")
|
||||
return self._coerce_decimal_money(match.group(1))
|
||||
|
||||
@staticmethod
|
||||
def _build_review_action_followup_copy(review_payload: UserAgentReviewPayload) -> str:
|
||||
missing_slots = [str(item).strip() for item in review_payload.missing_slots if str(item).strip()]
|
||||
@@ -3620,35 +3717,53 @@ class UserAgentService:
|
||||
if str(item).strip()
|
||||
]
|
||||
|
||||
lines = [
|
||||
f"您好:{user_name},根据您提交的票据信息,您可能出差的地点为 {destination},天数为:{days} 天。",
|
||||
f"根据票据,您现在提交的是{ticket_type_label}票,一共金额为:{self._format_decimal_money(ticket_amount)} 元。",
|
||||
]
|
||||
|
||||
provide_items: list[str] = []
|
||||
if required_labels:
|
||||
provide_items.append("1. 酒店住宿发票/住宿清单(必须,当前待上传)")
|
||||
if optional_labels:
|
||||
provide_items.append(f"{len(provide_items) + 1}. 市内交通/乘车票据(非必须,如打车、地铁、停车等)")
|
||||
|
||||
sections = [
|
||||
f"您好,{user_name}。我先按票据信息做一次差旅预检。",
|
||||
"\n".join(
|
||||
[
|
||||
"已识别信息:",
|
||||
f"1. 出差地点:{destination}",
|
||||
f"2. 预计天数:{days} 天",
|
||||
f"3. 票据类型:{ticket_type_label}票",
|
||||
f"4. 票据金额:{self._format_decimal_money(ticket_amount)} 元",
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
if provide_items:
|
||||
lines.append("根据公司相关报销制度,您还可以继续提供:\n" + "\n".join(provide_items))
|
||||
sections.append("还需补充:\n" + "\n".join(provide_items))
|
||||
else:
|
||||
lines.append("根据公司相关报销制度,当前核心票据已较完整,无需继续上传票据。")
|
||||
sections.append("票据完整性:当前核心票据已较完整,无需继续上传票据。")
|
||||
|
||||
if required_labels:
|
||||
lines.append("酒店票据仍缺失,所以暂时不能继续下一步;您可以先保存为草稿,补齐后再提交。")
|
||||
sections.append(
|
||||
"处理建议:酒店票据仍缺失,暂时不能继续下一步。"
|
||||
"您可以先保存为草稿,补齐后再提交。"
|
||||
)
|
||||
elif can_proceed and optional_labels:
|
||||
lines.append("当前必需票据已具备;如暂时没有乘车票据,也可以继续下一步,或先保存为草稿。")
|
||||
sections.append(
|
||||
"处理建议:必需票据已具备。"
|
||||
"如暂时没有乘车票据,也可以继续下一步,或先保存为草稿。"
|
||||
)
|
||||
elif can_proceed:
|
||||
lines.append("当前信息已较完整,确认无误后可以继续下一步,也可以先保存为草稿。")
|
||||
sections.append(
|
||||
"处理建议:当前信息已较完整,确认无误后可以继续下一步;"
|
||||
"暂时不提交时,也可以先保存为草稿。"
|
||||
)
|
||||
|
||||
estimate_copy = self._build_travel_receipt_estimate_copy(
|
||||
payload,
|
||||
travel_receipt_state=travel_receipt_state,
|
||||
)
|
||||
if estimate_copy:
|
||||
lines.append(estimate_copy)
|
||||
return "\n".join(line for line in lines if line)
|
||||
sections.append(estimate_copy)
|
||||
return "\n\n".join(section for section in sections if section)
|
||||
|
||||
def _build_travel_receipt_estimate_copy(
|
||||
self,
|
||||
@@ -3665,10 +3780,11 @@ class UserAgentService:
|
||||
|
||||
if not destination or not grade:
|
||||
return (
|
||||
"根据公司差旅费报销依据,"
|
||||
f"您的职级为:{grade or '待确认'},去{destination or '出差地点待确认'},"
|
||||
f"当前可确认的{ticket_type_label}票据金额为:{self._format_decimal_money(ticket_amount)} 元;"
|
||||
"住宿和补贴金额需补齐职级或地点后再核算。"
|
||||
"差旅费测算:\n"
|
||||
f"1. 职级:{grade or '待确认'}\n"
|
||||
f"2. 目的地:{destination or '出差地点待确认'}\n"
|
||||
f"3. 已提交{ticket_type_label}:{self._format_decimal_money(ticket_amount)} 元\n"
|
||||
"4. 住宿和补贴金额:需补齐职级或地点后再核算。"
|
||||
)
|
||||
|
||||
current_user = CurrentUserContext(
|
||||
@@ -3689,9 +3805,11 @@ class UserAgentService:
|
||||
)
|
||||
except Exception:
|
||||
return (
|
||||
"根据公司差旅费报销依据,"
|
||||
f"您的职级为:{grade},去{destination},当前可确认的{ticket_type_label}票据金额为:"
|
||||
f"{self._format_decimal_money(ticket_amount)} 元;住宿和补贴标准暂时无法自动测算,请以规则中心最新差旅标准为准。"
|
||||
"差旅费测算:\n"
|
||||
f"1. 职级:{grade}\n"
|
||||
f"2. 目的地:{destination}\n"
|
||||
f"3. 已提交{ticket_type_label}:{self._format_decimal_money(ticket_amount)} 元\n"
|
||||
"4. 住宿和补贴标准:暂时无法自动测算,请以规则中心最新差旅标准为准。"
|
||||
)
|
||||
|
||||
total_amount = (
|
||||
@@ -3700,13 +3818,13 @@ class UserAgentService:
|
||||
+ self._coerce_decimal_money(calculation.allowance_amount)
|
||||
).quantize(Decimal("0.01"))
|
||||
return (
|
||||
"根据公司差旅费报销依据,"
|
||||
f"您的职级为:{calculation.grade},去{calculation.matched_city or destination},"
|
||||
"报销费用核算约为:"
|
||||
f"已提交{ticket_type_label} {self._format_decimal_money(ticket_amount)} 元 + "
|
||||
f"住宿标准 {self._format_decimal_money(calculation.hotel_rate)} 元/天 × {calculation.days} 天 + "
|
||||
f"出差补贴 {self._format_decimal_money(calculation.total_allowance_rate)} 元/天 × {calculation.days} 天 = "
|
||||
f"{self._format_decimal_money(total_amount)} 元。"
|
||||
"差旅费测算:\n"
|
||||
f"1. 职级:{calculation.grade}\n"
|
||||
f"2. 目的地:{calculation.matched_city or destination}\n"
|
||||
f"3. 已提交{ticket_type_label}:{self._format_decimal_money(ticket_amount)} 元\n"
|
||||
f"4. 住宿标准:{self._format_decimal_money(calculation.hotel_rate)} 元/天 × {calculation.days} 天\n"
|
||||
f"5. 出差补贴:{self._format_decimal_money(calculation.total_allowance_rate)} 元/天 × {calculation.days} 天\n"
|
||||
f"6. 参考合计:{self._format_decimal_money(total_amount)} 元"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -3739,7 +3857,7 @@ class UserAgentService:
|
||||
if reminder_count:
|
||||
return (
|
||||
f"当前关键信息已基本齐全,但还有 {reminder_count} 条提醒。"
|
||||
"您可以展开下方卡片查看详情,确认无误后继续下一步。"
|
||||
"请核查对话中的文字说明,确认无误后继续下一步。"
|
||||
)
|
||||
return "当前关键信息已基本齐全,您确认无误后可以继续下一步。"
|
||||
|
||||
@@ -3750,10 +3868,10 @@ class UserAgentService:
|
||||
issue_parts.append(f"{reminder_count} 条提醒")
|
||||
issue_summary = "、".join(issue_parts) if issue_parts else "一些细节还需要进一步确认"
|
||||
|
||||
suffix = ";如果想先暂存,也可以点击下方按钮保存草稿。" if mention_save_draft else "。"
|
||||
suffix = ";如果想先暂存,也可以点击对话文字中的“草稿”。" if mention_save_draft else "。"
|
||||
return (
|
||||
f"当前还有 {issue_summary}。"
|
||||
f"您可以展开下方卡片查看详情,继续补充或修改{suffix}"
|
||||
f"请核查对话中的文字说明{suffix}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
Reference in New Issue
Block a user