feat: 重构报销单服务并完善前端提交与审核交互

重构 expense_claims 服务模块结构并优化差旅票据审核逻辑,
增强用户代理服务的票据类型识别,前端报销创建页面拆分为
附件模型和会话模型模块,重构提交编排器和草稿关联确认流
程,更新知识库索引,补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-22 08:58:59 +08:00
parent f6f787ff38
commit 5fe3b201d9
42 changed files with 13697 additions and 9496 deletions

1
nul
View File

@@ -1 +0,0 @@
/usr/bin/bash: line 1: rg: command not found

View File

@@ -956,7 +956,12 @@ class ExpenseClaimService:
) -> dict[str, Any]:
review_action = str(context_json.get("review_action") or "").strip()
if review_action not in PERSISTENT_EXPENSE_REVIEW_ACTIONS:
return self._build_expense_review_preview_result(context_json)
return self._build_expense_review_preview_result(
user_id=user_id,
message=message,
ontology=ontology,
context_json=context_json,
)
result = self.upsert_draft_from_ontology(
run_id=run_id,
@@ -1051,15 +1056,29 @@ class ExpenseClaimService:
"invoice_count": int(claim.invoice_count or 0),
}
def _build_expense_review_preview_result(self, context_json: dict[str, Any]) -> dict[str, Any]:
def _build_expense_review_preview_result(
self,
*,
user_id: str | None,
message: str,
ontology: OntologyParseResult,
context_json: dict[str, Any],
) -> dict[str, Any]:
attachment_count = self._resolve_attachment_count(context_json)
calculation_copy = self._build_expense_review_preview_calculation_copy(
user_id=user_id,
message=message,
ontology=ontology,
context_json=context_json,
)
return {
"message": (
"我已先整理出本次报销的待核对信息。"
"如果附件还没有上传,金额可以先按制度口径做参考测算:"
"差旅费按“交通票据金额 + 住宿标准 × 出差天数 + 出差补贴 × 出差天数”估算;"
"交通费、住宿费等其他费用以实际票据金额为基础,再按规则中心限额和审批口径复核。"
"后续补充票据后,我会用真实票据金额重新校验。"
"message": "\n\n".join(
item
for item in [
"我已先整理出本次报销的待核对信息。下面是基于当前信息的制度测算,票据补齐后会按真实金额重新复核。",
calculation_copy,
]
if item
),
"draft_only": True,
"preview_only": True,
@@ -1067,6 +1086,145 @@ class ExpenseClaimService:
"invoice_count": attachment_count,
}
def _build_expense_review_preview_calculation_copy(
self,
*,
user_id: str | None,
message: str,
ontology: OntologyParseResult,
context_json: dict[str, Any],
) -> str:
expense_type = self._resolve_explicit_review_expense_type(context_json) or self._resolve_expense_type(
ontology.entities,
context_json=context_json,
)
if expense_type == "travel" or (
(not expense_type or expense_type == "other")
and self._should_preview_as_travel(message=message, context_json=context_json)
):
return self._build_travel_review_preview_calculation_copy(
user_id=user_id,
message=message,
ontology=ontology,
context_json=context_json,
)
amount = self._resolve_amount(ontology.entities, context_json=context_json) or Decimal("0.00")
expense_label = EXPENSE_TYPE_LABELS.get(str(expense_type or "").strip(), "当前费用")
return "\n".join(
[
"报销测算参考:",
"",
"| 项目 | 当前信息 | 复核口径 |",
"| --- | --- | --- |",
f"| 费用类型 | {expense_label} | 匹配规则中心对应费用标准 |",
f"| 票据金额 | {self._format_decimal_amount(amount)} 元 | 以真实票据识别金额和用户确认金额为准 |",
"| 规则校验 | 待票据和关键信息补齐 | 按费用类型、发生地点、业务事由和审批口径复核 |",
]
)
def _build_travel_review_preview_calculation_copy(
self,
*,
user_id: str | None,
message: str,
ontology: OntologyParseResult,
context_json: dict[str, Any],
) -> str:
location = self._resolve_location(message=message, context_json=context_json) or "待确认"
occurred_at = self._resolve_occurred_at(ontology, context_json=context_json) or datetime.now(UTC)
days, _, _ = self._resolve_travel_allowance_days(
context_json=context_json,
occurred_at=occurred_at,
)
amount = self._resolve_amount(ontology.entities, context_json=context_json) or Decimal("0.00")
employee = self._resolve_employee(
ontology=ontology,
context_json=context_json,
user_id=user_id,
)
grade = str(
context_json.get("employee_grade")
or context_json.get("grade")
or context_json.get("user_grade")
or (employee.grade if employee is not None else "")
or ""
).strip()
if location == "待确认" or not grade:
return "\n".join(
[
"报销测算参考:",
"",
"| 项目 | 当前信息 | 测算说明 |",
"| --- | --- | --- |",
f"| 出差地点 | {location} | 用于匹配城市住宿标准和补贴区域 |",
f"| 出差天数 | {days} 天 | 来自业务发生时间或用户描述 |",
f"| 职级 | {grade or '待确认'} | 补齐后才能匹配住宿标准和补贴档位 |",
f"| 交通票据 | {self._format_decimal_amount(amount)} 元 | 上传票据后按真实金额重新复核 |",
]
)
try:
from app.services.travel_reimbursement_calculator import (
TravelReimbursementCalculatorService,
)
result = TravelReimbursementCalculatorService(self.db).calculate(
TravelReimbursementCalculatorRequest(days=days, location=location, grade=grade),
CurrentUserContext(
username=str(user_id or context_json.get("name") or "anonymous").strip() or "anonymous",
name=str(context_json.get("name") or user_id or "anonymous").strip() or "anonymous",
role_codes=[],
is_admin=False,
),
)
except ValueError:
return "\n".join(
[
"报销测算参考:",
"",
"| 项目 | 当前信息 | 测算说明 |",
"| --- | --- | --- |",
f"| 出差地点 | {location} | 暂时未能匹配规则中心地点 |",
f"| 出差天数 | {days} 天 | 来自业务发生时间或用户描述 |",
f"| 职级 | {grade} | 暂时无法自动匹配差旅标准 |",
f"| 交通票据 | {self._format_decimal_amount(amount)} 元 | 上传票据后按真实金额重新复核 |",
]
)
ticket_amount = amount.quantize(Decimal("0.01"))
total_amount = (
ticket_amount
+ Decimal(result.hotel_amount or Decimal("0.00"))
+ Decimal(result.allowance_amount or Decimal("0.00"))
).quantize(Decimal("0.01"))
ticket_basis = "当前未上传交通票据,先按 0.00 元占位" if ticket_amount <= Decimal("0.00") else "已识别或填写的交通票据金额"
return "\n".join(
[
"报销测算参考:",
"",
f"职级 {grade},目的地 {location},匹配城市 {result.matched_city};补齐交通、酒店等票据后,我会按真实票据金额和规则中心标准重新复核。",
"",
"| 项目 | 测算口径 | 金额 |",
"| --- | --- | ---: |",
f"| 交通票据 | {ticket_basis} | {self._format_decimal_amount(ticket_amount)} 元 |",
f"| 住宿标准 | {self._format_decimal_amount(result.hotel_rate)} 元/天 × {days} 天 | {self._format_decimal_amount(result.hotel_amount)} 元 |",
f"| 出差补贴 | {self._format_decimal_amount(result.total_allowance_rate)} 元/天 × {days} 天 | {self._format_decimal_amount(result.allowance_amount)} 元 |",
f"| 参考合计 | 交通票据 + 住宿标准 + 出差补贴 | {self._format_decimal_amount(total_amount)} 元 |",
]
)
@staticmethod
def _should_preview_as_travel(*, message: str, context_json: dict[str, Any]) -> bool:
text_parts = [message]
review_form_values = context_json.get("review_form_values")
if isinstance(review_form_values, dict):
text_parts.extend(str(value or "") for value in review_form_values.values())
text_parts.extend(str(context_json.get(key) or "") for key in ("user_input_text", "raw_text", "ocr_summary"))
compact = "".join(text_parts)
return any(keyword in compact for keyword in ("差旅", "出差", "火车票", "机票", "酒店", "住宿票"))
def delete_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
claim = self.get_claim(claim_id, current_user)
if claim is None:
@@ -1388,6 +1546,30 @@ class ExpenseClaimService:
existing_flags=list(claim.risk_flags_json or []) if claim is not None else [],
next_flags=list(ontology.risk_flags),
)
if context_documents or attachment_names:
document_specs = self._build_context_item_specs(
context_documents=context_documents,
attachment_names=attachment_names,
occurred_at=final_occurred_at,
expense_type=final_expense_type,
amount=final_amount,
reason=final_reason,
location=final_location,
context_json=context_json,
employee_grade=str(employee.grade or "").strip() if employee is not None else "",
user_id=user_id,
)
else:
document_specs = []
if claim is not None and review_action == "link_to_existing_draft" and document_specs:
duplicate_result = self._build_duplicate_attachment_block_result(
claim=claim,
document_specs=document_specs,
context_documents=context_documents,
)
if duplicate_result is not None:
return duplicate_result
try:
if claim is None:
@@ -1443,22 +1625,6 @@ class ExpenseClaimService:
claim.risk_flags_json = final_risk_flags
self.db.flush()
if context_documents or attachment_names:
document_specs = self._build_context_item_specs(
context_documents=context_documents,
attachment_names=attachment_names,
occurred_at=final_occurred_at,
expense_type=final_expense_type,
amount=final_amount,
reason=final_reason,
location=final_location,
context_json=context_json,
employee_grade=str(employee.grade or "").strip() if employee is not None else "",
user_id=user_id,
)
else:
document_specs = []
if document_specs and (is_new_claim or review_action in DOCUMENT_ASSOCIATION_REVIEW_ACTIONS):
if review_action == "link_to_existing_draft" and claim.items:
self._append_document_items(
@@ -2081,6 +2247,157 @@ class ExpenseClaimService:
)
self.db.add(claim.items[-1])
def _build_duplicate_attachment_block_result(
self,
*,
claim: ExpenseClaim,
document_specs: list[dict[str, Any]],
context_documents: list[dict[str, Any]],
) -> dict[str, Any] | None:
duplicate_matches = self._find_duplicate_attachment_matches(
claim=claim,
document_specs=document_specs,
context_documents=context_documents,
)
if not duplicate_matches:
return None
duplicate_labels = list(
dict.fromkeys(
str(item.get("incoming_label") or item.get("existing_label") or "").strip()
for item in duplicate_matches
if str(item.get("incoming_label") or item.get("existing_label") or "").strip()
)
)
duplicate_text = "".join(duplicate_labels[:3]) or "本次上传票据"
reason = (
f"检测到本次上传的票据与草稿 {claim.claim_no} 中已有票据重复:{duplicate_text}"
"请重新上传不同的票据后再归集。"
)
return {
"message": reason,
"draft_only": False,
"status": "blocked",
"duplicate_attachment_blocked": True,
"duplicate_invoice_blocked": True,
"submission_blocked": True,
"submission_blocked_reasons": [reason],
"missing_fields": [reason],
"risk_flags": ["duplicate_invoice"],
"duplicate_attachments": duplicate_matches,
"claim_id": claim.id,
"claim_no": claim.claim_no,
"amount": float(claim.amount or Decimal("0.00")),
"invoice_count": int(claim.invoice_count or 0),
}
def _find_duplicate_attachment_matches(
self,
*,
claim: ExpenseClaim,
document_specs: list[dict[str, Any]],
context_documents: list[dict[str, Any]],
) -> list[dict[str, str]]:
existing_tokens: dict[str, dict[str, str]] = {}
for item in list(claim.items or []):
if str(item.item_type or "").strip() in SYSTEM_GENERATED_ITEM_TYPES:
continue
invoice_id = str(item.invoice_id or "").strip()
if not invoice_id:
continue
display_name = self._resolve_attachment_display_name(invoice_id)
for token in self._build_duplicate_attachment_tokens(invoice_id):
existing_tokens.setdefault(
token,
{
"existing_label": display_name or invoice_id,
"existing_item_id": str(item.id or ""),
"match_type": "filename",
},
)
file_path = self._resolve_item_attachment_path(item)
if file_path is not None and file_path.exists():
metadata = self._read_attachment_meta(file_path)
document_info = metadata.get("document_info")
if isinstance(document_info, dict):
for invoice_key in self._collect_invoice_keys_from_document_info(document_info):
token = self._normalize_duplicate_attachment_token(invoice_key)
if token:
existing_tokens.setdefault(
token,
{
"existing_label": display_name or invoice_id,
"existing_item_id": str(item.id or ""),
"match_type": "invoice_key",
},
)
if not existing_tokens:
return []
document_by_filename = {
str(document.get("filename") or "").strip(): document
for document in context_documents
if isinstance(document, dict) and str(document.get("filename") or "").strip()
}
matches: list[dict[str, str]] = []
seen_tokens: set[str] = set()
for spec in document_specs:
if str(spec.get("item_type") or "").strip() in SYSTEM_GENERATED_ITEM_TYPES:
continue
invoice_id = str(spec.get("invoice_id") or "").strip()
if not invoice_id:
continue
incoming_tokens = self._build_duplicate_attachment_tokens(invoice_id)
document = document_by_filename.get(invoice_id)
if document is not None:
incoming_tokens.extend(
self._normalize_duplicate_attachment_token(invoice_key)
for invoice_key in self._collect_invoice_keys_from_incoming_document(document)
)
for token in incoming_tokens:
if not token or token in seen_tokens or token not in existing_tokens:
continue
seen_tokens.add(token)
existing = existing_tokens[token]
matches.append(
{
"incoming_label": self._resolve_attachment_display_name(invoice_id) or invoice_id,
"existing_label": existing.get("existing_label", ""),
"existing_item_id": existing.get("existing_item_id", ""),
"match_type": existing.get("match_type", "filename"),
}
)
return matches
@classmethod
def _build_duplicate_attachment_tokens(cls, value: str | None) -> list[str]:
raw = str(value or "").strip()
display_name = cls._resolve_attachment_display_name(raw)
candidates = [raw, display_name]
return list(
dict.fromkeys(
token
for token in (cls._normalize_duplicate_attachment_token(candidate) for candidate in candidates)
if token
)
)
@staticmethod
def _normalize_duplicate_attachment_token(value: str | None) -> str:
normalized = Path(str(value or "").strip()).name.lower()
normalized = re.sub(r"\s+", "", normalized)
normalized = re.sub(r"[^\w.\-\u4e00-\u9fff]+", "_", normalized).strip("._")
return normalized
def _collect_invoice_keys_from_incoming_document(self, document: dict[str, Any]) -> list[str]:
document_info = dict(document or {})
if "fields" not in document_info and isinstance(document_info.get("document_fields"), list):
document_info["fields"] = document_info.get("document_fields")
return self._collect_invoice_keys_from_document_info(document_info)
def _resolve_document_item_type(self, document: dict[str, Any], *, fallback: str) -> str:
document_type = str(document.get("document_type") or "").strip()
mapped_type = DOCUMENT_TYPE_ITEM_TYPE_MAP.get(document_type)

