feat: 增强差旅报销审核流程与票据智能推理

优化本体解析和编排器的差旅场景处理能力,完善报销单草稿
保存和费用明细同步逻辑,前端报销创建页面增加行程推理和
票据审核交互,新增助手会话快照工具函数,补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-21 16:09:47 +08:00
parent f28d7e6d16
commit e701fa01da
33 changed files with 3033 additions and 337 deletions

View File

@@ -22,6 +22,7 @@ class UserAgentSuggestedAction(BaseModel):
label: str = Field(description="建议动作文案。") label: str = Field(description="建议动作文案。")
action_type: str = Field(description="动作类型,例如 open_detail / create_draft。") action_type: str = Field(description="动作类型,例如 open_detail / create_draft。")
description: str = Field(default="", description="动作说明。") description: str = Field(default="", description="动作说明。")
payload: dict[str, Any] = Field(default_factory=dict, description="动作携带的结构化参数。")
class UserAgentDraftPayload(BaseModel): class UserAgentDraftPayload(BaseModel):

View File

@@ -22,6 +22,9 @@ STATEFUL_CONTEXT_KEYS = (
"business_time_context", "business_time_context",
) )
REVIEW_FLOW_CONTEXT_KEYS = { REVIEW_FLOW_CONTEXT_KEYS = {
"draft_claim_id",
"draft_claim_no",
"draft_status",
"request_context", "request_context",
"attachment_names", "attachment_names",
"attachment_count", "attachment_count",
@@ -131,7 +134,11 @@ class AgentConversationService:
resolved_retention_days = retention_days or self._resolve_retention_days() resolved_retention_days = retention_days or self._resolve_retention_days()
cutoff = datetime.now(UTC) - timedelta(days=max(1, resolved_retention_days)) cutoff = datetime.now(UTC) - timedelta(days=max(1, resolved_retention_days))
stmt = select(AgentConversation).where(AgentConversation.updated_at < cutoff) stmt = select(AgentConversation).where(AgentConversation.updated_at < cutoff)
expired_conversations = list(self.db.scalars(stmt).all()) expired_conversations = [
conversation
for conversation in self.db.scalars(stmt).all()
if not self._is_saved_conversation(conversation)
]
if not expired_conversations: if not expired_conversations:
return 0 return 0
@@ -141,6 +148,13 @@ class AgentConversationService:
self.db.commit() self.db.commit()
return len(expired_conversations) return len(expired_conversations)
@staticmethod
def _is_saved_conversation(conversation: AgentConversation) -> bool:
if str(conversation.draft_claim_id or "").strip():
return True
state_json = dict(conversation.state_json or {})
return bool(str(state_json.get("draft_claim_id") or "").strip())
def _resolve_retention_days(self) -> int: def _resolve_retention_days(self) -> int:
try: try:
settings_row, _ = SettingsService(self.db).ensure_settings_ready() settings_row, _ = SettingsService(self.db).ensure_settings_ready()
@@ -232,6 +246,9 @@ class AgentConversationService:
context_json=merged, context_json=merged,
message=message, message=message,
) )
if not should_hydrate_review_flow:
for key in REVIEW_FLOW_CONTEXT_KEYS:
merged.pop(key, None)
merged["conversation_id"] = conversation.conversation_id merged["conversation_id"] = conversation.conversation_id
merged["conversation_history"] = self.list_message_history( merged["conversation_history"] = self.list_message_history(
@@ -264,7 +281,12 @@ class AgentConversationService:
context_json: dict[str, Any], context_json: dict[str, Any],
message: str | None, message: str | None,
) -> bool: ) -> bool:
if isinstance(context_json.get("expense_scene_selection"), dict):
return True
if AgentConversationService._resolve_draft_claim_id(context_json): if AgentConversationService._resolve_draft_claim_id(context_json):
compact_message = str(message or "").replace(" ", "")
if compact_message and any(keyword in compact_message for keyword in NEW_EXPENSE_PROMPT_KEYWORDS):
return False
return True return True
if str(context_json.get("review_action") or "").strip(): if str(context_json.get("review_action") or "").strip():
return True return True

View File

@@ -177,8 +177,8 @@ SUPPORTED_DOCUMENT_TYPES = tuple(DOCUMENT_TYPE_RULE_MAP.keys()) + ("other",)
AMOUNT_PATTERNS = ( AMOUNT_PATTERNS = (
re.compile( re.compile(
r"(?:价税合计|合计金额|费用合计|订单(?:总)?金额|支付(?:金额)?|实付(?:金额)?|实收(?:金额)?|总(?:额|计|价)|票价|金额|车费|消费金额)" r"(?:价税合计|合计金额|费用合计|总费用|费用总计|订单(?:总)?金额|支付(?:金额)?|实付(?:金额)?|实收(?:金额)?|总(?:额|计|价)|票价|金额|车费|消费金额|房费|住宿费)"
r"[:\s¥¥人民币]*([0-9]+(?:[.,][0-9]{1,2})?)" r"[:\s¥¥人民币为是]*([0-9]+(?:[.,][0-9]{1,2})?)"
), ),
re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)"), re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)"),
re.compile(r"([0-9]+(?:[.,][0-9]{1,2})?)\s*元"), re.compile(r"([0-9]+(?:[.,][0-9]{1,2})?)\s*元"),
@@ -721,6 +721,8 @@ def _extract_amount(text: str) -> str:
continue continue
if candidate <= Decimal("0.00"): if candidate <= Decimal("0.00"):
continue continue
if _is_amount_match_date_fragment(candidate, text, match.start(1), match.end(1)):
continue
if best_value is None or candidate > best_value: if best_value is None or candidate > best_value:
best_value = candidate best_value = candidate
@@ -731,6 +733,22 @@ def _extract_amount(text: str) -> str:
return f"{text_value}" return f"{text_value}"
def _is_amount_match_date_fragment(amount: Decimal, text: str, start: int, end: int) -> bool:
if start < 0 or end < 0:
return False
normalized = amount.quantize(Decimal("0.01"))
if normalized != normalized.to_integral_value() or normalized < Decimal("1900") or normalized > Decimal("2099"):
return False
before = str(text or "")[max(0, start - 8):start]
after = str(text or "")[end:end + 10]
if re.match(r"\s*(?:年|[-/.])\s*\d{1,2}", after):
return True
if re.search(r"\d{1,2}\s*(?:年|[-/.])\s*$", before):
return True
return False
def _extract_date(text: str, *, document_type: str = "") -> str: def _extract_date(text: str, *, document_type: str = "") -> str:
matches = list(DATE_PATTERN.finditer(text)) matches = list(DATE_PATTERN.finditer(text))
if not matches: if not matches:

View File

