feat: 重构报销单服务并完善前端提交与审核交互
重构 expense_claims 服务模块结构并优化差旅票据审核逻辑, 增强用户代理服务的票据类型识别,前端报销创建页面拆分为 附件模型和会话模型模块,重构提交编排器和草稿关联确认流 程,更新知识库索引,补充单元测试。
This commit is contained in:
@@ -956,7 +956,12 @@ class ExpenseClaimService:
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
review_action = str(context_json.get("review_action") or "").strip()
|
review_action = str(context_json.get("review_action") or "").strip()
|
||||||
if review_action not in PERSISTENT_EXPENSE_REVIEW_ACTIONS:
|
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(
|
result = self.upsert_draft_from_ontology(
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
@@ -1051,15 +1056,29 @@ class ExpenseClaimService:
|
|||||||
"invoice_count": int(claim.invoice_count or 0),
|
"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)
|
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 {
|
return {
|
||||||
"message": (
|
"message": "\n\n".join(
|
||||||
"我已先整理出本次报销的待核对信息。"
|
item
|
||||||
"如果附件还没有上传,金额可以先按制度口径做参考测算:"
|
for item in [
|
||||||
"差旅费按“交通票据金额 + 住宿标准 × 出差天数 + 出差补贴 × 出差天数”估算;"
|
"我已先整理出本次报销的待核对信息。下面是基于当前信息的制度测算,票据补齐后会按真实金额重新复核。",
|
||||||
"交通费、住宿费等其他费用以实际票据金额为基础,再按规则中心限额和审批口径复核。"
|
calculation_copy,
|
||||||
"后续补充票据后,我会用真实票据金额重新校验。"
|
]
|
||||||
|
if item
|
||||||
),
|
),
|
||||||
"draft_only": True,
|
"draft_only": True,
|
||||||
"preview_only": True,
|
"preview_only": True,
|
||||||
@@ -1067,6 +1086,145 @@ class ExpenseClaimService:
|
|||||||
"invoice_count": attachment_count,
|
"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:
|
def delete_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
|
||||||
claim = self.get_claim(claim_id, current_user)
|
claim = self.get_claim(claim_id, current_user)
|
||||||
if claim is None:
|
if claim is None:
|
||||||
@@ -1388,6 +1546,30 @@ class ExpenseClaimService:
|
|||||||
existing_flags=list(claim.risk_flags_json or []) if claim is not None else [],
|
existing_flags=list(claim.risk_flags_json or []) if claim is not None else [],
|
||||||
next_flags=list(ontology.risk_flags),
|
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:
|
try:
|
||||||
if claim is None:
|
if claim is None:
|
||||||
@@ -1443,22 +1625,6 @@ class ExpenseClaimService:
|
|||||||
claim.risk_flags_json = final_risk_flags
|
claim.risk_flags_json = final_risk_flags
|
||||||
|
|
||||||
self.db.flush()
|
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 document_specs and (is_new_claim or review_action in DOCUMENT_ASSOCIATION_REVIEW_ACTIONS):
|
||||||
if review_action == "link_to_existing_draft" and claim.items:
|
if review_action == "link_to_existing_draft" and claim.items:
|
||||||
self._append_document_items(
|
self._append_document_items(
|
||||||
@@ -2081,6 +2247,157 @@ class ExpenseClaimService:
|
|||||||
)
|
)
|
||||||
self.db.add(claim.items[-1])
|
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:
|
def _resolve_document_item_type(self, document: dict[str, Any], *, fallback: str) -> str:
|
||||||
document_type = str(document.get("document_type") or "").strip()
|
document_type = str(document.get("document_type") or "").strip()
|
||||||
mapped_type = DOCUMENT_TYPE_ITEM_TYPE_MAP.get(document_type)
|
mapped_type = DOCUMENT_TYPE_ITEM_TYPE_MAP.get(document_type)
|
||||||
|
|||||||
@@ -3354,23 +3354,23 @@ class UserAgentService:
|
|||||||
location = slots.get("location")
|
location = slots.get("location")
|
||||||
customer = slots.get("customer_name")
|
customer = slots.get("customer_name")
|
||||||
|
|
||||||
summary = "我先根据您当前提供的信息整理出一笔报销。"
|
summary = "我先根据您当前提供的信息整理出一笔报销:"
|
||||||
if expense_type and expense_type.value:
|
if expense_type and expense_type.value:
|
||||||
summary = f"识别到您希望报销一笔“{expense_type.value}”费用。"
|
summary = f"识别到您希望报销一笔“{expense_type.value}”费用:"
|
||||||
details: list[str] = []
|
details: list[str] = []
|
||||||
if customer and customer.value:
|
if customer and customer.value:
|
||||||
details.append(f"客户为 {customer.value}")
|
details.append(f"客户:{customer.value}")
|
||||||
if time_range and time_range.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:
|
if location and location.value:
|
||||||
details.append(f"地点为 {location.value}")
|
details.append(f"地点:{location.value}")
|
||||||
if amount and amount.value:
|
if amount and amount.value:
|
||||||
details.append(f"金额为 {amount.value}")
|
details.append(f"金额:{amount.value}")
|
||||||
reason = slots.get("reason")
|
reason = slots.get("reason")
|
||||||
if reason and reason.value:
|
if reason and reason.value:
|
||||||
details.append(f"事由是 {reason.value}")
|
details.append(f"事由:{reason.value}")
|
||||||
if details:
|
if details:
|
||||||
return f"{summary} {','.join(details)}。"
|
return "\n\n".join([summary, "基础信息识别结果:", "\n".join(details)])
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
def _build_review_body_answer(
|
def _build_review_body_answer(
|
||||||
@@ -3399,6 +3399,11 @@ class UserAgentService:
|
|||||||
slot_cards=review_payload.slot_cards,
|
slot_cards=review_payload.slot_cards,
|
||||||
claim_groups=review_payload.claim_groups,
|
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 review_action == "save_draft":
|
||||||
if draft_payload is not None and draft_payload.claim_no:
|
if draft_payload is not None and draft_payload.claim_no:
|
||||||
return (
|
return (
|
||||||
@@ -3441,7 +3446,7 @@ class UserAgentService:
|
|||||||
)
|
)
|
||||||
return str(payload.tool_payload.get("message") or "").strip() or "当前报销单暂时还不能提交审批。"
|
return str(payload.tool_payload.get("message") or "").strip() or "当前报销单暂时还不能提交审批。"
|
||||||
return (
|
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
|
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)
|
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():
|
if expense_type_slot is not None and not str(expense_type_slot.value or "").strip():
|
||||||
return (
|
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"| 交通票据 | {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.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(calculation.total_allowance_rate)} 元/天 × {calculation.days} 天 | {self._format_decimal_money(calculation.allowance_amount)} 元 |",
|
||||||
f"| 参考合计 | 交通票据 + 住宿标准 + 出差补贴 | {self._format_decimal_money(total_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,
|
mention_save_draft: bool,
|
||||||
) -> str:
|
) -> str:
|
||||||
missing_count = len(review_payload.missing_slots)
|
|
||||||
reminder_count = len(review_payload.risk_briefs)
|
reminder_count = len(review_payload.risk_briefs)
|
||||||
|
|
||||||
if review_payload.can_proceed:
|
if review_payload.can_proceed:
|
||||||
@@ -3861,18 +3865,7 @@ class UserAgentService:
|
|||||||
)
|
)
|
||||||
return "当前关键信息已基本齐全,您确认无误后可以继续下一步。"
|
return "当前关键信息已基本齐全,您确认无误后可以继续下一步。"
|
||||||
|
|
||||||
issue_parts: list[str] = []
|
return ""
|
||||||
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}"
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _can_proceed_review(
|
def _can_proceed_review(
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 153 KiB |
@@ -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": []
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 153 KiB |
Binary file not shown.
@@ -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": []
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
@@ -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": []
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
@@ -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": []
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
@@ -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": []
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
@@ -35,13 +35,13 @@
|
|||||||
"updated_at": "2026-05-17T13:00:09.485818+00:00",
|
"updated_at": "2026-05-17T13:00:09.485818+00:00",
|
||||||
"uploaded_by": "admin",
|
"uploaded_by": "admin",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 4,
|
"ingest_status": 3,
|
||||||
"ingest_status_updated_at": "2026-05-20T16:00:02.515903+00:00",
|
"ingest_status_updated_at": "2026-05-21T15:56:58.286585+00:00",
|
||||||
"ingest_completed_at": "",
|
"ingest_completed_at": "2026-05-21T15:56:58.286585+00:00",
|
||||||
"ingest_document_name": "",
|
"ingest_document_name": "无单需求文档0506.docx",
|
||||||
"ingest_document_updated_at": "",
|
"ingest_document_updated_at": "2026-05-17T13:00:09.485818+00:00",
|
||||||
"ingest_document_sha256": "",
|
"ingest_document_sha256": "00985ec85a8163be9c9ffc5eb522df18ed52d4b131ceed12102c2d75e4df85a9",
|
||||||
"ingest_agent_run_id": "run_3a0b0ecb941b4c8e"
|
"ingest_agent_run_id": "run_9f4f60cf545c470f"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -26,8 +26,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"a8f8465df08e455ebe133351721d49f8": {
|
"a8f8465df08e455ebe133351721d49f8": {
|
||||||
"status": "failed",
|
"status": "processed",
|
||||||
"error_msg": "Embedding func: Worker execution timeout after 60s",
|
|
||||||
"chunks_count": 6,
|
"chunks_count": 6,
|
||||||
"chunks_list": [
|
"chunks_list": [
|
||||||
"chunk-07de6ea74f60535b689f977295770273",
|
"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_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,
|
"content_length": 9088,
|
||||||
"created_at": "2026-05-19T15:59:57.283110+00:00",
|
"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",
|
"file_path": "/app/server/storage/knowledge/报销制度/a8f8465df08e455ebe133351721d49f8__无单需求文档0506.docx",
|
||||||
"track_id": "insert_20260519_155957_88c49850",
|
"track_id": "insert_20260519_155957_88c49850",
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"processing_start_time": 1779206397,
|
"processing_start_time": 1779378923,
|
||||||
"processing_end_time": 1779206457
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -264,5 +264,124 @@
|
|||||||
"create_time": 1779012093,
|
"create_time": 1779012093,
|
||||||
"update_time": 1779012093,
|
"update_time": 1779012093,
|
||||||
"_id": "2c1cb358f08d44ceb0e4d287133206ec"
|
"_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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,5 +162,117 @@
|
|||||||
"create_time": 1779012093,
|
"create_time": 1779012093,
|
||||||
"update_time": 1779012093,
|
"update_time": 1779012093,
|
||||||
"_id": "2c1cb358f08d44ceb0e4d287133206ec"
|
"_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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,5 +349,239 @@
|
|||||||
"create_time": 1779012093,
|
"create_time": 1779012093,
|
||||||
"update_time": 1779012093,
|
"update_time": 1779012093,
|
||||||
"_id": "会议费<SEP>公司总裁"
|
"_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
@@ -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["preview_only"] is True
|
||||||
assert result["status"] == "preview"
|
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
|
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
|
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:
|
def test_upsert_travel_draft_uses_ticket_item_types_and_auto_allowance() -> None:
|
||||||
user_id = "travel-allowance@example.com"
|
user_id = "travel-allowance@example.com"
|
||||||
|
|
||||||
|
|||||||
@@ -624,7 +624,8 @@ def test_user_agent_guides_implicit_expense_draft_request() -> None:
|
|||||||
|
|
||||||
assert response.review_payload is not None
|
assert response.review_payload is not None
|
||||||
assert response.answer == response.review_payload.body_message
|
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 response.review_payload.missing_slots == ["客户名称", "参与人员", "票据附件"]
|
||||||
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
|
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
|
||||||
"save_draft",
|
"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}
|
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"].raw_value == "前天"
|
||||||
assert slot_map["time_range"].value == "2026-05-11"
|
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:
|
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",
|
"save_draft",
|
||||||
]
|
]
|
||||||
assert any(item.scene_label == "业务招待费" for item in response.review_payload.document_cards)
|
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}
|
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"].value == yesterday
|
||||||
assert slot_map["time_range"].raw_value == "昨天"
|
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",
|
"create_new_claim_from_documents",
|
||||||
]
|
]
|
||||||
assert "EXP-202605-008" in response.answer
|
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
|
||||||
|
|||||||
@@ -200,7 +200,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.review-message-block {
|
.review-message-block {
|
||||||
margin-top: 8px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.review-summary {
|
.review-summary {
|
||||||
@@ -208,12 +208,12 @@
|
|||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
font-size: var(--wb-fs-bubble);
|
font-size: var(--wb-fs-bubble);
|
||||||
line-height: 1.58;
|
line-height: 1.58;
|
||||||
white-space: pre-line;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.review-plain-followup {
|
.review-plain-followup {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 7px;
|
gap: 10px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
color: #334155;
|
color: #334155;
|
||||||
font-size: var(--wb-fs-bubble);
|
font-size: var(--wb-fs-bubble);
|
||||||
@@ -225,13 +225,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.review-plain-lead {
|
.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 {
|
.review-plain-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 7px;
|
||||||
margin: 0;
|
margin: 2px 0 0;
|
||||||
padding: 0 0 0 18px;
|
padding: 0 0 0 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,11 +265,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.review-plain-note {
|
.review-plain-note {
|
||||||
|
margin-top: 2px;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.review-inline-save-copy {
|
.review-inline-save-copy {
|
||||||
|
margin-top: 46px !important;
|
||||||
color: #475569;
|
color: #475569;
|
||||||
|
line-height: 1.62;
|
||||||
}
|
}
|
||||||
|
|
||||||
.review-inline-draft-link {
|
.review-inline-draft-link {
|
||||||
|
|||||||
@@ -185,7 +185,7 @@
|
|||||||
--wb-fs-welcome: 16px;
|
--wb-fs-welcome: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-modal-stage .message-answer-markdown table {
|
.assistant-modal-stage .message-answer-markdown :deep(table) {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -661,7 +661,7 @@
|
|||||||
|
|
||||||
.message-answer-content {
|
.message-answer-content {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 7px;
|
gap: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-answer-content p,
|
.message-answer-content p,
|
||||||
@@ -672,15 +672,33 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-answer-markdown h1,
|
.message-answer-markdown :deep(h1),
|
||||||
.message-answer-markdown h2,
|
.message-answer-markdown :deep(h2),
|
||||||
.message-answer-markdown h3,
|
.message-answer-markdown :deep(h3),
|
||||||
.message-answer-markdown h4 {
|
.message-answer-markdown :deep(h4) {
|
||||||
margin: 0;
|
margin: 12px 0 4px;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-size: var(--wb-fs-md-h3);
|
font-size: max(13px, calc(var(--wb-fs-bubble) + 1px));
|
||||||
font-weight: 750;
|
font-weight: 820;
|
||||||
line-height: 1.46;
|
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 {
|
.message-answer-markdown {
|
||||||
@@ -690,26 +708,31 @@
|
|||||||
line-height: 1.58;
|
line-height: 1.58;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-answer-markdown p,
|
.message-answer-markdown :deep(p),
|
||||||
.message-answer-markdown li,
|
.message-answer-markdown :deep(li),
|
||||||
.message-answer-markdown td,
|
.message-answer-markdown :deep(td),
|
||||||
.message-answer-markdown th,
|
.message-answer-markdown :deep(th),
|
||||||
.message-answer-markdown blockquote {
|
.message-answer-markdown :deep(blockquote) {
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
line-height: 1.58;
|
line-height: 1.58;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-answer-markdown ul,
|
.message-answer-markdown :deep(p) {
|
||||||
.message-answer-markdown ol {
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-answer-markdown :deep(ul),
|
||||||
|
.message-answer-markdown :deep(ol) {
|
||||||
|
margin: 0;
|
||||||
padding-left: 20px;
|
padding-left: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-answer-markdown strong {
|
.message-answer-markdown :deep(strong) {
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-answer-markdown blockquote {
|
.message-answer-markdown :deep(blockquote) {
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border-left: 3px solid #cbd5e1;
|
border-left: 3px solid #cbd5e1;
|
||||||
border-radius: 0 10px 10px 0;
|
border-radius: 0 10px 10px 0;
|
||||||
@@ -717,14 +740,14 @@
|
|||||||
color: #475569;
|
color: #475569;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-answer-markdown code {
|
.message-answer-markdown :deep(code) {
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: #e2e8f0;
|
background: #e2e8f0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-answer-markdown pre {
|
.message-answer-markdown :deep(pre) {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
@@ -732,47 +755,64 @@
|
|||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-answer-markdown pre code {
|
.message-answer-markdown :deep(pre code) {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-answer-markdown a {
|
.message-answer-markdown :deep(a) {
|
||||||
color: #2563eb;
|
color: #2563eb;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-answer-markdown table {
|
.message-answer-markdown :deep(.markdown-table-wrap) {
|
||||||
width: auto;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
margin: 8px 0 10px;
|
||||||
|
overflow-x: auto;
|
||||||
border: 1px solid #dbe4ee;
|
border: 1px solid #dbe4ee;
|
||||||
border-radius: 16px;
|
border-radius: 10px;
|
||||||
border-collapse: collapse;
|
background: #fff;
|
||||||
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
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;
|
font-size: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-answer-markdown th,
|
.message-answer-markdown :deep(th),
|
||||||
.message-answer-markdown td {
|
.message-answer-markdown :deep(td) {
|
||||||
padding: 10px 12px;
|
padding: 8px 10px;
|
||||||
border-bottom: 1px solid #e2e8f0;
|
border-bottom: 1px solid #e2e8f0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
white-space: nowrap;
|
vertical-align: top;
|
||||||
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-answer-markdown th {
|
.message-answer-markdown :deep(th) {
|
||||||
background: #eff6ff;
|
background: #f8fafc;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-weight: 850;
|
font-weight: 760;
|
||||||
|
border-bottom-color: #cbd5e1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-answer-markdown td {
|
.message-answer-markdown :deep(td) {
|
||||||
color: #334155;
|
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;
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { apiRequest } from './api.js'
|
import { apiRequest } from './api.js'
|
||||||
|
|
||||||
export function recognizeOcrFiles(files) {
|
export function recognizeOcrFiles(files, options = {}) {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
formData.append('files', file)
|
formData.append('files', file)
|
||||||
@@ -9,6 +9,7 @@ export function recognizeOcrFiles(files) {
|
|||||||
return apiRequest('/ocr/recognize', {
|
return apiRequest('/ocr/recognize', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
contentType: null
|
contentType: null,
|
||||||
|
...options
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,78 @@ const markdown = new MarkdownIt({
|
|||||||
breaks: true
|
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 = '') {
|
export function renderMarkdown(text = '') {
|
||||||
const normalized = String(text || '').trim()
|
const normalized = normalizeColonHeadings(text).trim()
|
||||||
return normalized ? markdown.render(normalized) : ''
|
return normalized ? markdown.render(normalized) : ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,9 +88,10 @@
|
|||||||
<time>{{ message.time }}</time>
|
<time>{{ message.time }}</time>
|
||||||
</header>
|
</header>
|
||||||
<div
|
<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"
|
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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -103,6 +104,7 @@
|
|||||||
v-else-if="message.text && message.role === 'assistant'"
|
v-else-if="message.text && message.role === 'assistant'"
|
||||||
class="message-answer-content message-answer-markdown"
|
class="message-answer-content message-answer-markdown"
|
||||||
v-html="renderMarkdown(message.text)"
|
v-html="renderMarkdown(message.text)"
|
||||||
|
@click="handleAssistantMarkdownClick($event, message)"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -298,7 +300,15 @@
|
|||||||
v-for="followup in [buildReviewPlainFollowupCopy(message.reviewPayload)]"
|
v-for="followup in [buildReviewPlainFollowupCopy(message.reviewPayload)]"
|
||||||
:key="`${message.id}-review-followup`"
|
: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">
|
<ul v-if="followup.items.length" class="review-plain-list">
|
||||||
<li
|
<li
|
||||||
v-for="item in followup.items"
|
v-for="item in followup.items"
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ import {
|
|||||||
VISIBLE_ATTACHMENT_CHIPS,
|
VISIBLE_ATTACHMENT_CHIPS,
|
||||||
buildAgentInsight,
|
buildAgentInsight,
|
||||||
buildErrorInsight,
|
buildErrorInsight,
|
||||||
|
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
|
||||||
buildFileIdentity,
|
buildFileIdentity,
|
||||||
buildFilePreviews,
|
buildFilePreviews,
|
||||||
buildOcrDocumentsFromReviewPayload,
|
buildOcrDocumentsFromReviewPayload,
|
||||||
@@ -431,6 +432,19 @@ function buildReviewRiskConversationText(item) {
|
|||||||
return lines.join('\n')
|
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 {
|
export default {
|
||||||
name: 'TravelReimbursementCreateView',
|
name: 'TravelReimbursementCreateView',
|
||||||
components: {
|
components: {
|
||||||
@@ -779,7 +793,10 @@ export default {
|
|||||||
attachedFiles,
|
attachedFiles,
|
||||||
composerFilesExpanded
|
composerFilesExpanded
|
||||||
}
|
}
|
||||||
const { submitComposerInternal } = useTravelReimbursementSubmitComposer({
|
const {
|
||||||
|
confirmPendingAttachmentAssociationInternal,
|
||||||
|
submitComposerInternal
|
||||||
|
} = useTravelReimbursementSubmitComposer({
|
||||||
MAX_ATTACHMENTS,
|
MAX_ATTACHMENTS,
|
||||||
activeReviewPayload,
|
activeReviewPayload,
|
||||||
activeSessionType,
|
activeSessionType,
|
||||||
@@ -1303,7 +1320,8 @@ export default {
|
|||||||
skipUploadDecisionPrompt: true,
|
skipUploadDecisionPrompt: true,
|
||||||
extraContext: {
|
extraContext: {
|
||||||
draft_claim_id: claimId,
|
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
|
// submitting.value = false
|
||||||
return submitComposerInternal(options)
|
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) {
|
async function handleReviewAction(message, action) {
|
||||||
const actionType = String(action?.action_type || '').trim()
|
const actionType = String(action?.action_type || '').trim()
|
||||||
if (!actionType || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return
|
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,
|
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,
|
workbenchVisible, reviewPanelConfidence, reviewRiskSummary, reviewRiskItems, reviewRiskEmpty, recognizedNarratives, reviewRecognitionNotes, reviewDocumentSummaries, reviewDocumentCount, reviewDocumentDirty, reviewHasUnsavedChanges, uploadDecisionDialogOpen,
|
||||||
travelCalculatorOpen, travelCalculatorBusy, travelCalculatorError, travelCalculatorResult, travelCalculatorForm, travelCalculatorCanSubmit, deleteSessionDialogOpen, reviewActionBusy, deleteSessionBusy, documentPreviewDialog, shortcuts,
|
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,
|
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,
|
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,
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ function resolveStatusTone(status) {
|
|||||||
export const MAX_ATTACHMENTS = 10
|
export const MAX_ATTACHMENTS = 10
|
||||||
export const MAX_OCR_DOCUMENTS = 10
|
export const MAX_OCR_DOCUMENTS = 10
|
||||||
export const VISIBLE_ATTACHMENT_CHIPS = 2
|
export const VISIBLE_ATTACHMENT_CHIPS = 2
|
||||||
|
export const ATTACHMENT_ASSOCIATION_CONFIRM_HREF = '#confirm-attachment-association'
|
||||||
|
|
||||||
export function normalizeOcrDocuments(payload) {
|
export function normalizeOcrDocuments(payload) {
|
||||||
const documents = Array.isArray(payload?.documents) ? payload.documents : []
|
const documents = Array.isArray(payload?.documents) ? payload.documents : []
|
||||||
@@ -85,6 +86,88 @@ export function buildOcrSummaryFromDocuments(documents) {
|
|||||||
.join(';')
|
.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) {
|
export function normalizeReviewDocumentFieldKey(label) {
|
||||||
const compact = String(label || '').replace(/\s+/g, '').toLowerCase()
|
const compact = String(label || '').replace(/\s+/g, '').toLowerCase()
|
||||||
if (!compact) return ''
|
if (!compact) return ''
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ export function createMessage(role, text, attachments = [], extras = {}) {
|
|||||||
draftPayload: null,
|
draftPayload: null,
|
||||||
reviewPayload: null,
|
reviewPayload: null,
|
||||||
riskFlags: [],
|
riskFlags: [],
|
||||||
|
pendingAttachmentAssociation: null,
|
||||||
...extras
|
...extras
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -666,6 +667,7 @@ export function serializeSessionMessages(messages) {
|
|||||||
draftPayload: message.draftPayload || null,
|
draftPayload: message.draftPayload || null,
|
||||||
reviewPayload: message.reviewPayload || null,
|
reviewPayload: message.reviewPayload || null,
|
||||||
riskFlags: Array.isArray(message.riskFlags) ? message.riskFlags : [],
|
riskFlags: Array.isArray(message.riskFlags) ? message.riskFlags : [],
|
||||||
|
pendingAttachmentAssociation: message.pendingAttachmentAssociation || null,
|
||||||
assistantName: message.assistantName || '',
|
assistantName: message.assistantName || '',
|
||||||
isWelcome: Boolean(message.isWelcome),
|
isWelcome: Boolean(message.isWelcome),
|
||||||
welcomeQuickActions: Array.isArray(message.welcomeQuickActions) ? message.welcomeQuickActions : []
|
welcomeQuickActions: Array.isArray(message.welcomeQuickActions) ? message.welcomeQuickActions : []
|
||||||
|
|||||||
@@ -224,14 +224,22 @@ export function resolveReviewMissingSlotCards(reviewPayload) {
|
|||||||
|
|
||||||
export function resolveReviewExtraMissingLabels(reviewPayload) {
|
export function resolveReviewExtraMissingLabels(reviewPayload) {
|
||||||
const labels = Array.isArray(reviewPayload?.missing_slots)
|
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 []
|
if (!labels.length) return []
|
||||||
|
|
||||||
const slotLabels = new Set(
|
const slotLabels = new Set(
|
||||||
(Array.isArray(reviewPayload?.slot_cards) ? reviewPayload.slot_cards : [])
|
(Array.isArray(reviewPayload?.slot_cards) ? reviewPayload.slot_cards : []).flatMap((item) => [
|
||||||
.map((item) => String(item?.label || item?.key || '').trim())
|
String(item?.label || '').trim(),
|
||||||
.filter(Boolean)
|
String(item?.key || '').trim()
|
||||||
|
]).filter(Boolean)
|
||||||
)
|
)
|
||||||
return labels.filter((label) => !slotLabels.has(label))
|
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) {
|
export function buildReviewPlainFollowupCopy(reviewPayload) {
|
||||||
const todoItems = buildReviewTodoItems(reviewPayload)
|
const todoItems = buildReviewTodoItems(reviewPayload)
|
||||||
const pendingCount = countReviewPendingItems(reviewPayload)
|
const pendingCount = countReviewPendingItems(reviewPayload)
|
||||||
const riskBriefs = resolvePresentationRiskBriefs(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 {
|
return {
|
||||||
lead: '我还需要你核查或补充下面这些信息:',
|
lead: '补充信息:',
|
||||||
|
tone: 'danger',
|
||||||
|
summary: buildReviewPendingSummary(pendingCount || extraMissingCount, riskBriefs.length, summarySignature),
|
||||||
items: todoItems.map((item) => buildReviewPlainFollowupItem(item, true)),
|
items: todoItems.map((item) => buildReviewPlainFollowupItem(item, true)),
|
||||||
notes: riskBriefs.length
|
notes: []
|
||||||
? [`另外还有 ${riskBriefs.length} 条风险提醒,提交前建议一起确认。`]
|
|
||||||
: []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
lead: todoItems.length ? '我已整理出当前识别到的关键信息:' : '当前关键信息已基本整理完成。',
|
lead: todoItems.length ? '我已整理出当前识别到的关键信息:' : '当前关键信息已基本整理完成。',
|
||||||
|
tone: 'neutral',
|
||||||
|
summary: '',
|
||||||
items: todoItems.map((item) => buildReviewPlainFollowupItem(item, false)),
|
items: todoItems.map((item) => buildReviewPlainFollowupItem(item, false)),
|
||||||
notes: [
|
notes: [
|
||||||
reviewPayload?.can_proceed ? '确认无误后,可以继续下一步。' : '',
|
reviewPayload?.can_proceed ? '确认无误后,可以继续下一步。' : '',
|
||||||
|
|||||||
@@ -103,15 +103,15 @@ export function useTravelReimbursementReviewDrawer({
|
|||||||
const isReviewFlowDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW)
|
const isReviewFlowDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW)
|
||||||
const reviewDrawerTitle = computed(() => (
|
const reviewDrawerTitle = computed(() => (
|
||||||
isReviewDocumentDrawer.value
|
isReviewDocumentDrawer.value
|
||||||
? '绁ㄦ嵁璇嗗埆缁撴灉'
|
? '票据识别结果'
|
||||||
: isReviewRiskDrawer.value
|
: isReviewRiskDrawer.value
|
||||||
? '椋庨櫓鎻愮ず'
|
? '风险提示'
|
||||||
: isReviewFlowDrawer.value
|
: isReviewFlowDrawer.value
|
||||||
? '璋冪敤娴佺▼'
|
? '执行流程'
|
||||||
: '鎶ラ攢璇嗗埆鏍稿'
|
: '报销识别核对'
|
||||||
))
|
))
|
||||||
const reviewDocumentDrawerLabel = computed(() => (
|
const reviewDocumentDrawerLabel = computed(() => (
|
||||||
'鍗曟嵁璇嗗埆'
|
'单据识别'
|
||||||
))
|
))
|
||||||
const reviewDocumentDrawerIcon = computed(() => (
|
const reviewDocumentDrawerIcon = computed(() => (
|
||||||
isReviewDocumentDrawer.value
|
isReviewDocumentDrawer.value
|
||||||
@@ -119,7 +119,7 @@ export function useTravelReimbursementReviewDrawer({
|
|||||||
: 'mdi mdi-file-document-multiple-outline'
|
: 'mdi mdi-file-document-multiple-outline'
|
||||||
))
|
))
|
||||||
const reviewRiskDrawerLabel = computed(() => (
|
const reviewRiskDrawerLabel = computed(() => (
|
||||||
'鏄剧ず椋庨櫓'
|
'显示风险'
|
||||||
))
|
))
|
||||||
const reviewRiskDrawerIcon = computed(() => (
|
const reviewRiskDrawerIcon = computed(() => (
|
||||||
isReviewRiskDrawer.value
|
isReviewRiskDrawer.value
|
||||||
@@ -127,7 +127,7 @@ export function useTravelReimbursementReviewDrawer({
|
|||||||
: 'mdi mdi-shield-alert-outline'
|
: 'mdi mdi-shield-alert-outline'
|
||||||
))
|
))
|
||||||
const reviewFlowDrawerLabel = computed(() => (
|
const reviewFlowDrawerLabel = computed(() => (
|
||||||
'璋冪敤娴佺▼'
|
'执行流程'
|
||||||
))
|
))
|
||||||
const reviewFlowDrawerIcon = computed(() => (
|
const reviewFlowDrawerIcon = computed(() => (
|
||||||
isReviewFlowDrawer.value
|
isReviewFlowDrawer.value
|
||||||
@@ -253,7 +253,7 @@ export function useTravelReimbursementReviewDrawer({
|
|||||||
) {
|
) {
|
||||||
nextForm.reason_value = String(reviewInlineForm.value.reason_value || '').trim()
|
nextForm.reason_value = String(reviewInlineForm.value.reason_value || '').trim()
|
||||||
if (!nextForm.reason_value) {
|
if (!nextForm.reason_value) {
|
||||||
setInlineReviewFieldError('scene', '璇烽€夋嫨鈥滃叾浠栧満鏅€濆悗锛岃琛ュ厖鍏蜂綋浜嬬敱')
|
setInlineReviewFieldError('scene', '请选择“其他场景”后,请补充具体事由')
|
||||||
reviewInlineForm.value = nextForm
|
reviewInlineForm.value = nextForm
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -262,14 +262,14 @@ export function useTravelReimbursementReviewDrawer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (activeEditorKey === 'occurred_date' && nextForm.occurred_date && !isValidIsoDateString(nextForm.occurred_date)) {
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeEditorKey === 'amount' && nextForm.amount) {
|
if (activeEditorKey === 'amount' && nextForm.amount) {
|
||||||
const normalizedAmount = normalizeAmountValue(nextForm.amount)
|
const normalizedAmount = normalizeAmountValue(nextForm.amount)
|
||||||
if (!normalizedAmount) {
|
if (!normalizedAmount) {
|
||||||
setInlineReviewFieldError('amount', '璇疯緭鍏ユ纭殑鏁板瓧閲戦锛屼緥濡?200 鎴?200.50')
|
setInlineReviewFieldError('amount', '请输入正确的数字金额,例如 200 或 200.50')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
nextForm.amount = normalizedAmount
|
nextForm.amount = normalizedAmount
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import {
|
||||||
|
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
|
||||||
|
buildAttachmentAssociationConfirmationMessage
|
||||||
|
} from './travelReimbursementAttachmentModel.js'
|
||||||
|
|
||||||
export function useTravelReimbursementSubmitComposer(ctx) {
|
export function useTravelReimbursementSubmitComposer(ctx) {
|
||||||
const {
|
const {
|
||||||
MAX_ATTACHMENTS,
|
MAX_ATTACHMENTS,
|
||||||
@@ -74,6 +79,87 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
uploadDecisionDialogOpen,
|
uploadDecisionDialogOpen,
|
||||||
toast
|
toast
|
||||||
} = ctx
|
} = 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 = '') {
|
function buildBackendMessage(rawText, fileNames, ocrSummary = '') {
|
||||||
const parts = []
|
const parts = []
|
||||||
const normalizedText = String(rawText || '').trim()
|
const normalizedText = String(rawText || '').trim()
|
||||||
@@ -128,6 +214,11 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
? initialExtraContext
|
? initialExtraContext
|
||||||
: mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext)
|
: mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext)
|
||||||
const reviewAction = String(extraContext.review_action || '').trim()
|
const reviewAction = String(extraContext.review_action || '').trim()
|
||||||
|
const attachmentAssociationConfirmed = Boolean(
|
||||||
|
options.associationConfirmed ||
|
||||||
|
extraContext.attachment_association_confirmed ||
|
||||||
|
reviewAction === 'link_to_existing_draft'
|
||||||
|
)
|
||||||
const hasSelectedExpenseType = Boolean(
|
const hasSelectedExpenseType = Boolean(
|
||||||
extraContext.expense_scene_selection ||
|
extraContext.expense_scene_selection ||
|
||||||
String(extraContext.review_form_values?.expense_type || extraContext.review_form_values?.reimbursement_type || '').trim()
|
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 ocrSummary = ''
|
||||||
let ocrDocuments = []
|
let ocrDocuments = []
|
||||||
let ocrFilePreviews = []
|
let ocrFilePreviews = []
|
||||||
|
const recognizedAttachmentData = normalizeRecognizedAttachmentData(options.recognizedAttachmentData)
|
||||||
|
|
||||||
if (files.length) {
|
if (files.length) {
|
||||||
const ocrStartedAt = Date.now()
|
const ocrStartedAt = Date.now()
|
||||||
startFlowStep('ocr', { detail: `正在识别 ${files.length} 份附件...`, startedAt: ocrStartedAt })
|
startFlowStep('ocr', { detail: `正在识别 ${files.length} 份附件...`, startedAt: ocrStartedAt })
|
||||||
try {
|
if (recognizedAttachmentData) {
|
||||||
ocrPayload = await recognizeOcrFiles(files)
|
ocrPayload = recognizedAttachmentData.ocrPayload
|
||||||
ocrSummary = buildOcrSummary(ocrPayload)
|
ocrSummary = recognizedAttachmentData.ocrSummary || buildOcrSummaryFromDocuments(recognizedAttachmentData.ocrDocuments)
|
||||||
ocrDocuments = normalizeOcrDocuments(ocrPayload)
|
ocrDocuments = [...recognizedAttachmentData.ocrDocuments]
|
||||||
ocrFilePreviews = buildOcrFilePreviews(ocrPayload)
|
ocrFilePreviews = [...recognizedAttachmentData.ocrFilePreviews]
|
||||||
rememberFilePreviews(ocrFilePreviews)
|
rememberFilePreviews(ocrFilePreviews)
|
||||||
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt)
|
completeFlowStep('ocr', `复用已确认的 ${ocrDocuments.length || files.length} 张票据识别结果`, Date.now() - ocrStartedAt)
|
||||||
} catch (error) {
|
} else {
|
||||||
console.warn('OCR request failed:', error)
|
try {
|
||||||
completeFlowStep('ocr', 'OCR识别失败,已继续使用附件名称', Date.now() - ocrStartedAt)
|
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]
|
let effectiveFileNames = [...fileNames]
|
||||||
@@ -359,6 +529,16 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary)
|
const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary)
|
||||||
|
const orchestratorOptions = isKnowledgeSession.value
|
||||||
|
? {
|
||||||
|
timeoutMs: 18000,
|
||||||
|
timeoutMessage: '知识问答整理超时,已停止等待。建议缩小问题范围或稍后重试。'
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
timeoutMs: 120000,
|
||||||
|
timeoutMessage: '票据归集处理超时,当前仍停留在原草稿,请稍后重试或重新选择附件。'
|
||||||
|
}
|
||||||
|
|
||||||
const payload = await runOrchestrator(
|
const payload = await runOrchestrator(
|
||||||
{
|
{
|
||||||
source: 'user_message',
|
source: 'user_message',
|
||||||
@@ -393,12 +573,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
...extraContext
|
...extraContext
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
isKnowledgeSession.value
|
orchestratorOptions
|
||||||
? {
|
|
||||||
timeoutMs: 18000,
|
|
||||||
timeoutMessage: '知识问答整理超时,已停止等待。建议缩小问题范围或稍后重试。'
|
|
||||||
}
|
|
||||||
: {}
|
|
||||||
)
|
)
|
||||||
responsePayload = payload
|
responsePayload = payload
|
||||||
flowRunId.value = String(payload?.run_id || '').trim()
|
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
|
: String(payload?.result?.draft_payload?.claim_id || '').trim() || draftClaimId.value
|
||||||
|
|
||||||
replaceMessage(
|
const reviewActionResult = String(extraContext.review_action || '').trim()
|
||||||
pendingMessage.id,
|
const resultClaimNo = String(payload?.result?.draft_payload?.claim_no || '').trim()
|
||||||
createMessage('assistant', payload?.result?.answer || payload?.result?.message || '智能体已完成处理。', [], {
|
const fallbackAnswer = reviewActionResult === 'link_to_existing_draft'
|
||||||
meta: buildMessageMeta(payload, effectiveFileNames),
|
? (resultClaimNo ? `已将本次上传的票据关联到草稿 ${resultClaimNo}。` : '已将本次上传的票据关联到现有草稿。')
|
||||||
citations: Array.isArray(payload?.result?.citations) ? payload.result.citations : [],
|
: '智能体已完成处理。'
|
||||||
suggestedActions: Array.isArray(payload?.result?.suggested_actions)
|
const assistantMessage = createMessage('assistant', payload?.result?.answer || payload?.result?.message || fallbackAnswer, [], {
|
||||||
? payload.result.suggested_actions
|
meta: buildMessageMeta(payload, effectiveFileNames),
|
||||||
: [],
|
citations: Array.isArray(payload?.result?.citations) ? payload.result.citations : [],
|
||||||
queryPayload: normalizeExpenseQueryPayload(payload?.result?.query_payload),
|
suggestedActions: Array.isArray(payload?.result?.suggested_actions)
|
||||||
draftPayload: payload?.result?.draft_payload || null,
|
? payload.result.suggested_actions
|
||||||
reviewPayload: payload?.result?.review_payload || null,
|
: [],
|
||||||
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : []
|
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(
|
currentInsight.value = buildAgentInsight(
|
||||||
payload,
|
payload,
|
||||||
effectiveFileNames,
|
effectiveFileNames,
|
||||||
mergeFilePreviews(filePreviews, ocrFilePreviews)
|
mergeFilePreviews(filePreviews, ocrFilePreviews)
|
||||||
)
|
)
|
||||||
completeFlowResult(payload, flowRunDetail)
|
completeFlowResult(payload, flowRunDetail)
|
||||||
|
persistSessionState()
|
||||||
|
nextTick(scrollToBottom)
|
||||||
|
|
||||||
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
|
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
|
||||||
if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) {
|
if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) {
|
||||||
try {
|
void syncComposerFilesToDraft(resolvedDraftClaimId, files)
|
||||||
await syncComposerFilesToDraft(resolvedDraftClaimId, files)
|
.then(() => {
|
||||||
} catch (error) {
|
persistSessionState()
|
||||||
console.warn('Failed to persist composer attachments to draft claim:', error)
|
})
|
||||||
toast(error?.message || '票据已识别,但附件原件保存失败,请重试上传。')
|
.catch((error) => {
|
||||||
}
|
console.warn('Failed to persist composer attachments to draft claim:', error)
|
||||||
|
toast(error?.message || '票据已归集到草稿,但附件原件保存失败,请在单据详情中重新上传。')
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
clearFlowSimulationTimers()
|
clearFlowSimulationTimers()
|
||||||
@@ -458,6 +640,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
currentInsight.value = buildErrorInsight(error, fileNames)
|
currentInsight.value = buildErrorInsight(error, fileNames)
|
||||||
|
persistSessionState()
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
composerUploadIntent.value = ''
|
composerUploadIntent.value = ''
|
||||||
@@ -469,6 +652,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
confirmPendingAttachmentAssociationInternal: confirmPendingAttachmentAssociation,
|
||||||
submitComposerInternal: submitComposer
|
submitComposerInternal: submitComposer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
34
web/tests/attachment-association-confirmation.test.mjs
Normal file
34
web/tests/attachment-association-confirmation.test.mjs
Normal 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}\\)`))
|
||||||
|
})
|
||||||
@@ -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(
|
assert.match(
|
||||||
submitComposerScript,
|
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(
|
assert.doesNotMatch(
|
||||||
submitComposerScript,
|
submitComposerScript,
|
||||||
/syncComposerFilesToDraft\(resolvedDraftClaimId, files\)\.catch/
|
/await syncComposerFilesToDraft\(resolvedDraftClaimId, files\)/
|
||||||
)
|
)
|
||||||
assert.ok(
|
assert.ok(
|
||||||
submitComposerScript.indexOf('await syncComposerFilesToDraft(resolvedDraftClaimId, files)') <
|
submitComposerScript.indexOf('replaceMessage(pendingMessage.id, assistantMessage)') <
|
||||||
submitComposerScript.indexOf('submitting.value = false'),
|
submitComposerScript.indexOf('void syncComposerFilesToDraft(resolvedDraftClaimId, files)'),
|
||||||
'attachment persistence should finish before submit state is cleared'
|
'assistant response should render before background attachment persistence starts'
|
||||||
)
|
)
|
||||||
assert.match(attachmentsScript, /function normalizeAttachmentMatchName\(value\)/)
|
assert.match(attachmentsScript, /function normalizeAttachmentMatchName\(value\)/)
|
||||||
assert.match(attachmentsScript, /const normalizedMatchBuckets = new Map\(\)/)
|
assert.match(attachmentsScript, /const normalizedMatchBuckets = new Map\(\)/)
|
||||||
|
|||||||
Reference in New Issue
Block a user