View File

@@ -3354,23 +3354,23 @@ class UserAgentService:
location = slots.get("location")
customer = slots.get("customer_name")
summary = "我先根据您当前提供的信息整理出一笔报销"
summary = "我先根据您当前提供的信息整理出一笔报销"
if expense_type and expense_type.value:
summary = f"识别到您希望报销一笔“{expense_type.value}”费用"
summary = f"识别到您希望报销一笔“{expense_type.value}”费用"
details: list[str] = []
if customer and customer.value:
details.append(f"客户{customer.value}")
details.append(f"客户{customer.value}")
if time_range and time_range.value:
details.append(f"时间{time_range.value}")
details.append(f"时间{time_range.value}")
if location and location.value:
details.append(f"地点{location.value}")
details.append(f"地点{location.value}")
if amount and amount.value:
details.append(f"金额{amount.value}")
details.append(f"金额{amount.value}")
reason = slots.get("reason")
if reason and reason.value:
details.append(f"事由{reason.value}")
details.append(f"事由{reason.value}")
if details:
return f"{summary} {''.join(details)}"
return "\n\n".join([summary, "基础信息识别结果:", "\n".join(details)])
return summary
def _build_review_body_answer(
@@ -3399,6 +3399,11 @@ class UserAgentService:
slot_cards=review_payload.slot_cards,
claim_groups=review_payload.claim_groups,
)
if payload.tool_payload.get("duplicate_attachment_blocked") or payload.tool_payload.get("duplicate_invoice_blocked"):
return (
str(payload.tool_payload.get("message") or "").strip()
or "检测到本次上传票据与当前单据已有票据重复,请重新上传不同的票据后再归集。"
)
if review_action == "save_draft":
if draft_payload is not None and draft_payload.claim_no:
return (
@@ -3441,7 +3446,7 @@ class UserAgentService:
)
return str(payload.tool_payload.get("message") or "").strip() or "当前报销单暂时还不能提交审批。"
return (
f"{self._build_review_intent_summary(payload, slot_cards=review_payload.slot_cards, claim_groups=review_payload.claim_groups)} "
f"{self._build_review_intent_summary(payload, slot_cards=review_payload.slot_cards, claim_groups=review_payload.claim_groups)}\n\n"
"当前关键信息已基本齐全,您确认无误后可以继续下一步。"
)
return review_payload.body_message or None
@@ -3497,7 +3502,7 @@ class UserAgentService:
expense_type_slot = next((item for item in slot_cards if item.key == "expense_type"), None)
if expense_type_slot is not None and not str(expense_type_slot.value or "").strip():
return (
f"{self._build_review_intent_summary(payload, slot_cards=slot_cards, claim_groups=[])} "
f"{self._build_review_intent_summary(payload, slot_cards=slot_cards, claim_groups=[])}\n\n"
"我已经先保留了当前识别出的时间、地点和事由,但还不能确定这张单据应该走哪类报销流程。"
"请先点击“选择报销类型”,在差旅费、交通费、住宿费等选项中选定;"
"选定后,后续上传的票据都会作为这张单据的补充继续核对,不会重新改判报销类型。"
@@ -3616,17 +3621,17 @@ class UserAgentService:
[
"报销测算参考:",
"",
(
f"职级 {calculation.grade},目的地 {destination},匹配城市 {calculation.matched_city}"
"补齐交通、酒店等票据后,我会按真实票据金额和规则中心标准重新复核。"
),
"",
"| 项目 | 测算口径 | 金额 |",
"| --- | --- | ---: |",
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}"
"补齐交通、酒店等票据后,我会按真实票据金额和规则中心标准重新复核。"
),
]
)
@@ -3850,7 +3855,6 @@ class UserAgentService:
*,
mention_save_draft: bool,
) -> str:
missing_count = len(review_payload.missing_slots)
reminder_count = len(review_payload.risk_briefs)
if review_payload.can_proceed:
@@ -3861,18 +3865,7 @@ class UserAgentService:
)
return "当前关键信息已基本齐全,您确认无误后可以继续下一步。"
issue_parts: list[str] = []
if missing_count:
issue_parts.append(f"{missing_count} 项信息待补充")
if reminder_count:
issue_parts.append(f"{reminder_count} 条提醒")
issue_summary = "".join(issue_parts) if issue_parts else "一些细节还需要进一步确认"
suffix = ";如果想先暂存,也可以点击对话文字中的“草稿”。" if mention_save_draft else ""
return (
f"当前还有 {issue_summary}"
f"请核查对话中的文字说明{suffix}"
)
return ""
@staticmethod
def _can_proceed_review(

View File

@@ -0,0 +1,82 @@
{
"file_name": "酒店2.jpg",
"storage_key": "5544b2a0-a6f5-4ef8-b5b6-c1ac1b03772f/07085673-a7df-4622-abb7-12f6552c780d/酒店2.jpg",
"media_type": "image/jpeg",
"size_bytes": 156877,
"uploaded_at": "2026-05-21T14:19:49.450265+00:00",
"previewable": true,
"preview_kind": "image",
"preview_storage_key": "5544b2a0-a6f5-4ef8-b5b6-c1ac1b03772f/07085673-a7df-4622-abb7-12f6552c780d/酒店2.preview.jpg",
"preview_media_type": "image/jpeg",
"preview_file_name": "酒店2.preview.jpg",
"analysis": {
"severity": "pass",
"label": "AI提示符合条件",
"headline": "AI提示附件符合基础校验条件",
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
"points": [
"票据类型:已识别为酒店住宿票据。",
"附件类型要求:当前费用项目为住宿票,已识别为酒店住宿票据。",
"金额字段:已识别到与当前明细接近的金额 2400.00 元。"
],
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
},
"document_info": {
"document_type": "hotel_invoice",
"document_type_label": "酒店住宿票据",
"scene_code": "hotel",
"scene_label": "住宿票据",
"fields": [
{
"key": "amount",
"label": "金额",
"value": "2400元"
},
{
"key": "date",
"label": "日期",
"value": "2026-02-23"
},
{
"key": "merchant_name",
"label": "商户",
"value": "上海喜来登酒店"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "SH-SAMPLE-20260223-003"
}
]
},
"requirement_check": {
"matches": true,
"current_expense_type": "hotel_ticket",
"current_expense_type_label": "住宿票",
"allowed_scene_labels": [],
"allowed_document_type_labels": [],
"recognized_scene_code": "hotel",
"recognized_scene_label": "住宿票据",
"recognized_document_type": "hotel_invoice",
"recognized_document_type_label": "酒店住宿票据",
"mismatch_severity": "high",
"rule_code": "rule.expense.scene_submission_standard",
"rule_name": "报销场景提交与附件标准",
"message": "当前费用项目为住宿票,已识别为酒店住宿票据。"
},
"ocr_status": "recognized",
"ocr_error": "",
"ocr_text": "上海喜来登酒店(样例)\n住宿消费明细单\n单号SH-SAMPLE-20260223-003\n出单期2026年2月23\n宾客姓名\n曹笑竹\n房间类型豪华床房\n入住日期\n2026年2月20日\n住晚数 3晚\n离店期 2026年223日\n付款式 现/信卡/其他\n日期\n项目\n计费说明\n单价\n数量\n金额\n2026年2月20日\n至\n住宿费\n豪华大床房\n¥800/晚\n3\n¥2400\n2026年2月22日\n额写贰仟肆佰元整\n合计¥2400\n温馨提示如您对以上账单有任何疑问请在离店后7天内与酒店联系感谢您的理解与支持。\n酒店联系式上海喜来登酒店\n地址上海市浦东新区银城中路88号 电话021-12345678\n样例票据|仅供系统测试|无效凭证",
"ocr_summary": "上海喜来登酒店样例住宿消费明细单单号SH-SAMPLE-20260223-003",
"ocr_avg_score": 0.9784442763775587,
"ocr_line_count": 32,
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.84,
"ocr_classification_evidence": [
"住宿",
"入住",
"离店",
"酒店"
],
"ocr_warnings": []
}

View File

@@ -0,0 +1,87 @@
{
"file_name": "2月23_上海-武汉.pdf",
"storage_key": "5544b2a0-a6f5-4ef8-b5b6-c1ac1b03772f/ac0a7cc8-7152-41e3-bcce-bd358459a5a8/2月23_上海-武汉.pdf",
"media_type": "application/pdf",
"size_bytes": 24940,
"uploaded_at": "2026-05-21T14:03:40.109269+00:00",
"previewable": true,
"preview_kind": "image",
"preview_storage_key": "5544b2a0-a6f5-4ef8-b5b6-c1ac1b03772f/ac0a7cc8-7152-41e3-bcce-bd358459a5a8/2月23_上海-武汉.preview.png",
"preview_media_type": "image/png",
"preview_file_name": "2月23_上海-武汉.preview.png",
"analysis": {
"severity": "pass",
"label": "AI提示符合条件",
"headline": "AI提示附件符合基础校验条件",
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
"points": [
"票据类型:已识别为火车/高铁票。",
"附件类型要求:当前费用项目为火车票,已识别为火车/高铁票。",
"金额字段:已识别到与当前明细接近的金额 354.00 元。"
],
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
},
"document_info": {
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-23 13:54"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26319166100006175398"
},
{
"key": "route",
"label": "行程",
"value": "上海-武汉"
}
]
},
"requirement_check": {
"matches": true,
"current_expense_type": "train_ticket",
"current_expense_type_label": "火车票",
"allowed_scene_labels": [],
"allowed_document_type_labels": [],
"recognized_scene_code": "travel",
"recognized_scene_label": "差旅票据",
"recognized_document_type": "train_ticket",
"recognized_document_type_label": "火车/高铁票",
"mismatch_severity": "high",
"rule_code": "rule.expense.scene_submission_standard",
"rule_name": "报销场景提交与附件标准",
"message": "当前费用项目为火车票,已识别为火车/高铁票。"
},
"ocr_status": "recognized",
"ocr_error": "",
"ocr_text": "电子发票\n铁路电子客票\n州\n国家税务总局\n发票号码26319166100006175398\n开票日期:2026年05月18日\n上海市税务局\n上海虹桥站\n武汉站\nG456\nShanghaihongqiao\nWuhan\n2026年02月23日\n13:54开\n12车08B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6610061086021394837402026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"ocr_summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9620026834309101,
"ocr_line_count": 24,
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"ocr_warnings": []
}