@@ -15,7 +15,7 @@ from types import SimpleNamespace
from typing import Any from typing import Any
from urllib.parse import quote from urllib.parse import quote
from sqlalchemy import and_, func, or_, select from sqlalchemy import and_, func, inspect as sqlalchemy_inspect, or_, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, selectinload from sqlalchemy.orm import Session, selectinload
@@ -78,6 +78,7 @@ TRAVEL_DETAIL_ITEM_TYPES = {
"ride_ticket", "ride_ticket",
"travel_allowance", "travel_allowance",
} }
TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES = {"train_ticket", "flight_ticket"}
DOCUMENT_TYPE_ITEM_TYPE_MAP = { DOCUMENT_TYPE_ITEM_TYPE_MAP = {
"train_ticket": "train_ticket", "train_ticket": "train_ticket",
"flight_itinerary": "flight_ticket", "flight_itinerary": "flight_ticket",
@@ -97,8 +98,8 @@ DOCUMENT_TYPE_SCENE_MAP = {
"meeting_invoice": "meeting", "meeting_invoice": "meeting",
"training_invoice": "training", "training_invoice": "training",
} }
DOCUMENT_FACT_ITEM_TYPES = {"train_ticket", "flight_ticket", "hotel_ticket", "ride_ticket"} DOCUMENT_FACT_ITEM_TYPES = {"train_ticket", "flight_ticket", "hotel_ticket", "ride_ticket", "ship_ticket", "ferry_ticket"}
ROUTE_DESCRIPTION_ITEM_TYPES = {"train_ticket", "flight_ticket", "ride_ticket"} ROUTE_DESCRIPTION_ITEM_TYPES = {"train_ticket", "flight_ticket", "ship_ticket", "ferry_ticket", "ride_ticket"}
DOCUMENT_TRIP_DATE_LABELS = { DOCUMENT_TRIP_DATE_LABELS = {
"train_ticket": "列车出发时间", "train_ticket": "列车出发时间",
"flight_itinerary": "起飞日期", "flight_itinerary": "起飞日期",
@@ -253,6 +254,11 @@ DOCUMENT_ASSOCIATION_REVIEW_ACTIONS = {
"link_to_existing_draft", "link_to_existing_draft",
"create_new_claim_from_documents", "create_new_claim_from_documents",
} }
PERSISTENT_EXPENSE_REVIEW_ACTIONS = {
"save_draft",
"next_step",
*DOCUMENT_ASSOCIATION_REVIEW_ACTIONS,
}
RETURN_REASON_OPTIONS = { RETURN_REASON_OPTIONS = {
"missing_attachment": "附件缺失或不清晰", "missing_attachment": "附件缺失或不清晰",
"invoice_mismatch": "票据类型/金额与明细不一致", "invoice_mismatch": "票据类型/金额与明细不一致",
@@ -264,8 +270,8 @@ RETURN_REASON_OPTIONS = {
MAX_CLAIM_NO_RETRY_ATTEMPTS = 3 MAX_CLAIM_NO_RETRY_ATTEMPTS = 3
DOCUMENT_AMOUNT_PATTERNS = ( DOCUMENT_AMOUNT_PATTERNS = (
re.compile( re.compile(
r"(?:价税合计|合计金额|费用合计|订单(?:总)?金额|支付(?:金额)?|实付(?:金额)?|实收(?:金额)?|总(?:额|计|价)|票价|金额|车费|消费金额)" r"(?:价税合计|合计金额|费用合计|总费用|费用总计|订单(?:总)?金额|支付(?:金额)?|实付(?:金额)?|实收(?:金额)?|总(?:额|计|价)|票价|金额|车费|消费金额|房费|住宿费)"
r"[:\s¥¥人民币]*([0-9]+(?:[.,][0-9]{1,2})?)" r"[:\s¥¥人民币为是]*([0-9]+(?:[.,][0-9]{1,2})?)"
), ),
re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)"), re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)"),
re.compile(r"([0-9]+(?:[.,][0-9]{1,2})?)\s*元"), re.compile(r"([0-9]+(?:[.,][0-9]{1,2})?)\s*元"),
@@ -522,16 +528,16 @@ class ExpenseClaimService:
item.item_type = self._normalize_optional_text(payload.item_type, fallback=item.item_type) or item.item_type item.item_type = self._normalize_optional_text(payload.item_type, fallback=item.item_type) or item.item_type
if payload.item_reason is not None: if payload.item_reason is not None:
item.item_reason = ( item.item_reason = (
self._normalize_optional_text(payload.item_reason, fallback=item.item_reason) or item.item_reason self._normalize_optional_text(payload.item_reason, allow_empty=True) or ""
) )
if payload.item_location is not None: if payload.item_location is not None:
item.item_location = ( item.item_location = (
self._normalize_optional_text(payload.item_location, fallback=item.item_location) or item.item_location self._normalize_optional_text(payload.item_location, allow_empty=True) or ""
) )
if payload.item_amount is not None: if payload.item_amount is not None:
amount = payload.item_amount.quantize(Decimal("0.01")) amount = payload.item_amount.quantize(Decimal("0.01"))
if amount <= Decimal("0.00"): if amount < Decimal("0.00"):
raise ValueError("费用金额必须大于 0。") raise ValueError("费用金额不能小于 0。")
item.item_amount = amount item.item_amount = amount
if payload.invoice_id is not None: if payload.invoice_id is not None:
item.invoice_id = self._normalize_optional_text(payload.invoice_id, allow_empty=True) item.invoice_id = self._normalize_optional_text(payload.invoice_id, allow_empty=True)
@@ -794,6 +800,10 @@ class ExpenseClaimService:
"claim_id": claim.id, "claim_id": claim.id,
"item_id": item.id, "item_id": item.id,
"invoice_id": item.invoice_id, "invoice_id": item.invoice_id,
"item_date": item.item_date.isoformat() if item.item_date else None,
"item_type": item.item_type,
"item_reason": item.item_reason,
"item_location": item.item_location,
"item_amount": item.item_amount, "item_amount": item.item_amount,
"claim_amount": claim.amount, "claim_amount": claim.amount,
"attachment": self._build_attachment_payload(item), "attachment": self._build_attachment_payload(item),
@@ -938,6 +948,10 @@ class ExpenseClaimService:
ontology: OntologyParseResult, ontology: OntologyParseResult,
context_json: dict[str, Any], context_json: dict[str, Any],
) -> dict[str, Any]: ) -> dict[str, Any]:
review_action = str(context_json.get("review_action") or "").strip()
if review_action not in PERSISTENT_EXPENSE_REVIEW_ACTIONS:
return self._build_expense_review_preview_result(context_json)
result = self.upsert_draft_from_ontology( result = self.upsert_draft_from_ontology(
run_id=run_id, run_id=run_id,
user_id=user_id, user_id=user_id,
@@ -946,7 +960,6 @@ class ExpenseClaimService:
context_json=context_json, context_json=context_json,
) )
review_action = str(context_json.get("review_action") or "").strip()
if review_action != "next_step": if review_action != "next_step":
return result return result
@@ -1032,6 +1045,19 @@ 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]:
attachment_count = self._resolve_attachment_count(context_json)
return {
"message": (
"我已根据当前信息整理出待核对的报销内容,但尚未保存为草稿。"
"请在右侧核对信息,只有点击“保存为草稿”或“继续下一步”后才会正式写入单据。"
),
"draft_only": True,
"preview_only": True,
"status": "preview",
"invoice_count": attachment_count,
}
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:
@@ -1832,7 +1858,7 @@ class ExpenseClaimService:
for document in context_documents: for document in context_documents:
document_type = str(document.get("document_type") or "").strip() document_type = str(document.get("document_type") or "").strip()
scene_code = str(document.get("scene_code") or "").strip() scene_code = str(document.get("scene_code") or "").strip()
if document_type in {"train_ticket", "flight_itinerary", "hotel_invoice"} or scene_code == "travel": if document_type in {"train_ticket", "flight_itinerary"} or scene_code == "travel":
return True return True
return False return False
@@ -2241,15 +2267,29 @@ class ExpenseClaimService:
return "" return ""
def _resolve_document_item_amount(self, document: dict[str, Any]) -> Decimal | None: def _resolve_document_item_amount(self, document: dict[str, Any]) -> Decimal | None:
text = " ".join(
[
str(document.get("summary") or "").strip(),
str(document.get("text") or "").strip(),
]
).strip()
field_amount = self._resolve_document_field_amount(document)
text_amount = self._resolve_document_text_amount(text)
if field_amount is not None:
if self._is_date_like_amount_candidate(field_amount, text):
return text_amount
return field_amount
return text_amount
def _resolve_document_field_amount(self, document: dict[str, Any]) -> Decimal | None:
for field in list(document.get("document_fields") or []): for field in list(document.get("document_fields") or []):
if not isinstance(field, dict): if not isinstance(field, dict):
continue continue
key = str(field.get("key") or "").strip().lower().replace("_", "") key = str(field.get("key") or "").strip().lower().replace("_", "")
label = str(field.get("label") or "").replace(" ", "") label = str(field.get("label") or "").replace(" ", "")
value = self._parse_document_amount_value(str(field.get("value") or "")) is_amount_field = key in {
if value is None:
continue
if key in {
"amount", "amount",
"totalamount", "totalamount",
"paymentamount", "paymentamount",
@@ -2258,16 +2298,26 @@ class ExpenseClaimService:
} or any( } or any(
token in label token in label
for token in ("金额", "价税合计", "合计", "总额", "总计", "票价", "支付金额", "实付金额", "实收金额") for token in ("金额", "价税合计", "合计", "总额", "总计", "票价", "支付金额", "实付金额", "实收金额")
): )
if not is_amount_field:
continue
raw_value = str(field.get("value") or "")
value = self._parse_document_amount_value(raw_value) or self._parse_plain_document_amount_value(raw_value)
if value is not None:
return value return value
text = " ".join( return None
[
str(document.get("summary") or "").strip(), def _resolve_document_text_amount(self, text: str) -> Decimal | None:
str(document.get("text") or "").strip(), candidates = [
candidate
for candidate in self._extract_amount_candidates(text)
if not self._is_date_like_amount_candidate(candidate, text)
] ]
).strip() if not candidates:
return self._parse_document_amount_value(text) return None
return max(candidates)
def _parse_document_amount_value(self, value: str) -> Decimal | None: def _parse_document_amount_value(self, value: str) -> Decimal | None:
raw_value = str(value or "").strip() raw_value = str(value or "").strip()
@@ -2286,6 +2336,42 @@ class ExpenseClaimService:
return amount return amount
return None return None
@staticmethod
def _parse_plain_document_amount_value(value: str) -> Decimal | None:
raw_value = str(value or "").strip()
if not re.fullmatch(r"[0-9]{1,6}(?:[.,][0-9]{1,2})?", raw_value):
return None
try:
amount = Decimal(raw_value.replace(",", ".")).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return None
return amount if amount > Decimal("0.00") else None
@staticmethod
def _is_probable_year_amount(amount: Decimal | None) -> bool:
if amount is None:
return False
try:
normalized = Decimal(amount).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return False
return normalized == normalized.to_integral_value() and Decimal("1900") <= normalized <= Decimal("2099")
@classmethod
def _is_date_like_amount_candidate(cls, amount: Decimal | None, text: str) -> bool:
if not cls._is_probable_year_amount(amount):
return False
year = str(int(Decimal(amount or 0)))
pattern = re.compile(rf"(?<!\d){re.escape(year)}\s*(?:年|[-/.])\s*\d{{1,2}}")
return bool(pattern.search(str(text or "")))
@staticmethod
def _format_decimal_amount(amount: Decimal | None) -> str:
if amount is None:
return ""
normalized = Decimal(amount).quantize(Decimal("0.01"))
return format(normalized, "f")
def _resolve_document_item_date(self, document: dict[str, Any], *, fallback: date) -> date: def _resolve_document_item_date(self, document: dict[str, Any], *, fallback: date) -> date:
return self._resolve_document_item_date_candidate(document) or fallback return self._resolve_document_item_date_candidate(document) or fallback
@@ -3318,6 +3404,54 @@ class ExpenseClaimService:
if amount is not None and amount > Decimal("0.00"): if amount is not None and amount > Decimal("0.00"):
item.item_amount = amount item.item_amount = amount
def _build_attachment_expense_audit_points(
self,
*,
document: Any,
item: ExpenseClaimItem,
document_info: dict[str, Any],
) -> list[str]:
text = " ".join(
[
str(getattr(document, "summary", "") or "").strip(),
str(getattr(document, "text", "") or "").strip(),
]
).strip()
document_payload = {
"document_fields": document_info.get("fields") or [],
"summary": str(getattr(document, "summary", "") or ""),
"text": str(getattr(document, "text", "") or ""),
}
field_amount = self._resolve_document_field_amount(document_payload)
audited_amount = self._resolve_document_item_amount(document_payload)
item_amount = Decimal(item.item_amount or Decimal("0.00")).quantize(Decimal("0.01"))
points: list[str] = []
if (
field_amount is not None
and audited_amount is not None
and self._is_date_like_amount_candidate(field_amount, text)
and abs(field_amount - audited_amount) > Decimal("1.00")
):
points.append(
"费用核算OCR 金额疑似误取日期"
f" {self._format_decimal_amount(field_amount)}"
f"已按票据文本中的总费用 {self._format_decimal_amount(audited_amount)} 元回填,"
"请核对酒店或票据原文总额。"
)
if (
audited_amount is not None
and item_amount > Decimal("0.00")
and abs(audited_amount - item_amount) > Decimal("1.00")
):
points.append(
f"费用核算:票据文本复核金额为 {self._format_decimal_amount(audited_amount)} 元,"
f"当前明细金额为 {self._format_decimal_amount(item_amount)} 元,请确认是否需要调整。"
)
return points
def _backfill_item_date_from_attachment( def _backfill_item_date_from_attachment(
self, self,
*, *,
@@ -3428,7 +3562,7 @@ class ExpenseClaimService:
values: list[Decimal] = [] values: list[Decimal] = []
seen: set[Decimal] = set() seen: set[Decimal] = set()
def append_candidate(raw: str) -> None: def append_candidate(raw: str, *, source_text: str = "", start: int = -1, end: int = -1) -> None:
compact = str(raw or "").replace(",", ".").strip() compact = str(raw or "").replace(",", ".").strip()
if not compact: if not compact:
return return
@@ -3436,26 +3570,46 @@ class ExpenseClaimService:
candidate = Decimal(compact).quantize(Decimal("0.01")) candidate = Decimal(compact).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError): except (InvalidOperation, ValueError):
return return
if ExpenseClaimService._is_amount_match_date_fragment(candidate, source_text, start, end):
return
if candidate in seen: if candidate in seen:
return return
seen.add(candidate) seen.add(candidate)
values.append(candidate) values.append(candidate)
for pattern in ( for pattern in (
r"(?:金额|价税合计|合计|小写|实收金额|支付金额|订单金额|总额|票价|房费|餐费)[:\s¥¥]*([0-9]{1,6}(?:[.,][0-9]{1,2})?)", r"(?:金额|价税合计|合计|小写|实收金额|支付金额|订单金额|总额|总计|总费用|费用总计|票价|房费|住宿费|餐费)[:\s¥¥人民币为是]*([0-9]{1,6}(?:[.,][0-9]{1,2})?)",
r"[¥¥]\s*([0-9]{1,6}(?:[.,][0-9]{1,2})?)", r"[¥¥]\s*([0-9]{1,6}(?:[.,][0-9]{1,2})?)",
r"([0-9]{1,6}(?:[.,][0-9]{1,2})?)\s*元", r"([0-9]{1,6}(?:[.,][0-9]{1,2})?)\s*元",
): ):
for raw in re.findall(pattern, text, flags=re.IGNORECASE): for match in re.finditer(pattern, text, flags=re.IGNORECASE):
append_candidate(raw) append_candidate(match.group(1), source_text=text, start=match.start(1), end=match.end(1))
if values: if values:
return values return values
for raw in re.findall(r"(?<!\d)(\d{1,6}\.\d{1,2})(?!\d)", text): for match in re.finditer(r"(?<!\d)(\d{1,6}\.\d{1,2})(?!\d)", text):
append_candidate(raw) append_candidate(match.group(1), source_text=text, start=match.start(1), end=match.end(1))
return values return values
@staticmethod
def _is_amount_match_date_fragment(
amount: Decimal,
text: str,
start: int,
end: int,
) -> bool:
if start < 0 or end < 0 or not ExpenseClaimService._is_probable_year_amount(amount):
return False
before = str(text or "")[max(0, start - 8):start]
after = str(text or "")[end:end + 10]
if re.match(r"\s*(?:年|[-/.])\s*\d{1,2}", after):
return True
if re.search(r"\d{1,2}\s*(?:年|[-/.])\s*$", before):
return True
return False
@staticmethod @staticmethod
def _has_date_like_text(text: str) -> bool: def _has_date_like_text(text: str) -> bool:
return bool(re.search(r"(20\d{2}[年/\-.]\d{1,2}[月/\-.]\d{1,2}日?)", text)) return bool(re.search(r"(20\d{2}[年/\-.]\d{1,2}[月/\-.]\d{1,2}日?)", text))
@@ -3559,7 +3713,7 @@ class ExpenseClaimService:
example = "广州南-北京南" if item_type != "ride_ticket" else "深圳北站-腾讯滨海大厦" example = "广州南-北京南" if item_type != "ride_ticket" else "深圳北站-腾讯滨海大厦"
current = f"当前为“{reason[:30]}”," if reason else "" current = f"当前为“{reason[:30]}”," if reason else ""
return ( return (
f"行程说明:{current}格式应为“始地-目的地”," f"行程说明:{current}格式应为“始地-目的地”,"
f"例如“{example}”,请按票据行程补充。" f"例如“{example}”,请按票据行程补充。"
) )
@@ -3633,6 +3787,11 @@ class ExpenseClaimService:
item=item, item=item,
document_info=document_info, document_info=document_info,
) )
expense_audit_points = self._build_attachment_expense_audit_points(
document=document,
item=item,
document_info=document_info,
)
recognized_document_type = str(document_info.get("document_type") or "other").strip().lower() or "other" recognized_document_type = str(document_info.get("document_type") or "other").strip().lower() or "other"
recognized_document_label = str(document_info.get("document_type_label") or "其他单据").strip() or "其他单据" recognized_document_label = str(document_info.get("document_type_label") or "其他单据").strip() or "其他单据"
requirement_matches = bool(requirement_check.get("matches")) requirement_matches = bool(requirement_check.get("matches"))
@@ -3680,6 +3839,7 @@ class ExpenseClaimService:
points.append(f"日期字段:未识别到{date_requirement}") points.append(f"日期字段:未识别到{date_requirement}")
if not requirement_matches: if not requirement_matches:
points.append(f"附件类型要求:{requirement_check.get('message')}") points.append(f"附件类型要求:{requirement_check.get('message')}")
points.extend(expense_audit_points)
if purpose_mismatch_point: if purpose_mismatch_point:
points.append(purpose_mismatch_point) points.append(purpose_mismatch_point)
if route_format_point: if route_format_point:
@@ -3721,6 +3881,7 @@ class ExpenseClaimService:
elif ( elif (
purpose_mismatch_point purpose_mismatch_point
or route_format_point or route_format_point
or expense_audit_points
or amount_mismatch or amount_mismatch
or issue_count >= 2 or issue_count >= 2
or warnings or warnings
@@ -3732,7 +3893,9 @@ class ExpenseClaimService:
headline = "AI提示附件存在明显待整改项" headline = "AI提示附件存在明显待整改项"
summary = "当前附件可见部分内容,但金额、用途、日期或附件类型仍有缺失或不一致。" summary = "当前附件可见部分内容,但金额、用途、日期或附件类型仍有缺失或不一致。"
if route_format_point and issue_count == 1: if route_format_point and issue_count == 1:
summary = "票据行程已识别,但费用明细说明未按“始地-目的地”格式填写。" summary = "票据行程已识别,但费用明细说明未按“始地-目的地”格式填写。"
elif expense_audit_points and issue_count == len(expense_audit_points):
summary = "OCR 金额已完成二次核算,请按票据原文总额复核。"
suggestion = { suggestion = {
"high": "建议过滤当前不匹配的票据,重新上传符合当前费用场景的清晰原件。", "high": "建议过滤当前不匹配的票据,重新上传符合当前费用场景的清晰原件。",
@@ -5337,7 +5500,162 @@ class ExpenseClaimService:
return True return True
return scene_code == "travel" return scene_code == "travel"
def _sync_travel_allowance_item(self, claim: ExpenseClaim) -> None:
items = list(claim.items or [])
allowance_items = [
item for item in items if str(item.item_type or "").strip().lower() == "travel_allowance"
]
business_items = [
item for item in items if str(item.item_type or "").strip().lower() != "travel_allowance"
]
business_types = {str(item.item_type or "").strip().lower() for item in business_items}
is_travel_claim = str(claim.expense_type or "").strip().lower() == "travel"
has_travel_detail = bool(business_types & TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES)
if not is_travel_claim and not has_travel_detail:
for item in allowance_items:
self._discard_claim_item(claim, item)
return
grade = str(claim.employee_grade or "").strip()
if not grade:
return
allowance_location = self._resolve_travel_allowance_location_from_claim(
claim=claim,
business_items=business_items,
)
if not allowance_location:
return
existing_allowance = allowance_items[0] if allowance_items else None
days, start_date, end_date = self._resolve_travel_allowance_days_from_claim(
claim=claim,
business_items=business_items,
existing_allowance=existing_allowance,
)
if days < 1:
return
try:
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
result = TravelReimbursementCalculatorService(self.db).calculate(
TravelReimbursementCalculatorRequest(
days=days,
location=allowance_location,
grade=grade,
),
CurrentUserContext(
username=str(claim.employee_id or claim.employee_name or "system"),
name=str(claim.employee_name or ""),
role_codes=[],
is_admin=False,
),
)
except ValueError:
return
allowance_amount = Decimal(result.allowance_amount or Decimal("0.00")).quantize(Decimal("0.01"))
allowance_rate = Decimal(result.total_allowance_rate or Decimal("0.00")).quantize(Decimal("0.01"))
if allowance_amount <= Decimal("0.00") or allowance_rate <= Decimal("0.00"):
return
item = existing_allowance
if item is None:
item = ExpenseClaimItem(claim_id=claim.id)
claim.items.append(item)
self.db.add(item)
for duplicate in allowance_items[1:]:
self._discard_claim_item(claim, duplicate)
item.item_date = end_date
item.item_type = "travel_allowance"
item.item_reason = (
f"系统自动计算出差补贴:{result.matched_city}{days}天,"
f"{allowance_rate:.2f}元/天"
)
item.item_location = str(result.allowance_region or allowance_location).strip()
item.item_amount = allowance_amount
item.invoice_id = None
def _discard_claim_item(self, claim: ExpenseClaim, item: ExpenseClaimItem) -> None:
if item in claim.items:
claim.items.remove(item)
state = sqlalchemy_inspect(item)
if state.persistent:
self.db.delete(item)
elif state.pending:
self.db.expunge(item)
@staticmethod
def _resolve_travel_allowance_days_from_claim(
*,
claim: ExpenseClaim,
business_items: list[ExpenseClaimItem],
existing_allowance: ExpenseClaimItem | None,
) -> tuple[int, date, date]:
dated_items = sorted(
[item.item_date for item in business_items if item.item_date is not None]
)
if dated_items:
start_date = dated_items[0]
end_date = dated_items[-1]
elif claim.occurred_at is not None:
start_date = claim.occurred_at.date()
end_date = start_date
else:
start_date = date.today()
end_date = start_date
days = (end_date - start_date).days + 1
existing_days = ExpenseClaimService._extract_travel_allowance_days(existing_allowance)
unique_dates = {value for value in dated_items}
if existing_days > days and len(unique_dates) <= 1:
days = existing_days
end_date = start_date + timedelta(days=days - 1)
return max(1, days), start_date, end_date
@staticmethod
def _extract_travel_allowance_days(item: ExpenseClaimItem | None) -> int:
if item is None:
return 0
match = re.search(r"(\d+)\s*天", str(item.item_reason or ""))
if not match:
return 0
try:
return max(0, int(match.group(1)))
except ValueError:
return 0
@staticmethod
def _resolve_travel_allowance_location_from_claim(
*,
claim: ExpenseClaim,
business_items: list[ExpenseClaimItem],
) -> str:
claim_location = str(claim.location or "").strip()
if claim_location and claim_location not in {"待补充", "未知", "暂无", "非必填"}:
return claim_location
sorted_items = sorted(
business_items,
key=lambda item: (item.item_date or date.max, ExpenseClaimService._normalize_sort_datetime(item.created_at)),
)
for item in sorted_items:
location = str(item.item_location or "").strip()
if location and location not in {"待补充", "未知", "暂无", "非必填"}:
return location
reason = str(item.item_reason or "").strip()
for separator in ("-", "", "", "", "->"):
if separator in reason:
destination = reason.split(separator)[-1].strip()
if destination:
return destination
return ""
def _sync_claim_from_items(self, claim: ExpenseClaim) -> None: def _sync_claim_from_items(self, claim: ExpenseClaim) -> None:
self._sync_travel_allowance_item(claim)
if not claim.items: if not claim.items:
claim.amount = Decimal("0.00") claim.amount = Decimal("0.00")
claim.invoice_count = 0 claim.invoice_count = 0
@@ -5391,7 +5709,7 @@ class ExpenseClaimService:
) -> str: ) -> str:
fallback_type = str(fallback or "").strip() or "other" fallback_type = str(fallback or "").strip() or "other"
item_types = {str(item.item_type or "").strip().lower() for item in items} item_types = {str(item.item_type or "").strip().lower() for item in items}
if item_types & TRAVEL_DETAIL_ITEM_TYPES: if item_types & (TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES | {"travel_allowance"}):
return "travel" return "travel"
return fallback_type return fallback_type
@@ -5574,6 +5892,7 @@ class ExpenseClaimService:
for index, item in enumerate(claim.items, start=1): for index, item in enumerate(claim.items, start=1):
prefix = f"费用明细第 {index}" prefix = f"费用明细第 {index}"
is_system_generated = str(item.item_type or "").strip().lower() in SYSTEM_GENERATED_ITEM_TYPES
item_location_required = self._is_location_required_expense_type(item.item_type or claim.expense_type) item_location_required = self._is_location_required_expense_type(item.item_type or claim.expense_type)
if item.item_date is None: if item.item_date is None:
issues.append(f"{prefix}缺少日期") issues.append(f"{prefix}缺少日期")
@@ -5585,7 +5904,7 @@ class ExpenseClaimService:
issues.append(f"{prefix}缺少地点") issues.append(f"{prefix}缺少地点")
if item.item_amount is None or item.item_amount <= Decimal("0.00"): if item.item_amount is None or item.item_amount <= Decimal("0.00"):
issues.append(f"{prefix}缺少金额") issues.append(f"{prefix}缺少金额")
if self._is_missing_value(item.invoice_id): if not is_system_generated and self._is_missing_value(item.invoice_id):
issues.append(f"{prefix}缺少票据标识") issues.append(f"{prefix}缺少票据标识")
return issues return issues

View File

@@ -64,6 +64,8 @@ TOP_N_PATTERN = re.compile(r"(?:top|TOP|前|最高的?|最低的?)\s*(?P<top>\d+
SCENARIO_KEYWORDS = { SCENARIO_KEYWORDS = {
"expense": ( "expense": (
("报销", 0.20), ("报销", 0.20),
("报销单", 0.20),
("单据报销", 0.18),
("报账", 0.20), ("报账", 0.20),
("差旅", 0.20), ("差旅", 0.20),
("费用", 0.14), ("费用", 0.14),
@@ -250,16 +252,51 @@ MISSING_SLOT_LABELS = {
} }
STATUS_KEYWORDS = { STATUS_KEYWORDS = {
"草稿": "draft",
"待提交": "draft",
"待补充": "supplement",
"退回": "returned",
"已退回": "returned",
"进行中": "review",
"审批中": "review",
"审核中": "review",
"流转中": "review",
"已提交": "submitted",
"逾期": "overdue", "逾期": "overdue",
"待审批": "pending", "待审批": "pending",
"待审": "pending", "待审": "pending",
"已审批": "approved", "已审批": "approved",
"已通过": "approved", "已通过": "approved",
"已审核": "approved",
"已入账": "paid",
"已付款": "paid", "已付款": "paid",
"未付款": "unpaid", "未付款": "unpaid",
"未回款": "unreceived", "未回款": "unreceived",
} }
LOCATION_KEYWORDS = (
"北京",
"上海",
"广州",
"深圳",
"杭州",
"南京",
"苏州",
"成都",
"重庆",
"天津",
"武汉",
"西安",
"郑州",
"长沙",
"青岛",
"厦门",
"宁波",
"合肥",
"济南",
"福州",
)
PRIVILEGED_ROLE_CODES = {"manager", "finance", "approver", "executive"} PRIVILEGED_ROLE_CODES = {"manager", "finance", "approver", "executive"}
CONTEXTUAL_SCENARIOS = {"expense", "accounts_receivable", "accounts_payable", "knowledge"} CONTEXTUAL_SCENARIOS = {"expense", "accounts_receivable", "accounts_payable", "knowledge"}
KNOWLEDGE_INTENTS = {"query", "explain", "compare"} KNOWLEDGE_INTENTS = {"query", "explain", "compare"}
@@ -685,6 +722,10 @@ class SemanticOntologyService:
best_scenario = max(scores, key=scores.get) best_scenario = max(scores, key=scores.get)
best_score = scores[best_scenario] best_score = scores[best_scenario]
if best_score <= 0: if best_score <= 0:
if "单据" in compact_query and any(
keyword in compact_query for keyword in STATUS_KEYWORDS
):
return "expense", 0.14
return "unknown", 0.0 return "unknown", 0.0
if best_scenario == "knowledge": if best_scenario == "knowledge":
@@ -711,6 +752,40 @@ class SemanticOntologyService:
) -> tuple[str, float]: ) -> tuple[str, float]:
if any(keyword in compact_query for keyword in OPERATE_KEYWORDS): if any(keyword in compact_query for keyword in OPERATE_KEYWORDS):
return "operate", 0.30 return "operate", 0.30
status_document_query = (
"单据" in compact_query
and any(keyword in compact_query for keyword in STATUS_KEYWORDS)
and not any(keyword in compact_query for keyword in DRAFT_KEYWORDS if keyword != "草稿")
)
historical_document_query = any(
keyword in compact_query
for keyword in ("报销的单据", "报销单据", "报销过的单据", "报销记录")
)
if scenario == "expense" and any(
keyword in compact_query
for keyword in (
"报销了吗",
"报销了么",
"报销了没",
"报销了没有",
"报销没",
"单据状态",
"审批状态",
"报销进度",
"到哪了",
"到了哪",
"有没有报销",
"是否报销",
"进行中的单据",
"草稿单据",
"草稿的单据",
"待补充单据",
"审批中的单据",
"已提交单据",
"已入账单据",
)
) or (scenario == "expense" and (status_document_query or historical_document_query)):
return "query", 0.24
if any(keyword in compact_query for keyword in DRAFT_KEYWORDS): if any(keyword in compact_query for keyword in DRAFT_KEYWORDS):
return "draft", 0.26 return "draft", 0.26
if scenario == "expense" and self._is_generic_expense_prompt(compact_query): if scenario == "expense" and self._is_generic_expense_prompt(compact_query):
@@ -1181,6 +1256,9 @@ class SemanticOntologyService:
upsert(self._make_entity("invoice", code, code.upper())) upsert(self._make_entity("invoice", code, code.upper()))
for code in re.findall(r"CTR-[A-Z]+-\d+", query, flags=re.IGNORECASE): for code in re.findall(r"CTR-[A-Z]+-\d+", query, flags=re.IGNORECASE):
upsert(self._make_entity("contract", code, code.upper())) upsert(self._make_entity("contract", code, code.upper()))
for location in LOCATION_KEYWORDS:
if location in query:
upsert(self._make_entity("location", location, location, role="filter", confidence=0.86))
for label, normalized in EXPENSE_TYPE_KEYWORDS.items(): for label, normalized in EXPENSE_TYPE_KEYWORDS.items():
if label in query: if label in query:
@@ -1344,6 +1422,12 @@ class SemanticOntologyService:
self._range(date(today.year, 1, 1), date(today.year, 12, 31), "今年", "year"), self._range(date(today.year, 1, 1), date(today.year, 12, 31), "今年", "year"),
0.10, 0.10,
) )
if "去年" in query or "上一年" in query:
year = today.year - 1
return (
self._range(date(year, 1, 1), date(year, 12, 31), "去年", "year"),
0.10,
)
match = DATE_RANGE_PATTERN.search(query) match = DATE_RANGE_PATTERN.search(query)
if match: if match:
@@ -1493,6 +1577,7 @@ class SemanticOntologyService:
"customer", "customer",
"vendor", "vendor",
"project", "project",
"location",
"expense_type", "expense_type",
}: }:
upsert( upsert(

View File

@@ -670,8 +670,17 @@ class OrchestratorService:
} }
if ontology.scenario == "expense" or self._is_expense_review_action(context_json): if ontology.scenario == "expense" or self._is_expense_review_action(context_json):
tool_type = AgentToolType.DATABASE.value is_persistence_action = self._is_expense_persistence_action(context_json)
tool_name = "database.expense_claims.save_or_submit" tool_type = (
AgentToolType.DATABASE.value
if is_persistence_action
else AgentToolType.LLM.value
)
tool_name = (
"database.expense_claims.save_or_submit"
if is_persistence_action
else "user_agent.expense_review_preview"
)
executor = lambda: self.expense_claim_service.save_or_submit_from_ontology( executor = lambda: self.expense_claim_service.save_or_submit_from_ontology(
run_id=run_id, run_id=run_id,
user_id=payload.user_id, user_id=payload.user_id,
@@ -680,7 +689,11 @@ class OrchestratorService:
context_json=context_json, context_json=context_json,
) )
fallback_factory = lambda exc: { fallback_factory = lambda exc: {
"message": f"报销草稿落库失败,请稍后再试:{exc}", "message": (
f"报销草稿落库失败,请稍后再试:{exc}"
if is_persistence_action
else f"报销内容预览生成失败,请稍后再试:{exc}"
),
"degraded": True, "degraded": True,
} }
@@ -820,6 +833,16 @@ class OrchestratorService:
"create_new_claim_from_documents", "create_new_claim_from_documents",
} }
@staticmethod
def _is_expense_persistence_action(context_json: dict[str, Any]) -> bool:
review_action = str((context_json or {}).get("review_action") or "").strip()
return review_action in {
"save_draft",
"next_step",
"link_to_existing_draft",
"create_new_claim_from_documents",
}
@staticmethod @staticmethod
def _flatten_capability_codes( def _flatten_capability_codes(
capabilities: dict[str, list[AgentAssetListItem | AgentAssetRead]], capabilities: dict[str, list[AgentAssetListItem | AgentAssetRead]],
@@ -1172,6 +1195,8 @@ class OrchestratorService:
if item.type == "expense_type" and str(item.normalized_value or item.value or "").strip() if item.type == "expense_type" and str(item.normalized_value or item.value or "").strip()
) )
) )
project_values = self._collect_expense_query_filter_values(ontology, "project")
location_values = self._collect_expense_query_filter_values(ontology, "location")
status_values = list( status_values = list(
dict.fromkeys( dict.fromkeys(
str(item.value).strip() str(item.value).strip()
@@ -1193,6 +1218,20 @@ class OrchestratorService:
conditions.append(ExpenseClaim.expense_type.in_(expense_types)) conditions.append(ExpenseClaim.expense_type.in_(expense_types))
if status_values: if status_values:
conditions.append(ExpenseClaim.status.in_(status_values)) conditions.append(ExpenseClaim.status.in_(status_values))
if project_values:
project_conditions = []
for value in project_values:
pattern = f"%{value}%"
project_conditions.append(ExpenseClaim.project_code.ilike(pattern))
project_conditions.append(ExpenseClaim.reason.ilike(pattern))
conditions.append(or_(*project_conditions))
if location_values:
location_conditions = []
for value in location_values:
pattern = f"%{value}%"
location_conditions.append(ExpenseClaim.location.ilike(pattern))
location_conditions.append(ExpenseClaim.reason.ilike(pattern))
conditions.append(or_(*location_conditions))
for item in amount_constraints: for item in amount_constraints:
amount_value = float(item.value) amount_value = float(item.value)
@@ -1254,6 +1293,26 @@ class OrchestratorService:
return conditions, scope_label, scoped_to_current_user return conditions, scope_label, scoped_to_current_user
@staticmethod
def _collect_expense_query_filter_values(
ontology: OntologyParseResult,
field_name: str,
) -> list[str]:
values: list[str] = []
for entity in ontology.entities:
if entity.type != field_name:
continue
value = str(entity.normalized_value or entity.value or "").strip()
if value:
values.append(value)
for constraint in ontology.constraints:
if constraint.field != field_name or constraint.operator != "=":
continue
value = str(constraint.value or "").strip()
if value:
values.append(value)
return list(dict.fromkeys(values))
def _build_current_user_claim_conditions( def _build_current_user_claim_conditions(
self, self,
*, *,

View File

@@ -97,6 +97,15 @@ GROUP_SCENE_LABELS = {
"other": "其他费用", "other": "其他费用",
} }
EXPENSE_SCENE_SELECTION_OPTIONS = (
("travel", "差旅费", "出差、长途交通、住宿、差旅补贴等场景。"),
("transport", "交通费", "市内打车、停车、过路费等日常交通场景。"),
("hotel", "住宿费", "单独住宿、酒店发票等场景。"),
("entertainment", "业务招待费", "客户接待、宴请、招待等场景。"),
("office", "办公费", "办公用品、耗材、办公设备等采购场景。"),
("other", "其他费用", "暂不属于以上分类的报销场景。"),
)
KNOWLEDGE_MODEL_MAIN_TIMEOUT_SECONDS = 3 KNOWLEDGE_MODEL_MAIN_TIMEOUT_SECONDS = 3
KNOWLEDGE_MODEL_BACKUP_TIMEOUT_SECONDS = 5 KNOWLEDGE_MODEL_BACKUP_TIMEOUT_SECONDS = 5
KNOWLEDGE_MODEL_TIMEOUT_SECONDS = KNOWLEDGE_MODEL_BACKUP_TIMEOUT_SECONDS KNOWLEDGE_MODEL_TIMEOUT_SECONDS = KNOWLEDGE_MODEL_BACKUP_TIMEOUT_SECONDS
@@ -275,6 +284,17 @@ class UserAgentService:
AgentFoundationService(self.db).ensure_foundation_ready() AgentFoundationService(self.db).ensure_foundation_ready()
citations = self._build_citations(payload) citations = self._build_citations(payload)
suggested_actions = self._build_suggested_actions(payload) suggested_actions = self._build_suggested_actions(payload)
if self._should_prompt_expense_scene_selection(payload):
return UserAgentResponse(
answer=self._build_expense_scene_selection_answer(payload),
citations=citations,
suggested_actions=suggested_actions,
query_payload=None,
draft_payload=None,
review_payload=None,
risk_flags=[],
requires_confirmation=False,
)
risk_flags = self._resolve_risk_flags(payload) risk_flags = self._resolve_risk_flags(payload)
query_payload = self._build_query_payload(payload) query_payload = self._build_query_payload(payload)
draft_payload = ( draft_payload = (
@@ -1801,6 +1821,11 @@ class UserAgentService:
@staticmethod @staticmethod
def _should_build_draft_payload(payload: UserAgentRequest) -> bool: def _should_build_draft_payload(payload: UserAgentRequest) -> bool:
if payload.ontology.scenario == "expense" and payload.tool_payload.get("preview_only"):
return any(
str(payload.tool_payload.get(key) or "").strip()
for key in ("claim_id", "claim_no")
)
if payload.ontology.intent == "draft": if payload.ontology.intent == "draft":
return True return True
if payload.ontology.scenario != "expense": if payload.ontology.scenario != "expense":
@@ -1817,6 +1842,21 @@ class UserAgentService:
if payload.ontology.scenario == "knowledge": if payload.ontology.scenario == "knowledge":
return [] return []
if self._should_prompt_expense_scene_selection(payload):
return [
UserAgentSuggestedAction(
label=label,
action_type="select_expense_type",
description=description,
payload={
"expense_type": code,
"expense_type_label": label,
"original_message": payload.message,
},
)
for code, label, description in EXPENSE_SCENE_SELECTION_OPTIONS
]
if self._is_generic_expense_prompt(payload): if self._is_generic_expense_prompt(payload):
return [ return [
UserAgentSuggestedAction( UserAgentSuggestedAction(
@@ -1886,6 +1926,35 @@ class UserAgentService:
), ),
] ]
def _should_prompt_expense_scene_selection(self, payload: UserAgentRequest) -> bool:
if payload.ontology.scenario != "expense":
return False
if payload.ontology.intent not in {"draft", "operate"}:
return False
if str(payload.context_json.get("review_action") or "").strip():
return False
review_form_values = self._resolve_review_form_values(payload)
if str(review_form_values.get("expense_type") or review_form_values.get("reimbursement_type") or "").strip():
return False
if self._resolve_attachment_count(payload) > 0 or self._resolve_ocr_documents(payload):
return False
return not any(
item.type == "expense_type" and str(item.normalized_value or item.value or "").strip()
for item in payload.ontology.entities
)
@staticmethod
def _build_expense_scene_selection_answer(payload: UserAgentRequest) -> str:
has_time = bool(payload.ontology.time_range.start_date or payload.ontology.time_range.raw)
context_hint = "我先识别到这是一次报销申请"
if has_time:
context_hint += ",并看到了业务发生时间"
return (
f"{context_hint}。但你还没有明确这笔单据属于哪类报销。"
"请先在下面选择报销场景,我会按你选择的场景再继续识别时间、地点、事由、金额和所需票据,"
"避免系统先入为主把项目支持、部署等描述误判成差旅。"
)
def _build_review_payload( def _build_review_payload(
self, self,
payload: UserAgentRequest, payload: UserAgentRequest,
@@ -3363,6 +3432,17 @@ class UserAgentService:
) )
review_action = str(payload.context_json.get("review_action") or "").strip() review_action = str(payload.context_json.get("review_action") or "").strip()
if payload.tool_payload.get("preview_only") and not review_action:
base_message = review_payload.body_message or self._build_review_intent_summary(
payload,
slot_cards=review_payload.slot_cards,
claim_groups=review_payload.claim_groups,
)
return (
f"{base_message} "
"本次只是核对预览,尚未保存为草稿;需要暂存时请点击“保存为草稿”,"
"需要正式提交时再点击“继续下一步”。"
)
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 (

View File

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

View File

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

View File

@@ -0,0 +1,77 @@
{
"file_name": "酒店1.jpg",
"storage_key": "f8bb5866-1a52-4371-a0e5-257493a8a491/f4d818ec-8a17-44e3-98f4-99c1e97b63b1/酒店1.jpg",
"media_type": "image/jpeg",
"size_bytes": 135977,
"uploaded_at": "2026-05-21T07:21:03.814491+00:00",
"previewable": true,
"preview_kind": "image",
"preview_storage_key": "f8bb5866-1a52-4371-a0e5-257493a8a491/f4d818ec-8a17-44e3-98f4-99c1e97b63b1/酒店1.preview.jpg",
"preview_media_type": "image/jpeg",
"preview_file_name": "酒店1.preview.jpg",
"analysis": {
"severity": "pass",
"label": "AI提示符合条件",
"headline": "AI提示附件符合基础校验条件",
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
"points": [
"票据类型:已识别为酒店住宿票据。",
"附件类型要求:当前费用项目为住宿票,已识别为酒店住宿票据。",
"金额字段:已识别到与当前明细接近的金额 2026.00 元。"
],
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
},
"document_info": {
"document_type": "hotel_invoice",
"document_type_label": "酒店住宿票据",
"scene_code": "hotel",
"scene_label": "住宿票据",
"fields": [
{
"key": "amount",
"label": "金额",
"value": "2026元"
},
{
"key": "date",
"label": "日期",
"value": "2026-02-23"
},
{
"key": "merchant_name",
"label": "商户",
"value": "上海喜来登酒店"
}
]
},
"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-002\n开票日期2026年2月23日\n客姓名曹笑\n住晚数3晚\n住期2026年220\n房型豪华床房\n离店期2026年223\n预订渠道酒店官\n日期\n项目\n房费单价\n数量\n金额\n2026-02-20至2026-02-22\n住宿费\n¥276/晚\n3晚\n¥828\n合计¥828\n额写捌佰贰拾捌元整\n备注\n以上费用已由酒店收取并开具发票。\n本发票仅含住宿费不含其他增值服务费。\n如有疑问请联系酒店前台或致电酒店财务部。\n样例票据|仅供系统测试|无效凭证",
"ocr_summary": "上海喜来登酒店样例住宿发票发票编号SH-SAMPLE-20260223-002",
"ocr_avg_score": 0.9884135921796163,
"ocr_line_count": 27,
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.84,
"ocr_classification_evidence": [
"住宿",
"房费",
"离店",
"酒店"
],
"ocr_warnings": []
}

View File

@@ -51,6 +51,18 @@ def test_document_intelligence_extracts_larger_decimal_amount_from_multiple_cand
assert any(field.label == "金额" and field.value == "13.4元" for field in insight.fields) assert any(field.label == "金额" and field.value == "13.4元" for field in insight.fields)
def test_document_intelligence_extracts_hotel_total_fee_instead_of_date_year() -> None:
insight = build_document_insight(
filename="hotel-invoice.png",
summary="酒店住宿票据",
text="北京中心酒店 金额 2026-02-20 入住 总费用是828元 离店日期 2026-02-21",
)
assert insight.document_type == "hotel_invoice"
assert any(field.label == "金额" and field.value == "828元" for field in insight.fields)
assert not any(field.label == "金额" and field.value == "2026元" for field in insight.fields)
def test_document_intelligence_prefers_train_ticket_for_railway_e_ticket_invoice_text() -> None: def test_document_intelligence_prefers_train_ticket_for_railway_e_ticket_invoice_text() -> None:
insight = build_document_insight( insight = build_document_insight(
filename="铁路电子客票.pdf", filename="铁路电子客票.pdf",

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, date, datetime from datetime import UTC, date, datetime, timedelta
from decimal import Decimal from decimal import Decimal
import pytest import pytest
@@ -69,6 +69,10 @@ def build_session() -> Session:
return session_factory() return session_factory()
def _count_claims(db: Session) -> int:
return int(db.query(ExpenseClaim).count())
def test_validate_claim_for_submission_allows_office_claim_without_location() -> None: def test_validate_claim_for_submission_allows_office_claim_without_location() -> None:
service = ExpenseClaimService.__new__(ExpenseClaimService) service = ExpenseClaimService.__new__(ExpenseClaimService)
claim = build_claim(expense_type="office", location="待补充") claim = build_claim(expense_type="office", location="待补充")
@@ -99,6 +103,112 @@ def test_validate_claim_for_submission_still_requires_location_for_travel_claim(
assert any("缺少地点" in item for item in issues) assert any("缺少地点" in item for item in issues)
def test_save_or_submit_preview_does_not_create_claim_without_explicit_action() -> None:
user_id = "preview-only@example.com"
message = "业务发生时间:2026-03-04打车去客户现场交通费32元请帮我看看怎么报"
with build_session() as db:
employee = Employee(
employee_no="E5100",
name="预览员工",
email=user_id,
)
db.add(employee)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id=user_id,
)
)
before_count = _count_claims(db)
result = ExpenseClaimService(db).save_or_submit_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message=message,
ontology=ontology,
context_json={
"name": "预览员工",
"user_input_text": message,
},
)
assert result["preview_only"] is True
assert result["status"] == "preview"
assert "尚未保存为草稿" in result["message"]
assert _count_claims(db) == before_count
def test_save_or_submit_persists_claim_only_after_save_draft_action() -> None:
user_id = "save-draft-explicit@example.com"
message = "业务发生时间:2026-03-04打车去客户现场交通费32元请帮我看看怎么报"
with build_session() as db:
employee = Employee(
employee_no="E5101",
name="保存员工",
email=user_id,
)
db.add(employee)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id=user_id,
)
)
before_count = _count_claims(db)
result = ExpenseClaimService(db).save_or_submit_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message=message,
ontology=ontology,
context_json={
"name": "保存员工",
"user_input_text": message,
"review_action": "save_draft",
},
)
assert result["draft_only"] is True
assert result["claim_id"]
assert result["status"] == "draft"
assert _count_claims(db) == before_count + 1
def test_unsaved_conversation_expires_after_retention_but_saved_conversation_stays() -> None:
with build_session() as db:
service = AgentConversationService(db)
unsaved = service.get_or_create_conversation(
conversation_id="conv-unsaved-expire",
user_id="expire@example.com",
source="user_message",
context_json={"session_type": "expense"},
)
saved = service.get_or_create_conversation(
conversation_id="conv-saved-keep",
user_id="expire@example.com",
source="user_message",
context_json={
"session_type": "expense",
"draft_claim_id": "claim-saved",
},
)
old_time = datetime.now(UTC) - timedelta(days=4)
unsaved.updated_at = old_time
saved.updated_at = old_time
db.add_all([unsaved, saved])
db.commit()
deleted_count = service.prune_expired_conversations(retention_days=3)
assert deleted_count == 1
assert service.get_conversation("conv-unsaved-expire") is None
assert service.get_conversation("conv-saved-keep") is not None
def test_resolve_expense_type_maps_office_supplies_review_value_to_office() -> None: def test_resolve_expense_type_maps_office_supplies_review_value_to_office() -> None:
expense_type = ExpenseClaimService._resolve_expense_type( expense_type = ExpenseClaimService._resolve_expense_type(
[], [],
@@ -574,6 +684,83 @@ def test_upsert_travel_draft_uses_ticket_item_types_and_auto_allowance() -> None
) )
def test_sync_travel_claim_adds_allowance_from_manual_ticket_dates() -> None:
with build_session() as db:
employee = Employee(
employee_no="E5011",
name="手工差旅员工",
email="manual-travel-allowance@example.com",
grade="P4",
)
db.add(employee)
db.flush()
claim = build_claim(expense_type="travel", location="北京")
claim.employee_id = employee.id
claim.employee_name = employee.name
claim.items[0].item_date = date(2026, 5, 13)
claim.items[0].item_type = "train_ticket"
claim.items[0].item_reason = "广州南-北京南"
claim.items[0].item_location = "北京"
claim.items[0].item_amount = Decimal("354.00")
claim.items.append(
ExpenseClaimItem(
claim_id=claim.id,
item_date=date(2026, 5, 15),
item_type="train_ticket",
item_reason="北京南-广州南",
item_location="北京",
item_amount=Decimal("354.00"),
invoice_id="return-train.png",
)
)
db.add(claim)
db.commit()
service = ExpenseClaimService(db)
service._sync_claim_from_items(claim)
db.commit()
db.refresh(claim)
allowance_item = next(item for item in claim.items if item.item_type == "travel_allowance")
assert allowance_item.item_amount == Decimal("300.00")
assert "3天" in allowance_item.item_reason
assert allowance_item.invoice_id is None
assert claim.amount == Decimal("1008.00")
def test_update_claim_item_allows_placeholder_date_reason_and_amount() -> None:
current_user = CurrentUserContext(
username="emp-1",
name="张三",
role_codes=[],
is_admin=False,
)
with build_session() as db:
claim = build_claim(expense_type="office", location="深圳")
db.add(claim)
db.commit()
updated = ExpenseClaimService(db).update_claim_item(
claim_id=claim.id,
item_id=claim.items[0].id,
payload=ExpenseClaimItemUpdate(
item_reason="",
item_location="",
item_amount=Decimal("0.00"),
),
current_user=current_user,
)
assert updated is not None
db.refresh(claim)
assert claim.items[0].item_date == date(2026, 5, 13)
assert claim.items[0].item_reason == ""
assert claim.items[0].item_location == ""
assert claim.items[0].item_amount == Decimal("0.00")
def test_upsert_draft_from_ontology_updates_returned_claim_and_preserves_return_events() -> None: def test_upsert_draft_from_ontology_updates_returned_claim_and_preserves_return_events() -> None:
user_id = "returned-owner@example.com" user_id = "returned-owner@example.com"
return_flag = { return_flag = {
@@ -989,6 +1176,9 @@ def test_upload_train_ticket_attachment_backfills_item_amount(monkeypatch, tmp_p
assert updated is not None assert updated is not None
assert updated["item_amount"] == Decimal("354.00") assert updated["item_amount"] == Decimal("354.00")
assert updated["item_date"] == "2026-02-20"
assert updated["item_type"] == "train_ticket"
assert updated["item_reason"] == "广州南-北京南"
assert updated["claim_amount"] == Decimal("354.00") assert updated["claim_amount"] == Decimal("354.00")
db.refresh(claim) db.refresh(claim)
assert claim.items[0].item_amount == Decimal("354.00") assert claim.items[0].item_amount == Decimal("354.00")
@@ -1018,6 +1208,86 @@ def test_upload_train_ticket_attachment_backfills_item_amount(monkeypatch, tmp_p
assert not any("用途字段" in point for point in uploaded_meta["analysis"]["points"]) assert not any("用途字段" in point for point in uploaded_meta["analysis"]["points"])
def test_upload_hotel_attachment_audits_date_like_amount(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext(
username="emp-1",
name="张三",
role_codes=[],
is_admin=False,
)
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
return OcrRecognizeBatchRead(
total_file_count=1,
success_count=1,
documents=[
OcrRecognizeDocumentRead(
filename="hotel-invoice.png",
media_type="image/png",
text="北京中心酒店 总费用是828元 入住日期 2026-02-20 离店日期 2026-02-21",
summary="酒店住宿票据,住宿总费用 828 元。",
avg_score=0.96,
line_count=1,
page_count=1,
document_type="hotel_invoice",
document_type_label="酒店住宿票据",
scene_code="hotel",
scene_label="住宿票据",
document_fields=[
{"key": "amount", "label": "金额", "value": "2026元"},
{"key": "hotel_name", "label": "酒店", "value": "北京中心酒店"},
{"key": "check_in", "label": "入住日期", "value": "2026-02-20"},
{"key": "check_out", "label": "离店日期", "value": "2026-02-21"},
],
)
],
)
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
monkeypatch.setattr(ExpenseClaimService, "_get_attachment_storage_root", lambda self: tmp_path)
with build_session() as db:
claim = build_claim(expense_type="hotel", location="北京")
claim.amount = Decimal("0.00")
claim.invoice_count = 0
claim.items[0].item_type = "hotel"
claim.items[0].item_reason = ""
claim.items[0].item_amount = Decimal("0.00")
claim.items[0].invoice_id = None
db.add(claim)
db.commit()
service = ExpenseClaimService(db)
updated = service.upload_claim_item_attachment(
claim_id=claim.id,
item_id=claim.items[0].id,
filename="hotel-invoice.png",
content=b"fake-image-bytes",
media_type="image/png",
current_user=current_user,
)
assert updated is not None
assert updated["item_type"] == "hotel_ticket"
assert updated["item_amount"] == Decimal("828.00")
assert updated["claim_amount"] == Decimal("828.00")
db.refresh(claim)
assert claim.items[0].item_amount == Decimal("828.00")
assert claim.amount == Decimal("828.00")
uploaded_meta = service.get_claim_item_attachment_meta(
claim_id=claim.id,
item_id=claim.items[0].id,
current_user=current_user,
)
assert uploaded_meta is not None
assert uploaded_meta["analysis"]["severity"] == "medium"
assert any("费用核算" in point and "828.00 元" in point for point in uploaded_meta["analysis"]["points"])
assert not any("2026.00 元与报销金额" in point for point in uploaded_meta["analysis"]["points"])
def test_attachment_analysis_does_not_compare_business_purpose_with_ticket_scene() -> None: def test_attachment_analysis_does_not_compare_business_purpose_with_ticket_scene() -> None:
with build_session() as db: with build_session() as db:
claim = build_claim(expense_type="travel", location="上海") claim = build_claim(expense_type="travel", location="上海")
@@ -1053,7 +1323,7 @@ def test_attachment_analysis_does_not_compare_business_purpose_with_ticket_scene
assert analysis["severity"] == "medium" assert analysis["severity"] == "medium"
assert not any("用途字段" in point for point in analysis["points"]) assert not any("用途字段" in point for point in analysis["points"])
assert any("行程说明" in point and "地-目的地" in point for point in analysis["points"]) assert any("行程说明" in point and "始地-目的地" in point for point in analysis["points"])
def test_attachment_risk_flag_message_uses_specific_points(monkeypatch, tmp_path) -> None: def test_attachment_risk_flag_message_uses_specific_points(monkeypatch, tmp_path) -> None:

View File

@@ -433,6 +433,71 @@ def test_semantic_ontology_service_extracts_day_before_yesterday_from_client_loc
assert result.time_range.end_date == "2026-05-11" assert result.time_range.end_date == "2026-05-11"
def test_semantic_ontology_service_treats_status_document_text_as_query() -> None:
session_factory = build_session_factory()
with session_factory() as db:
result = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="查询草稿的单据",
user_id="pytest",
)
)
assert result.scenario == "expense"
assert result.intent == "query"
assert result.permission.level == "read"
assert any(
item.field == "status" and item.value == "draft"
for item in result.constraints
)
def test_semantic_ontology_service_extracts_history_query_time_and_location() -> None:
session_factory = build_session_factory()
with session_factory() as db:
result = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我去年去北京报销的单据",
user_id="pytest",
context_json={
"client_now_iso": "2026-05-21T04:00:00.000Z",
"client_timezone_offset_minutes": -480,
},
)
)
assert result.scenario == "expense"
assert result.intent == "query"
assert result.time_range.raw == "去年"
assert result.time_range.start_date == "2025-01-01"
assert result.time_range.end_date == "2025-12-31"
assert any(
item.type == "location" and item.normalized_value == "北京"
for item in result.entities
)
def test_semantic_ontology_service_understands_last_week_claim_progress_query() -> None:
session_factory = build_session_factory()
with session_factory() as db:
result = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我上周提交的单据报销了么?",
user_id="pytest",
context_json={
"client_now_iso": "2026-05-21T04:00:00.000Z",
"client_timezone_offset_minutes": -480,
},
)
)
assert result.scenario == "expense"
assert result.intent == "query"
assert result.time_range.raw == "上周"
assert result.time_range.start_date == "2026-05-11"
assert result.time_range.end_date == "2026-05-17"
def test_semantic_ontology_service_maps_office_supplies_to_office_expense_type() -> None: def test_semantic_ontology_service_maps_office_supplies_to_office_expense_type() -> None:
session_factory = build_session_factory() session_factory = build_session_factory()
with session_factory() as db: with session_factory() as db:

View File

@@ -202,7 +202,7 @@ def test_conversation_hydration_does_not_reuse_review_type_for_fresh_expense_pro
fresh_context = service.hydrate_context_json( fresh_context = service.hydrate_context_json(
conversation=conversation, conversation=conversation,
context_json={}, context_json={"draft_claim_id": "claim-old"},
message="业务发生时间:2026-02-20 至 2026-02-23去上海支持上海电力部署项目申请报销", message="业务发生时间:2026-02-20 至 2026-02-23去上海支持上海电力部署项目申请报销",
) )
continued_context = service.hydrate_context_json( continued_context = service.hydrate_context_json(
@@ -217,3 +217,183 @@ def test_conversation_hydration_does_not_reuse_review_type_for_fresh_expense_pro
assert fresh_context["conversation_state"]["review_form_values"]["expense_type"] == "差旅费" assert fresh_context["conversation_state"]["review_form_values"]["expense_type"] == "差旅费"
assert continued_context["draft_claim_id"] == "claim-old" assert continued_context["draft_claim_id"] == "claim-old"
assert continued_context["review_form_values"]["expense_type"] == "差旅费" assert continued_context["review_form_values"]["expense_type"] == "差旅费"
def test_orchestrator_history_query_filters_location_time_and_returns_real_amount(
monkeypatch,
) -> None:
monkeypatch.setattr(
"app.services.runtime_chat.RuntimeChatService.complete",
lambda *_args, **_kwargs: None,
)
session_factory = build_session_factory()
with session_factory() as db:
employee = Employee(
id="emp-history-query",
employee_no="E9020",
name="张三",
email="history-query@example.com",
)
beijing_claim = ExpenseClaim(
id="claim-history-beijing",
claim_no="EXP-202506-001",
employee=employee,
employee_id=employee.id,
employee_name="张三",
department_name="交付部",
expense_type="travel",
reason="去北京支持客户项目",
location="北京",
amount=Decimal("321.45"),
currency="CNY",
invoice_count=2,
occurred_at=datetime(2025, 6, 18, 9, 0, tzinfo=UTC),
submitted_at=datetime(2025, 6, 19, 10, 0, tzinfo=UTC),
status="paid",
approval_stage="已入账",
)
shanghai_claim = ExpenseClaim(
id="claim-history-shanghai",
claim_no="EXP-202507-001",
employee=employee,
employee_id=employee.id,
employee_name="张三",
department_name="交付部",
expense_type="travel",
reason="去上海支持项目",
location="上海",
amount=Decimal("888.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2025, 7, 8, 9, 0, tzinfo=UTC),
submitted_at=datetime(2025, 7, 9, 10, 0, tzinfo=UTC),
status="paid",
approval_stage="已入账",
)
current_year_claim = ExpenseClaim(
id="claim-history-beijing-current",
claim_no="EXP-202601-001",
employee=employee,
employee_id=employee.id,
employee_name="张三",
department_name="交付部",
expense_type="travel",
reason="去北京支持年度项目",
location="北京",
amount=Decimal("666.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 1, 8, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 1, 9, 10, 0, tzinfo=UTC),
status="paid",
approval_stage="已入账",
)
db.add_all([employee, beijing_claim, shanghai_claim, current_year_claim])
db.commit()
response = OrchestratorService(db).run(
OrchestratorRequest(
source="user_message",
user_id="history-query@example.com",
message="我去年去北京报销的单据",
context_json={
"client_now_iso": "2026-05-21T04:00:00.000Z",
"client_timezone_offset_minutes": -480,
},
)
)
query_payload = response.result["query_payload"]
assert response.status == "succeeded"
assert response.trace_summary.scenario == "expense"
assert response.trace_summary.intent == "query"
assert query_payload["record_count"] == 1
assert query_payload["total_amount"] == 321.45
assert [item["claim_no"] for item in query_payload["records"]] == ["EXP-202506-001"]
assert "321.45" in response.result["answer"]
def test_orchestrator_expense_preview_does_not_persist_claim_before_user_action(
monkeypatch,
) -> None:
monkeypatch.setattr(
"app.services.runtime_chat.RuntimeChatService.complete",
lambda *_args, **_kwargs: None,
)
session_factory = build_session_factory()
with session_factory() as db:
employee = Employee(
employee_no="E9030",
name="预览员工",
email="preview-orchestrator@example.com",
)
db.add(employee)
db.commit()
response = OrchestratorService(db).run(
OrchestratorRequest(
source="user_message",
user_id="preview-orchestrator@example.com",
message="业务发生时间:2026-03-04打车去客户现场交通费32元请帮我看看怎么报",
context_json={
"name": "预览员工",
"user_input_text": "业务发生时间:2026-03-04打车去客户现场交通费32元请帮我看看怎么报",
},
)
)
user_claims = [
claim
for claim in db.query(ExpenseClaim).all()
if claim.employee_name == "预览员工"
]
assert response.status == "succeeded"
assert response.result.get("review_payload") is not None
assert response.result.get("draft_payload") is None
assert "尚未保存为草稿" in response.result["answer"]
assert user_claims == []
def test_orchestrator_prompts_scene_choices_before_review_for_fresh_ambiguous_expense(
monkeypatch,
) -> None:
monkeypatch.setattr(
"app.services.runtime_chat.RuntimeChatService.complete",
lambda *_args, **_kwargs: None,
)
session_factory = build_session_factory()
with session_factory() as db:
service = AgentConversationService(db)
conversation = service.get_or_create_conversation(
conversation_id="conv-scene-choice",
user_id="emp-scene-choice@example.com",
source="user_message",
context_json={
"session_type": "expense",
"draft_claim_id": "claim-old",
"review_form_values": {
"expense_type": "差旅费",
"business_location": "北京",
},
},
)
response = OrchestratorService(db).run(
OrchestratorRequest(
source="user_message",
user_id="emp-scene-choice@example.com",
conversation_id=conversation.conversation_id,
message="业务发生时间:2026-02-20 至 2026-02-23去上海支持上海电力部署项目申请报销",
context_json={
"session_type": "expense",
"draft_claim_id": "claim-old",
},
)
)
result = response.result
assert response.status == "succeeded"
assert result.get("review_payload") is None
assert result.get("draft_payload") is None
assert "请先在下面选择报销场景" in result["answer"]
assert [item["label"] for item in result["suggested_actions"][:3]] == ["差旅费", "交通费", "住宿费"]

View File

@@ -496,25 +496,18 @@ def test_user_agent_guides_generic_expense_request() -> None:
) )
) )
assert response.review_payload is not None assert response.review_payload is None
assert response.answer == response.review_payload.body_message assert response.draft_payload is None
assert response.review_payload.can_proceed is False assert "请先在下面选择报销场景" in response.answer
assert response.review_payload.missing_slots == [ assert [item.action_type for item in response.suggested_actions] == [
"报销类型", "select_expense_type",
"发生时间", "select_expense_type",
"金额", "select_expense_type",
"事由说明", "select_expense_type",
"票据附件", "select_expense_type",
"select_expense_type",
] ]
assert [item.action_type for item in response.review_payload.confirmation_actions] == [ assert [item.label for item in response.suggested_actions[:3]] == ["差旅费", "交通费", "住宿费"]
"cancel_review",
"edit_review",
]
edit_action = next(
item for item in response.review_payload.confirmation_actions if item.action_type == "edit_review"
)
assert edit_action.label == "选择报销类型"
assert edit_action.emphasis == "primary"
def test_user_agent_asks_for_type_when_trip_context_is_ambiguous() -> None: def test_user_agent_asks_for_type_when_trip_context_is_ambiguous() -> None:
@@ -537,25 +530,69 @@ def test_user_agent_asks_for_type_when_trip_context_is_ambiguous() -> None:
) )
) )
assert response.review_payload is None
assert response.draft_payload is None
assert "请先在下面选择报销场景" in response.answer
assert "避免系统先入为主" in response.answer
assert [item.label for item in response.suggested_actions] == [
"差旅费",
"交通费",
"住宿费",
"业务招待费",
"办公费",
"其他费用",
]
assert response.suggested_actions[0].payload["original_message"] == message
def test_user_agent_continues_identification_after_expense_type_selection() -> None:
session_factory = build_session_factory()
with session_factory() as db:
message = "业务发生时间:2026-02-20 至 2026-02-23去上海支持上海电力部署项目申请报销"
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=f"{message}\n用户选择报销场景:差旅费",
user_id="pytest-selected-type@example.com",
context_json={
"expense_scene_selection": {
"expense_type": "travel",
"expense_type_label": "差旅费",
"original_message": message,
},
"review_form_values": {
"expense_type": "差旅费",
},
"user_input_text": message,
},
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest-selected-type@example.com",
message=f"{message}\n用户选择报销场景:差旅费",
ontology=ontology,
context_json={
"expense_scene_selection": {
"expense_type": "travel",
"expense_type_label": "差旅费",
"original_message": message,
},
"review_form_values": {
"expense_type": "差旅费",
},
"user_input_text": message,
},
tool_payload={"draft_only": True},
)
)
assert response.review_payload is not None assert response.review_payload is not 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["expense_type"].value == "" assert slot_map["expense_type"].value == "差旅费"
assert slot_map["expense_type"].status == "missing" assert slot_map["expense_type"].normalized_value == "travel"
assert slot_map["time_range"].value == "2026-02-20 至 2026-02-23" assert slot_map["time_range"].value == "2026-02-20 至 2026-02-23"
assert slot_map["location"].value == "上海" assert slot_map["location"].value == "上海"
assert response.review_payload.can_proceed is False
assert "报销类型" in response.review_payload.missing_slots
assert "选择报销类型" in response.review_payload.body_message
assert "不会重新改判报销类型" in response.review_payload.body_message
edit_action = next(
item for item in response.review_payload.confirmation_actions if item.action_type == "edit_review"
)
assert edit_action.label == "选择报销类型"
assert edit_action.emphasis == "primary"
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
"cancel_review",
"edit_review",
]
def test_user_agent_guides_implicit_expense_draft_request() -> None: def test_user_agent_guides_implicit_expense_draft_request() -> None:

View File

@@ -793,6 +793,133 @@
margin-top: 10px; margin-top: 10px;
} }
.message-suggested-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 14px;
padding: 10px;
border: 1px solid rgba(203, 213, 225, 0.72);
border-radius: 14px;
background:
linear-gradient(180deg, rgba(248, 250, 252, 0.92), rgba(255, 255, 255, 0.98));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.78);
}
.message-suggested-action-btn {
position: relative;
min-height: 70px;
display: grid;
grid-template-columns: 34px minmax(0, 1fr) 18px;
align-items: center;
gap: 10px;
padding: 12px 11px;
border: 1px solid rgba(203, 213, 225, 0.8);
border-radius: 10px;
background: rgba(255, 255, 255, 0.9);
color: #0f172a;
text-align: left;
cursor: pointer;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
transition:
border-color 0.16s ease,
background 0.16s ease,
box-shadow 0.16s ease,
transform 0.16s ease;
}
.message-suggested-action-icon {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border-radius: 10px;
background: #f1f5f9;
color: #0f766e;
font-size: 18px;
box-shadow: inset 0 0 0 1px rgba(15, 118, 110, 0.08);
}
.message-suggested-action-copy {
min-width: 0;
display: grid;
gap: 2px;
}
.message-suggested-action-title {
color: #0f172a;
font-size: var(--wb-fs-body);
font-weight: 850;
line-height: 1.25;
}
.message-suggested-action-btn small {
color: #64748b;
font-size: var(--wb-fs-caption);
font-weight: 650;
line-height: 1.35;
}
.message-suggested-action-arrow {
color: #94a3b8;
font-size: 15px;
justify-self: end;
transition: color 0.16s ease, transform 0.16s ease;
}
.message-suggested-action-btn:hover:not(:disabled) {
border-color: rgba(20, 184, 166, 0.72);
background: #ffffff;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.09);
transform: translateY(-1px);
}
.message-suggested-action-btn:hover:not(:disabled) .message-suggested-action-icon,
.message-suggested-action-btn:focus-visible .message-suggested-action-icon {
background: #ccfbf1;
color: #0f766e;
}
.message-suggested-action-btn:hover:not(:disabled) .message-suggested-action-arrow,
.message-suggested-action-btn:focus-visible .message-suggested-action-arrow {
color: #0f766e;
transform: translateX(2px);
}
.message-suggested-action-btn:focus-visible {
outline: 3px solid rgba(20, 184, 166, 0.18);
outline-offset: 2px;
border-color: #14b8a6;
}
.message-suggested-action-btn.selected {
border-color: rgba(13, 148, 136, 0.78);
background: #f0fdfa;
box-shadow: inset 0 0 0 1px rgba(13, 148, 136, 0.18);
}
.message-suggested-action-btn.selected .message-suggested-action-icon {
background: #99f6e4;
color: #115e59;
}
.message-suggested-action-btn.selected .message-suggested-action-arrow {
color: #0f766e;
}
.message-suggested-action-btn.locked:not(.selected) {
background: #f8fafc;
}
.message-suggested-action-btn:disabled {
cursor: not-allowed;
opacity: 0.62;
}
.message-suggested-action-btn.selected:disabled {
opacity: 1;
}
.message-meta-chip, .message-meta-chip,
.capability-chip, .capability-chip,
.risk-chip, .risk-chip,
@@ -982,6 +1109,27 @@
box-shadow: 0 8px 18px rgba(148, 163, 184, 0.12); box-shadow: 0 8px 18px rgba(148, 163, 184, 0.12);
} }
.expense-query-record-list.compact .expense-query-record-card.selectable {
border-color: rgba(20, 184, 166, 0.35);
background: #ffffff;
}
.expense-query-record-list.compact .expense-query-record-card.selected {
border-color: rgba(13, 148, 136, 0.82);
background: #f0fdfa;
box-shadow: inset 0 0 0 1px rgba(13, 148, 136, 0.18);
}
.expense-query-record-list.compact .expense-query-record-card.locked:not(.selected) {
background: #f8fafc;
opacity: 0.58;
}
.expense-query-record-list.compact .expense-query-record-card:disabled {
cursor: not-allowed;
transform: none;
}
.expense-query-record-card > i { .expense-query-record-card > i {
color: #94a3b8; color: #94a3b8;
font-size: 16px; font-size: 16px;
@@ -4775,6 +4923,10 @@
justify-self: stretch; justify-self: stretch;
} }
.message-suggested-actions {
grid-template-columns: 1fr;
}
.composer { .composer {
padding: 0 16px 16px; padding: 0 16px 16px;
} }