View File

@@ -0,0 +1,87 @@
{
"file_name": "2月20_武汉-上海.pdf",
"storage_key": "5544b2a0-a6f5-4ef8-b5b6-c1ac1b03772f/b4143190-f375-4f6b-8836-23eee534c99e/2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"uploaded_at": "2026-05-21T14:03:02.982421+00:00",
"previewable": true,
"preview_kind": "image",
"preview_storage_key": "5544b2a0-a6f5-4ef8-b5b6-c1ac1b03772f/b4143190-f375-4f6b-8836-23eee534c99e/2月20_武汉-上海.preview.png",
"preview_media_type": "image/png",
"preview_file_name": "2月20_武汉-上海.preview.png",
"analysis": {
"severity": "pass",
"label": "AI提示符合条件",
"headline": "AI提示附件符合基础校验条件",
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
"points": [
"票据类型:已识别为火车/高铁票。",
"附件类型要求:当前费用项目为火车票,已识别为火车/高铁票。",
"金额字段:已识别到与当前明细接近的金额 354.00 元。"
],
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
},
"document_info": {
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-20 07:55"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26429165800002785705"
},
{
"key": "route",
"label": "行程",
"value": "武汉-上海"
}
]
},
"requirement_check": {
"matches": true,
"current_expense_type": "train_ticket",
"current_expense_type_label": "火车票",
"allowed_scene_labels": [],
"allowed_document_type_labels": [],
"recognized_scene_code": "travel",
"recognized_scene_label": "差旅票据",
"recognized_document_type": "train_ticket",
"recognized_document_type_label": "火车/高铁票",
"mismatch_severity": "high",
"rule_code": "rule.expense.scene_submission_standard",
"rule_name": "报销场景提交与附件标准",
"message": "当前费用项目为火车票,已识别为火车/高铁票。"
},
"ocr_status": "recognized",
"ocr_error": "",
"ocr_text": "电子发票\n铁路电子客票)\n州\n国家税务总局\n发票号码26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"ocr_summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9580968717734019,
"ocr_line_count": 24,
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"ocr_warnings": []
}

View File

@@ -0,0 +1,88 @@
{
"file_name": "2月20_武汉-上海.pdf",
"storage_key": "b00cb2a5-0af3-4a49-9f7a-1f79d0ab873a/ab4d8fae-f59d-460d-94a8-eaf644c83591/2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"uploaded_at": "2026-05-22T00:38:09.743522+00:00",
"previewable": true,
"preview_kind": "image",
"preview_storage_key": "b00cb2a5-0af3-4a49-9f7a-1f79d0ab873a/ab4d8fae-f59d-460d-94a8-eaf644c83591/2月20_武汉-上海.preview.png",
"preview_media_type": "image/png",
"preview_file_name": "2月20_武汉-上海.preview.png",
"analysis": {
"severity": "pass",
"label": "AI提示符合条件",
"headline": "AI提示附件符合基础校验条件",
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
"points": [
"票据类型:已识别为火车/高铁票。",
"附件类型要求:当前费用项目为火车票,已识别为火车/高铁票。",
"金额字段:已识别到与当前明细接近的金额 354.00 元。"
],
"rule_basis": [],
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
},
"document_info": {
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-20 07:55"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26429165800002785705"
},
{
"key": "route",
"label": "行程",
"value": "武汉-上海"
}
]
},
"requirement_check": {
"matches": true,
"current_expense_type": "train_ticket",
"current_expense_type_label": "火车票",
"allowed_scene_labels": [],
"allowed_document_type_labels": [],
"recognized_scene_code": "travel",
"recognized_scene_label": "差旅票据",
"recognized_document_type": "train_ticket",
"recognized_document_type_label": "火车/高铁票",
"mismatch_severity": "high",
"rule_code": "rule.expense.scene_submission_standard",
"rule_name": "报销场景提交与附件标准",
"message": "当前费用项目为火车票,已识别为火车/高铁票。"
},
"ocr_status": "recognized",
"ocr_error": "",
"ocr_text": "电子发票\n铁路电子客票)\n州\n国家税务总局\n发票号码26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"ocr_summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9580968717734019,
"ocr_line_count": 24,
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"ocr_warnings": []
}

View File

@@ -0,0 +1,88 @@
{
"file_name": "2月23_上海-武汉.pdf",
"storage_key": "b00cb2a5-0af3-4a49-9f7a-1f79d0ab873a/b2edd3f3-9efc-44ab-bd3b-60a42f204a60/2月23_上海-武汉.pdf",
"media_type": "application/pdf",
"size_bytes": 24940,
"uploaded_at": "2026-05-22T00:38:30.927361+00:00",
"previewable": true,
"preview_kind": "image",
"preview_storage_key": "b00cb2a5-0af3-4a49-9f7a-1f79d0ab873a/b2edd3f3-9efc-44ab-bd3b-60a42f204a60/2月23_上海-武汉.preview.png",
"preview_media_type": "image/png",
"preview_file_name": "2月23_上海-武汉.preview.png",
"analysis": {
"severity": "pass",
"label": "AI提示符合条件",
"headline": "AI提示附件符合基础校验条件",
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
"points": [
"票据类型:已识别为火车/高铁票。",
"附件类型要求:当前费用项目为火车票,已识别为火车/高铁票。",
"金额字段:已识别到与当前明细接近的金额 354.00 元。"
],
"rule_basis": [],
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
},
"document_info": {
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-23 13:54"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26319166100006175398"
},
{
"key": "route",
"label": "行程",
"value": "上海-武汉"
}
]
},
"requirement_check": {
"matches": true,
"current_expense_type": "train_ticket",
"current_expense_type_label": "火车票",
"allowed_scene_labels": [],
"allowed_document_type_labels": [],
"recognized_scene_code": "travel",
"recognized_scene_label": "差旅票据",
"recognized_document_type": "train_ticket",
"recognized_document_type_label": "火车/高铁票",
"mismatch_severity": "high",
"rule_code": "rule.expense.scene_submission_standard",
"rule_name": "报销场景提交与附件标准",
"message": "当前费用项目为火车票,已识别为火车/高铁票。"
},
"ocr_status": "recognized",
"ocr_error": "",
"ocr_text": "电子发票\n铁路电子客票\n州\n国家税务总局\n发票号码26319166100006175398\n开票日期:2026年05月18日\n上海市税务局\n上海虹桥站\n武汉站\nG456\nShanghaihongqiao\nWuhan\n2026年02月23日\n13:54开\n12车08B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6610061086021394837402026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"ocr_summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9620026834309101,
"ocr_line_count": 24,
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"ocr_warnings": []
}

View File

@@ -35,13 +35,13 @@
"updated_at": "2026-05-17T13:00:09.485818+00:00",
"uploaded_by": "admin",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-20T16:00:02.515903+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": "run_3a0b0ecb941b4c8e"
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-21T15:56:58.286585+00:00",
"ingest_completed_at": "2026-05-21T15:56:58.286585+00:00",
"ingest_document_name": "无单需求文档0506.docx",
"ingest_document_updated_at": "2026-05-17T13:00:09.485818+00:00",
"ingest_document_sha256": "00985ec85a8163be9c9ffc5eb522df18ed52d4b131ceed12102c2d75e4df85a9",
"ingest_agent_run_id": "run_9f4f60cf545c470f"
}
]
}

View File

@@ -26,8 +26,7 @@
}
},
"a8f8465df08e455ebe133351721d49f8": {
"status": "failed",
"error_msg": "Embedding func: Worker execution timeout after 60s",
"status": "processed",
"chunks_count": 6,
"chunks_list": [
"chunk-07de6ea74f60535b689f977295770273",
@@ -40,12 +39,29 @@
"content_summary": "# 产品需求文档\n## 文档信息\n| 项目 | 内容 |\n|------|------|\n| 项目名称 |\n无单报销\n|\n| 版本 | V1.0 |\n| 日期 | 2026-05-06 |\n| 状态 | 正式版 |\n---\n## 1. 项目概述\n### 1.1 项目背景\n面向\n大型企业\n从业务人员视角出发解决现有ERP使用体验不佳的问题。\n在ERP的发展历程中“单据化”曾是财务合规的一大进步它确保了每笔支出都有据可查。但不可否认传统的人工填单确实\n也制造了很多\n“枷锁”。在AI时代解...",
"content_length": 9088,
"created_at": "2026-05-19T15:59:57.283110+00:00",
"updated_at": "2026-05-19T16:00:57.323299+00:00",
"updated_at": "2026-05-21T15:56:58.097242+00:00",
"file_path": "/app/server/storage/knowledge/报销制度/a8f8465df08e455ebe133351721d49f8__无单需求文档0506.docx",
"track_id": "insert_20260519_155957_88c49850",
"metadata": {
"processing_start_time": 1779206397,
"processing_end_time": 1779206457
"processing_start_time": 1779378923,
"processing_end_time": 1779379018
}
},
"dup-de90fa8775923ae9a1669c8e24d60529": {
"status": "failed",
"content_summary": "[DUPLICATE] Original document: a8f8465df08e455ebe133351721d49f8",
"content_length": 9088,
"chunks_count": 0,
"chunks_list": [],
"created_at": "2026-05-21T15:55:23.540372+00:00",
"updated_at": "2026-05-21T15:55:23.540380+00:00",
"file_path": "/app/server/storage/knowledge/报销制度/a8f8465df08e455ebe133351721d49f8__无单需求文档0506.docx",
"track_id": "insert_20260521_155523_1e232e61",
"error_msg": "Content already exists. Original doc_id: a8f8465df08e455ebe133351721d49f8, Status: failed",
"metadata": {
"is_duplicate": true,
"original_doc_id": "a8f8465df08e455ebe133351721d49f8",
"original_track_id": "insert_20260519_155957_88c49850"
}
}
}

View File

@@ -264,5 +264,124 @@
"create_time": 1779012093,
"update_time": 1779012093,
"_id": "2c1cb358f08d44ceb0e4d287133206ec"
},
"a8f8465df08e455ebe133351721d49f8": {
"entity_names": [
"User Menu",
"Logout",
"行政秘书",
"配色方案",
"Q1 Quarterly Business Progress Report",
"补充上传按钮",
"AI",
"侧边栏",
"凭证识别",
"预审按钮",
"标题栏",
"Sidebar",
"Mini Program Code",
"Smart Platform Jiangsu Province Company Operations Support",
"Pre-Review Result Display",
"扫码上传",
"出差",
"影子ERP",
"Preset Assistant Cards",
"Trip Time Check",
"合规性检查",
"Airport Bus Ticket Check",
"Train Ticket Compliance Check",
"总裁办",
"ERP系统",
"Initiate Matter",
"Procurement Specialist",
"Submit Reimbursement",
"商旅附件",
"Title Bar",
"Web端",
"用户菜单",
"Time Track",
"2026 National AI Technology Summit in Hefei",
"Taxi Invoice Check",
"Activity Bar",
"Pre-Review Problem Summary",
"Matter List",
"Global Chat Bar",
"Problem Items",
"状态栏",
"Personal Center",
"Flight Ticket Compliance Check",
"UI Layout",
"飞机票行程单",
"Ashy有限公司",
"User Center Module",
"正文",
"Go Reimburse Button",
"Menu 2",
"Normal Items",
"住宿发票及流水",
"Business Travel",
"报销流程",
"火车票",
"活动栏",
"Invoice Title Check",
"采购专员",
"线下自付",
"火车票超标",
"Document Upload",
"Submission Confirmation Prompt",
"功能需求",
"零报销",
"User Message",
"AI Assistant",
"ERP",
"无单报销",
"底部对话栏",
"审核点",
"Submission Result Prompt",
"Document Recognition",
"即效合规",
"Main Content Area",
"费控专责",
"完整性检查",
"User Center",
"Cost Control Specialist",
"AI Reply",
"公共交通发票及行程单",
"Summon Panel",
"Receipt-Free Reimbursement",
"Status Bar",
"Hotel Booking Compliance Check",
"Warning Icon",
"Finance BP",
"菜单1",
"Taxi Trip Time Compliance Check",
"大型企业",
"AI预审",
"凭证处理流程",
"Expense Reimbursement Submission Process",
"Role Prompt Templates",
"Pre-Review Pass Notification",
"AI Assistant Module",
"用户中心",
"Supplementary Upload Button",
"问题项目标识",
"Subway Invoice Check",
"业务人员",
"Role Configuration Pop-up Window",
"AI Pre-Review",
"出租车发票及行程单",
"无单报销工具",
"财务BP",
"飞机票超标",
"Bottom Dialog Bar",
"商旅预订",
"凭证缩略图",
"Administrative Secretary",
"问题标注"
],
"count": 111,
"create_time": 1779379018,
"update_time": 1779379018,
"_id": "a8f8465df08e455ebe133351721d49f8"
}
}

View File

@@ -162,5 +162,117 @@
"create_time": 1779012093,
"update_time": 1779012093,
"_id": "2c1cb358f08d44ceb0e4d287133206ec"
},
"a8f8465df08e455ebe133351721d49f8": {
"relation_pairs": [
[
"AI预审",
"预审按钮"
],
[
"Menu 2",
"User Center"
],
[
"Pre-Review Result Display",
"Train Ticket Compliance Check"
],
[
"AI Assistant",
"Menu 2"
],
[
"AI预审",
"问题标注"
],
[
"业务人员",
"无单报销"
],
[
"状态栏",
"配色方案"
],
[
"Matter List",
"Smart Platform Jiangsu Province Company Operations Support"
],
[
"总裁办",
"无单报销"
],
[
"Document Upload",
"Receipt-Free Reimbursement"
],
[
"Flight Ticket Compliance Check",
"Pre-Review Result Display"
],
[
"Web端",
"无单报销"
],
[
"Receipt-Free Reimbursement",
"Submit Reimbursement"
],
[
"功能需求",
"配色方案"
],
[
"完整性检查",
"审核点"
],
[
"Receipt-Free Reimbursement",
"Time Track"
],
[
"合规性检查",
"审核点"
],
[
"ERP",
"无单报销"
],
[
"AI预审",
"完整性检查"
],
[
"Matter List",
"Q1 Quarterly Business Progress Report"
],
[
"Menu 2",
"Receipt-Free Reimbursement"
],
[
"AI预审",
"合规性检查"
],
[
"Matter List",
"Menu 2"
],
[
"2026 National AI Technology Summit in Hefei",
"Matter List"
],
[
"大型企业",
"无单报销"
],
[
"Initiate Matter",
"Receipt-Free Reimbursement"
]
],
"count": 26,
"create_time": 1779379018,
"update_time": 1779379018,
"_id": "a8f8465df08e455ebe133351721d49f8"
}
}

View File

@@ -349,5 +349,239 @@
"create_time": 1779012093,
"update_time": 1779012093,
"_id": "会议费<SEP>公司总裁"
},
"总裁办<SEP>无单报销": {
"chunk_ids": [
"chunk-07de6ea74f60535b689f977295770273"
],
"count": 1,
"create_time": 1779379014,
"update_time": 1779379014,
"_id": "总裁办<SEP>无单报销"
},
"AI预审<SEP>完整性检查": {
"chunk_ids": [
"chunk-1746bd83138e85e66a78e0cb9ad79272"
],
"count": 1,
"create_time": 1779379014,
"update_time": 1779379014,
"_id": "AI预审<SEP>完整性检查"
},
"AI预审<SEP>合规性检查": {
"chunk_ids": [
"chunk-1746bd83138e85e66a78e0cb9ad79272"
],
"count": 1,
"create_time": 1779379015,
"update_time": 1779379015,
"_id": "AI预审<SEP>合规性检查"
},
"Web端<SEP>无单报销": {
"chunk_ids": [
"chunk-07de6ea74f60535b689f977295770273"
],
"count": 1,
"create_time": 1779379015,
"update_time": 1779379015,
"_id": "Web端<SEP>无单报销"
},
"完整性检查<SEP>审核点": {
"chunk_ids": [
"chunk-1746bd83138e85e66a78e0cb9ad79272"
],
"count": 1,
"create_time": 1779379015,
"update_time": 1779379015,
"_id": "完整性检查<SEP>审核点"
},
"AI预审<SEP>问题标注": {
"chunk_ids": [
"chunk-1746bd83138e85e66a78e0cb9ad79272"
],
"count": 1,
"create_time": 1779379015,
"update_time": 1779379015,
"_id": "AI预审<SEP>问题标注"
},
"合规性检查<SEP>审核点": {
"chunk_ids": [
"chunk-1746bd83138e85e66a78e0cb9ad79272"
],
"count": 1,
"create_time": 1779379015,
"update_time": 1779379015,
"_id": "合规性检查<SEP>审核点"
},
"Pre-Review Result Display<SEP>Train Ticket Compliance Check": {
"chunk_ids": [
"chunk-ce44e4483e4119265b43eacb72e0326a"
],
"count": 1,
"create_time": 1779379015,
"update_time": 1779379015,
"_id": "Pre-Review Result Display<SEP>Train Ticket Compliance Check"
},
"大型企业<SEP>无单报销": {
"chunk_ids": [
"chunk-07de6ea74f60535b689f977295770273"
],
"count": 1,
"create_time": 1779379015,
"update_time": 1779379015,
"_id": "大型企业<SEP>无单报销"
},
"AI预审<SEP>预审按钮": {
"chunk_ids": [
"chunk-1746bd83138e85e66a78e0cb9ad79272"
],
"count": 1,
"create_time": 1779379015,
"update_time": 1779379015,
"_id": "AI预审<SEP>预审按钮"
},
"状态栏<SEP>配色方案": {
"chunk_ids": [
"chunk-2224d777c0b72d0b2dab622c79096c2c"
],
"count": 1,
"create_time": 1779379015,
"update_time": 1779379015,
"_id": "状态栏<SEP>配色方案"
},
"Flight Ticket Compliance Check<SEP>Pre-Review Result Display": {
"chunk_ids": [
"chunk-ce44e4483e4119265b43eacb72e0326a"
],
"count": 1,
"create_time": 1779379015,
"update_time": 1779379015,
"_id": "Flight Ticket Compliance Check<SEP>Pre-Review Result Display"
},
"Matter List<SEP>Menu 2": {
"chunk_ids": [
"chunk-99c6f377dff2b9a37a7214b7b05ea9a8"
],
"count": 1,
"create_time": 1779379015,
"update_time": 1779379015,
"_id": "Matter List<SEP>Menu 2"
},
"业务人员<SEP>无单报销": {
"chunk_ids": [
"chunk-07de6ea74f60535b689f977295770273"
],
"count": 1,
"create_time": 1779379015,
"update_time": 1779379015,
"_id": "业务人员<SEP>无单报销"
},
"Menu 2<SEP>Receipt-Free Reimbursement": {
"chunk_ids": [
"chunk-99c6f377dff2b9a37a7214b7b05ea9a8"
],
"count": 1,
"create_time": 1779379016,
"update_time": 1779379016,
"_id": "Menu 2<SEP>Receipt-Free Reimbursement"
},
"ERP<SEP>无单报销": {
"chunk_ids": [
"chunk-07de6ea74f60535b689f977295770273"
],
"count": 1,
"create_time": 1779379016,
"update_time": 1779379016,
"_id": "ERP<SEP>无单报销"
},
"功能需求<SEP>配色方案": {
"chunk_ids": [
"chunk-2224d777c0b72d0b2dab622c79096c2c"
],
"count": 1,
"create_time": 1779379016,
"update_time": 1779379016,
"_id": "功能需求<SEP>配色方案"
},
"Initiate Matter<SEP>Receipt-Free Reimbursement": {
"chunk_ids": [
"chunk-99c6f377dff2b9a37a7214b7b05ea9a8"
],
"count": 1,
"create_time": 1779379016,
"update_time": 1779379016,
"_id": "Initiate Matter<SEP>Receipt-Free Reimbursement"
},
"AI Assistant<SEP>Menu 2": {
"chunk_ids": [
"chunk-99c6f377dff2b9a37a7214b7b05ea9a8"
],
"count": 1,
"create_time": 1779379016,
"update_time": 1779379016,
"_id": "AI Assistant<SEP>Menu 2"
},
"Matter List<SEP>Q1 Quarterly Business Progress Report": {
"chunk_ids": [
"chunk-99c6f377dff2b9a37a7214b7b05ea9a8"
],
"count": 1,
"create_time": 1779379016,
"update_time": 1779379016,
"_id": "Matter List<SEP>Q1 Quarterly Business Progress Report"
},
"Menu 2<SEP>User Center": {
"chunk_ids": [
"chunk-99c6f377dff2b9a37a7214b7b05ea9a8"
],
"count": 1,
"create_time": 1779379016,
"update_time": 1779379016,
"_id": "Menu 2<SEP>User Center"
},
"2026 National AI Technology Summit in Hefei<SEP>Matter List": {
"chunk_ids": [
"chunk-99c6f377dff2b9a37a7214b7b05ea9a8"
],
"count": 1,
"create_time": 1779379016,
"update_time": 1779379016,
"_id": "2026 National AI Technology Summit in Hefei<SEP>Matter List"
},
"Matter List<SEP>Smart Platform Jiangsu Province Company Operations Support": {
"chunk_ids": [
"chunk-99c6f377dff2b9a37a7214b7b05ea9a8"
],
"count": 1,
"create_time": 1779379017,
"update_time": 1779379017,
"_id": "Matter List<SEP>Smart Platform Jiangsu Province Company Operations Support"
},
"Document Upload<SEP>Receipt-Free Reimbursement": {
"chunk_ids": [
"chunk-99c6f377dff2b9a37a7214b7b05ea9a8"
],
"count": 1,
"create_time": 1779379017,
"update_time": 1779379017,
"_id": "Document Upload<SEP>Receipt-Free Reimbursement"
},
"Receipt-Free Reimbursement<SEP>Time Track": {
"chunk_ids": [
"chunk-99c6f377dff2b9a37a7214b7b05ea9a8"
],
"count": 1,
"create_time": 1779379017,
"update_time": 1779379017,
"_id": "Receipt-Free Reimbursement<SEP>Time Track"
},
"Receipt-Free Reimbursement<SEP>Submit Reimbursement": {
"chunk_ids": [
"chunk-99c6f377dff2b9a37a7214b7b05ea9a8"
],
"count": 1,
"create_time": 1779379017,
"update_time": 1779379017,
"_id": "Receipt-Free Reimbursement<SEP>Submit Reimbursement"
}
}

File diff suppressed because one or more lines are too long

View File