View File

@@ -1586,6 +1586,11 @@
background: #fffcf7; background: #fffcf7;
} }
.validation-section--risk .risk-advice-card.low {
border-color: #dbeafe;
background: #f8fbff;
}
.validation-section--risk .risk-advice-card-head { .validation-section--risk .risk-advice-card-head {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1611,6 +1616,11 @@
color: #c2410c; color: #c2410c;
} }
.validation-section--risk .risk-advice-card.low .risk-advice-card-head span {
background: #eff6ff;
color: #2563eb;
}
.validation-section--risk .risk-advice-card-head strong { .validation-section--risk .risk-advice-card-head strong {
min-width: 0; min-width: 0;
color: #0f172a; color: #0f172a;
@@ -1660,6 +1670,11 @@
background: #fffaf2; background: #fffaf2;
} }
.risk-advice-card.low {
border-color: #bfdbfe;
background: #f8fbff;
}
.risk-advice-card-head { .risk-advice-card-head {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1685,6 +1700,11 @@
color: #c2410c; color: #c2410c;
} }
.risk-advice-card.low .risk-advice-card-head span {
background: #dbeafe;
color: #2563eb;
}
.risk-advice-card-head strong { .risk-advice-card-head strong {
min-width: 0; min-width: 0;
color: #0f172a; color: #0f172a;

View File

@@ -143,13 +143,17 @@
</template> </template>
<script setup> <script setup>
import { computed, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import PanelHead from '../shared/PanelHead.vue' import PanelHead from '../shared/PanelHead.vue'
import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue' import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue'
import robotAssistant from '../../assets/robot-helper.png' import robotAssistant from '../../assets/robot-helper.png'
import { useSystemState } from '../../composables/useSystemState.js' import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js' import { useToast } from '../../composables/useToast.js'
import { clearUserConversations, fetchLatestConversation } from '../../services/orchestrator.js' import { clearUserConversations, fetchLatestConversation } from '../../services/orchestrator.js'
import {
ASSISTANT_SESSION_SNAPSHOT_EVENT,
hasAssistantSessionSnapshot
} from '../../utils/assistantSessionSnapshot.js'
const props = defineProps({ const props = defineProps({
showHeader: { type: Boolean, default: true }, showHeader: { type: Boolean, default: true },
@@ -164,11 +168,15 @@ const fileInputRef = ref(null)
const selectedFiles = ref([]) const selectedFiles = ref([])
const pendingAction = ref('') const pendingAction = ref('')
const latestExpenseConversation = ref(null) const latestExpenseConversation = ref(null)
const hasLocalExpenseSnapshot = ref(false)
const MAX_ATTACHMENTS = 10 const MAX_ATTACHMENTS = 10
const SESSION_TYPE_EXPENSE = 'expense' const SESSION_TYPE_EXPENSE = 'expense'
const SESSION_TYPE_KNOWLEDGE = 'knowledge' const SESSION_TYPE_KNOWLEDGE = 'knowledge'
const hasExpenseConversation = computed(() => Boolean(latestExpenseConversation.value?.conversation_id || latestExpenseConversation.value?.conversationId)) const hasExpenseConversation = computed(() =>
Boolean(latestExpenseConversation.value?.conversation_id || latestExpenseConversation.value?.conversationId)
|| hasLocalExpenseSnapshot.value
)
const expenseActionLabel = computed(() => (hasExpenseConversation.value ? '继续报销' : '新建报销')) const expenseActionLabel = computed(() => (hasExpenseConversation.value ? '继续报销' : '新建报销'))
const expenseActionIcon = computed(() => (hasExpenseConversation.value ? 'mdi mdi-history' : 'mdi mdi-magnify-scan')) const expenseActionIcon = computed(() => (hasExpenseConversation.value ? 'mdi mdi-history' : 'mdi mdi-magnify-scan'))
const assistantGreetingName = computed(() => { const assistantGreetingName = computed(() => {
@@ -271,6 +279,7 @@ function handleWorkbenchFilesChange(event) {
} }
async function refreshLatestExpenseConversation() { async function refreshLatestExpenseConversation() {
refreshLocalExpenseSnapshot()
try { try {
latestExpenseConversation.value = await loadLatestConversation() latestExpenseConversation.value = await loadLatestConversation()
} catch (error) { } catch (error) {
@@ -279,6 +288,17 @@ async function refreshLatestExpenseConversation() {
} }
} }
function refreshLocalExpenseSnapshot() {
hasLocalExpenseSnapshot.value = hasAssistantSessionSnapshot(resolveCurrentUserId(), SESSION_TYPE_EXPENSE)
}
function handleAssistantSessionSnapshotChange(event) {
const sessionType = String(event?.detail?.sessionType || '').trim()
if (!sessionType || sessionType === SESSION_TYPE_EXPENSE) {
refreshLocalExpenseSnapshot()
}
}
async function clearKnowledgeHistoryBeforeExpense() { async function clearKnowledgeHistoryBeforeExpense() {
await clearUserConversations(resolveCurrentUserId(), SESSION_TYPE_KNOWLEDGE) await clearUserConversations(resolveCurrentUserId(), SESSION_TYPE_KNOWLEDGE)
} }
@@ -414,7 +434,13 @@ const policyItems = [
] ]
onMounted(() => { onMounted(() => {
refreshLocalExpenseSnapshot()
refreshLatestExpenseConversation() refreshLatestExpenseConversation()
window.addEventListener(ASSISTANT_SESSION_SNAPSHOT_EVENT, handleAssistantSessionSnapshotChange)
})
onBeforeUnmount(() => {
window.removeEventListener(ASSISTANT_SESSION_SNAPSHOT_EVENT, handleAssistantSessionSnapshotChange)
}) })
watch( watch(

View File

@@ -0,0 +1,99 @@
const SNAPSHOT_VERSION = 1
const STORAGE_PREFIX = 'x-financial:assistant-session'
export const ASSISTANT_SESSION_SNAPSHOT_EVENT = 'x-financial-assistant-session-snapshot'
function normalizeSessionType(sessionType) {
return String(sessionType || 'expense').trim() || 'expense'
}
function normalizeUserId(userId) {
return String(userId || 'anonymous').trim() || 'anonymous'
}
export function resolveAssistantSessionSnapshotKey(userId, sessionType = 'expense') {
return `${STORAGE_PREFIX}:${normalizeUserId(userId)}:${normalizeSessionType(sessionType)}`
}
function getStorage() {
if (typeof window === 'undefined' || !window.localStorage) {
return null
}
return window.localStorage
}
function emitSnapshotChange(sessionType) {
if (typeof window === 'undefined') {
return
}
window.dispatchEvent(new CustomEvent(ASSISTANT_SESSION_SNAPSHOT_EVENT, {
detail: { sessionType: normalizeSessionType(sessionType) }
}))
}
export function readAssistantSessionSnapshot(userId, sessionType = 'expense') {
const storage = getStorage()
if (!storage) {
return null
}
try {
const rawValue = storage.getItem(resolveAssistantSessionSnapshotKey(userId, sessionType))
if (!rawValue) {
return null
}
const parsed = JSON.parse(rawValue)
if (!parsed || parsed.version !== SNAPSHOT_VERSION || !parsed.state) {
return null
}
return parsed
} catch (error) {
console.warn('Failed to read assistant session snapshot:', error)
return null
}
}
export function writeAssistantSessionSnapshot(userId, sessionType = 'expense', state = {}) {
const storage = getStorage()
if (!storage) {
return
}
const normalizedSessionType = normalizeSessionType(sessionType)
const snapshot = {
version: SNAPSHOT_VERSION,
updatedAt: Date.now(),
userId: normalizeUserId(userId),
sessionType: normalizedSessionType,
state
}
try {
storage.setItem(
resolveAssistantSessionSnapshotKey(userId, normalizedSessionType),
JSON.stringify(snapshot)
)
emitSnapshotChange(normalizedSessionType)
} catch (error) {
console.warn('Failed to write assistant session snapshot:', error)
}
}
export function clearAssistantSessionSnapshot(userId, sessionType = 'expense') {
const storage = getStorage()
if (!storage) {
return
}
const normalizedSessionType = normalizeSessionType(sessionType)
try {
storage.removeItem(resolveAssistantSessionSnapshotKey(userId, normalizedSessionType))
emitSnapshotChange(normalizedSessionType)
} catch (error) {
console.warn('Failed to clear assistant session snapshot:', error)
}
}
export function hasAssistantSessionSnapshot(userId, sessionType = 'expense') {
return Boolean(readAssistantSessionSnapshot(userId, sessionType)?.state)
}

View File

@@ -41,6 +41,10 @@ const FLOW_INTENT_KEYWORDS = {
explain: ['为什么', '依据', '规则', '怎么'] explain: ['为什么', '依据', '规则', '怎么']
} }
const EXPLICIT_EXPENSE_INTENT_PATTERN = /报销|报账|费用|发票|票据|单据|垫付|报销单|冲销|借款/
const NON_EXPENSE_INTENT_PATTERN = /怎么部署|如何部署|部署步骤|技术方案|排期|任务|工单|需求|代码|脚本|服务器配置|运维|实施计划|项目计划|会议纪要|周报|日报|总结/
const BUSINESS_ACTIVITY_PATTERN = /去|到|赴|前往|支撑|支持|部署|实施|驻场|出差|拜访|客户|项目|现场|电力|银行|医院|学校|园区|公司|集团|服务器/
function normalizeCompactText(value) { function normalizeCompactText(value) {
return String(value || '').trim().replace(/\s+/g, '') return String(value || '').trim().replace(/\s+/g, '')
} }
@@ -125,11 +129,74 @@ export function inferLocalFlowCandidates(rawText) {
} }
} }
export function shouldRequestExpenseSceneSelection(rawText, options = {}) {
if (options.sessionType === DEFAULT_SESSION_TYPE_KNOWLEDGE) {
return false
}
if (Number(options.attachmentCount || 0) > 0) {
return false
}
if (String(options.reviewAction || '').trim()) {
return false
}
if (options.hasSelectedExpenseType) {
return false
}
const compact = normalizeCompactText(rawText)
if (!compact) {
return false
}
const hasExpenseIntent = /报销|报账|费用|申请/.test(compact)
if (!hasExpenseIntent) {
return false
}
const candidates = inferLocalFlowCandidates(rawText)
return !candidates.expenseType
}
export function shouldRequestExpenseIntentConfirmation(rawText, options = {}) {
if (options.sessionType === DEFAULT_SESSION_TYPE_KNOWLEDGE) {
return false
}
if (Number(options.attachmentCount || 0) > 0) {
return false
}
if (String(options.reviewAction || '').trim()) {
return false
}
if (options.hasConfirmedExpenseIntent || options.hasSelectedExpenseType) {
return false
}
const compact = normalizeCompactText(rawText)
if (!compact || compact.length < 6) {
return false
}
if (EXPLICIT_EXPENSE_INTENT_PATTERN.test(compact)) {
return false
}
if (NON_EXPENSE_INTENT_PATTERN.test(compact)) {
return false
}
return BUSINESS_ACTIVITY_PATTERN.test(compact)
}
export function buildLocalIntentPreview(rawText, sessionType = DEFAULT_SESSION_TYPE_EXPENSE, options = {}) { export function buildLocalIntentPreview(rawText, sessionType = DEFAULT_SESSION_TYPE_EXPENSE, options = {}) {
if (sessionType === DEFAULT_SESSION_TYPE_KNOWLEDGE) { if (sessionType === DEFAULT_SESSION_TYPE_KNOWLEDGE) {
return '初步识别为财务知识问答,正在准备检索范围' return '初步识别为财务知识问答,正在准备检索范围'
} }
if (shouldRequestExpenseIntentConfirmation(rawText, { ...options, sessionType })) {
return '识别到业务事项描述,但是否发起报销尚不明确,需要先由用户确认'
}
if (shouldRequestExpenseSceneSelection(rawText, { ...options, sessionType })) {
return '初步识别为报销申请,但报销场景尚未明确,需要先由用户选择场景'
}
const compact = normalizeCompactText(rawText) const compact = normalizeCompactText(rawText)
const intentLabels = options.intentLabels || DEFAULT_INTENT_LABELS const intentLabels = options.intentLabels || DEFAULT_INTENT_LABELS
const intentKey = Object.entries(FLOW_INTENT_KEYWORDS).find(([, keywords]) => const intentKey = Object.entries(FLOW_INTENT_KEYWORDS).find(([, keywords]) =>

View File

@@ -131,6 +131,33 @@
</span> </span>
</div> </div>
<div
v-if="message.role === 'assistant' && !message.reviewPayload && message.suggestedActions?.length"
class="message-suggested-actions"
>
<button
v-for="action in message.suggestedActions"
:key="`${message.id}-${action.action_type}-${action.label}`"
type="button"
class="message-suggested-action-btn"
:class="{
selected: isSuggestedActionSelected(message, action),
locked: message.suggestedActionsLocked
}"
:disabled="message.suggestedActionsLocked || submitting || reviewActionBusy || sessionSwitchBusy"
@click="handleSuggestedAction(message, action)"
>
<span class="message-suggested-action-icon" aria-hidden="true">
<i :class="action.icon || 'mdi mdi-shape-outline'"></i>
</span>
<span class="message-suggested-action-copy">
<span class="message-suggested-action-title">{{ action.label }}</span>
<small v-if="action.description">{{ action.description }}</small>
</span>
<i class="message-suggested-action-arrow mdi mdi-arrow-right" aria-hidden="true"></i>
</button>
</div>
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.riskFlags?.length" class="message-detail-block"> <div v-if="message.role === 'assistant' && !message.reviewPayload && message.riskFlags?.length" class="message-detail-block">
<strong>风险标签</strong> <strong>风险标签</strong>
<div class="message-detail-chip-row"> <div class="message-detail-chip-row">
@@ -162,7 +189,9 @@
v-if="message.role === 'assistant' && !message.reviewPayload && message.queryPayload?.resultType === 'expense_claim_list'" v-if="message.role === 'assistant' && !message.reviewPayload && message.queryPayload?.resultType === 'expense_claim_list'"
class="message-detail-block expense-query-block" class="message-detail-block expense-query-block"
> >
<strong>{{ message.queryPayload.recentWindowApplied ? '近 10 日单据' : '单据明细' }}</strong> <strong>
{{ message.queryPayload.title || (message.queryPayload.selectionMode === 'draft_association' ? '选择关联草稿' : (message.queryPayload.recentWindowApplied ? '近 10 日单据' : '单据明细')) }}
</strong>
<p v-if="buildExpenseQueryWindowLabel(message.queryPayload)" class="expense-query-window-label"> <p v-if="buildExpenseQueryWindowLabel(message.queryPayload)" class="expense-query-window-label">
{{ buildExpenseQueryWindowLabel(message.queryPayload) }} {{ buildExpenseQueryWindowLabel(message.queryPayload) }}
@@ -185,7 +214,13 @@
:key="`${message.id}-${record.claimId}`" :key="`${message.id}-${record.claimId}`"
type="button" type="button"
class="expense-query-record-card" class="expense-query-record-card"
@click="openExpenseQueryRecord(record)" :class="{
selectable: message.queryPayload.selectionMode === 'draft_association',
selected: message.selectedQueryRecordId === record.claimId || message.queryPayload.selectedClaimId === record.claimId,
locked: message.querySelectionLocked || message.queryPayload.selectionLocked
}"
:disabled="message.queryPayload.selectionMode === 'draft_association' && (message.querySelectionLocked || message.queryPayload.selectionLocked)"
@click="handleExpenseQueryRecordClick(message, record)"
> >
<div class="expense-query-record-main"> <div class="expense-query-record-main">
<div class="expense-query-record-top"> <div class="expense-query-record-top">
@@ -244,7 +279,7 @@
<div v-else class="expense-query-empty"> <div v-else class="expense-query-empty">
<i class="mdi mdi-file-search-outline"></i> <i class="mdi mdi-file-search-outline"></i>
<span>当前没有可直接展开的近期待办单据</span> <span>{{ message.queryPayload.emptyText || '当前没有可直接展开的近期待办单据。' }}</span>
</div> </div>
<p v-if="buildExpenseQueryHint(message.queryPayload)" class="expense-query-hint"> <p v-if="buildExpenseQueryHint(message.queryPayload)" class="expense-query-hint">

View File

@@ -335,14 +335,6 @@
> >
{{ savingExpenseId === item.id ? '保存中' : '保存' }} {{ savingExpenseId === item.id ? '保存中' : '保存' }}
</button> </button>
<button
class="inline-action"
type="button"
:disabled="actionBusy"
@click="cancelExpenseEdit"
>
取消
</button>
<button <button
class="inline-action danger" class="inline-action danger"
type="button" type="button"

View File

@@ -8,14 +8,22 @@ import { recognizeOcrFiles } from '../../services/ocr.js'
import { fetchAgentRunDetail } from '../../services/agentAssets.js' import { fetchAgentRunDetail } from '../../services/agentAssets.js'
import { clearUserConversations, deleteConversation, fetchLatestConversation, runOrchestrator } from '../../services/orchestrator.js' import { clearUserConversations, deleteConversation, fetchLatestConversation, runOrchestrator } from '../../services/orchestrator.js'
import { renderMarkdown } from '../../utils/markdown.js' import { renderMarkdown } from '../../utils/markdown.js'
import {
clearAssistantSessionSnapshot,
readAssistantSessionSnapshot,
writeAssistantSessionSnapshot
} from '../../utils/assistantSessionSnapshot.js'
import { import {
buildLocalExtractionProgressMessages, buildLocalExtractionProgressMessages,
buildLocalIntentPreview, buildLocalIntentPreview,
shouldRequestExpenseIntentConfirmation,
shouldRequestExpenseSceneSelection,
summarizeSemanticIntentDetail, summarizeSemanticIntentDetail,
TRANSPORT_KEYWORD_PATTERN TRANSPORT_KEYWORD_PATTERN
} from '../../utils/reimbursementTextInference.js' } from '../../utils/reimbursementTextInference.js'
import { import {
calculateTravelReimbursement, calculateTravelReimbursement,
fetchExpenseClaims,
fetchExpenseClaimAttachmentAsset, fetchExpenseClaimAttachmentAsset,
fetchExpenseClaimDetail, fetchExpenseClaimDetail,
fetchExpenseClaimItemAttachmentMeta, fetchExpenseClaimItemAttachmentMeta,
@@ -99,6 +107,16 @@ const EXPENSE_TYPE_LABELS = {
other: '其他费用' other: '其他费用'
} }
const EXPENSE_STATUS_LABELS = {
draft: '草稿',
supplement: '待补充',
returned: '已退回',
submitted: '已提交',
review: '审批中',
approved: '已审核',
paid: '已入账'
}
const REVIEW_SLOT_CONFIG = { const REVIEW_SLOT_CONFIG = {
expense_type: { expense_type: {
title: '报销分类', title: '报销分类',
@@ -198,6 +216,20 @@ const EXPENSE_CODE_TO_PRESET_SCENE = {
entertainment: '请客户吃饭', entertainment: '请客户吃饭',
meal: '请客户吃饭' meal: '请客户吃饭'
} }
const EXPENSE_SCENE_SELECTION_OPTIONS = [
{ key: 'travel', label: '差旅费', description: '出差行程、住宿、跨城交通等费用', icon: 'mdi mdi-bag-suitcase-outline' },
{ key: 'transport', label: '交通费', description: '市内交通、打车、停车、通行等费用', icon: 'mdi mdi-car-outline' },
{ key: 'hotel', label: '住宿费', description: '单独住宿或酒店发票报销', icon: 'mdi mdi-bed-outline' },
{ key: 'entertainment', label: '业务招待费', description: '客户接待、餐饮招待等费用', icon: 'mdi mdi-food-fork-drink' },
{ key: 'office', label: '办公费', description: '办公用品、低值易耗品等费用', icon: 'mdi mdi-briefcase-outline' },
{ key: 'other', label: '其他费用', description: '暂不属于以上类型的费用', icon: 'mdi mdi-dots-horizontal-circle-outline' }
]
const EXPENSE_INTENT_CONFIRMATION_ACTION = {
label: '我要报销',
description: '按报销流程继续,并选择具体费用场景',
icon: 'mdi mdi-receipt-text-check-outline',
action_type: 'confirm_expense_intent'
}
const DATE_INPUT_FORMAT = 'YYYY-MM-DD' const DATE_INPUT_FORMAT = 'YYYY-MM-DD'
const MAX_ATTACHMENTS = 10 const MAX_ATTACHMENTS = 10
const MAX_OCR_DOCUMENTS = 10 const MAX_OCR_DOCUMENTS = 10
@@ -215,6 +247,7 @@ const FLOW_STEP_STATUS_PENDING = 'pending'
const FLOW_STEP_STATUS_RUNNING = 'running' const FLOW_STEP_STATUS_RUNNING = 'running'
const FLOW_STEP_STATUS_COMPLETED = 'completed' const FLOW_STEP_STATUS_COMPLETED = 'completed'
const FLOW_STEP_STATUS_FAILED = 'failed' const FLOW_STEP_STATUS_FAILED = 'failed'
const ASSOCIATABLE_CLAIM_STATUSES = new Set(['draft', 'supplement', 'returned'])
const FLOW_STEP_FALLBACKS = { const FLOW_STEP_FALLBACKS = {
intent: { intent: {
title: '意图识别', title: '意图识别',
@@ -239,6 +272,18 @@ const FLOW_STEP_FALLBACKS = {
tool: 'database.expense_claims.save_or_submit', tool: 'database.expense_claims.save_or_submit',
runningText: '正在根据识别结果更新草稿和右侧核对信息...', runningText: '正在根据识别结果更新草稿和右侧核对信息...',
completedText: '草稿和核对信息已更新' completedText: '草稿和核对信息已更新'
},
'expense-scene-selection': {
title: '报销场景确认',
tool: 'UserConfirmation',
runningText: '等待用户选择报销场景...',
completedText: '已进入场景选择,等待用户确认'
},
'expense-intent-confirmation': {
title: '报销意图确认',
tool: 'UserConfirmation',
runningText: '等待用户确认是否发起报销...',
completedText: '用户已确认报销意图'
} }
} }
const ASSISTANT_DISPLAY_NAME = '财务助手' const ASSISTANT_DISPLAY_NAME = '财务助手'
@@ -333,6 +378,11 @@ function createMessage(role, text, attachments = [], extras = {}) {
meta: [], meta: [],
citations: [], citations: [],
suggestedActions: [], suggestedActions: [],
suggestedActionsLocked: false,
selectedSuggestedActionKey: '',
selectedSuggestedActionLabel: '',
querySelectionLocked: false,
selectedQueryRecordId: '',
queryPayload: null, queryPayload: null,
draftPayload: null, draftPayload: null,
reviewPayload: null, reviewPayload: null,
@@ -341,6 +391,62 @@ function createMessage(role, text, attachments = [], extras = {}) {
} }
} }
function buildExpenseSceneSelectionActions(rawText) {
const originalMessage = String(rawText || '').trim()
return EXPENSE_SCENE_SELECTION_OPTIONS.map((option) => ({
label: option.label,
description: option.description,
icon: option.icon,
action_type: 'select_expense_type',
payload: {
expense_type: option.key,
expense_type_label: option.label,
original_message: originalMessage
}
}))
}
function buildExpenseIntentConfirmationActions(rawText) {
const originalMessage = String(rawText || '').trim()
return [{
...EXPENSE_INTENT_CONFIRMATION_ACTION,
payload: {
original_message: originalMessage
}
}]
}
function buildExpenseIntentConfirmationMessage(rawText) {
const text = String(rawText || '').trim()
return [
text
? `我看到了「${text}」这类业务事项描述。`
: '我看到了这类业务事项描述。',
'但现在还不能确定你是要发起报销,还是要处理其他事项,所以我先暂停后续识别。',
'如果你是想报销,请点击下面的“我要报销”,我再继续引导你选择具体报销场景。'
].join('\n')
}
function buildExpenseSceneSelectionMessage(rawText) {
const text = String(rawText || '').trim()
const hasBusinessTime = /业务发生时间|发生时间|20\d{2}[-年\/.]\d{1,2}/.test(text)
const prefix = hasBusinessTime
? '我已看到你提供了业务发生时间和报销意图。'
: '我已识别到这是报销申请。'
return [
`${prefix}但现在还不能确定具体报销场景,所以我先暂停信息抽取和草稿生成。`,
'请先选择本次要发起的报销场景,选择后我再按对应规则继续识别和生成单据。'
].join('\n')
}
function buildSuggestedActionKey(action) {
const actionType = String(action?.action_type || '').trim()
const payload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
const payloadKey = String(payload.expense_type || payload.expense_type_label || action?.label || '').trim()
return `${actionType}:${payloadKey}`
}
function formatMessageTime(value) { function formatMessageTime(value) {
if (!value) { if (!value) {
return nowTime() return nowTime()
@@ -1248,7 +1354,7 @@ function sortConversationMessages(messages) {
function normalizeInitialConversationMessages(conversation) { function normalizeInitialConversationMessages(conversation) {
const rawMessages = sortConversationMessages(conversation?.messages) const rawMessages = sortConversationMessages(conversation?.messages)
return rawMessages.map((item) => { const restoredMessages = rawMessages.map((item) => {
const messageJson = item?.message_json || item?.messageJson || {} const messageJson = item?.message_json || item?.messageJson || {}
const attachmentNames = Array.isArray(messageJson?.attachment_names) const attachmentNames = Array.isArray(messageJson?.attachment_names)
? messageJson.attachment_names.filter(Boolean) ? messageJson.attachment_names.filter(Boolean)
@@ -1271,6 +1377,144 @@ function normalizeInitialConversationMessages(conversation) {
riskFlags: item.role === 'assistant' && Array.isArray(result?.risk_flags) ? result.risk_flags : [] riskFlags: item.role === 'assistant' && Array.isArray(result?.risk_flags) ? result.risk_flags : []
}) })
}) })
return markResolvedSuggestedActionMessages(restoredMessages)
}
function normalizeSnapshotMessage(message) {
const extras = message && typeof message === 'object' ? { ...message } : {}
const role = String(extras.role || 'assistant').trim() || 'assistant'
const text = String(extras.text || '')
const attachments = Array.isArray(extras.attachments) ? extras.attachments.filter(Boolean) : []
delete extras.role
delete extras.text
delete extras.attachments
return createMessage(role, text, attachments, extras)
}
function normalizeSnapshotMessages(messages) {
return Array.isArray(messages)
? markResolvedSuggestedActionMessages(messages.map(normalizeSnapshotMessage))
: []
}
function serializeSessionMessages(messages) {
return (Array.isArray(messages) ? messages : []).map((message) => ({
id: message.id,
role: message.role,
text: message.text,
attachments: Array.isArray(message.attachments) ? message.attachments.filter(Boolean) : [],
time: message.time,
meta: Array.isArray(message.meta) ? message.meta.filter(Boolean) : [],
metaTone: message.metaTone || '',
citations: Array.isArray(message.citations) ? message.citations : [],
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [],
suggestedActionsLocked: Boolean(message.suggestedActionsLocked),
selectedSuggestedActionKey: String(message.selectedSuggestedActionKey || ''),
selectedSuggestedActionLabel: String(message.selectedSuggestedActionLabel || ''),
querySelectionLocked: Boolean(message.querySelectionLocked),
selectedQueryRecordId: String(message.selectedQueryRecordId || ''),
queryPayload: message.queryPayload || null,
draftPayload: message.draftPayload || null,
reviewPayload: message.reviewPayload || null,
riskFlags: Array.isArray(message.riskFlags) ? message.riskFlags : [],
assistantName: message.assistantName || '',
isWelcome: Boolean(message.isWelcome),
welcomeQuickActions: Array.isArray(message.welcomeQuickActions) ? message.welcomeQuickActions : []
}))
}
function hasMeaningfulSessionMessages(messages) {
return (Array.isArray(messages) ? messages : []).some((message) => {
if (!message || message.isWelcome) {
return false
}
if (message.role === 'user') {
return true
}
return Boolean(
String(message.text || '').trim()
|| (Array.isArray(message.suggestedActions) && message.suggestedActions.length)
|| message.reviewPayload
|| message.queryPayload
|| message.draftPayload
)
})
}
function hasActiveSuggestedActionMessage(messages) {
return (Array.isArray(messages) ? messages : []).some(
(message) =>
message?.role === 'assistant'
&& Array.isArray(message.suggestedActions)
&& message.suggestedActions.length > 0
&& !message.suggestedActionsLocked
)
}
function resolveConversationUpdatedAt(conversation) {
const timestamp = new Date(conversation?.updated_at || conversation?.updatedAt || 0).getTime()
return Number.isFinite(timestamp) ? timestamp : 0
}
function shouldPreferPersistedSessionState(persistedState, snapshot, conversation) {
if (!persistedState) {
return false
}
if (!conversation) {
return true
}
if (hasActiveSuggestedActionMessage(persistedState.messages)) {
return true
}
const snapshotUpdatedAt = Number(snapshot?.updatedAt || 0)
return snapshotUpdatedAt >= resolveConversationUpdatedAt(conversation)
}
function markResolvedSuggestedActionMessages(messages) {
const items = Array.isArray(messages) ? messages : []
const selectedLabels = new Set()
for (const message of items) {
if (message?.role !== 'user') {
continue
}
const text = String(message.text || '').trim()
const selectedMatch = text.match(/^选择(.+)$/) || text.match(/用户选择报销场景[:]\s*([^\n\r]+)/)
if (selectedMatch?.[1]) {
selectedLabels.add(selectedMatch[1].trim())
} else if (text === '我要报销') {
selectedLabels.add(text)
}
}
if (!selectedLabels.size) {
return items
}
return items.map((message) => {
if (
message?.role !== 'assistant'
|| message.suggestedActionsLocked
|| !Array.isArray(message.suggestedActions)
|| !message.suggestedActions.length
) {
return message
}
const selectedAction = message.suggestedActions.find((action) =>
selectedLabels.has(String(action?.label || action?.payload?.expense_type_label || '').trim())
)
if (!selectedAction) {
return message
}
return {
...message,
suggestedActionsLocked: true,
selectedSuggestedActionKey: buildSuggestedActionKey(selectedAction),
selectedSuggestedActionLabel: String(selectedAction.label || selectedAction?.payload?.expense_type_label || '').trim()
}
})
} }
function cloneReviewEditFields(fields) { function cloneReviewEditFields(fields) {
@@ -1728,6 +1972,89 @@ function normalizeExpenseQueryRecord(item) {
} }
} }
function resolveExpenseStatusGroup(status) {
const normalized = String(status || '').trim()
if (['draft', 'supplement', 'returned'].includes(normalized)) {
return { key: 'draft', label: normalized === 'draft' ? '草稿' : '待完善' }
}
if (['submitted', 'review'].includes(normalized)) {
return { key: 'in_progress', label: '审批中' }
}
if (['approved', 'paid'].includes(normalized)) {
return { key: 'completed', label: '已完成' }
}
return { key: 'other', label: '其他状态' }
}
function formatQueryRecordDate(value) {
const text = String(value || '').trim()
if (!text) return ''
return text.includes('T') ? text.split('T')[0] : text.slice(0, 10)
}
function buildQueryRecordFromClaim(claim) {
if (!claim || typeof claim !== 'object') {
return null
}
const claimId = String(claim.id || claim.claim_id || '').trim()
if (!claimId) {
return null
}
const status = String(claim.status || '').trim()
const statusGroup = resolveExpenseStatusGroup(status)
return {
claim_id: claimId,
claim_no: String(claim.claim_no || claim.claimNo || '').trim() || '未编号',
employee_name: String(claim.employee_name || claim.employeeName || '').trim(),
expense_type: String(claim.expense_type || claim.expenseType || '').trim(),
expense_type_label: EXPENSE_TYPE_LABELS[String(claim.expense_type || claim.expenseType || '').trim()] || String(claim.expense_type || claim.expenseType || '报销').trim(),
amount: Number(claim.amount || 0),
status,
status_label: EXPENSE_STATUS_LABELS[status] || statusGroup.label,
status_group: statusGroup.key,
status_group_label: statusGroup.label,
approval_stage: String(claim.approval_stage || claim.approvalStage || '').trim(),
document_date: formatQueryRecordDate(claim.submitted_at || claim.submittedAt || claim.created_at || claim.createdAt || claim.occurred_at || claim.occurredAt),
occurred_at: formatQueryRecordDate(claim.occurred_at || claim.occurredAt),
reason: String(claim.reason || '').trim(),
location: String(claim.location || '').trim()
}
}
function buildDraftAssociationQueryPayload(claims) {
const records = (Array.isArray(claims) ? claims : [])
.filter((claim) => ASSOCIATABLE_CLAIM_STATUSES.has(String(claim?.status || '').trim()))
.map(buildQueryRecordFromClaim)
.filter(Boolean)
const statusGroups = records.reduce((groups, record) => {
const key = String(record.status_group || 'other')
const existing = groups.get(key) || {
key,
label: String(record.status_group_label || '其他状态'),
count: 0
}
existing.count += 1
groups.set(key, existing)
return groups
}, new Map())
return normalizeExpenseQueryPayload({
result_type: 'expense_claim_list',
title: '选择关联草稿',
scope_label: '可关联草稿',
selection_mode: 'draft_association',
empty_text: '当前没有可关联的草稿单据。',
recent_window_applied: false,
record_count: records.length,
preview_count: records.length,
total_amount: records.reduce((sum, record) => sum + Number(record.amount || 0), 0),
status_groups: Array.from(statusGroups.values()),
records
})
}
function normalizeExpenseQueryPayload(payload) { function normalizeExpenseQueryPayload(payload) {
if (!payload || typeof payload !== 'object') { if (!payload || typeof payload !== 'object') {
return null return null
@@ -1756,6 +2083,11 @@ function normalizeExpenseQueryPayload(payload) {
return { return {
resultType: 'expense_claim_list', resultType: 'expense_claim_list',
scopeLabel: String(payload.scope_label || '报销单').trim() || '报销单', scopeLabel: String(payload.scope_label || '报销单').trim() || '报销单',
selectionMode: String(payload.selection_mode || payload.selectionMode || '').trim(),
selectionLocked: Boolean(payload.selection_locked || payload.selectionLocked),
selectedClaimId: String(payload.selected_claim_id || payload.selectedClaimId || '').trim(),
title: String(payload.title || '').trim(),
emptyText: String(payload.empty_text || payload.emptyText || '').trim(),
recentWindowApplied: Boolean(payload.recent_window_applied), recentWindowApplied: Boolean(payload.recent_window_applied),
windowDays: windowDays:
payload.window_days === null || payload.window_days === undefined || payload.window_days === '' payload.window_days === null || payload.window_days === undefined || payload.window_days === ''
@@ -1779,6 +2111,10 @@ function buildExpenseQueryWindowLabel(queryPayload) {
return '' return ''
} }
if (queryPayload.selectionMode === 'draft_association') {
return '先选择要关联的草稿,确认后我再识别附件并归集到该单据。'
}
if (queryPayload.windowStartDate && queryPayload.windowEndDate) { if (queryPayload.windowStartDate && queryPayload.windowEndDate) {
return `${queryPayload.windowStartDate}${queryPayload.windowEndDate}` return `${queryPayload.windowStartDate}${queryPayload.windowEndDate}`
} }
@@ -1816,6 +2152,13 @@ function buildExpenseQueryHint(queryPayload) {
return '' return ''
} }
if (queryPayload.selectionMode === 'draft_association') {
if (queryPayload.selectionLocked && queryPayload.selectedClaimId) {
return '已选择关联草稿,附件将按该单据继续识别和归集。'
}
return '如果这些都不是本次要关联的单据,可以补充单号或先到个人报销列表新建草稿。'
}
const parts = [] const parts = []
const windowText = buildExpenseQueryWindowLabel(queryPayload) const windowText = buildExpenseQueryWindowLabel(queryPayload)
@@ -3129,9 +3472,24 @@ export default {
const workbenchVisible = ref(false) const workbenchVisible = ref(false)
const linkedRequest = computed(() => sanitizeRequest(props.requestContext)) const linkedRequest = computed(() => sanitizeRequest(props.requestContext))
const initialSessionType = resolveInitialSessionType(props.initialConversation) const initialSessionType = resolveInitialSessionType(props.initialConversation)
const initialSessionState = props.initialConversation const conversationInitialState = props.initialConversation
? buildConversationSessionState(props.initialConversation, initialSessionType) ? buildConversationSessionState(props.initialConversation, initialSessionType)
: buildEmptySessionState(initialSessionType) : buildEmptySessionState(initialSessionType)
const canRestorePersistedInitialState =
props.entrySource === 'workbench'
&& !String(props.initialPrompt || '').trim()
&& !props.initialFiles.length
const persistedInitialSnapshot = readAssistantSessionSnapshot(resolveCurrentUserId(), initialSessionType)
const persistedInitialState = canRestorePersistedInitialState
? buildPersistedSessionState(persistedInitialSnapshot, initialSessionType)
: null
const initialSessionState = canRestorePersistedInitialState && shouldPreferPersistedSessionState(
persistedInitialState,
persistedInitialSnapshot,
props.initialConversation
)
? persistedInitialState
: conversationInitialState
const activeSessionType = ref(initialSessionState.sessionType) const activeSessionType = ref(initialSessionState.sessionType)
const messages = ref(initialSessionState.messages) const messages = ref(initialSessionState.messages)
const conversationId = ref(initialSessionState.conversationId) const conversationId = ref(initialSessionState.conversationId)
@@ -3454,11 +3812,79 @@ export default {
} }
} }
function buildPersistedSessionState(snapshot, fallbackSessionType = SESSION_TYPE_EXPENSE) {
const state = snapshot?.state && typeof snapshot.state === 'object' ? snapshot.state : null
if (!state) {
return null
}
const sessionType = String(state.sessionType || snapshot.sessionType || fallbackSessionType || '').trim() || SESSION_TYPE_EXPENSE
const restoredMessages = normalizeSnapshotMessages(state.messages)
if (
!hasMeaningfulSessionMessages(restoredMessages)
&& !String(state.conversationId || '').trim()
&& !String(state.draftClaimId || '').trim()
) {
return null
}
return {
sessionType,
messages: restoredMessages.length
? restoredMessages
: [createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, sessionType, currentUser.value)],
conversationId: String(state.conversationId || '').trim(),
draftClaimId: String(state.draftClaimId || '').trim(),
currentInsight:
state.currentInsight
|| buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType, currentUser.value),
reviewFilePreviews: Array.isArray(state.reviewFilePreviews) ? state.reviewFilePreviews : [],
composerDraft: String(state.composerDraft || ''),
attachedFiles: [],
composerFilesExpanded: false,
composerUploadIntent: String(state.composerUploadIntent || '').trim(),
insightPanelCollapsed: Boolean(state.insightPanelCollapsed)
}
}
function resolveCurrentUserId() { function resolveCurrentUserId() {
const user = currentUser.value || {} const user = currentUser.value || {}
return String(user.username || user.name || 'anonymous').trim() || 'anonymous' return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
} }
function buildPersistableSessionState(sessionState) {
const state = sessionState || captureCurrentSessionState()
return {
sessionType: state.sessionType || SESSION_TYPE_EXPENSE,
messages: serializeSessionMessages(state.messages),
conversationId: String(state.conversationId || '').trim(),
draftClaimId: String(state.draftClaimId || '').trim(),
currentInsight: state.currentInsight || null,
reviewFilePreviews: Array.isArray(state.reviewFilePreviews) ? state.reviewFilePreviews : [],
composerDraft: String(state.composerDraft || ''),
composerUploadIntent: String(state.composerUploadIntent || '').trim(),
insightPanelCollapsed: Boolean(state.insightPanelCollapsed)
}
}
function persistSessionState(sessionState = null) {
const state = sessionState || captureCurrentSessionState()
const persistedState = buildPersistableSessionState(state)
const meaningful = Boolean(
String(persistedState.conversationId || '').trim()
|| String(persistedState.draftClaimId || '').trim()
|| hasMeaningfulSessionMessages(persistedState.messages)
|| String(persistedState.composerDraft || '').trim()
)
if (!meaningful) {
clearAssistantSessionSnapshot(resolveCurrentUserId(), persistedState.sessionType)
return
}
writeAssistantSessionSnapshot(resolveCurrentUserId(), persistedState.sessionType, persistedState)
}
function captureCurrentSessionState() { function captureCurrentSessionState() {
return { return {
sessionType: activeSessionType.value, sessionType: activeSessionType.value,
@@ -3639,6 +4065,24 @@ export default {
} }
) )
watch(
() => ({
sessionType: activeSessionType.value,
conversationId: conversationId.value,
draftClaimId: draftClaimId.value,
messages: messages.value,
currentInsight: currentInsight.value,
reviewFilePreviews: reviewFilePreviews.value,
composerDraft: composerDraft.value,
composerUploadIntent: composerUploadIntent.value,
insightPanelCollapsed: insightPanelCollapsed.value
}),
() => {
persistSessionState()
},
{ deep: true }
)
watch( watch(
() => [activeSessionType.value, resolveActiveClaimId()], () => [activeSessionType.value, resolveActiveClaimId()],
([sessionType, claimId]) => { ([sessionType, claimId]) => {
@@ -3930,6 +4374,59 @@ export default {
flowSimulationTimers.push(startExtractionTimer) flowSimulationTimers.push(startExtractionTimer)
} }
function startExpenseSceneSelectionFlowPreview(rawText) {
clearFlowSimulationTimers()
const intentPreview = buildLocalIntentPreview(rawText, activeSessionType.value, { intentLabels: INTENT_LABELS })
const completeIntentTimer = window.setTimeout(() => {
completePendingFlowStep('intent', intentPreview, null)
}, 220)
flowSimulationTimers.push(completeIntentTimer)
const startSelectionTimer = window.setTimeout(() => {
startFlowStep('expense-scene-selection', {
detail: '报销意图已确认,但费用场景还不明确;暂停抽取和草稿生成,等待用户先选择报销场景。'
})
}, 320)
flowSimulationTimers.push(startSelectionTimer)
}
function startExpenseIntentConfirmationFlowPreview(rawText) {
clearFlowSimulationTimers()
const intentPreview = buildLocalIntentPreview(rawText, activeSessionType.value, { intentLabels: INTENT_LABELS })
const completeIntentTimer = window.setTimeout(() => {
completePendingFlowStep('intent', intentPreview, null)
}, 220)
flowSimulationTimers.push(completeIntentTimer)
const startConfirmationTimer = window.setTimeout(() => {
startFlowStep('expense-intent-confirmation', {
detail: '识别到业务事项描述,但是否发起报销还不明确;暂停抽取和草稿生成,等待用户确认。'
})
}, 320)
flowSimulationTimers.push(startConfirmationTimer)
}
function startExpenseSceneSelectionAfterIntentConfirmation(rawText) {
clearFlowSimulationTimers()
completePendingFlowStep('expense-intent-confirmation', '用户已确认要发起报销', null)
startFlowStep('expense-scene-selection', {
detail: '报销意图已确认,但费用场景还不明确;暂停抽取和草稿生成,等待用户先选择报销场景。'
})
if (reviewDrawerMode.value !== REVIEW_DRAWER_MODE_FLOW) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_FLOW
}
}
function isExpenseSceneSelectionResult(payload) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
if (result.review_payload) {
return false
}
return (Array.isArray(result.suggested_actions) ? result.suggested_actions : []).some(
(item) => String(item?.action_type || '').trim() === 'select_expense_type'
)
}
function startReviewActionFlowStep(reviewAction) { function startReviewActionFlowStep(reviewAction) {
if (reviewAction !== 'next_step') { if (reviewAction !== 'next_step') {
return return
@@ -3946,6 +4443,9 @@ export default {
if (isKnowledgeSession.value) { if (isKnowledgeSession.value) {
return return
} }
if (options.waitForSceneSelection) {
return
}
if (reviewAction === 'next_step') { if (reviewAction === 'next_step') {
startReviewActionFlowStep(reviewAction) startReviewActionFlowStep(reviewAction)
return return
@@ -4085,13 +4585,17 @@ export default {
if (!answer && !payload?.result) { if (!answer && !payload?.result) {
return return
} }
const sceneSelectionPending = isExpenseSceneSelectionResult(payload)
flowSteps.value flowSteps.value
.filter((step) => ![FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status)) .filter((step) => ![FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status))
.forEach((step) => { .forEach((step) => {
completeFlowStep(step.key, resolveFlowStepDetail({ ...step, status: FLOW_STEP_STATUS_COMPLETED })) const detail = sceneSelectionPending && step.key === 'expense-scene-selection'
? '已暂停后续识别,请先在主对话中选择报销场景。'
: resolveFlowStepDetail({ ...step, status: FLOW_STEP_STATUS_COMPLETED })
completeFlowStep(step.key, detail)
}) })
flowFinishedAt.value = Date.now() flowFinishedAt.value = Date.now()
if (reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW) { if (reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW && !sceneSelectionPending) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
} }
} }
@@ -4714,6 +5218,101 @@ export default {
submitComposer() submitComposer()
} }
function isSuggestedActionSelected(message, action) {
const selectedKey = String(message?.selectedSuggestedActionKey || '').trim()
return Boolean(selectedKey) && selectedKey === buildSuggestedActionKey(action)
}
function lockSuggestedActionMessage(message, action) {
const messageId = String(message?.id || '').trim()
const targetMessage = messages.value.find((item) => String(item.id || '') === messageId) || message
if (!targetMessage || targetMessage.suggestedActionsLocked) {
return false
}
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
const selectedLabel = String(action?.label || actionPayload.expense_type_label || '').trim()
const nextMeta = Array.isArray(targetMessage.meta)
? targetMessage.meta.filter((item) => item !== '等待选择场景')
: []
const selectedMeta = selectedLabel ? `已选择${selectedLabel}` : '已选择场景'
targetMessage.suggestedActionsLocked = true
targetMessage.selectedSuggestedActionKey = buildSuggestedActionKey(action)
targetMessage.selectedSuggestedActionLabel = selectedLabel
targetMessage.meta = Array.from(new Set([...nextMeta, selectedMeta]))
persistSessionState()
return true
}
function pushExpenseSceneSelectionPrompt(originalMessage) {
const sourceText = String(originalMessage || '').trim()
if (!sourceText) {
return
}
startExpenseSceneSelectionAfterIntentConfirmation(sourceText)
messages.value.push(createMessage('user', '我要报销'))
messages.value.push(createMessage('assistant', buildExpenseSceneSelectionMessage(sourceText), [], {
meta: ['等待选择场景'],
suggestedActions: buildExpenseSceneSelectionActions(sourceText)
}))
nextTick(scrollToBottom)
persistSessionState()
}
async function handleSuggestedAction(message, action) {
const actionType = String(action?.action_type || '').trim()
if (!actionType || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return
if (message?.suggestedActionsLocked) return
if (actionType === 'confirm_expense_intent') {
const originalMessage = String(action?.payload?.original_message || message?.text || '').trim()
if (!originalMessage) return
if (!lockSuggestedActionMessage(message, action)) return
pushExpenseSceneSelectionPrompt(originalMessage)
return
}
if (actionType !== 'select_expense_type') {
const fallbackText = String(action?.description || action?.label || '').trim()
if (!fallbackText) return
if (!lockSuggestedActionMessage(message, action)) return
await submitComposer({
rawText: fallbackText,
userText: fallbackText,
pendingText: '正在继续处理...'
})
return
}
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
const expenseType = String(actionPayload.expense_type || '').trim()
const expenseTypeLabel = String(actionPayload.expense_type_label || action?.label || '').trim()
const originalMessage = String(actionPayload.original_message || message?.text || '').trim()
if (!expenseTypeLabel || !originalMessage) return
if (!lockSuggestedActionMessage(message, action)) return
await submitComposer({
rawText: `${originalMessage}\n用户选择报销场景:${expenseTypeLabel}`,
userText: `选择${expenseTypeLabel}`,
pendingText: `已选择${expenseTypeLabel},正在按该场景识别...`,
systemGenerated: true,
extraContext: {
draft_claim_id: '',
user_input_text: originalMessage,
expense_scene_selection: {
expense_type: expenseType,
expense_type_label: expenseTypeLabel,
original_message: originalMessage
},
review_form_values: {
expense_type: expenseTypeLabel
}
}
})
}
function toggleInsightPanel() { function toggleInsightPanel() {
if (!hasInsightPanelContent.value) { if (!hasInsightPanelContent.value) {
return return
@@ -4941,6 +5540,7 @@ export default {
} }
function requestCloseWorkbench() { function requestCloseWorkbench() {
persistSessionState()
workbenchVisible.value = false workbenchVisible.value = false
} }
@@ -4961,6 +5561,46 @@ export default {
emit('close') emit('close')
} }
async function handleExpenseQueryRecordClick(message, record) {
if (message?.queryPayload?.selectionMode !== 'draft_association') {
openExpenseQueryRecord(record)
return
}
if (message.querySelectionLocked || message.queryPayload.selectionLocked || submitting.value || reviewActionBusy.value) {
return
}
const claimId = String(record?.claimId || '').trim()
if (!claimId) {
return
}
const files = Array.from(attachedFiles.value || [])
if (!files.length) {
toast('本次上传的附件已不在当前会话中,请重新选择附件后再关联草稿。')
return
}
message.querySelectionLocked = true
message.selectedQueryRecordId = claimId
message.queryPayload.selectionLocked = true
message.queryPayload.selectedClaimId = claimId
draftClaimId.value = claimId
persistSessionState()
await submitComposer({
rawText: `将本次上传的 ${files.length} 份票据关联到报销草稿 ${record.claimNo}`,
userText: `关联到草稿 ${record.claimNo}`,
pendingText: `已选择草稿 ${record.claimNo},正在识别并归集附件...`,
files,
uploadDisposition: 'continue_existing',
skipUploadDecisionPrompt: true,
extraContext: {
draft_claim_id: claimId,
selected_claim_id: claimId
}
})
}
function setExpenseQueryPage(message, page) { function setExpenseQueryPage(message, page) {
if (!message?.queryPayload) { if (!message?.queryPayload) {
return return
@@ -5004,6 +5644,7 @@ export default {
await deleteConversation(conversationId.value, resolveCurrentUserId()) await deleteConversation(conversationId.value, resolveCurrentUserId())
} }
clearAssistantSessionSnapshot(resolveCurrentUserId(), activeSessionType.value)
resetCurrentSessionState() resetCurrentSessionState()
deleteSessionDialogOpen.value = false deleteSessionDialogOpen.value = false
toast('当前会话已删除。') toast('当前会话已删除。')
@@ -5122,6 +5763,7 @@ export default {
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`) toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
} }
if (!rawText && !files.length) return if (!rawText && !files.length) return
const fileNames = files.map((file) => file.name)
const initialExtraContext = options.extraContext && typeof options.extraContext === 'object' const initialExtraContext = options.extraContext && typeof options.extraContext === 'object'
? { ...options.extraContext } ? { ...options.extraContext }
@@ -5131,9 +5773,37 @@ export default {
? initialExtraContext ? initialExtraContext
: mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext) : mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext)
const reviewAction = String(extraContext.review_action || '').trim() const reviewAction = String(extraContext.review_action || '').trim()
const hasSelectedExpenseType = Boolean(
extraContext.expense_scene_selection ||
String(extraContext.review_form_values?.expense_type || extraContext.review_form_values?.reimbursement_type || '').trim()
)
const hasConfirmedExpenseIntent = Boolean(extraContext.expense_intent_confirmed)
const waitForExpenseIntentConfirmation = shouldRequestExpenseIntentConfirmation(rawText, {
sessionType: activeSessionType.value,
attachmentCount: files.length,
reviewAction,
hasSelectedExpenseType,
hasConfirmedExpenseIntent
})
const waitForExpenseSceneSelection = !waitForExpenseIntentConfirmation && shouldRequestExpenseSceneSelection(rawText, {
sessionType: activeSessionType.value,
attachmentCount: files.length,
reviewAction,
hasSelectedExpenseType
})
const reviewAttachmentNames = extractReviewAttachmentNames(activeReviewPayload.value) const reviewAttachmentNames = extractReviewAttachmentNames(activeReviewPayload.value)
const hasExistingDocumentEvent = const hasExistingDocumentEvent =
Boolean(String(draftClaimId.value || '').trim()) || reviewAttachmentNames.length > 0 Boolean(String(draftClaimId.value || '').trim()) || reviewAttachmentNames.length > 0
const userText =
String(options.userText || '').trim() ||
rawText ||
(isKnowledgeSession.value
? `我上传了 ${fileNames.length} 份附件,请帮我回答相关财务问题。`
: resolvedUploadDisposition === 'continue_existing'
? `继续上传 ${fileNames.length} 份票据,并归集到当前单据。`
: resolvedUploadDisposition === 'new_document'
? `新上传 ${fileNames.length} 份票据,请单独建立报销单。`
: `我上传了 ${fileNames.length} 份票据,请帮我识别并给出报销建议。`)
if ( if (
!isKnowledgeSession.value && !isKnowledgeSession.value &&
@@ -5147,34 +5817,112 @@ export default {
return null return null
} }
if (
!isKnowledgeSession.value &&
files.length &&
!hasExistingDocumentEvent &&
!resolvedUploadDisposition &&
!options.skipDraftAssociationPrompt &&
!reviewAction
) {
try {
const claims = await fetchExpenseClaims()
const queryPayload = buildDraftAssociationQueryPayload(claims)
if (queryPayload?.records?.length) {
resetFlowRun()
if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText, fileNames))
}
messages.value.push(createMessage(
'assistant',
`我找到 ${queryPayload.records.length} 张可关联的草稿/待补单据。请先选择这批附件要归集到哪张单据,我再开始识别附件。`,
[],
{
meta: ['等待选择关联单据'],
queryPayload
}
))
composerDraft.value = ''
composerBusinessTimeTags.value = []
composerBusinessTimeDraftTouched.value = false
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
persistSessionState()
return null
}
} catch (error) {
console.warn('Failed to load draft claims before attachment recognition:', error)
toast(error?.message || '查询可关联草稿失败,已继续按新单据识别。')
}
}
resetFlowRun() resetFlowRun()
if (rawText && !reviewAction) { if (rawText && !reviewAction) {
startFlowStep('intent', '正在识别业务意图...') startFlowStep('intent', '正在识别业务意图...')
if (waitForExpenseIntentConfirmation) {
startExpenseIntentConfirmationFlowPreview(rawText)
} else if (waitForExpenseSceneSelection) {
startExpenseSceneSelectionFlowPreview(rawText)
} else {
startSemanticFlowPreview(rawText, { attachmentCount: files.length }) startSemanticFlowPreview(rawText, { attachmentCount: files.length })
} }
}
const fileNames = files.map((file) => file.name)
const filePreviews = buildFilePreviews(files, previewRegistry) const filePreviews = buildFilePreviews(files, previewRegistry)
rememberFilePreviews(filePreviews) rememberFilePreviews(filePreviews)
const userText =
String(options.userText || '').trim() ||
rawText ||
(isKnowledgeSession.value
? `我上传了 ${fileNames.length} 份附件,请帮我回答相关财务问题。`
: resolvedUploadDisposition === 'continue_existing'
? `继续上传 ${fileNames.length} 份票据,并归集到当前单据。`
: resolvedUploadDisposition === 'new_document'
? `新上传 ${fileNames.length} 份票据,请单独建立报销单。`
: `我上传了 ${fileNames.length} 份票据,请帮我识别并给出报销建议。`)
// 只有在非静默模式下才添加用户消息 // 只有在非静默模式下才添加用户消息
if (!options.skipUserMessage) { if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText, fileNames)) messages.value.push(createMessage('user', userText, fileNames))
} }
if (waitForExpenseIntentConfirmation) {
messages.value.push(createMessage('assistant', buildExpenseIntentConfirmationMessage(rawText), [], {
meta: ['等待确认意图'],
suggestedActions: buildExpenseIntentConfirmationActions(rawText)
}))
composerDraft.value = ''
composerBusinessTimeTags.value = []
composerBusinessTimeDraftTouched.value = false
clearAttachedFiles()
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
return null
}
if (waitForExpenseSceneSelection) {
messages.value.push(createMessage('assistant', buildExpenseSceneSelectionMessage(rawText), [], {
meta: ['等待选择场景'],
suggestedActions: buildExpenseSceneSelectionActions(rawText)
}))
composerDraft.value = ''
composerBusinessTimeTags.value = []
composerBusinessTimeDraftTouched.value = false
clearAttachedFiles()
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
return null
}
const pendingMessage = createMessage( const pendingMessage = createMessage(
'assistant', 'assistant',
options.pendingText || (isKnowledgeSession.value ? '正在整理财务知识答案...' : '正在识别并更新右侧核对信息...'), options.pendingText || (
isKnowledgeSession.value
? '正在整理财务知识答案...'
: '正在识别并更新右侧核对信息...'
),
[], [],
{ {
meta: ['处理中'] meta: ['处理中']
@@ -5251,7 +5999,8 @@ export default {
} }
startExpenseClaimDraftFlowStep(String(extraContext.review_action || '').trim(), { startExpenseClaimDraftFlowStep(String(extraContext.review_action || '').trim(), {
attachmentCount: effectiveFileNames.length attachmentCount: effectiveFileNames.length,
waitForSceneSelection: waitForExpenseSceneSelection
}) })
const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary) const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary)
@@ -5777,6 +6526,8 @@ export default {
handleComposerEnter, handleComposerEnter,
runShortcut, runShortcut,
runWelcomeQuickAction: runShortcut, runWelcomeQuickAction: runShortcut,
handleSuggestedAction,
isSuggestedActionSelected,
askHotKnowledgeQuestion, askHotKnowledgeQuestion,
resolveKnowledgeRankLabel, resolveKnowledgeRankLabel,
resolveKnowledgeRankTone, resolveKnowledgeRankTone,
@@ -5799,6 +6550,7 @@ export default {
requestCloseWorkbench, requestCloseWorkbench,
emitCloseAfterLeave, emitCloseAfterLeave,
openExpenseQueryRecord, openExpenseQueryRecord,
handleExpenseQueryRecordClick,
setExpenseQueryPage, setExpenseQueryPage,
shiftExpenseQueryPage, shiftExpenseQueryPage,
openDeleteSessionDialog, openDeleteSessionDialog,

View File

@@ -56,7 +56,8 @@ const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance']) const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket']) const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'])
const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ride_ticket']) const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'])
const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
const ROUTE_DESCRIPTION_PATTERN = /^[A-Za-z0-9\u4e00-\u9fa5()·]{2,40}\s*-\s*[A-Za-z0-9\u4e00-\u9fa5()·]{2,40}$/ const ROUTE_DESCRIPTION_PATTERN = /^[A-Za-z0-9\u4e00-\u9fa5()·]{2,40}\s*-\s*[A-Za-z0-9\u4e00-\u9fa5()·]{2,40}$/
function parseCurrency(value) { function parseCurrency(value) {
@@ -93,29 +94,59 @@ function resolveLocationSummaryLabel(value) {
return isLocationRequiredExpenseType(value) ? '业务地点' : '采购/收货地点' return isLocationRequiredExpenseType(value) ? '业务地点' : '采购/收货地点'
} }
function resolveLocationDisplay(value, expenseType) {
if (!isLocationRequiredExpenseType(expenseType) && isPlaceholderValue(value)) {
return '非必填'
}
return isPlaceholderValue(value) ? '待补充' : value
}
function isRouteDescriptionExpenseType(value) { function isRouteDescriptionExpenseType(value) {
return ROUTE_DESCRIPTION_EXPENSE_TYPES.has(normalizeExpenseType(value)) return ROUTE_DESCRIPTION_EXPENSE_TYPES.has(normalizeExpenseType(value))
} }
function isHotelDescriptionExpenseType(value) {
return HOTEL_DESCRIPTION_EXPENSE_TYPES.has(normalizeExpenseType(value))
}
function resolveExpenseDetailHint(expenseType) {
if (isRouteDescriptionExpenseType(expenseType)) {
return '起始地-目的地'
}
if (isHotelDescriptionExpenseType(expenseType)) {
return '目的地酒店'
}
if (!isLocationRequiredExpenseType(expenseType)) {
return '非必填'
}
return '待补充'
}
function resolveLocationDisplay(value, expenseType) {
return isPlaceholderValue(value) ? resolveExpenseDetailHint(expenseType) : value
}
function isSyntheticLocationDisplay(value, expenseType) {
const text = String(value || '').trim()
return ['待补充', '非必填', resolveExpenseDetailHint(expenseType)].includes(text)
}
function isValidRouteDescription(value) { function isValidRouteDescription(value) {
const text = String(value || '').trim() const text = String(value || '').trim()
return ROUTE_DESCRIPTION_PATTERN.test(text) && !/\d{4}[-/年.]\d{1,2}[-/月.]\d{1,2}/.test(text) return ROUTE_DESCRIPTION_PATTERN.test(text) && !/\d{4}[-/年.]\d{1,2}[-/月.]\d{1,2}/.test(text)
} }
function resolveExpenseReasonPlaceholder(itemType) { function resolveExpenseReasonPlaceholder(itemType) {
return isRouteDescriptionExpenseType(itemType) ? '始发地-目的地,例如:广州南-北京南' : '输入费用说明' if (isRouteDescriptionExpenseType(itemType)) {
return '起始地-目的地,例如:广州南-北京南'
}
if (isHotelDescriptionExpenseType(itemType)) {
return '目的地酒店,例如:北京中心酒店'
}
return '输入费用说明'
} }
function resolveExpenseReasonHelper(itemType) { function resolveExpenseReasonHelper(itemType) {
return isRouteDescriptionExpenseType(itemType) ? '始发地-目的地' : '业务报销说明' if (isRouteDescriptionExpenseType(itemType)) {
return '起始地-目的地'
}
if (isHotelDescriptionExpenseType(itemType)) {
return '目的地酒店'
}
return '业务报销说明'
} }
function buildFallbackProgressSteps() { function buildFallbackProgressSteps() {
@@ -399,6 +430,46 @@ function buildExpenseDraftIssues(item) {
return issues return issues
} }
function buildOptionalTravelReceiptRiskCards(requestModel, items) {
const normalizedItems = Array.isArray(items) ? items : []
const isTravelContext =
requestModel?.detailVariant === 'travel' ||
requestModel?.typeCode === 'travel' ||
normalizedItems.some((item) => ['train_ticket', 'flight_ticket', 'hotel_ticket', 'ride_ticket', 'travel_allowance'].includes(item.itemType))
if (!isTravelContext) {
return []
}
const hasUploadedType = (itemType) =>
normalizedItems.some((item) => item.itemType === itemType && !item.isSystemGenerated && !isPlaceholderValue(item.invoiceId))
const cards = []
if (!hasUploadedType('hotel_ticket')) {
cards.push({
id: 'travel-optional-hotel-ticket',
tone: 'low',
label: '低风险',
title: '住宿票据提醒',
risk: '当前差旅单暂未上传住宿票据;如果本次出差发生住宿费用,请不要忘记补充酒店住宿票据。',
summary: '住宿票据缺失不阻断当前提交,但会影响住宿费用报销完整性。',
ruleBasis: ['差旅费可以包含交通、住宿和补贴等明细;住宿费用需要住宿票据支撑。'],
suggestion: '如有住宿费用,请新增住宿票明细并上传酒店发票或住宿清单;如未住宿,可忽略该提醒。'
})
}
if (!hasUploadedType('ride_ticket')) {
cards.push({
id: 'travel-optional-ride-ticket',
tone: 'low',
label: '低风险',
title: '乘车票据提醒',
risk: '当前差旅单暂未上传市内乘车票据;如果发生打车或市内交通费用,可以继续补充票据报销。',
summary: '市内交通票据缺失不阻断当前提交,但可能遗漏可报销费用。',
ruleBasis: ['差旅费可以补充市内交通/乘车票据;该类票据通常作为差旅费用的可选补充材料。'],
suggestion: '如有打车、网约车或市内交通费用,请新增乘车明细并上传对应票据。'
})
}
return cards
}
function buildDraftBlockingIssues(request, expenseItems) { function buildDraftBlockingIssues(request, expenseItems) {
const issues = [] const issues = []
const locationRequired = isLocationRequiredExpenseType(request.typeCode) const locationRequired = isLocationRequiredExpenseType(request.typeCode)
@@ -482,7 +553,7 @@ function mapIssueToAdvice(issue) {
return `${labelPrefix}的用途说明。` return `${labelPrefix}的用途说明。`
} }
if (fieldText === '行程说明格式错误') { if (fieldText === '行程说明格式错误') {
return `${labelPrefix}的行程说明,格式应为“始地-目的地”。` return `${labelPrefix}的行程说明,格式应为“始地-目的地”。`
} }
if (fieldText === '缺少地点') { if (fieldText === '缺少地点') {
return `${labelPrefix}的业务地点。` return `${labelPrefix}的业务地点。`
@@ -987,11 +1058,14 @@ export default {
const aiAdvice = computed(() => { const aiAdvice = computed(() => {
const completionItems = draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean) const completionItems = draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean)
const riskCards = buildAttachmentRiskCards({ const riskCards = [
...buildAttachmentRiskCards({
expenseItems: expenseItems.value, expenseItems: expenseItems.value,
attachmentMetaByItemId: expenseAttachmentMeta, attachmentMetaByItemId: expenseAttachmentMeta,
claimRiskFlags: request.value.riskFlags || request.value.risk_flags_json || [] claimRiskFlags: request.value.riskFlags || request.value.risk_flags_json || []
}) }),
...buildOptionalTravelReceiptRiskCards(request.value, expenseItems.value)
]
return buildAiAdviceViewModel({ return buildAiAdviceViewModel({
completionItems, completionItems,
@@ -1029,6 +1103,17 @@ export default {
} }
} }
function populateExpenseEditor(item) {
editingExpenseId.value = item.id
expenseEditor.itemDate = item.itemDate || ''
expenseEditor.itemType = item.itemType || 'other'
expenseEditor.itemReason = item.itemReason || (item.desc === '待补充' ? '' : item.desc)
expenseEditor.itemLocation =
item.itemLocation || (isSyntheticLocationDisplay(item.detail, item.itemType) ? '' : item.detail)
expenseEditor.itemAmount = item.itemAmount ? String(item.itemAmount) : ''
expenseEditor.invoiceId = item.invoiceId || ''
}
function startExpenseEdit(item) { function startExpenseEdit(item) {
if (!isEditableRequest.value || actionBusy.value) { if (!isEditableRequest.value || actionBusy.value) {
return return
@@ -1038,40 +1123,31 @@ export default {
return return
} }
editingExpenseId.value = item.id populateExpenseEditor(item)
expenseEditor.itemDate = item.itemDate || ''
expenseEditor.itemType = item.itemType || 'other'
expenseEditor.itemReason = item.itemReason || (item.desc === '待补充' ? '' : item.desc)
expenseEditor.itemLocation =
item.itemLocation || (['待补充', '非必填'].includes(item.detail) ? '' : item.detail)
expenseEditor.itemAmount = item.itemAmount ? String(item.itemAmount) : ''
expenseEditor.invoiceId = item.invoiceId || ''
}
function cancelExpenseEdit() {
editingExpenseId.value = ''
} }
function validateExpenseEditor() { function validateExpenseEditor() {
if (!isValidIsoDate(expenseEditor.itemDate)) { if (expenseEditor.itemDate && !isValidIsoDate(expenseEditor.itemDate)) {
return '请输入正确的费用日期,格式为 YYYY-MM-DD。' return '请输入正确的费用日期,格式为 YYYY-MM-DD。'
} }
if (isPlaceholderValue(expenseEditor.itemType)) { if (isPlaceholderValue(expenseEditor.itemType)) {
return '请选择费用项目。' return '请选择费用项目。'
} }
if (isPlaceholderValue(expenseEditor.itemReason)) {
return '请输入费用说明。'
}
if ( if (
!isPlaceholderValue(expenseEditor.itemReason)
&&
isRouteDescriptionExpenseType(expenseEditor.itemType) isRouteDescriptionExpenseType(expenseEditor.itemType)
&& !isValidRouteDescription(expenseEditor.itemReason) && !isValidRouteDescription(expenseEditor.itemReason)
) { ) {
return '行程说明格式应为“始地-目的地”,例如:广州南-北京南。' return '行程说明格式应为“始地-目的地”,例如:广州南-北京南。'
} }
const amount = Number(expenseEditor.itemAmount) const amountText = String(expenseEditor.itemAmount || '').trim()
if (!Number.isFinite(amount) || amount <= 0) { if (amountText) {
return '请输入大于 0 的费用金额。' const amount = Number(amountText)
if (!Number.isFinite(amount) || amount < 0) {
return '请输入不小于 0 的费用金额。'
}
} }
return '' return ''
} }
@@ -1223,10 +1299,26 @@ export default {
const payload = await uploadExpenseClaimItemAttachment(request.value.claimId, item.id, file) const payload = await uploadExpenseClaimItemAttachment(request.value.claimId, item.id, file)
expenseAttachmentMeta[item.id] = payload?.attachment || null expenseAttachmentMeta[item.id] = payload?.attachment || null
const recognizedItemAmount = Number(payload?.item_amount ?? payload?.itemAmount) const recognizedItemAmount = Number(payload?.item_amount ?? payload?.itemAmount)
const recognizedItemDate = normalizeIsoDateValue(payload?.item_date ?? payload?.itemDate)
const recognizedItemType = String(payload?.item_type ?? payload?.itemType ?? '').trim()
const recognizedItemReason = String(payload?.item_reason ?? payload?.itemReason ?? '').trim()
const recognizedItemLocation = String(payload?.item_location ?? payload?.itemLocation ?? '').trim()
const itemPatch = { const itemPatch = {
invoiceId: String(payload?.invoice_id || '').trim(), invoiceId: String(payload?.invoice_id || '').trim(),
attachmentHint: String(payload?.attachment?.file_name || file.name || '').trim() attachmentHint: String(payload?.attachment?.file_name || file.name || '').trim()
} }
if (recognizedItemDate) {
itemPatch.itemDate = recognizedItemDate
}
if (recognizedItemType) {
itemPatch.itemType = recognizedItemType
}
if (recognizedItemReason) {
itemPatch.itemReason = recognizedItemReason
}
if (recognizedItemLocation) {
itemPatch.itemLocation = recognizedItemLocation
}
if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) { if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) {
itemPatch.itemAmount = recognizedItemAmount itemPatch.itemAmount = recognizedItemAmount
itemPatch.amount = formatCurrency(recognizedItemAmount) itemPatch.amount = formatCurrency(recognizedItemAmount)
@@ -1234,12 +1326,7 @@ export default {
applyLocalExpenseItemPatch(item.id, { applyLocalExpenseItemPatch(item.id, {
...itemPatch ...itemPatch
}) })
if (editingExpenseId.value === item.id) { populateExpenseEditor({ ...item, ...itemPatch })
expenseEditor.invoiceId = String(payload?.invoice_id || '').trim()
if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) {
expenseEditor.itemAmount = String(recognizedItemAmount)
}
}
emit('request-updated', { claimId: request.value.claimId }) emit('request-updated', { claimId: request.value.claimId })
const riskNotice = buildAttachmentRiskNotice(payload?.attachment) const riskNotice = buildAttachmentRiskNotice(payload?.attachment)
@@ -1370,20 +1457,25 @@ export default {
try { try {
const nextInvoiceId = expenseEditor.invoiceId.trim() const nextInvoiceId = expenseEditor.invoiceId.trim()
const preservedLocation = String(item.itemLocation || expenseEditor.itemLocation || '').trim() const preservedLocation = String(item.itemLocation || expenseEditor.itemLocation || '').trim()
await updateExpenseClaimItem(request.value.claimId, item.id, { const amountText = String(expenseEditor.itemAmount || '').trim()
item_date: expenseEditor.itemDate, const nextAmount = amountText ? Number(amountText) : 0
const itemPayload = {
item_type: expenseEditor.itemType, item_type: expenseEditor.itemType,
item_reason: expenseEditor.itemReason.trim(), item_reason: expenseEditor.itemReason.trim(),
item_location: preservedLocation, item_location: preservedLocation,
item_amount: Number(expenseEditor.itemAmount), item_amount: nextAmount,
invoice_id: nextInvoiceId invoice_id: nextInvoiceId
}) }
if (expenseEditor.itemDate) {
itemPayload.item_date = expenseEditor.itemDate
}
await updateExpenseClaimItem(request.value.claimId, item.id, itemPayload)
applyLocalExpenseItemPatch(item.id, { applyLocalExpenseItemPatch(item.id, {
itemDate: expenseEditor.itemDate, itemDate: expenseEditor.itemDate || item.itemDate,
itemType: expenseEditor.itemType, itemType: expenseEditor.itemType,
itemReason: expenseEditor.itemReason.trim(), itemReason: expenseEditor.itemReason.trim(),
itemLocation: preservedLocation, itemLocation: preservedLocation,
itemAmount: Number(expenseEditor.itemAmount), itemAmount: nextAmount,
invoiceId: nextInvoiceId invoiceId: nextInvoiceId
}) })
let riskNotice = '' let riskNotice = ''
@@ -1713,7 +1805,6 @@ export default {
triggerExpenseUpload, triggerExpenseUpload,
uploadedExpenseCount, uploadedExpenseCount,
uploadingExpenseId, uploadingExpenseId,
cancelExpenseEdit,
saveExpenseEdit saveExpenseEdit
} }
} }

View File

@@ -288,13 +288,7 @@ export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] }
summary: 'AI判断当前草稿已具备提交条件可以直接发起审批。', summary: 'AI判断当前草稿已具备提交条件可以直接发起审批。',
items, items,
riskCards: [], riskCards: [],
sections: [ sections: []
{
kind: 'completion',
title: '建议补充字段',
items
}
]
} }
} }

View File

@@ -5,6 +5,8 @@ import {
buildLocalExtractionProgressMessages, buildLocalExtractionProgressMessages,
buildLocalIntentPreview, buildLocalIntentPreview,
inferLocalFlowCandidates, inferLocalFlowCandidates,
shouldRequestExpenseIntentConfirmation,
shouldRequestExpenseSceneSelection,
summarizeSemanticIntentDetail summarizeSemanticIntentDetail
} from '../src/utils/reimbursementTextInference.js' } from '../src/utils/reimbursementTextInference.js'
@@ -40,3 +42,32 @@ test('semantic intent detail includes recognized expense type', () => {
'已识别为报销场景,当前目标是草稿生成,费用类型为交通费' '已识别为报销场景,当前目标是草稿生成,费用类型为交通费'
) )
}) })
test('ambiguous expense prompt waits for scene selection before extraction preview', () => {
const ambiguousMessage = '业务发生时间:2026-02-20 至 2026-02-23去上海支持上海电力部署项目申请报销'
assert.equal(shouldRequestExpenseSceneSelection(ambiguousMessage), true)
assert.match(buildLocalIntentPreview(ambiguousMessage), /需要先由用户选择场景/)
assert.doesNotMatch(buildLocalIntentPreview(ambiguousMessage), /草稿生成/)
})
test('clear expense type does not request scene selection', () => {
const travelMessage = '业务发生时间:2026-02-20 至 2026-02-23去上海出差支持上海电力部署项目申请差旅报销'
assert.equal(shouldRequestExpenseSceneSelection(travelMessage), false)
assert.match(buildLocalIntentPreview(travelMessage), /差旅费/)
})
test('business activity without expense intent asks for reimbursement confirmation first', () => {
const businessMessage = '去上海电力支撑项目部署'
assert.equal(shouldRequestExpenseIntentConfirmation(businessMessage), true)
assert.match(buildLocalIntentPreview(businessMessage), /是否发起报销尚不明确/)
assert.equal(shouldRequestExpenseSceneSelection(businessMessage), false)
})
test('explicit technical operation does not ask for reimbursement confirmation', () => {
const operationMessage = '去上海电力支撑项目部署,帮我整理服务器部署步骤'
assert.equal(shouldRequestExpenseIntentConfirmation(operationMessage), false)
})

View File

@@ -153,6 +153,10 @@ test('AI advice view model exposes grouped completion and risk sections', () =>
}) })
test('AI advice view model omits empty sections', () => { test('AI advice view model omits empty sections', () => {
const readyAdvice = buildAiAdviceViewModel({
completionItems: [],
riskCards: []
})
const completionOnlyAdvice = buildAiAdviceViewModel({ const completionOnlyAdvice = buildAiAdviceViewModel({
completionItems: ['补充业务地点'], completionItems: ['补充业务地点'],
riskCards: [] riskCards: []
@@ -172,6 +176,8 @@ test('AI advice view model omits empty sections', () => {
] ]
}) })
assert.deepEqual(readyAdvice.sections, [])
assert.equal(readyAdvice.badge, '可直接提交')
assert.deepEqual(completionOnlyAdvice.sections.map((section) => section.title), ['建议补充字段']) assert.deepEqual(completionOnlyAdvice.sections.map((section) => section.title), ['建议补充字段'])
assert.deepEqual(riskOnlyAdvice.sections.map((section) => section.title), ['已知存在风险']) assert.deepEqual(riskOnlyAdvice.sections.map((section) => section.title), ['已知存在风险'])
}) })
@@ -192,6 +198,8 @@ test('AI advice risk section uses compact card styling hooks', () => {
assert.match(detailViewTemplate, /class="\['risk-advice-card', card\.tone\]"/) assert.match(detailViewTemplate, /class="\['risk-advice-card', card\.tone\]"/)
assert.match(detailViewStyle, /\.validation-card \{\s*border: 1px solid #e5e7eb;/) assert.match(detailViewStyle, /\.validation-card \{\s*border: 1px solid #e5e7eb;/)
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card \{\s*display: grid;\s*gap: 8px;\s*padding: 12px 12px 11px;/) assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card \{\s*display: grid;\s*gap: 8px;\s*padding: 12px 12px 11px;/)
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card\.low/)
assert.match(detailViewStyle, /\.risk-advice-card\.low/)
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-meta ul,\s*\.validation-section--risk \.risk-advice-meta p \{\s*margin: 0;/) assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-meta ul,\s*\.validation-section--risk \.risk-advice-meta p \{\s*margin: 0;/)
}) })
@@ -316,9 +324,35 @@ test('expense item upload remains limited to one receipt per detail row', () =>
test('expense item upload patches OCR amount into the visible detail row', () => { test('expense item upload patches OCR amount into the visible detail row', () => {
assert.match(detailViewScript, /const recognizedItemAmount = Number\(payload\?\.item_amount \?\? payload\?\.itemAmount\)/) assert.match(detailViewScript, /const recognizedItemAmount = Number\(payload\?\.item_amount \?\? payload\?\.itemAmount\)/)
assert.match(detailViewScript, /const recognizedItemDate = normalizeIsoDateValue\(payload\?\.item_date \?\? payload\?\.itemDate\)/)
assert.match(detailViewScript, /const recognizedItemType = String\(payload\?\.item_type \?\? payload\?\.itemType \?\? ''\)\.trim\(\)/)
assert.match(detailViewScript, /const recognizedItemReason = String\(payload\?\.item_reason \?\? payload\?\.itemReason \?\? ''\)\.trim\(\)/)
assert.match(detailViewScript, /itemPatch\.itemAmount = recognizedItemAmount/) assert.match(detailViewScript, /itemPatch\.itemAmount = recognizedItemAmount/)
assert.match(detailViewScript, /itemPatch\.amount = formatCurrency\(recognizedItemAmount\)/) assert.match(detailViewScript, /itemPatch\.amount = formatCurrency\(recognizedItemAmount\)/)
assert.match(detailViewScript, /expenseEditor\.itemAmount = String\(recognizedItemAmount\)/) assert.match(detailViewScript, /populateExpenseEditor\(\{ \.\.\.item, \.\.\.itemPatch \}\)/)
})
test('expense detail edit keeps delete but removes cancel and allows draft placeholders', () => {
assert.doesNotMatch(detailViewTemplate, /@click="cancelExpenseEdit"/)
assert.doesNotMatch(detailViewScript, /function cancelExpenseEdit/)
assert.match(detailViewScript, /if \(expenseEditor\.itemDate && !isValidIsoDate\(expenseEditor\.itemDate\)\)/)
assert.doesNotMatch(detailViewScript, /请输入费用说明。/)
assert.doesNotMatch(detailViewScript, /请输入大于 0 的费用金额。/)
assert.match(detailViewScript, /const amountText = String\(expenseEditor\.itemAmount \|\| ''\)\.trim\(\)/)
assert.match(detailViewScript, /const nextAmount = amountText \? Number\(amountText\) : 0/)
assert.match(detailViewScript, /if \(expenseEditor\.itemDate\) \{[\s\S]*itemPayload\.item_date = expenseEditor\.itemDate/)
})
test('travel detail AI advice adds low risk reminders for optional receipts', () => {
assert.match(detailViewScript, /function buildOptionalTravelReceiptRiskCards\(requestModel, items\)/)
assert.match(detailViewScript, /id: 'travel-optional-hotel-ticket'[\s\S]*tone: 'low'[\s\S]*住宿票据提醒/)
assert.match(detailViewScript, /不要忘记补充酒店住宿票据/)
assert.match(detailViewScript, /id: 'travel-optional-ride-ticket'[\s\S]*tone: 'low'[\s\S]*乘车票据提醒/)
assert.match(detailViewScript, /可以继续补充票据报销/)
assert.match(
detailViewScript,
/\.\.\.buildOptionalTravelReceiptRiskCards\(request\.value, expenseItems\.value\)/
)
}) })
test('expense detail save is blocked while attachment recognition is running', () => { test('expense detail save is blocked while attachment recognition is running', () => {
@@ -335,21 +369,25 @@ test('expense detail save is blocked while attachment recognition is running', (
}) })
test('transport ticket descriptions use route format and invalid format becomes risk advice', () => { test('transport ticket descriptions use route format and invalid format becomes risk advice', () => {
assert.match(detailViewScript, /const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set\(\['train_ticket', 'flight_ticket', 'ride_ticket'\]\)/) assert.match(detailViewScript, /const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set\(\['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'\]\)/)
assert.match(detailViewScript, /const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set\(\['hotel_ticket'\]\)/)
assert.match(detailViewScript, /const ROUTE_DESCRIPTION_PATTERN = \/\^\[A-Za-z0-9\\u4e00-\\u9fa5/) assert.match(detailViewScript, /const ROUTE_DESCRIPTION_PATTERN = \/\^\[A-Za-z0-9\\u4e00-\\u9fa5/)
assert.match(detailViewScript, /return isRouteDescriptionExpenseType\(itemType\) \? '始发地-目的地,例如:广州南-北京南' : '输入费用说明'/) assert.match(detailViewScript, /return '起始地-目的地,例如:广州南-北京南'/)
assert.match(detailViewScript, /return isRouteDescriptionExpenseType\(itemType\) \? '始发地-目的地' : '业务报销说明'/) assert.match(detailViewScript, /return '起始地-目的地'/)
assert.match(detailViewScript, /return '目的地酒店,例如:北京中心酒店'/)
assert.match(detailViewScript, /return '目的地酒店'/)
assert.match(detailViewScript, /isSyntheticLocationDisplay\(item\.detail, item\.itemType\)/)
assert.match( assert.match(
detailViewScript, detailViewScript,
/isRouteDescriptionExpenseType\(item\.itemType\) && !isValidRouteDescription\(item\.itemReason\)[\s\S]*issues\.push\('行程说明格式错误'\)/ /isRouteDescriptionExpenseType\(item\.itemType\) && !isValidRouteDescription\(item\.itemReason\)[\s\S]*issues\.push\('行程说明格式错误'\)/
) )
assert.match( assert.match(
detailViewScript, detailViewScript,
/isRouteDescriptionExpenseType\(expenseEditor\.itemType\)[\s\S]*!isValidRouteDescription\(expenseEditor\.itemReason\)[\s\S]*return '行程说明格式应为“始地-目的地”,例如:广州南-北京南。'/ /isRouteDescriptionExpenseType\(expenseEditor\.itemType\)[\s\S]*!isValidRouteDescription\(expenseEditor\.itemReason\)[\s\S]*return '行程说明格式应为“始地-目的地”,例如:广州南-北京南。'/
) )
assert.match( assert.match(
detailViewScript, detailViewScript,
/fieldText === '行程说明格式错误'[\s\S]*return `\$\{labelPrefix\}的行程说明,格式应为“始地-目的地”。`/ /fieldText === '行程说明格式错误'[\s\S]*return `\$\{labelPrefix\}的行程说明,格式应为“始地-目的地”。`/
) )
}) })