@@ -136,7 +136,9 @@ def test_save_or_submit_preview_does_not_create_claim_without_explicit_action()
assert result["preview_only"] is True
assert result["status"] == "preview"
assert "差旅费按“交通票据金额 + 住宿标准 × 出差天数 + 出差补贴 × 出差天数”估算" in result["message"]
assert "报销测算参考:" in result["message"]
assert "| 项目 | 当前信息 | 复核口径 |" in result["message"]
assert "交通票据金额 + 住宿标准" not in result["message"]
assert _count_claims(db) == before_count
@@ -598,6 +600,91 @@ def test_upsert_draft_from_ontology_supports_link_or_create_for_multi_documents(
assert float(new_claim.amount) == 50.5
def test_link_existing_draft_blocks_duplicate_uploaded_invoice() -> None:
user_id = "duplicate@example.com"
with build_session() as db:
employee = Employee(
employee_no="E5010",
name="重复票据员工",
email=user_id,
)
db.add(employee)
db.flush()
existing_claim = ExpenseClaim(
claim_no="EXP-202605-021",
employee_id=employee.id,
employee_name="重复票据员工",
department_name="销售部",
project_code=None,
expense_type="transport",
reason="原有交通报销",
location="上海",
amount=Decimal("32.50"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 13, tzinfo=UTC),
status="draft",
approval_stage="待提交",
risk_flags_json=[],
)
existing_claim.items = [
ExpenseClaimItem(
claim_id=existing_claim.id,
item_date=date(2026, 5, 13),
item_type="transport",
item_reason="原有交通报销",
item_location="上海",
item_amount=Decimal("32.50"),
invoice_id="didi-trip.png",
)
]
db.add(existing_claim)
db.commit()
context_json = {
"name": "重复票据员工",
"review_action": "link_to_existing_draft",
"draft_claim_id": existing_claim.id,
"attachment_names": ["didi-trip.png"],
"attachment_count": 1,
"ocr_documents": [
{
"filename": "didi-trip.png",
"summary": "滴滴出行 支付金额 32.50 元",
"text": "滴滴出行 支付金额 32.50 元",
"document_type": "taxi_receipt",
"scene_code": "transport",
"document_fields": [{"key": "amount", "label": "支付金额", "value": "32.50"}],
}
],
}
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="把这张票据关联到已有草稿",
user_id=user_id,
context_json=context_json,
)
)
result = ExpenseClaimService(db).upsert_draft_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message="把这张票据关联到已有草稿",
ontology=ontology,
context_json=context_json,
)
db.refresh(existing_claim)
assert result["duplicate_attachment_blocked"] is True
assert result["submission_blocked"] is True
assert "重复" in result["message"]
assert "重新上传不同的票据" in result["message"]
assert len(existing_claim.items) == 1
assert existing_claim.invoice_count == 1
assert float(existing_claim.amount) == 32.5
def test_upsert_travel_draft_uses_ticket_item_types_and_auto_allowance() -> None:
user_id = "travel-allowance@example.com"

View File

@@ -624,7 +624,8 @@ def test_user_agent_guides_implicit_expense_draft_request() -> None:
assert response.review_payload is not None
assert response.answer == response.review_payload.body_message
assert response.review_payload.intent_summary.startswith("识别到您希望报销一笔“业务招待费”费用")
assert response.review_payload.intent_summary.startswith("识别到您希望报销一笔“业务招待费”费用")
assert "基础信息识别结果:" in response.review_payload.intent_summary
assert response.review_payload.missing_slots == ["客户名称", "参与人员", "票据附件"]
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
"save_draft",
@@ -665,7 +666,7 @@ def test_user_agent_guides_narrative_with_day_before_yesterday() -> None:
slot_map = {item.key: item for item in response.review_payload.slot_cards}
assert slot_map["time_range"].raw_value == "前天"
assert slot_map["time_range"].value == "2026-05-11"
assert "时间2026-05-11" in response.review_payload.intent_summary
assert "时间2026-05-11" in response.review_payload.intent_summary
def test_user_agent_guides_riding_fare_as_transport_expense() -> None:
@@ -1164,7 +1165,7 @@ def test_user_agent_builds_review_payload_for_multi_document_expense_flow() -> N
"save_draft",
]
assert any(item.scene_label == "业务招待费" for item in response.review_payload.document_cards)
assert f"时间{yesterday}" in response.review_payload.intent_summary
assert f"时间{yesterday}" in response.review_payload.intent_summary
slot_map = {item.key: item for item in response.review_payload.slot_cards}
assert slot_map["time_range"].value == yesterday
assert slot_map["time_range"].raw_value == "昨天"
@@ -2074,3 +2075,54 @@ def test_user_agent_prompts_existing_draft_association_choice_for_multi_document
"create_new_claim_from_documents",
]
assert "EXP-202605-008" in response.answer
def test_user_agent_reports_duplicate_invoice_block_when_linking_draft() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="把这张票据关联到已有草稿",
user_id="pytest-duplicate",
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest-duplicate",
message="把这张票据关联到已有草稿",
ontology=ontology,
context_json={
"review_action": "link_to_existing_draft",
"attachment_names": ["didi-trip.png"],
"attachment_count": 1,
"ocr_documents": [
{
"filename": "didi-trip.png",
"summary": "滴滴出行 支付金额 32.50 元",
"text": "滴滴出行 支付金额 32.50 元",
}
],
},
tool_payload={
"review_action": "link_to_existing_draft",
"duplicate_attachment_blocked": True,
"duplicate_invoice_blocked": True,
"submission_blocked": True,
"submission_blocked_reasons": [
"检测到本次上传的票据与草稿 EXP-202605-021 中已有票据重复didi-trip.png。请重新上传不同的票据后再归集。"
],
"message": "检测到本次上传的票据与草稿 EXP-202605-021 中已有票据重复didi-trip.png。请重新上传不同的票据后再归集。",
"claim_id": "claim-duplicate",
"claim_no": "EXP-202605-021",
"status": "blocked",
},
)
)
assert "重复" in response.answer
assert "重新上传不同的票据" in response.answer
assert "已将本次上传" not in response.answer
assert response.review_payload is not None
assert response.review_payload.can_proceed is False

View File

@@ -200,7 +200,7 @@
}
.review-message-block {
margin-top: 8px;
margin-top: 10px;
}
.review-summary {
@@ -208,12 +208,12 @@
color: #1f2937;
font-size: var(--wb-fs-bubble);
line-height: 1.58;
white-space: pre-line;
white-space: normal;
}
.review-plain-followup {
display: grid;
gap: 7px;
gap: 10px;
padding: 0;
color: #334155;
font-size: var(--wb-fs-bubble);
@@ -225,13 +225,31 @@
}
.review-plain-lead {
color: #334155;
margin: 0 0 2px;
padding-left: 8px;
border-left: 3px solid #2563eb;
color: #0f172a;
font-size: max(13px, calc(var(--wb-fs-bubble) + 1px));
font-weight: 820;
line-height: 1.42;
letter-spacing: 0;
}
.review-plain-lead.danger {
border-left-color: #dc2626;
color: #b91c1c;
}
.review-plain-summary {
margin: 0;
color: #64748b;
line-height: 1.62;
}
.review-plain-list {
display: grid;
gap: 4px;
margin: 0;
gap: 7px;
margin: 2px 0 0;
padding: 0 0 0 18px;
}
@@ -247,11 +265,14 @@
}
.review-plain-note {
margin-top: 2px;
color: #64748b;
}
.review-inline-save-copy {
margin-top: 46px !important;
color: #475569;
line-height: 1.62;
}
.review-inline-draft-link {

View File

@@ -185,7 +185,7 @@
--wb-fs-welcome: 16px;
}
.assistant-modal-stage .message-answer-markdown table {
.assistant-modal-stage .message-answer-markdown :deep(table) {
font-size: 12px;
}

View File

@@ -661,7 +661,7 @@
.message-answer-content {
display: grid;
gap: 7px;
gap: 9px;
}
.message-answer-content p,
@@ -672,15 +672,33 @@
margin: 0;
}
.message-answer-markdown h1,
.message-answer-markdown h2,
.message-answer-markdown h3,
.message-answer-markdown h4 {
margin: 0;
.message-answer-markdown :deep(h1),
.message-answer-markdown :deep(h2),
.message-answer-markdown :deep(h3),
.message-answer-markdown :deep(h4) {
margin: 12px 0 4px;
color: #0f172a;
font-size: var(--wb-fs-md-h3);
font-weight: 750;
line-height: 1.46;
font-size: max(13px, calc(var(--wb-fs-bubble) + 1px));
font-weight: 820;
line-height: 1.42;
letter-spacing: 0;
}
.message-answer-markdown :deep(h1:first-child),
.message-answer-markdown :deep(h2:first-child),
.message-answer-markdown :deep(h3:first-child),
.message-answer-markdown :deep(h4:first-child) {
margin-top: 0;
}
.message-answer-markdown :deep(h3) {
padding-left: 8px;
border-left: 3px solid #2563eb;
}
.message-answer-markdown :deep(h3 + p),
.message-answer-markdown :deep(h3 + .markdown-table-wrap) {
margin-top: 6px;
}
.message-answer-markdown {
@@ -690,26 +708,31 @@
line-height: 1.58;
}
.message-answer-markdown p,
.message-answer-markdown li,
.message-answer-markdown td,
.message-answer-markdown th,
.message-answer-markdown blockquote {
.message-answer-markdown :deep(p),
.message-answer-markdown :deep(li),
.message-answer-markdown :deep(td),
.message-answer-markdown :deep(th),
.message-answer-markdown :deep(blockquote) {
font-size: inherit;
color: inherit;
line-height: 1.58;
}
.message-answer-markdown ul,
.message-answer-markdown ol {
.message-answer-markdown :deep(p) {
margin: 0;
}
.message-answer-markdown :deep(ul),
.message-answer-markdown :deep(ol) {
margin: 0;
padding-left: 20px;
}
.message-answer-markdown strong {
.message-answer-markdown :deep(strong) {
color: #0f172a;
}
.message-answer-markdown blockquote {
.message-answer-markdown :deep(blockquote) {
padding: 8px 10px;
border-left: 3px solid #cbd5e1;
border-radius: 0 10px 10px 0;
@@ -717,14 +740,14 @@
color: #475569;
}
.message-answer-markdown code {
.message-answer-markdown :deep(code) {
padding: 2px 6px;
border-radius: 6px;
background: #e2e8f0;
font-size: 12px;
}
.message-answer-markdown pre {
.message-answer-markdown :deep(pre) {
overflow-x: auto;
padding: 12px;
border-radius: 14px;
@@ -732,47 +755,64 @@
color: #e2e8f0;
}
.message-answer-markdown pre code {
.message-answer-markdown :deep(pre code) {
padding: 0;
background: transparent;
color: inherit;
}
.message-answer-markdown a {
.message-answer-markdown :deep(a) {
color: #2563eb;
text-decoration: underline;
}
.message-answer-markdown table {
width: auto;
.message-answer-markdown :deep(.markdown-table-wrap) {
width: 100%;
max-width: 100%;
margin: 8px 0 10px;
overflow-x: auto;
border: 1px solid #dbe4ee;
border-radius: 16px;
border-collapse: collapse;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
border-radius: 10px;
background: #fff;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.05);
}
.message-answer-markdown :deep(table) {
width: 100%;
min-width: 460px;
border: 0;
border-collapse: separate;
border-spacing: 0;
background: #fff;
font-size: inherit;
}
.message-answer-markdown th,
.message-answer-markdown td {
padding: 10px 12px;
.message-answer-markdown :deep(th),
.message-answer-markdown :deep(td) {
padding: 8px 10px;
border-bottom: 1px solid #e2e8f0;
text-align: left;
white-space: nowrap;
vertical-align: top;
white-space: normal;
}
.message-answer-markdown th {
background: #eff6ff;
.message-answer-markdown :deep(th) {
background: #f8fafc;
color: #0f172a;
font-weight: 850;
font-weight: 760;
border-bottom-color: #cbd5e1;
}
.message-answer-markdown td {
.message-answer-markdown :deep(td) {
color: #334155;
font-weight: 650;
font-weight: 520;
}
.message-answer-markdown tbody tr:last-child td {
.message-answer-markdown :deep(tbody tr:nth-child(even) td) {
background: #fbfdff;
}
.message-answer-markdown :deep(tbody tr:last-child td) {
border-bottom: 0;
}

View File

@@ -1,6 +1,6 @@
import { apiRequest } from './api.js'
export function recognizeOcrFiles(files) {
export function recognizeOcrFiles(files, options = {}) {
const formData = new FormData()
for (const file of files) {
formData.append('files', file)
@@ -9,6 +9,7 @@ export function recognizeOcrFiles(files) {
return apiRequest('/ocr/recognize', {
method: 'POST',
body: formData,
contentType: null
contentType: null,
...options
})
}

View File

@@ -6,7 +6,78 @@ const markdown = new MarkdownIt({
breaks: true
})
const defaultTableOpen = markdown.renderer.rules.table_open
const defaultTableClose = markdown.renderer.rules.table_close
markdown.renderer.rules.table_open = (tokens, idx, options, env, self) => (
`<div class="markdown-table-wrap">${defaultTableOpen ? defaultTableOpen(tokens, idx, options, env, self) : '<table>'}`
)
markdown.renderer.rules.table_close = (tokens, idx, options, env, self) => (
`${defaultTableClose ? defaultTableClose(tokens, idx, options, env, self) : '</table>'}</div>`
)
const ALLOWED_COLON_HEADING_TITLES = new Set([
'基础信息识别结果',
'报销测算参考',
'补充信息'
])
function splitColonHeadingLine(line) {
const rawLine = String(line || '')
const trimmed = rawLine.trim()
if (!trimmed || trimmed.startsWith('|') || /^#{1,6}\s/.test(trimmed)) {
return [rawLine]
}
const chineseColonIndex = trimmed.indexOf('')
const asciiColonIndex = trimmed.indexOf(':')
const colonIndexes = [chineseColonIndex, asciiColonIndex].filter((index) => index > 0)
if (!colonIndexes.length) {
return [rawLine]
}
const colonIndex = Math.min(...colonIndexes)
const title = trimmed.slice(0, colonIndex + 1)
const titleText = title.slice(0, -1)
const body = trimmed.slice(colonIndex + 1).trim()
if (!ALLOWED_COLON_HEADING_TITLES.has(titleText)) {
return [rawLine]
}
return body ? [`### ${title}`, '', body] : [`### ${title}`]
}
function normalizeColonHeadings(text) {
const lines = String(text || '').replace(/\r\n?/g, '\n').split('\n')
const normalizedLines = []
let inFence = false
lines.forEach((line) => {
if (/^\s*(```|~~~)/.test(line)) {
inFence = !inFence
normalizedLines.push(line)
return
}
if (inFence) {
normalizedLines.push(line)
return
}
const nextLines = splitColonHeadingLine(line)
if (nextLines[0]?.startsWith('### ') && normalizedLines.length) {
const previousLine = normalizedLines[normalizedLines.length - 1]
if (String(previousLine || '').trim()) {
normalizedLines.push('')
}
}
normalizedLines.push(...nextLines)
})
return normalizedLines.join('\n').replace(/\n{3,}/g, '\n\n')
}
export function renderMarkdown(text = '') {
const normalized = String(text || '').trim()
const normalized = normalizeColonHeadings(text).trim()
return normalized ? markdown.render(normalized) : ''
}

View File

@@ -88,9 +88,10 @@
<time>{{ message.time }}</time>
</header>
<div
v-if="message.text && message.role === 'assistant' && message.reviewPayload"
v-if="message.text && message.role === 'assistant' && message.reviewPayload && buildReviewMainMessageText(message)"
class="review-summary message-answer-content message-answer-markdown"
v-html="renderMarkdown(message.text)"
v-html="renderMarkdown(buildReviewMainMessageText(message))"
@click="handleAssistantMarkdownClick($event, message)"
></div>
<div
@@ -103,6 +104,7 @@
v-else-if="message.text && message.role === 'assistant'"
class="message-answer-content message-answer-markdown"
v-html="renderMarkdown(message.text)"
@click="handleAssistantMarkdownClick($event, message)"
></div>
<div
@@ -298,7 +300,15 @@
v-for="followup in [buildReviewPlainFollowupCopy(message.reviewPayload)]"
:key="`${message.id}-review-followup`"
>
<p class="review-plain-lead">{{ followup.lead }}</p>
<h3
class="review-plain-lead"
:class="{ danger: followup.tone === 'danger' }"
>
{{ followup.lead }}
</h3>
<p v-if="followup.summary" class="review-plain-summary">
{{ followup.summary }}
</p>
<ul v-if="followup.items.length" class="review-plain-list">
<li
v-for="item in followup.items"

View File

@@ -120,6 +120,7 @@ import {
VISIBLE_ATTACHMENT_CHIPS,
buildAgentInsight,
buildErrorInsight,
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
buildFileIdentity,
buildFilePreviews,
buildOcrDocumentsFromReviewPayload,
@@ -431,6 +432,19 @@ function buildReviewRiskConversationText(item) {
return lines.join('\n')
}
const REVIEW_PENDING_SUMMARY_PATTERN = /(^|\n)\s*(?:当前还有|我这边看到还有|下方还有|这笔报销还有|目前还有|还有|这次识别结果里还有|我还需要你确认|当前信息还差|本次报销还有)\s+[^\n]*(?:信息待补充|风险提醒|细节还需要进一步确认)[^\n]*(?:草稿)[^\n]*。\s*/g
function buildReviewMainMessageText(message) {
const text = String(message?.text || '')
if (!message?.reviewPayload) {
return text
}
return text
.replace(REVIEW_PENDING_SUMMARY_PATTERN, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
export default {
name: 'TravelReimbursementCreateView',
components: {
@@ -779,7 +793,10 @@ export default {
attachedFiles,
composerFilesExpanded
}
const { submitComposerInternal } = useTravelReimbursementSubmitComposer({
const {
confirmPendingAttachmentAssociationInternal,
submitComposerInternal
} = useTravelReimbursementSubmitComposer({
MAX_ATTACHMENTS,
activeReviewPayload,
activeSessionType,
@@ -1303,7 +1320,8 @@ export default {
skipUploadDecisionPrompt: true,
extraContext: {
draft_claim_id: claimId,
selected_claim_id: claimId
selected_claim_id: claimId,
selected_claim_no: String(record?.claimNo || '').trim()
}
})
}
@@ -1443,6 +1461,27 @@ export default {
// submitting.value = false
return submitComposerInternal(options)
}
async function handleAssistantMarkdownClick(event, message) {
const anchor = event?.target?.closest?.('a')
if (!anchor || !message || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) {
return
}
const href = String(anchor.getAttribute('href') || '').trim()
if (href !== ATTACHMENT_ASSOCIATION_CONFIRM_HREF) {
return
}
event.preventDefault()
reviewActionBusy.value = true
try {
await confirmPendingAttachmentAssociationInternal(message)
} finally {
reviewActionBusy.value = false
}
}
async function handleReviewAction(message, action) {
const actionType = String(action?.action_type || '').trim()
if (!actionType || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return
@@ -1481,11 +1520,11 @@ export default {
reviewIntentText, reviewFactCards, reviewCategoryOptions, reviewOtherCategoryOptions, reviewSelectedOtherCategory, reviewInlineDirty, reviewInlineForm, reviewInlineEditorKey, reviewInlineErrors, reviewOtherCategoryOpen, reviewInlinePendingFiles, DATE_INPUT_FORMAT, REVIEW_SCENE_OTHER_OPTION, REVIEW_SCENE_OPTIONS, REVIEW_OTHER_CATEGORY_OPTIONS,
workbenchVisible, reviewPanelConfidence, reviewRiskSummary, reviewRiskItems, reviewRiskEmpty, recognizedNarratives, reviewRecognitionNotes, reviewDocumentSummaries, reviewDocumentCount, reviewDocumentDirty, reviewHasUnsavedChanges, uploadDecisionDialogOpen,
travelCalculatorOpen, travelCalculatorBusy, travelCalculatorError, travelCalculatorResult, travelCalculatorForm, travelCalculatorCanSubmit, deleteSessionDialogOpen, reviewActionBusy, deleteSessionBusy, documentPreviewDialog, shortcuts,
resolveReviewMissingSlotCards, resolveReviewRiskBriefs, buildReviewHeadline, buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, buildReviewPlainFollowupCopy, resolveReviewFooterActions, resolveReviewSaveDraftAction, buildReviewPrimaryButtonLabel,
resolveReviewMissingSlotCards, resolveReviewRiskBriefs, buildReviewHeadline, buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, buildReviewPlainFollowupCopy, resolveReviewFooterActions, resolveReviewSaveDraftAction, buildReviewPrimaryButtonLabel, buildReviewMainMessageText,
renderMarkdown, buildExpenseQueryWindowLabel, buildExpenseQueryHint, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, resolveDocumentPreview, triggerFileUpload, applyComposerDateSelection, handleFilesChange, handleComposerInput, handleComposerEnter, runShortcut, runWelcomeQuickAction: runShortcut, handleSuggestedAction, isSuggestedActionSelected, askHotKnowledgeQuestion, resolveKnowledgeRankLabel, resolveKnowledgeRankTone,
refreshFlowRunDetail, formatFlowStepDuration, resolveFlowStepStatusLabel, resolveFlowStepDetail, toggleInsightPanel, openTravelCalculator, toggleTravelCalculator, closeTravelCalculator, submitTravelCalculator, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewRiskDrawer, toggleReviewFlowDrawer, toggleAttachedFilesExpanded, removeAttachedFile, clearAttachedFiles,
requestCloseWorkbench, emitCloseAfterLeave, openExpenseQueryRecord, handleExpenseQueryRecordClick, setExpenseQueryPage, shiftExpenseQueryPage, openDeleteSessionDialog, closeDeleteSessionDialog, confirmDeleteCurrentSession, closeUploadDecisionDialog, continueExistingUpload, createNewUploadDocument, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, clearInlineReviewFieldError, selectInlineScene, selectReviewCategory, selectReviewOtherCategory,
queryDraftByClaimNo, appendReviewRiskBriefToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleReviewAction, handleSaveDraftDirectly, canUseInlineSaveDraft, handleInlineSaveDraft
queryDraftByClaimNo, appendReviewRiskBriefToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, canUseInlineSaveDraft, handleInlineSaveDraft
}
}
}

View File

@@ -37,6 +37,7 @@ function resolveStatusTone(status) {
export const MAX_ATTACHMENTS = 10
export const MAX_OCR_DOCUMENTS = 10
export const VISIBLE_ATTACHMENT_CHIPS = 2
export const ATTACHMENT_ASSOCIATION_CONFIRM_HREF = '#confirm-attachment-association'
export function normalizeOcrDocuments(payload) {
const documents = Array.isArray(payload?.documents) ? payload.documents : []
@@ -85,6 +86,88 @@ export function buildOcrSummaryFromDocuments(documents) {
.join('')
}
function resolveAssociationDocumentTypeLabel(document) {
const explicitLabel = String(document?.document_type_label || '').trim()
if (explicitLabel) {
return explicitLabel
}
const sceneLabel = String(document?.scene_label || '').trim()
if (sceneLabel) {
return sceneLabel
}
const typeLabel = resolveDocumentTypeLabel(document?.document_type)
return String(typeLabel || '').trim() || '其他票据'
}
function buildAssociationDocumentContentLines(document) {
const fields = Array.isArray(document?.document_fields) ? document.document_fields : []
const fieldLines = fields
.map((field) => {
const label = String(field?.label || '').trim()
const value = String(field?.value || '').trim()
return label && value ? `- ${label}${value}` : ''
})
.filter(Boolean)
if (fieldLines.length) {
return fieldLines.slice(0, 8)
}
const summary = String(document?.summary || document?.text || '').trim()
if (summary) {
return [`- 识别内容:${summary}`]
}
return ['- 识别内容:暂未提取到结构化字段,请以票据原件为准。']
}
export function buildAttachmentAssociationConfirmationMessage({
claimNo = '',
claimTitle = '',
fileNames = [],
ocrDocuments = []
} = {}) {
const documents = Array.isArray(ocrDocuments) && ocrDocuments.length
? ocrDocuments
: (Array.isArray(fileNames) ? fileNames : [])
.map((filename) => ({ filename }))
.filter((item) => String(item.filename || '').trim())
const targetLines = [
claimNo ? `- 草稿单号:${claimNo}` : '',
claimTitle ? `- 单据说明:${claimTitle}` : '',
`- 本次待归集附件:${documents.length || fileNames.length || 0}`
].filter(Boolean)
const documentBlocks = documents.map((document, index) => {
const filename = String(document?.filename || '').trim() || `附件 ${index + 1}`
const typeLabel = resolveAssociationDocumentTypeLabel(document)
const contentLines = buildAssociationDocumentContentLines(document)
return [
`附件 ${index + 1}${filename}`,
'',
`附件类型:${typeLabel}`,
'',
...contentLines
].join('\n')
})
return [
'已识别附件信息:',
'',
documentBlocks.join('\n\n'),
'',
'请问是否确定将票据信息归集到单据:',
'',
targetLines.join('\n'),
'',
`如果 [确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF}) 该信息,我将直接将票据进行归集。`
]
.filter((part) => String(part || '').trim())
.join('\n')
}
export function normalizeReviewDocumentFieldKey(label) {
const compact = String(label || '').replace(/\s+/g, '').toLowerCase()
if (!compact) return ''

View File

@@ -167,6 +167,7 @@ export function createMessage(role, text, attachments = [], extras = {}) {
draftPayload: null,
reviewPayload: null,
riskFlags: [],
pendingAttachmentAssociation: null,
...extras
}
}
@@ -666,6 +667,7 @@ export function serializeSessionMessages(messages) {
draftPayload: message.draftPayload || null,
reviewPayload: message.reviewPayload || null,
riskFlags: Array.isArray(message.riskFlags) ? message.riskFlags : [],
pendingAttachmentAssociation: message.pendingAttachmentAssociation || null,
assistantName: message.assistantName || '',
isWelcome: Boolean(message.isWelcome),
welcomeQuickActions: Array.isArray(message.welcomeQuickActions) ? message.welcomeQuickActions : []

View File

@@ -224,14 +224,22 @@ export function resolveReviewMissingSlotCards(reviewPayload) {
export function resolveReviewExtraMissingLabels(reviewPayload) {
const labels = Array.isArray(reviewPayload?.missing_slots)
? reviewPayload.missing_slots.map((item) => String(item || '').trim()).filter(Boolean)
? reviewPayload.missing_slots
.map((item) => {
if (item && typeof item === 'object') {
return String(item.label || item.title || item.key || '').trim()
}
return String(item || '').trim()
})
.filter(Boolean)
: []
if (!labels.length) return []
const slotLabels = new Set(
(Array.isArray(reviewPayload?.slot_cards) ? reviewPayload.slot_cards : [])
.map((item) => String(item?.label || item?.key || '').trim())
.filter(Boolean)
(Array.isArray(reviewPayload?.slot_cards) ? reviewPayload.slot_cards : []).flatMap((item) => [
String(item?.label || '').trim(),
String(item?.key || '').trim()
]).filter(Boolean)
)
return labels.filter((label) => !slotLabels.has(label))
}
@@ -1239,23 +1247,66 @@ function buildReviewPlainFollowupItem(item, pendingMode) {
}
}
const REVIEW_PENDING_SUMMARY_TEMPLATES = [
({ issueSummary }) => `当前还有 ${issueSummary}。请核查对话中的文字说明;如果想先暂存,也可以点击对话文字中的“草稿”。`,
({ issueSummary }) => `我这边看到还有 ${issueSummary},建议先把下方内容核对一下;暂时不处理也没关系,可以点击“草稿”先保存。`,
({ issueSummary }) => `下方还有 ${issueSummary},需要你确认。信息没补齐前可以先核查说明,后续需要暂存时点“草稿”。`,
({ issueSummary }) => `这笔报销还有 ${issueSummary},尚未完全确认。请先看一下下面的补充项;需要中途保存时,可以点“草稿”。`,
({ issueSummary }) => `目前还有 ${issueSummary}。你可以先按下面的提示补充,也可以稍后再处理,点击“草稿”即可暂存当前信息。`,
({ issueSummary }) => `还有 ${issueSummary},建议先核对下面说明;如果票据或金额暂时不全,可以通过“草稿”保留当前进度。`,
({ issueSummary }) => `这次识别结果里还有 ${issueSummary}。请重点看下面几项,暂不提交时可以点“草稿”保存。`,
({ issueSummary }) => `我还需要你确认 ${issueSummary}。下面列出了具体内容;如果现在不方便补齐,可以先点“草稿”。`,
({ issueSummary }) => `当前还有 ${issueSummary},需要进一步处理。请根据下面提示核查,待补充完再继续;临时保存可点击“草稿”。`,
({ issueSummary }) => `本次报销还有 ${issueSummary},请先检查下面的补充项;想先留存当前识别结果时可以点“草稿”。`
]
function buildStableTemplateIndex(signature, total) {
const source = String(signature || '')
let hash = 0
for (let index = 0; index < source.length; index += 1) {
hash = ((hash << 5) - hash + source.charCodeAt(index)) >>> 0
}
return total ? hash % total : 0
}
function buildReviewPendingSummary(pendingCount, riskCount, signature = '') {
const issueParts = []
if (pendingCount) {
issueParts.push(`${pendingCount} 项信息待补充`)
}
if (riskCount) {
issueParts.push(`${riskCount} 条风险提醒`)
}
const issueSummary = issueParts.length ? issueParts.join('、') : '一些细节还需要进一步确认'
const templateIndex = buildStableTemplateIndex(signature || issueSummary, REVIEW_PENDING_SUMMARY_TEMPLATES.length)
return REVIEW_PENDING_SUMMARY_TEMPLATES[templateIndex]({ issueSummary })
}
export function buildReviewPlainFollowupCopy(reviewPayload) {
const todoItems = buildReviewTodoItems(reviewPayload)
const pendingCount = countReviewPendingItems(reviewPayload)
const riskBriefs = resolvePresentationRiskBriefs(reviewPayload)
const extraMissingCount = resolveReviewExtraMissingLabels(reviewPayload).length
if (pendingCount || resolveReviewExtraMissingLabels(reviewPayload).length) {
if (pendingCount || extraMissingCount) {
const summarySignature = [
pendingCount || extraMissingCount,
riskBriefs.length,
...todoItems.map((item) => `${item.key}:${item.title}:${item.status}`)
].join('|')
return {
lead: '我还需要你核查或补充下面这些信息:',
lead: '补充信息:',
tone: 'danger',
summary: buildReviewPendingSummary(pendingCount || extraMissingCount, riskBriefs.length, summarySignature),
items: todoItems.map((item) => buildReviewPlainFollowupItem(item, true)),
notes: riskBriefs.length
? [`另外还有 ${riskBriefs.length} 条风险提醒,提交前建议一起确认。`]
: []
notes: []
}
}
return {
lead: todoItems.length ? '我已整理出当前识别到的关键信息:' : '当前关键信息已基本整理完成。',
tone: 'neutral',
summary: '',
items: todoItems.map((item) => buildReviewPlainFollowupItem(item, false)),
notes: [
reviewPayload?.can_proceed ? '确认无误后,可以继续下一步。' : '',

View File

@@ -103,15 +103,15 @@ export function useTravelReimbursementReviewDrawer({
const isReviewFlowDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW)
const reviewDrawerTitle = computed(() => (
isReviewDocumentDrawer.value
? '绁ㄦ嵁璇嗗埆缁撴灉'
? '票据识别结果'
: isReviewRiskDrawer.value
? '椋庨櫓鎻愮ず'
? '风险提示'
: isReviewFlowDrawer.value
? '璋冪敤娴佺▼'
: '鎶ラ攢璇嗗埆鏍稿'
? '执行流程'
: '报销识别核对'
))
const reviewDocumentDrawerLabel = computed(() => (
'鍗曟嵁璇嗗埆'
'单据识别'
))
const reviewDocumentDrawerIcon = computed(() => (
isReviewDocumentDrawer.value
@@ -119,7 +119,7 @@ export function useTravelReimbursementReviewDrawer({
: 'mdi mdi-file-document-multiple-outline'
))
const reviewRiskDrawerLabel = computed(() => (
'鏄剧ず椋庨櫓'
'显示风险'
))
const reviewRiskDrawerIcon = computed(() => (
isReviewRiskDrawer.value
@@ -127,7 +127,7 @@ export function useTravelReimbursementReviewDrawer({
: 'mdi mdi-shield-alert-outline'
))
const reviewFlowDrawerLabel = computed(() => (
'璋冪敤娴佺▼'
'执行流程'
))
const reviewFlowDrawerIcon = computed(() => (
isReviewFlowDrawer.value
@@ -253,7 +253,7 @@ export function useTravelReimbursementReviewDrawer({
) {
nextForm.reason_value = String(reviewInlineForm.value.reason_value || '').trim()
if (!nextForm.reason_value) {
setInlineReviewFieldError('scene', '璇烽€夋嫨鈥滃叾浠栧満鏅€濆悗锛岃琛ュ厖鍏蜂綋浜嬬敱')
setInlineReviewFieldError('scene', '请选择“其他场景”后,请补充具体事由')
reviewInlineForm.value = nextForm
return false
}
@@ -262,14 +262,14 @@ export function useTravelReimbursementReviewDrawer({
}
if (activeEditorKey === 'occurred_date' && nextForm.occurred_date && !isValidIsoDateString(nextForm.occurred_date)) {
setInlineReviewFieldError('occurred_date', `璇疯緭鍏ユ纭殑鏃堕棿鏍煎紡锛?{DATE_INPUT_FORMAT}`)
setInlineReviewFieldError('occurred_date', `请输入正确的时间格式:${DATE_INPUT_FORMAT}`)
return false
}
if (activeEditorKey === 'amount' && nextForm.amount) {
const normalizedAmount = normalizeAmountValue(nextForm.amount)
if (!normalizedAmount) {
setInlineReviewFieldError('amount', '璇疯緭鍏ユ纭殑鏁板瓧閲戦锛屼緥濡?200 鎴?200.50')
setInlineReviewFieldError('amount', '请输入正确的数字金额,例如 200 200.50')
return false
}
nextForm.amount = normalizedAmount

View File

@@ -1,3 +1,8 @@
import {
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
buildAttachmentAssociationConfirmationMessage
} from './travelReimbursementAttachmentModel.js'
export function useTravelReimbursementSubmitComposer(ctx) {
const {
MAX_ATTACHMENTS,
@@ -74,6 +79,87 @@ export function useTravelReimbursementSubmitComposer(ctx) {
uploadDecisionDialogOpen,
toast
} = ctx
const pendingAttachmentAssociations = new Map()
function createPendingAttachmentAssociationId() {
return `attachment-association-${Date.now()}-${Math.random().toString(16).slice(2)}`
}
function normalizeRecognizedAttachmentData(data) {
if (!data || typeof data !== 'object') {
return null
}
const documents = Array.isArray(data.ocrDocuments) ? data.ocrDocuments : []
if (!documents.length) {
return null
}
return {
ocrPayload: data.ocrPayload || null,
ocrSummary: String(data.ocrSummary || '').trim(),
ocrDocuments: documents,
ocrFilePreviews: Array.isArray(data.ocrFilePreviews) ? data.ocrFilePreviews : []
}
}
function buildConfirmedAssociationText(message) {
return String(message?.text || '').replace(
`[确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})`,
'已确认'
)
}
async function confirmPendingAttachmentAssociation(message) {
if (submitting.value || sessionSwitchBusy.value) return null
const pending = message?.pendingAttachmentAssociation && typeof message.pendingAttachmentAssociation === 'object'
? message.pendingAttachmentAssociation
: null
const associationId = String(pending?.id || '').trim()
if (!associationId || pending?.status === 'confirmed') {
return null
}
const runtime = pendingAttachmentAssociations.get(associationId)
if (!runtime || !Array.isArray(runtime.files) || !runtime.files.length) {
toast('当前会话里没有可归集的附件原件,请重新上传票据后再确认。')
return null
}
pending.status = 'confirmed'
message.pendingAttachmentAssociation = pending
message.text = buildConfirmedAssociationText(message)
message.meta = ['已确认归集']
persistSessionState()
return submitComposer({
rawText: `确认将本次上传的 ${runtime.fileNames.length} 份票据归集到草稿 ${runtime.claimNo || '当前草稿'}`,
userText: `确认归集到草稿 ${runtime.claimNo || '当前草稿'}`,
files: runtime.files,
uploadDisposition: 'continue_existing',
skipUploadDecisionPrompt: true,
skipDraftAssociationPrompt: true,
pendingText: runtime.claimNo
? `正在将票据归集到草稿 ${runtime.claimNo}...`
: '正在将票据归集到当前草稿...',
associationConfirmed: true,
recognizedAttachmentData: {
ocrPayload: runtime.ocrPayload,
ocrSummary: runtime.ocrSummary,
ocrDocuments: runtime.ocrDocuments,
ocrFilePreviews: runtime.ocrFilePreviews
},
extraContext: {
...runtime.extraContext,
review_action: 'link_to_existing_draft',
draft_claim_id: runtime.claimId,
selected_claim_id: runtime.claimId,
selected_claim_no: runtime.claimNo,
attachment_association_confirmed: true
}
})
}
function buildBackendMessage(rawText, fileNames, ocrSummary = '') {
const parts = []
const normalizedText = String(rawText || '').trim()
@@ -128,6 +214,11 @@ export function useTravelReimbursementSubmitComposer(ctx) {
? initialExtraContext
: mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext)
const reviewAction = String(extraContext.review_action || '').trim()
const attachmentAssociationConfirmed = Boolean(
options.associationConfirmed ||
extraContext.attachment_association_confirmed ||
reviewAction === 'link_to_existing_draft'
)
const hasSelectedExpenseType = Boolean(
extraContext.expense_scene_selection ||
String(extraContext.review_form_values?.expense_type || extraContext.review_form_values?.reimbursement_type || '').trim()
@@ -305,21 +396,100 @@ export function useTravelReimbursementSubmitComposer(ctx) {
let ocrSummary = ''
let ocrDocuments = []
let ocrFilePreviews = []
const recognizedAttachmentData = normalizeRecognizedAttachmentData(options.recognizedAttachmentData)
if (files.length) {
const ocrStartedAt = Date.now()
startFlowStep('ocr', { detail: `正在识别 ${files.length} 份附件...`, startedAt: ocrStartedAt })
try {
ocrPayload = await recognizeOcrFiles(files)
ocrSummary = buildOcrSummary(ocrPayload)
ocrDocuments = normalizeOcrDocuments(ocrPayload)
ocrFilePreviews = buildOcrFilePreviews(ocrPayload)
if (recognizedAttachmentData) {
ocrPayload = recognizedAttachmentData.ocrPayload
ocrSummary = recognizedAttachmentData.ocrSummary || buildOcrSummaryFromDocuments(recognizedAttachmentData.ocrDocuments)
ocrDocuments = [...recognizedAttachmentData.ocrDocuments]
ocrFilePreviews = [...recognizedAttachmentData.ocrFilePreviews]
rememberFilePreviews(ocrFilePreviews)
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt)
} catch (error) {
console.warn('OCR request failed:', error)
completeFlowStep('ocr', 'OCR识别失败已继续使用附件名称', Date.now() - ocrStartedAt)
completeFlowStep('ocr', `复用已确认的 ${ocrDocuments.length || files.length} 张票据识别结果`, Date.now() - ocrStartedAt)
} else {
try {
ocrPayload = await recognizeOcrFiles(files, {
timeoutMs: 90000,
timeoutMessage: '票据 OCR 识别超时,已继续使用附件名称处理。'
})
ocrSummary = buildOcrSummary(ocrPayload)
ocrDocuments = normalizeOcrDocuments(ocrPayload)
ocrFilePreviews = buildOcrFilePreviews(ocrPayload)
rememberFilePreviews(ocrFilePreviews)
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt)
} catch (error) {
console.warn('OCR request failed:', error)
completeFlowStep('ocr', 'OCR识别失败已继续使用附件名称', Date.now() - ocrStartedAt)
}
}
if (resolvedUploadDisposition === 'continue_existing') {
replaceMessage(pendingMessage.id, {
...pendingMessage,
text: attachmentAssociationConfirmed
? '票据识别已完成,正在把本次附件归集到已选择的草稿...'
: '票据识别已完成,正在整理归集前确认信息...',
meta: attachmentAssociationConfirmed ? ['正在归集'] : ['等待确认归集']
})
persistSessionState()
}
}
const associationTargetClaimId = String(extraContext.draft_claim_id || draftClaimId.value || '').trim()
const associationTargetClaimNo = String(
extraContext.selected_claim_no ||
extraContext.draft_claim_no ||
''
).trim()
if (
files.length &&
resolvedUploadDisposition === 'continue_existing' &&
associationTargetClaimId &&
!attachmentAssociationConfirmed
) {
const associationId = createPendingAttachmentAssociationId()
const pendingAssociation = {
id: associationId,
status: 'pending',
claimId: associationTargetClaimId,
claimNo: associationTargetClaimNo,
fileNames
}
pendingAttachmentAssociations.set(associationId, {
files,
fileNames,
ocrPayload,
ocrSummary,
ocrDocuments,
ocrFilePreviews,
filePreviews,
claimId: associationTargetClaimId,
claimNo: associationTargetClaimNo,
extraContext: {
...extraContext,
draft_claim_id: associationTargetClaimId,
selected_claim_id: associationTargetClaimId,
selected_claim_no: associationTargetClaimNo
}
})
replaceMessage(pendingMessage.id, createMessage(
'assistant',
buildAttachmentAssociationConfirmationMessage({
claimNo: associationTargetClaimNo,
fileNames,
ocrDocuments
}),
[],
{
meta: ['等待确认归集'],
pendingAttachmentAssociation: pendingAssociation
}
))
persistSessionState()
nextTick(scrollToBottom)
return null
}
let effectiveFileNames = [...fileNames]
@@ -359,6 +529,16 @@ export function useTravelReimbursementSubmitComposer(ctx) {
})
const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary)
const orchestratorOptions = isKnowledgeSession.value
? {
timeoutMs: 18000,
timeoutMessage: '知识问答整理超时,已停止等待。建议缩小问题范围或稍后重试。'
}
: {
timeoutMs: 120000,
timeoutMessage: '票据归集处理超时,当前仍停留在原草稿,请稍后重试或重新选择附件。'
}
const payload = await runOrchestrator(
{
source: 'user_message',
@@ -393,12 +573,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
...extraContext
}
},
isKnowledgeSession.value
? {
timeoutMs: 18000,
timeoutMessage: '知识问答整理超时,已停止等待。建议缩小问题范围或稍后重试。'
}
: {}
orchestratorOptions
)
responsePayload = payload
flowRunId.value = String(payload?.run_id || '').trim()
@@ -413,35 +588,42 @@ export function useTravelReimbursementSubmitComposer(ctx) {
? ''
: String(payload?.result?.draft_payload?.claim_id || '').trim() || draftClaimId.value
replaceMessage(
pendingMessage.id,
createMessage('assistant', payload?.result?.answer || payload?.result?.message || '智能体已完成处理。', [], {
meta: buildMessageMeta(payload, effectiveFileNames),
citations: Array.isArray(payload?.result?.citations) ? payload.result.citations : [],
suggestedActions: Array.isArray(payload?.result?.suggested_actions)
? payload.result.suggested_actions
: [],
queryPayload: normalizeExpenseQueryPayload(payload?.result?.query_payload),
draftPayload: payload?.result?.draft_payload || null,
reviewPayload: payload?.result?.review_payload || null,
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : []
})
)
const reviewActionResult = String(extraContext.review_action || '').trim()
const resultClaimNo = String(payload?.result?.draft_payload?.claim_no || '').trim()
const fallbackAnswer = reviewActionResult === 'link_to_existing_draft'
? (resultClaimNo ? `已将本次上传的票据关联到草稿 ${resultClaimNo}` : '已将本次上传的票据关联到现有草稿。')
: '智能体已完成处理。'
const assistantMessage = createMessage('assistant', payload?.result?.answer || payload?.result?.message || fallbackAnswer, [], {
meta: buildMessageMeta(payload, effectiveFileNames),
citations: Array.isArray(payload?.result?.citations) ? payload.result.citations : [],
suggestedActions: Array.isArray(payload?.result?.suggested_actions)
? payload.result.suggested_actions
: [],
queryPayload: normalizeExpenseQueryPayload(payload?.result?.query_payload),
draftPayload: payload?.result?.draft_payload || null,
reviewPayload: payload?.result?.review_payload || null,
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : []
})
replaceMessage(pendingMessage.id, assistantMessage)
currentInsight.value = buildAgentInsight(
payload,
effectiveFileNames,
mergeFilePreviews(filePreviews, ocrFilePreviews)
)
completeFlowResult(payload, flowRunDetail)
persistSessionState()
nextTick(scrollToBottom)
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) {
try {
await syncComposerFilesToDraft(resolvedDraftClaimId, files)
} catch (error) {
console.warn('Failed to persist composer attachments to draft claim:', error)
toast(error?.message || '票据已识别,但附件原件保存失败,请重试上传。')
}
void syncComposerFilesToDraft(resolvedDraftClaimId, files)
.then(() => {
persistSessionState()
})
.catch((error) => {
console.warn('Failed to persist composer attachments to draft claim:', error)
toast(error?.message || '票据已归集到草稿,但附件原件保存失败,请在单据详情中重新上传。')
})
}
} catch (error) {
clearFlowSimulationTimers()
@@ -458,6 +640,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
)
)
currentInsight.value = buildErrorInsight(error, fileNames)
persistSessionState()
} finally {
submitting.value = false
composerUploadIntent.value = ''
@@ -469,6 +652,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
return {
confirmPendingAttachmentAssociationInternal: confirmPendingAttachmentAssociation,
submitComposerInternal: submitComposer
}
}

View File

@@ -0,0 +1,34 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
buildAttachmentAssociationConfirmationMessage
} from '../src/views/scripts/travelReimbursementAttachmentModel.js'
test('attachment association prompt prints recognized receipt details before confirmation link', () => {
const message = buildAttachmentAssociationConfirmationMessage({
claimNo: 'EXP-202605-001',
fileNames: ['train-ticket.pdf'],
ocrDocuments: [
{
filename: 'train-ticket.pdf',
document_type: 'train_ticket',
scene_label: '差旅票据',
summary: '铁路电子客票 武汉-上海 票价 354 元',
document_fields: [
{ key: 'route', label: '行程', value: '武汉-上海' },
{ key: 'amount', label: '票价', value: '354.00' },
{ key: 'date', label: '乘车日期', value: '2026-02-20' }
]
}
]
})
assert.match(message, /已识别附件信息:/)
assert.match(message, /附件类型:差旅票据/)
assert.match(message, /行程:武汉-上海/)
assert.match(message, /票价354.00/)
assert.match(message, /草稿单号EXP-202605-001/)
assert.match(message, new RegExp(`\\[确认\\]\\(${ATTACHMENT_ASSOCIATION_CONFIRM_HREF}\\)`))
})

View File

@@ -156,19 +156,19 @@ test('review drawer save action is disabled while receipt recognition is submitt
)
})
test('draft creation waits for composer attachments to be persisted before leaving submit state', () => {
test('draft creation starts composer attachment persistence after response rendering', () => {
assert.match(
submitComposerScript,
/try \{\s*await syncComposerFilesToDraft\(resolvedDraftClaimId, files\)\s*\} catch \(error\) \{/s
/void syncComposerFilesToDraft\(resolvedDraftClaimId, files\)\s*\.then\(\(\) => \{\s*persistSessionState\(\)\s*\}\)\s*\.catch\(\(error\) => \{/s
)
assert.doesNotMatch(
submitComposerScript,
/syncComposerFilesToDraft\(resolvedDraftClaimId, files\)\.catch/
/await syncComposerFilesToDraft\(resolvedDraftClaimId, files\)/
)
assert.ok(
submitComposerScript.indexOf('await syncComposerFilesToDraft(resolvedDraftClaimId, files)') <
submitComposerScript.indexOf('submitting.value = false'),
'attachment persistence should finish before submit state is cleared'
submitComposerScript.indexOf('replaceMessage(pendingMessage.id, assistantMessage)') <
submitComposerScript.indexOf('void syncComposerFilesToDraft(resolvedDraftClaimId, files)'),
'assistant response should render before background attachment persistence starts'
)
assert.match(attachmentsScript, /function normalizeAttachmentMatchName\(value\)/)
assert.match(attachmentsScript, /const normalizedMatchBuckets = new Map\(\)/)