feat: 增强差旅报销审核流程与票据智能推理
优化本体解析和编排器的差旅场景处理能力,完善报销单草稿 保存和费用明细同步逻辑,前端报销创建页面增加行程推理和 票据审核交互,新增助手会话快照工具函数,补充单元测试。
This commit is contained in:
@@ -22,6 +22,7 @@ class UserAgentSuggestedAction(BaseModel):
|
||||
label: str = Field(description="建议动作文案。")
|
||||
action_type: str = Field(description="动作类型,例如 open_detail / create_draft。")
|
||||
description: str = Field(default="", description="动作说明。")
|
||||
payload: dict[str, Any] = Field(default_factory=dict, description="动作携带的结构化参数。")
|
||||
|
||||
|
||||
class UserAgentDraftPayload(BaseModel):
|
||||
|
||||
@@ -22,6 +22,9 @@ STATEFUL_CONTEXT_KEYS = (
|
||||
"business_time_context",
|
||||
)
|
||||
REVIEW_FLOW_CONTEXT_KEYS = {
|
||||
"draft_claim_id",
|
||||
"draft_claim_no",
|
||||
"draft_status",
|
||||
"request_context",
|
||||
"attachment_names",
|
||||
"attachment_count",
|
||||
@@ -131,7 +134,11 @@ class AgentConversationService:
|
||||
resolved_retention_days = retention_days or self._resolve_retention_days()
|
||||
cutoff = datetime.now(UTC) - timedelta(days=max(1, resolved_retention_days))
|
||||
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:
|
||||
return 0
|
||||
|
||||
@@ -141,6 +148,13 @@ class AgentConversationService:
|
||||
self.db.commit()
|
||||
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:
|
||||
try:
|
||||
settings_row, _ = SettingsService(self.db).ensure_settings_ready()
|
||||
@@ -232,6 +246,9 @@ class AgentConversationService:
|
||||
context_json=merged,
|
||||
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_history"] = self.list_message_history(
|
||||
@@ -264,7 +281,12 @@ class AgentConversationService:
|
||||
context_json: dict[str, Any],
|
||||
message: str | None,
|
||||
) -> bool:
|
||||
if isinstance(context_json.get("expense_scene_selection"), dict):
|
||||
return True
|
||||
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
|
||||
if str(context_json.get("review_action") or "").strip():
|
||||
return True
|
||||
|
||||
@@ -177,8 +177,8 @@ SUPPORTED_DOCUMENT_TYPES = tuple(DOCUMENT_TYPE_RULE_MAP.keys()) + ("other",)
|
||||
|
||||
AMOUNT_PATTERNS = (
|
||||
re.compile(
|
||||
r"(?:价税合计|合计金额|费用合计|订单(?:总)?金额|支付(?:金额)?|实付(?:金额)?|实收(?:金额)?|总(?:额|计|价)|票价|金额|车费|消费金额)"
|
||||
r"[::\s¥¥人民币]*([0-9]+(?:[.,][0-9]{1,2})?)"
|
||||
r"(?:价税合计|合计金额|费用合计|总费用|费用总计|订单(?:总)?金额|支付(?:金额)?|实付(?:金额)?|实收(?:金额)?|总(?:额|计|价)|票价|金额|车费|消费金额|房费|住宿费)"
|
||||
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*元"),
|
||||
@@ -721,6 +721,8 @@ def _extract_amount(text: str) -> str:
|
||||
continue
|
||||
if candidate <= Decimal("0.00"):
|
||||
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:
|
||||
best_value = candidate
|
||||
|
||||
@@ -731,6 +733,22 @@ def _extract_amount(text: str) -> str:
|
||||
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:
|
||||
matches = list(DATE_PATTERN.finditer(text))
|
||||
if not matches:
|
||||
|
||||
@@ -15,7 +15,7 @@ from types import SimpleNamespace
|
||||
from typing import Any
|
||||
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.orm import Session, selectinload
|
||||
|
||||
@@ -78,6 +78,7 @@ TRAVEL_DETAIL_ITEM_TYPES = {
|
||||
"ride_ticket",
|
||||
"travel_allowance",
|
||||
}
|
||||
TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES = {"train_ticket", "flight_ticket"}
|
||||
DOCUMENT_TYPE_ITEM_TYPE_MAP = {
|
||||
"train_ticket": "train_ticket",
|
||||
"flight_itinerary": "flight_ticket",
|
||||
@@ -97,8 +98,8 @@ DOCUMENT_TYPE_SCENE_MAP = {
|
||||
"meeting_invoice": "meeting",
|
||||
"training_invoice": "training",
|
||||
}
|
||||
DOCUMENT_FACT_ITEM_TYPES = {"train_ticket", "flight_ticket", "hotel_ticket", "ride_ticket"}
|
||||
ROUTE_DESCRIPTION_ITEM_TYPES = {"train_ticket", "flight_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", "ship_ticket", "ferry_ticket", "ride_ticket"}
|
||||
DOCUMENT_TRIP_DATE_LABELS = {
|
||||
"train_ticket": "列车出发时间",
|
||||
"flight_itinerary": "起飞日期",
|
||||
@@ -253,6 +254,11 @@ DOCUMENT_ASSOCIATION_REVIEW_ACTIONS = {
|
||||
"link_to_existing_draft",
|
||||
"create_new_claim_from_documents",
|
||||
}
|
||||
PERSISTENT_EXPENSE_REVIEW_ACTIONS = {
|
||||
"save_draft",
|
||||
"next_step",
|
||||
*DOCUMENT_ASSOCIATION_REVIEW_ACTIONS,
|
||||
}
|
||||
RETURN_REASON_OPTIONS = {
|
||||
"missing_attachment": "附件缺失或不清晰",
|
||||
"invoice_mismatch": "票据类型/金额与明细不一致",
|
||||
@@ -262,11 +268,11 @@ RETURN_REASON_OPTIONS = {
|
||||
"approval_question": "审批人需要补充说明",
|
||||
}
|
||||
MAX_CLAIM_NO_RETRY_ATTEMPTS = 3
|
||||
DOCUMENT_AMOUNT_PATTERNS = (
|
||||
re.compile(
|
||||
r"(?:价税合计|合计金额|费用合计|订单(?:总)?金额|支付(?:金额)?|实付(?:金额)?|实收(?:金额)?|总(?:额|计|价)|票价|金额|车费|消费金额)"
|
||||
r"[::\s¥¥人民币]*([0-9]+(?:[.,][0-9]{1,2})?)"
|
||||
),
|
||||
DOCUMENT_AMOUNT_PATTERNS = (
|
||||
re.compile(
|
||||
r"(?:价税合计|合计金额|费用合计|总费用|费用总计|订单(?:总)?金额|支付(?:金额)?|实付(?:金额)?|实收(?:金额)?|总(?:额|计|价)|票价|金额|车费|消费金额|房费|住宿费)"
|
||||
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*元"),
|
||||
)
|
||||
@@ -518,21 +524,21 @@ class ExpenseClaimService:
|
||||
|
||||
if payload.item_date is not None:
|
||||
item.item_date = payload.item_date
|
||||
if payload.item_type is not None:
|
||||
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:
|
||||
item.item_reason = (
|
||||
self._normalize_optional_text(payload.item_reason, fallback=item.item_reason) or item.item_reason
|
||||
)
|
||||
if payload.item_location is not None:
|
||||
item.item_location = (
|
||||
self._normalize_optional_text(payload.item_location, fallback=item.item_location) or item.item_location
|
||||
)
|
||||
if payload.item_amount is not None:
|
||||
amount = payload.item_amount.quantize(Decimal("0.01"))
|
||||
if amount <= Decimal("0.00"):
|
||||
raise ValueError("费用金额必须大于 0。")
|
||||
item.item_amount = amount
|
||||
if payload.item_type is not None:
|
||||
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:
|
||||
item.item_reason = (
|
||||
self._normalize_optional_text(payload.item_reason, allow_empty=True) or ""
|
||||
)
|
||||
if payload.item_location is not None:
|
||||
item.item_location = (
|
||||
self._normalize_optional_text(payload.item_location, allow_empty=True) or ""
|
||||
)
|
||||
if payload.item_amount is not None:
|
||||
amount = payload.item_amount.quantize(Decimal("0.01"))
|
||||
if amount < Decimal("0.00"):
|
||||
raise ValueError("费用金额不能小于 0。")
|
||||
item.item_amount = amount
|
||||
if payload.invoice_id is not None:
|
||||
item.invoice_id = self._normalize_optional_text(payload.invoice_id, allow_empty=True)
|
||||
|
||||
@@ -794,6 +800,10 @@ class ExpenseClaimService:
|
||||
"claim_id": claim.id,
|
||||
"item_id": item.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,
|
||||
"claim_amount": claim.amount,
|
||||
"attachment": self._build_attachment_payload(item),
|
||||
@@ -929,26 +939,29 @@ class ExpenseClaimService:
|
||||
|
||||
return claim
|
||||
|
||||
def save_or_submit_from_ontology(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
user_id: str | None,
|
||||
def save_or_submit_from_ontology(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
user_id: str | None,
|
||||
message: str,
|
||||
ontology: OntologyParseResult,
|
||||
context_json: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
result = self.upsert_draft_from_ontology(
|
||||
run_id=run_id,
|
||||
user_id=user_id,
|
||||
message=message,
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
)
|
||||
|
||||
review_action = str(context_json.get("review_action") or "").strip()
|
||||
if review_action != "next_step":
|
||||
return result
|
||||
ontology: OntologyParseResult,
|
||||
context_json: 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(
|
||||
run_id=run_id,
|
||||
user_id=user_id,
|
||||
message=message,
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
)
|
||||
|
||||
if review_action != "next_step":
|
||||
return result
|
||||
|
||||
claim_id = str(result.get("claim_id") or "").strip()
|
||||
if not claim_id or result.get("draft_limit_reached"):
|
||||
@@ -1029,9 +1042,22 @@ class ExpenseClaimService:
|
||||
"status": claim.status,
|
||||
"approval_stage": claim.approval_stage,
|
||||
"amount": float(claim.amount),
|
||||
"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:
|
||||
claim = self.get_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
@@ -1832,7 +1858,7 @@ class ExpenseClaimService:
|
||||
for document in context_documents:
|
||||
document_type = str(document.get("document_type") 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 False
|
||||
|
||||
@@ -2241,33 +2267,57 @@ class ExpenseClaimService:
|
||||
return ""
|
||||
|
||||
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 []):
|
||||
if not isinstance(field, dict):
|
||||
continue
|
||||
key = str(field.get("key") or "").strip().lower().replace("_", "")
|
||||
label = str(field.get("label") or "").replace(" ", "")
|
||||
value = self._parse_document_amount_value(str(field.get("value") or ""))
|
||||
if value is None:
|
||||
continue
|
||||
if key in {
|
||||
"amount",
|
||||
"totalamount",
|
||||
"paymentamount",
|
||||
"paidamount",
|
||||
"actualamount",
|
||||
} or any(
|
||||
token in label
|
||||
for token in ("金额", "价税合计", "合计", "总额", "总计", "票价", "支付金额", "实付金额", "实收金额")
|
||||
):
|
||||
return value
|
||||
|
||||
text = " ".join(
|
||||
[
|
||||
str(document.get("summary") or "").strip(),
|
||||
str(document.get("text") or "").strip(),
|
||||
]
|
||||
).strip()
|
||||
return self._parse_document_amount_value(text)
|
||||
continue
|
||||
key = str(field.get("key") or "").strip().lower().replace("_", "")
|
||||
label = str(field.get("label") or "").replace(" ", "")
|
||||
is_amount_field = key in {
|
||||
"amount",
|
||||
"totalamount",
|
||||
"paymentamount",
|
||||
"paidamount",
|
||||
"actualamount",
|
||||
} or any(
|
||||
token in label
|
||||
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 None
|
||||
|
||||
def _resolve_document_text_amount(self, text: str) -> Decimal | None:
|
||||
candidates = [
|
||||
candidate
|
||||
for candidate in self._extract_amount_candidates(text)
|
||||
if not self._is_date_like_amount_candidate(candidate, text)
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
return max(candidates)
|
||||
|
||||
def _parse_document_amount_value(self, value: str) -> Decimal | None:
|
||||
raw_value = str(value or "").strip()
|
||||
@@ -2282,9 +2332,45 @@ class ExpenseClaimService:
|
||||
amount = Decimal(numeric).quantize(Decimal("0.01"))
|
||||
except (InvalidOperation, ValueError):
|
||||
continue
|
||||
if amount > Decimal("0.00"):
|
||||
return amount
|
||||
return None
|
||||
if amount > Decimal("0.00"):
|
||||
return amount
|
||||
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:
|
||||
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"):
|
||||
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(
|
||||
self,
|
||||
*,
|
||||
@@ -3428,33 +3562,53 @@ class ExpenseClaimService:
|
||||
values: list[Decimal] = []
|
||||
seen: set[Decimal] = set()
|
||||
|
||||
def append_candidate(raw: str) -> None:
|
||||
compact = str(raw or "").replace(",", ".").strip()
|
||||
if not compact:
|
||||
return
|
||||
try:
|
||||
candidate = Decimal(compact).quantize(Decimal("0.01"))
|
||||
except (InvalidOperation, ValueError):
|
||||
return
|
||||
if candidate in seen:
|
||||
return
|
||||
seen.add(candidate)
|
||||
values.append(candidate)
|
||||
|
||||
for pattern in (
|
||||
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*元",
|
||||
):
|
||||
for raw in re.findall(pattern, text, flags=re.IGNORECASE):
|
||||
append_candidate(raw)
|
||||
|
||||
if values:
|
||||
return values
|
||||
|
||||
for raw in re.findall(r"(?<!\d)(\d{1,6}\.\d{1,2})(?!\d)", text):
|
||||
append_candidate(raw)
|
||||
return values
|
||||
def append_candidate(raw: str, *, source_text: str = "", start: int = -1, end: int = -1) -> None:
|
||||
compact = str(raw or "").replace(",", ".").strip()
|
||||
if not compact:
|
||||
return
|
||||
try:
|
||||
candidate = Decimal(compact).quantize(Decimal("0.01"))
|
||||
except (InvalidOperation, ValueError):
|
||||
return
|
||||
if ExpenseClaimService._is_amount_match_date_fragment(candidate, source_text, start, end):
|
||||
return
|
||||
if candidate in seen:
|
||||
return
|
||||
seen.add(candidate)
|
||||
values.append(candidate)
|
||||
|
||||
for pattern in (
|
||||
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*元",
|
||||
):
|
||||
for match in re.finditer(pattern, text, flags=re.IGNORECASE):
|
||||
append_candidate(match.group(1), source_text=text, start=match.start(1), end=match.end(1))
|
||||
|
||||
if values:
|
||||
return values
|
||||
|
||||
for match in re.finditer(r"(?<!\d)(\d{1,6}\.\d{1,2})(?!\d)", text):
|
||||
append_candidate(match.group(1), source_text=text, start=match.start(1), end=match.end(1))
|
||||
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
|
||||
def _has_date_like_text(text: str) -> bool:
|
||||
@@ -3559,7 +3713,7 @@ class ExpenseClaimService:
|
||||
example = "广州南-北京南" if item_type != "ride_ticket" else "深圳北站-腾讯滨海大厦"
|
||||
current = f"当前为“{reason[:30]}”," if reason else ""
|
||||
return (
|
||||
f"行程说明:{current}格式应为“始发地-目的地”,"
|
||||
f"行程说明:{current}格式应为“起始地-目的地”,"
|
||||
f"例如“{example}”,请按票据行程补充。"
|
||||
)
|
||||
|
||||
@@ -3633,6 +3787,11 @@ class ExpenseClaimService:
|
||||
item=item,
|
||||
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_label = str(document_info.get("document_type_label") or "其他单据").strip() or "其他单据"
|
||||
requirement_matches = bool(requirement_check.get("matches"))
|
||||
@@ -3678,8 +3837,9 @@ class ExpenseClaimService:
|
||||
"开票日期或业务发生日期",
|
||||
)
|
||||
points.append(f"日期字段:未识别到{date_requirement}。")
|
||||
if not requirement_matches:
|
||||
points.append(f"附件类型要求:{requirement_check.get('message')}")
|
||||
if not requirement_matches:
|
||||
points.append(f"附件类型要求:{requirement_check.get('message')}")
|
||||
points.extend(expense_audit_points)
|
||||
if purpose_mismatch_point:
|
||||
points.append(purpose_mismatch_point)
|
||||
if route_format_point:
|
||||
@@ -3721,6 +3881,7 @@ class ExpenseClaimService:
|
||||
elif (
|
||||
purpose_mismatch_point
|
||||
or route_format_point
|
||||
or expense_audit_points
|
||||
or amount_mismatch
|
||||
or issue_count >= 2
|
||||
or warnings
|
||||
@@ -3732,7 +3893,9 @@ class ExpenseClaimService:
|
||||
headline = "AI提示:附件存在明显待整改项"
|
||||
summary = "当前附件可见部分内容,但金额、用途、日期或附件类型仍有缺失或不一致。"
|
||||
if route_format_point and issue_count == 1:
|
||||
summary = "票据行程已识别,但费用明细说明未按“始发地-目的地”格式填写。"
|
||||
summary = "票据行程已识别,但费用明细说明未按“起始地-目的地”格式填写。"
|
||||
elif expense_audit_points and issue_count == len(expense_audit_points):
|
||||
summary = "OCR 金额已完成二次核算,请按票据原文总额复核。"
|
||||
|
||||
suggestion = {
|
||||
"high": "建议过滤当前不匹配的票据,重新上传符合当前费用场景的清晰原件。",
|
||||
@@ -5337,10 +5500,165 @@ class ExpenseClaimService:
|
||||
return True
|
||||
return scene_code == "travel"
|
||||
|
||||
def _sync_claim_from_items(self, claim: ExpenseClaim) -> None:
|
||||
if not claim.items:
|
||||
claim.amount = Decimal("0.00")
|
||||
claim.invoice_count = 0
|
||||
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:
|
||||
self._sync_travel_allowance_item(claim)
|
||||
if not claim.items:
|
||||
claim.amount = Decimal("0.00")
|
||||
claim.invoice_count = 0
|
||||
claim.risk_flags_json = self._merge_claim_attachment_risk_flags(claim, [])
|
||||
return
|
||||
|
||||
@@ -5391,7 +5709,7 @@ class ExpenseClaimService:
|
||||
) -> str:
|
||||
fallback_type = str(fallback or "").strip() or "other"
|
||||
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 fallback_type
|
||||
|
||||
@@ -5572,21 +5890,22 @@ class ExpenseClaimService:
|
||||
if not claim.items:
|
||||
issues.append("费用明细不能为空")
|
||||
|
||||
for index, item in enumerate(claim.items, start=1):
|
||||
prefix = f"费用明细第 {index} 条"
|
||||
item_location_required = self._is_location_required_expense_type(item.item_type or claim.expense_type)
|
||||
if item.item_date is None:
|
||||
issues.append(f"{prefix}缺少日期")
|
||||
for index, item in enumerate(claim.items, start=1):
|
||||
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)
|
||||
if item.item_date is None:
|
||||
issues.append(f"{prefix}缺少日期")
|
||||
if self._is_missing_value(item.item_type):
|
||||
issues.append(f"{prefix}缺少费用项目")
|
||||
if self._is_missing_value(item.item_reason):
|
||||
issues.append(f"{prefix}缺少说明")
|
||||
if item_location_required and self._is_missing_value(item.item_location):
|
||||
issues.append(f"{prefix}缺少地点")
|
||||
if item.item_amount is None or item.item_amount <= Decimal("0.00"):
|
||||
issues.append(f"{prefix}缺少金额")
|
||||
if self._is_missing_value(item.invoice_id):
|
||||
issues.append(f"{prefix}缺少票据标识")
|
||||
if item.item_amount is None or item.item_amount <= Decimal("0.00"):
|
||||
issues.append(f"{prefix}缺少金额")
|
||||
if not is_system_generated and self._is_missing_value(item.invoice_id):
|
||||
issues.append(f"{prefix}缺少票据标识")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
@@ -62,11 +62,13 @@ AMOUNT_PATTERN = re.compile(
|
||||
TOP_N_PATTERN = re.compile(r"(?:top|TOP|前|最高的?|最低的?)\s*(?P<top>\d+)")
|
||||
|
||||
SCENARIO_KEYWORDS = {
|
||||
"expense": (
|
||||
("报销", 0.20),
|
||||
("报账", 0.20),
|
||||
("差旅", 0.20),
|
||||
("费用", 0.14),
|
||||
"expense": (
|
||||
("报销", 0.20),
|
||||
("报销单", 0.20),
|
||||
("单据报销", 0.18),
|
||||
("报账", 0.20),
|
||||
("差旅", 0.20),
|
||||
("费用", 0.14),
|
||||
("发票", 0.14),
|
||||
("票据", 0.12),
|
||||
("借款", 0.12),
|
||||
@@ -249,16 +251,51 @@ MISSING_SLOT_LABELS = {
|
||||
"document_id": "单据号",
|
||||
}
|
||||
|
||||
STATUS_KEYWORDS = {
|
||||
"逾期": "overdue",
|
||||
"待审批": "pending",
|
||||
"待审": "pending",
|
||||
"已审批": "approved",
|
||||
"已通过": "approved",
|
||||
"已付款": "paid",
|
||||
"未付款": "unpaid",
|
||||
"未回款": "unreceived",
|
||||
}
|
||||
STATUS_KEYWORDS = {
|
||||
"草稿": "draft",
|
||||
"待提交": "draft",
|
||||
"待补充": "supplement",
|
||||
"退回": "returned",
|
||||
"已退回": "returned",
|
||||
"进行中": "review",
|
||||
"审批中": "review",
|
||||
"审核中": "review",
|
||||
"流转中": "review",
|
||||
"已提交": "submitted",
|
||||
"逾期": "overdue",
|
||||
"待审批": "pending",
|
||||
"待审": "pending",
|
||||
"已审批": "approved",
|
||||
"已通过": "approved",
|
||||
"已审核": "approved",
|
||||
"已入账": "paid",
|
||||
"已付款": "paid",
|
||||
"未付款": "unpaid",
|
||||
"未回款": "unreceived",
|
||||
}
|
||||
|
||||
LOCATION_KEYWORDS = (
|
||||
"北京",
|
||||
"上海",
|
||||
"广州",
|
||||
"深圳",
|
||||
"杭州",
|
||||
"南京",
|
||||
"苏州",
|
||||
"成都",
|
||||
"重庆",
|
||||
"天津",
|
||||
"武汉",
|
||||
"西安",
|
||||
"郑州",
|
||||
"长沙",
|
||||
"青岛",
|
||||
"厦门",
|
||||
"宁波",
|
||||
"合肥",
|
||||
"济南",
|
||||
"福州",
|
||||
)
|
||||
|
||||
PRIVILEGED_ROLE_CODES = {"manager", "finance", "approver", "executive"}
|
||||
CONTEXTUAL_SCENARIOS = {"expense", "accounts_receivable", "accounts_payable", "knowledge"}
|
||||
@@ -683,9 +720,13 @@ class SemanticOntologyService:
|
||||
scores[scenario] += weight
|
||||
|
||||
best_scenario = max(scores, key=scores.get)
|
||||
best_score = scores[best_scenario]
|
||||
if best_score <= 0:
|
||||
return "unknown", 0.0
|
||||
best_score = scores[best_scenario]
|
||||
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
|
||||
|
||||
if best_scenario == "knowledge":
|
||||
business_scores = [
|
||||
@@ -701,18 +742,52 @@ class SemanticOntologyService:
|
||||
|
||||
return best_scenario, round(min(best_score, 0.34), 2)
|
||||
|
||||
def _detect_intent(
|
||||
self,
|
||||
compact_query: str,
|
||||
def _detect_intent(
|
||||
self,
|
||||
compact_query: str,
|
||||
*,
|
||||
scenario: str,
|
||||
entities: list[OntologyEntity],
|
||||
time_range: OntologyTimeRange,
|
||||
) -> tuple[str, float]:
|
||||
if any(keyword in compact_query for keyword in OPERATE_KEYWORDS):
|
||||
return "operate", 0.30
|
||||
if any(keyword in compact_query for keyword in DRAFT_KEYWORDS):
|
||||
return "draft", 0.26
|
||||
) -> tuple[str, float]:
|
||||
if any(keyword in compact_query for keyword in OPERATE_KEYWORDS):
|
||||
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):
|
||||
return "draft", 0.26
|
||||
if scenario == "expense" and self._is_generic_expense_prompt(compact_query):
|
||||
return "draft", 0.24
|
||||
if any(keyword in compact_query for keyword in COMPARE_KEYWORDS):
|
||||
@@ -1177,13 +1252,16 @@ class SemanticOntologyService:
|
||||
upsert(self._make_entity("receivable", code, code.upper()))
|
||||
for code in re.findall(r"AP-\d{6}-\d{3}", query, flags=re.IGNORECASE):
|
||||
upsert(self._make_entity("payable", code, code.upper()))
|
||||
for code in re.findall(r"INV-[A-Z]+-\d+", query, flags=re.IGNORECASE):
|
||||
upsert(self._make_entity("invoice", code, code.upper()))
|
||||
for code in re.findall(r"CTR-[A-Z]+-\d+", query, flags=re.IGNORECASE):
|
||||
upsert(self._make_entity("contract", code, code.upper()))
|
||||
|
||||
for label, normalized in EXPENSE_TYPE_KEYWORDS.items():
|
||||
if label in query:
|
||||
for code in re.findall(r"INV-[A-Z]+-\d+", query, flags=re.IGNORECASE):
|
||||
upsert(self._make_entity("invoice", code, code.upper()))
|
||||
for code in re.findall(r"CTR-[A-Z]+-\d+", query, flags=re.IGNORECASE):
|
||||
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():
|
||||
if label in query:
|
||||
upsert(self._make_entity("expense_type", label, normalized, role="filter"))
|
||||
|
||||
has_customer_entertainment_signal = "客户" in query and any(
|
||||
@@ -1339,11 +1417,17 @@ class SemanticOntologyService:
|
||||
start = date(today.year, start_month, 1)
|
||||
end = date(today.year, end_month, calendar.monthrange(today.year, end_month)[1])
|
||||
return self._range(start, end, "本季度", "quarter"), 0.10
|
||||
if "今年" in query:
|
||||
return (
|
||||
self._range(date(today.year, 1, 1), date(today.year, 12, 31), "今年", "year"),
|
||||
0.10,
|
||||
)
|
||||
if "今年" in query:
|
||||
return (
|
||||
self._range(date(today.year, 1, 1), date(today.year, 12, 31), "今年", "year"),
|
||||
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)
|
||||
if match:
|
||||
@@ -1491,10 +1575,11 @@ class SemanticOntologyService:
|
||||
"employee",
|
||||
"department",
|
||||
"customer",
|
||||
"vendor",
|
||||
"project",
|
||||
"expense_type",
|
||||
}:
|
||||
"vendor",
|
||||
"project",
|
||||
"location",
|
||||
"expense_type",
|
||||
}:
|
||||
upsert(
|
||||
OntologyConstraint(
|
||||
field=entity.type,
|
||||
|
||||
@@ -670,19 +670,32 @@ class OrchestratorService:
|
||||
}
|
||||
|
||||
if ontology.scenario == "expense" or self._is_expense_review_action(context_json):
|
||||
tool_type = AgentToolType.DATABASE.value
|
||||
tool_name = "database.expense_claims.save_or_submit"
|
||||
executor = lambda: self.expense_claim_service.save_or_submit_from_ontology(
|
||||
run_id=run_id,
|
||||
user_id=payload.user_id,
|
||||
message=payload.message or "",
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
)
|
||||
fallback_factory = lambda exc: {
|
||||
"message": f"报销草稿落库失败,请稍后再试:{exc}",
|
||||
"degraded": True,
|
||||
}
|
||||
is_persistence_action = self._is_expense_persistence_action(context_json)
|
||||
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(
|
||||
run_id=run_id,
|
||||
user_id=payload.user_id,
|
||||
message=payload.message or "",
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
)
|
||||
fallback_factory = lambda exc: {
|
||||
"message": (
|
||||
f"报销草稿落库失败,请稍后再试:{exc}"
|
||||
if is_persistence_action
|
||||
else f"报销内容预览生成失败,请稍后再试:{exc}"
|
||||
),
|
||||
"degraded": True,
|
||||
}
|
||||
|
||||
tool_payload, degraded = self._invoke_tool(
|
||||
run_id=run_id,
|
||||
@@ -819,6 +832,16 @@ class OrchestratorService:
|
||||
"link_to_existing_draft",
|
||||
"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
|
||||
def _flatten_capability_codes(
|
||||
@@ -1165,16 +1188,18 @@ class OrchestratorService:
|
||||
if item.type == "expense_claim" and str(item.normalized_value or item.value or "").strip()
|
||||
)
|
||||
)
|
||||
expense_types = list(
|
||||
dict.fromkeys(
|
||||
str(item.normalized_value or item.value or "").strip()
|
||||
for item in ontology.entities
|
||||
if item.type == "expense_type" and str(item.normalized_value or item.value or "").strip()
|
||||
)
|
||||
)
|
||||
status_values = list(
|
||||
dict.fromkeys(
|
||||
str(item.value).strip()
|
||||
expense_types = list(
|
||||
dict.fromkeys(
|
||||
str(item.normalized_value or item.value or "").strip()
|
||||
for item in ontology.entities
|
||||
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(
|
||||
dict.fromkeys(
|
||||
str(item.value).strip()
|
||||
for item in ontology.constraints
|
||||
if item.field == "status" and item.operator == "=" and str(item.value).strip()
|
||||
)
|
||||
@@ -1189,10 +1214,24 @@ class OrchestratorService:
|
||||
|
||||
if expense_claim_nos:
|
||||
conditions.append(ExpenseClaim.claim_no.in_(expense_claim_nos))
|
||||
if expense_types:
|
||||
conditions.append(ExpenseClaim.expense_type.in_(expense_types))
|
||||
if status_values:
|
||||
conditions.append(ExpenseClaim.status.in_(status_values))
|
||||
if expense_types:
|
||||
conditions.append(ExpenseClaim.expense_type.in_(expense_types))
|
||||
if 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:
|
||||
amount_value = float(item.value)
|
||||
@@ -1251,11 +1290,31 @@ class OrchestratorService:
|
||||
scoped_to_current_user = True
|
||||
else:
|
||||
scope_label = "全部报销单"
|
||||
|
||||
return conditions, scope_label, scoped_to_current_user
|
||||
|
||||
def _build_current_user_claim_conditions(
|
||||
self,
|
||||
|
||||
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(
|
||||
self,
|
||||
*,
|
||||
user_id: str | None,
|
||||
context_json: dict[str, Any],
|
||||
|
||||
@@ -97,6 +97,15 @@ GROUP_SCENE_LABELS = {
|
||||
"other": "其他费用",
|
||||
}
|
||||
|
||||
EXPENSE_SCENE_SELECTION_OPTIONS = (
|
||||
("travel", "差旅费", "出差、长途交通、住宿、差旅补贴等场景。"),
|
||||
("transport", "交通费", "市内打车、停车、过路费等日常交通场景。"),
|
||||
("hotel", "住宿费", "单独住宿、酒店发票等场景。"),
|
||||
("entertainment", "业务招待费", "客户接待、宴请、招待等场景。"),
|
||||
("office", "办公费", "办公用品、耗材、办公设备等采购场景。"),
|
||||
("other", "其他费用", "暂不属于以上分类的报销场景。"),
|
||||
)
|
||||
|
||||
KNOWLEDGE_MODEL_MAIN_TIMEOUT_SECONDS = 3
|
||||
KNOWLEDGE_MODEL_BACKUP_TIMEOUT_SECONDS = 5
|
||||
KNOWLEDGE_MODEL_TIMEOUT_SECONDS = KNOWLEDGE_MODEL_BACKUP_TIMEOUT_SECONDS
|
||||
@@ -275,6 +284,17 @@ class UserAgentService:
|
||||
AgentFoundationService(self.db).ensure_foundation_ready()
|
||||
citations = self._build_citations(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)
|
||||
query_payload = self._build_query_payload(payload)
|
||||
draft_payload = (
|
||||
@@ -1801,6 +1821,11 @@ class UserAgentService:
|
||||
|
||||
@staticmethod
|
||||
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":
|
||||
return True
|
||||
if payload.ontology.scenario != "expense":
|
||||
@@ -1817,6 +1842,21 @@ class UserAgentService:
|
||||
if payload.ontology.scenario == "knowledge":
|
||||
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):
|
||||
return [
|
||||
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(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
@@ -3363,6 +3432,17 @@ class UserAgentService:
|
||||
)
|
||||
|
||||
review_action = str(payload.context_json.get("review_action") or "").strip()
|
||||
if payload.tool_payload.get("preview_only") and not review_action:
|
||||
base_message = review_payload.body_message or self._build_review_intent_summary(
|
||||
payload,
|
||||
slot_cards=review_payload.slot_cards,
|
||||
claim_groups=review_payload.claim_groups,
|
||||
)
|
||||
return (
|
||||
f"{base_message} "
|
||||
"本次只是核对预览,尚未保存为草稿;需要暂存时请点击“保存为草稿”,"
|
||||
"需要正式提交时再点击“继续下一步”。"
|
||||
)
|
||||
if review_action == "save_draft":
|
||||
if draft_payload is not None and draft_payload.claim_no:
|
||||
return (
|
||||
|
||||
Binary file not shown.
@@ -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": []
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"file_name": "2月20_武汉-上海.pdf",
|
||||
"storage_key": "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": []
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
@@ -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": []
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
@@ -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)
|
||||
|
||||
|
||||
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:
|
||||
insight = build_document_insight(
|
||||
filename="铁路电子客票.pdf",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
@@ -69,6 +69,10 @@ def build_session() -> Session:
|
||||
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:
|
||||
service = ExpenseClaimService.__new__(ExpenseClaimService)
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
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:
|
||||
user_id = "returned-owner@example.com"
|
||||
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["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")
|
||||
db.refresh(claim)
|
||||
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"])
|
||||
|
||||
|
||||
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:
|
||||
with build_session() as db:
|
||||
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 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:
|
||||
|
||||
@@ -414,11 +414,11 @@ def test_semantic_ontology_service_uses_client_local_date_for_relative_time() ->
|
||||
assert result.time_range.end_date == "2026-05-12"
|
||||
|
||||
|
||||
def test_semantic_ontology_service_extracts_day_before_yesterday_from_client_local_date() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
result = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
def test_semantic_ontology_service_extracts_day_before_yesterday_from_client_local_date() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
result = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query="我前天请客户吃饭花了200元",
|
||||
user_id="pytest",
|
||||
context_json={
|
||||
@@ -427,12 +427,77 @@ def test_semantic_ontology_service_extracts_day_before_yesterday_from_client_loc
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert result.time_range.raw == "前天"
|
||||
assert result.time_range.start_date == "2026-05-11"
|
||||
assert result.time_range.end_date == "2026-05-11"
|
||||
|
||||
|
||||
|
||||
assert result.time_range.raw == "前天"
|
||||
assert result.time_range.start_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:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
|
||||
@@ -202,7 +202,7 @@ def test_conversation_hydration_does_not_reuse_review_type_for_fresh_expense_pro
|
||||
|
||||
fresh_context = service.hydrate_context_json(
|
||||
conversation=conversation,
|
||||
context_json={},
|
||||
context_json={"draft_claim_id": "claim-old"},
|
||||
message="业务发生时间:2026-02-20 至 2026-02-23,去上海支持上海电力部署项目,申请报销",
|
||||
)
|
||||
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 continued_context["draft_claim_id"] == "claim-old"
|
||||
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]] == ["差旅费", "交通费", "住宿费"]
|
||||
|
||||
@@ -496,25 +496,18 @@ def test_user_agent_guides_generic_expense_request() -> None:
|
||||
)
|
||||
)
|
||||
|
||||
assert response.review_payload is not None
|
||||
assert response.answer == response.review_payload.body_message
|
||||
assert response.review_payload.can_proceed is False
|
||||
assert response.review_payload.missing_slots == [
|
||||
"报销类型",
|
||||
"发生时间",
|
||||
"金额",
|
||||
"事由说明",
|
||||
"票据附件",
|
||||
]
|
||||
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
|
||||
"cancel_review",
|
||||
"edit_review",
|
||||
assert response.review_payload is None
|
||||
assert response.draft_payload is None
|
||||
assert "请先在下面选择报销场景" in response.answer
|
||||
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",
|
||||
]
|
||||
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.label for item in response.suggested_actions[:3]] == ["差旅费", "交通费", "住宿费"]
|
||||
|
||||
|
||||
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
|
||||
slot_map = {item.key: item for item in response.review_payload.slot_cards}
|
||||
assert slot_map["expense_type"].value == ""
|
||||
assert slot_map["expense_type"].status == "missing"
|
||||
assert slot_map["expense_type"].value == "差旅费"
|
||||
assert slot_map["expense_type"].normalized_value == "travel"
|
||||
assert slot_map["time_range"].value == "2026-02-20 至 2026-02-23"
|
||||
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:
|
||||
|
||||
@@ -793,6 +793,133 @@
|
||||
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,
|
||||
.capability-chip,
|
||||
.risk-chip,
|
||||
@@ -982,6 +1109,27 @@
|
||||
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 {
|
||||
color: #94a3b8;
|
||||
font-size: 16px;
|
||||
@@ -4775,6 +4923,10 @@
|
||||
justify-self: stretch;
|
||||
}
|
||||
|
||||
.message-suggested-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.composer {
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
@@ -1586,6 +1586,11 @@
|
||||
background: #fffcf7;
|
||||
}
|
||||
|
||||
.validation-section--risk .risk-advice-card.low {
|
||||
border-color: #dbeafe;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.validation-section--risk .risk-advice-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1611,6 +1616,11 @@
|
||||
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 {
|
||||
min-width: 0;
|
||||
color: #0f172a;
|
||||
@@ -1660,6 +1670,11 @@
|
||||
background: #fffaf2;
|
||||
}
|
||||
|
||||
.risk-advice-card.low {
|
||||
border-color: #bfdbfe;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.risk-advice-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1685,6 +1700,11 @@
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.risk-advice-card.low .risk-advice-card-head span {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.risk-advice-card-head strong {
|
||||
min-width: 0;
|
||||
color: #0f172a;
|
||||
|
||||
@@ -143,13 +143,17 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import PanelHead from '../shared/PanelHead.vue'
|
||||
import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue'
|
||||
import robotAssistant from '../../assets/robot-helper.png'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import { clearUserConversations, fetchLatestConversation } from '../../services/orchestrator.js'
|
||||
import {
|
||||
ASSISTANT_SESSION_SNAPSHOT_EVENT,
|
||||
hasAssistantSessionSnapshot
|
||||
} from '../../utils/assistantSessionSnapshot.js'
|
||||
|
||||
const props = defineProps({
|
||||
showHeader: { type: Boolean, default: true },
|
||||
@@ -164,11 +168,15 @@ const fileInputRef = ref(null)
|
||||
const selectedFiles = ref([])
|
||||
const pendingAction = ref('')
|
||||
const latestExpenseConversation = ref(null)
|
||||
const hasLocalExpenseSnapshot = ref(false)
|
||||
const MAX_ATTACHMENTS = 10
|
||||
const SESSION_TYPE_EXPENSE = 'expense'
|
||||
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 expenseActionIcon = computed(() => (hasExpenseConversation.value ? 'mdi mdi-history' : 'mdi mdi-magnify-scan'))
|
||||
const assistantGreetingName = computed(() => {
|
||||
@@ -271,6 +279,7 @@ function handleWorkbenchFilesChange(event) {
|
||||
}
|
||||
|
||||
async function refreshLatestExpenseConversation() {
|
||||
refreshLocalExpenseSnapshot()
|
||||
try {
|
||||
latestExpenseConversation.value = await loadLatestConversation()
|
||||
} 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() {
|
||||
await clearUserConversations(resolveCurrentUserId(), SESSION_TYPE_KNOWLEDGE)
|
||||
}
|
||||
@@ -414,7 +434,13 @@ const policyItems = [
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
refreshLocalExpenseSnapshot()
|
||||
refreshLatestExpenseConversation()
|
||||
window.addEventListener(ASSISTANT_SESSION_SNAPSHOT_EVENT, handleAssistantSessionSnapshotChange)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener(ASSISTANT_SESSION_SNAPSHOT_EVENT, handleAssistantSessionSnapshotChange)
|
||||
})
|
||||
|
||||
watch(
|
||||
|
||||
99
web/src/utils/assistantSessionSnapshot.js
Normal file
99
web/src/utils/assistantSessionSnapshot.js
Normal 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)
|
||||
}
|
||||
@@ -41,6 +41,10 @@ const FLOW_INTENT_KEYWORDS = {
|
||||
explain: ['为什么', '依据', '规则', '怎么']
|
||||
}
|
||||
|
||||
const EXPLICIT_EXPENSE_INTENT_PATTERN = /报销|报账|费用|发票|票据|单据|垫付|报销单|冲销|借款/
|
||||
const NON_EXPENSE_INTENT_PATTERN = /怎么部署|如何部署|部署步骤|技术方案|排期|任务|工单|需求|代码|脚本|服务器配置|运维|实施计划|项目计划|会议纪要|周报|日报|总结/
|
||||
const BUSINESS_ACTIVITY_PATTERN = /去|到|赴|前往|支撑|支持|部署|实施|驻场|出差|拜访|客户|项目|现场|电力|银行|医院|学校|园区|公司|集团|服务器/
|
||||
|
||||
function normalizeCompactText(value) {
|
||||
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 = {}) {
|
||||
if (sessionType === DEFAULT_SESSION_TYPE_KNOWLEDGE) {
|
||||
return '初步识别为财务知识问答,正在准备检索范围'
|
||||
}
|
||||
|
||||
if (shouldRequestExpenseIntentConfirmation(rawText, { ...options, sessionType })) {
|
||||
return '识别到业务事项描述,但是否发起报销尚不明确,需要先由用户确认'
|
||||
}
|
||||
|
||||
if (shouldRequestExpenseSceneSelection(rawText, { ...options, sessionType })) {
|
||||
return '初步识别为报销申请,但报销场景尚未明确,需要先由用户选择场景'
|
||||
}
|
||||
|
||||
const compact = normalizeCompactText(rawText)
|
||||
const intentLabels = options.intentLabels || DEFAULT_INTENT_LABELS
|
||||
const intentKey = Object.entries(FLOW_INTENT_KEYWORDS).find(([, keywords]) =>
|
||||
|
||||
@@ -131,6 +131,33 @@
|
||||
</span>
|
||||
</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">
|
||||
<strong>风险标签</strong>
|
||||
<div class="message-detail-chip-row">
|
||||
@@ -162,7 +189,9 @@
|
||||
v-if="message.role === 'assistant' && !message.reviewPayload && message.queryPayload?.resultType === 'expense_claim_list'"
|
||||
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">
|
||||
{{ buildExpenseQueryWindowLabel(message.queryPayload) }}
|
||||
@@ -185,7 +214,13 @@
|
||||
:key="`${message.id}-${record.claimId}`"
|
||||
type="button"
|
||||
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-top">
|
||||
@@ -244,7 +279,7 @@
|
||||
|
||||
<div v-else class="expense-query-empty">
|
||||
<i class="mdi mdi-file-search-outline"></i>
|
||||
<span>当前没有可直接展开的近期待办单据。</span>
|
||||
<span>{{ message.queryPayload.emptyText || '当前没有可直接展开的近期待办单据。' }}</span>
|
||||
</div>
|
||||
|
||||
<p v-if="buildExpenseQueryHint(message.queryPayload)" class="expense-query-hint">
|
||||
|
||||
@@ -335,14 +335,6 @@
|
||||
>
|
||||
{{ savingExpenseId === item.id ? '保存中' : '保存' }}
|
||||
</button>
|
||||
<button
|
||||
class="inline-action"
|
||||
type="button"
|
||||
:disabled="actionBusy"
|
||||
@click="cancelExpenseEdit"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="inline-action danger"
|
||||
type="button"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -56,7 +56,8 @@ const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
|
||||
|
||||
const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
|
||||
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}$/
|
||||
|
||||
function parseCurrency(value) {
|
||||
@@ -93,29 +94,59 @@ function resolveLocationSummaryLabel(value) {
|
||||
return isLocationRequiredExpenseType(value) ? '业务地点' : '采购/收货地点'
|
||||
}
|
||||
|
||||
function resolveLocationDisplay(value, expenseType) {
|
||||
if (!isLocationRequiredExpenseType(expenseType) && isPlaceholderValue(value)) {
|
||||
return '非必填'
|
||||
}
|
||||
|
||||
return isPlaceholderValue(value) ? '待补充' : value
|
||||
}
|
||||
|
||||
function isRouteDescriptionExpenseType(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) {
|
||||
const text = String(value || '').trim()
|
||||
return ROUTE_DESCRIPTION_PATTERN.test(text) && !/\d{4}[-/年.]\d{1,2}[-/月.]\d{1,2}/.test(text)
|
||||
}
|
||||
|
||||
function resolveExpenseReasonPlaceholder(itemType) {
|
||||
return isRouteDescriptionExpenseType(itemType) ? '始发地-目的地,例如:广州南-北京南' : '输入费用说明'
|
||||
if (isRouteDescriptionExpenseType(itemType)) {
|
||||
return '起始地-目的地,例如:广州南-北京南'
|
||||
}
|
||||
if (isHotelDescriptionExpenseType(itemType)) {
|
||||
return '目的地酒店,例如:北京中心酒店'
|
||||
}
|
||||
return '输入费用说明'
|
||||
}
|
||||
|
||||
function resolveExpenseReasonHelper(itemType) {
|
||||
return isRouteDescriptionExpenseType(itemType) ? '始发地-目的地' : '业务报销说明'
|
||||
if (isRouteDescriptionExpenseType(itemType)) {
|
||||
return '起始地-目的地'
|
||||
}
|
||||
if (isHotelDescriptionExpenseType(itemType)) {
|
||||
return '目的地酒店'
|
||||
}
|
||||
return '业务报销说明'
|
||||
}
|
||||
|
||||
function buildFallbackProgressSteps() {
|
||||
@@ -399,6 +430,46 @@ function buildExpenseDraftIssues(item) {
|
||||
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) {
|
||||
const issues = []
|
||||
const locationRequired = isLocationRequiredExpenseType(request.typeCode)
|
||||
@@ -482,7 +553,7 @@ function mapIssueToAdvice(issue) {
|
||||
return `${labelPrefix}的用途说明。`
|
||||
}
|
||||
if (fieldText === '行程说明格式错误') {
|
||||
return `${labelPrefix}的行程说明,格式应为“始发地-目的地”。`
|
||||
return `${labelPrefix}的行程说明,格式应为“起始地-目的地”。`
|
||||
}
|
||||
if (fieldText === '缺少地点') {
|
||||
return `${labelPrefix}的业务地点。`
|
||||
@@ -987,11 +1058,14 @@ export default {
|
||||
|
||||
const aiAdvice = computed(() => {
|
||||
const completionItems = draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean)
|
||||
const riskCards = buildAttachmentRiskCards({
|
||||
expenseItems: expenseItems.value,
|
||||
attachmentMetaByItemId: expenseAttachmentMeta,
|
||||
claimRiskFlags: request.value.riskFlags || request.value.risk_flags_json || []
|
||||
})
|
||||
const riskCards = [
|
||||
...buildAttachmentRiskCards({
|
||||
expenseItems: expenseItems.value,
|
||||
attachmentMetaByItemId: expenseAttachmentMeta,
|
||||
claimRiskFlags: request.value.riskFlags || request.value.risk_flags_json || []
|
||||
}),
|
||||
...buildOptionalTravelReceiptRiskCards(request.value, expenseItems.value)
|
||||
]
|
||||
|
||||
return buildAiAdviceViewModel({
|
||||
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) {
|
||||
if (!isEditableRequest.value || actionBusy.value) {
|
||||
return
|
||||
@@ -1038,40 +1123,31 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
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 || (['待补充', '非必填'].includes(item.detail) ? '' : item.detail)
|
||||
expenseEditor.itemAmount = item.itemAmount ? String(item.itemAmount) : ''
|
||||
expenseEditor.invoiceId = item.invoiceId || ''
|
||||
}
|
||||
|
||||
function cancelExpenseEdit() {
|
||||
editingExpenseId.value = ''
|
||||
populateExpenseEditor(item)
|
||||
}
|
||||
|
||||
function validateExpenseEditor() {
|
||||
if (!isValidIsoDate(expenseEditor.itemDate)) {
|
||||
if (expenseEditor.itemDate && !isValidIsoDate(expenseEditor.itemDate)) {
|
||||
return '请输入正确的费用日期,格式为 YYYY-MM-DD。'
|
||||
}
|
||||
if (isPlaceholderValue(expenseEditor.itemType)) {
|
||||
return '请选择费用项目。'
|
||||
}
|
||||
if (isPlaceholderValue(expenseEditor.itemReason)) {
|
||||
return '请输入费用说明。'
|
||||
}
|
||||
if (
|
||||
!isPlaceholderValue(expenseEditor.itemReason)
|
||||
&&
|
||||
isRouteDescriptionExpenseType(expenseEditor.itemType)
|
||||
&& !isValidRouteDescription(expenseEditor.itemReason)
|
||||
) {
|
||||
return '行程说明格式应为“始发地-目的地”,例如:广州南-北京南。'
|
||||
return '行程说明格式应为“起始地-目的地”,例如:广州南-北京南。'
|
||||
}
|
||||
|
||||
const amount = Number(expenseEditor.itemAmount)
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
return '请输入大于 0 的费用金额。'
|
||||
const amountText = String(expenseEditor.itemAmount || '').trim()
|
||||
if (amountText) {
|
||||
const amount = Number(amountText)
|
||||
if (!Number.isFinite(amount) || amount < 0) {
|
||||
return '请输入不小于 0 的费用金额。'
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
@@ -1223,10 +1299,26 @@ export default {
|
||||
const payload = await uploadExpenseClaimItemAttachment(request.value.claimId, item.id, file)
|
||||
expenseAttachmentMeta[item.id] = payload?.attachment || null
|
||||
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 = {
|
||||
invoiceId: String(payload?.invoice_id || '').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) {
|
||||
itemPatch.itemAmount = recognizedItemAmount
|
||||
itemPatch.amount = formatCurrency(recognizedItemAmount)
|
||||
@@ -1234,12 +1326,7 @@ export default {
|
||||
applyLocalExpenseItemPatch(item.id, {
|
||||
...itemPatch
|
||||
})
|
||||
if (editingExpenseId.value === item.id) {
|
||||
expenseEditor.invoiceId = String(payload?.invoice_id || '').trim()
|
||||
if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) {
|
||||
expenseEditor.itemAmount = String(recognizedItemAmount)
|
||||
}
|
||||
}
|
||||
populateExpenseEditor({ ...item, ...itemPatch })
|
||||
|
||||
emit('request-updated', { claimId: request.value.claimId })
|
||||
const riskNotice = buildAttachmentRiskNotice(payload?.attachment)
|
||||
@@ -1370,20 +1457,25 @@ export default {
|
||||
try {
|
||||
const nextInvoiceId = expenseEditor.invoiceId.trim()
|
||||
const preservedLocation = String(item.itemLocation || expenseEditor.itemLocation || '').trim()
|
||||
await updateExpenseClaimItem(request.value.claimId, item.id, {
|
||||
item_date: expenseEditor.itemDate,
|
||||
const amountText = String(expenseEditor.itemAmount || '').trim()
|
||||
const nextAmount = amountText ? Number(amountText) : 0
|
||||
const itemPayload = {
|
||||
item_type: expenseEditor.itemType,
|
||||
item_reason: expenseEditor.itemReason.trim(),
|
||||
item_location: preservedLocation,
|
||||
item_amount: Number(expenseEditor.itemAmount),
|
||||
item_amount: nextAmount,
|
||||
invoice_id: nextInvoiceId
|
||||
})
|
||||
}
|
||||
if (expenseEditor.itemDate) {
|
||||
itemPayload.item_date = expenseEditor.itemDate
|
||||
}
|
||||
await updateExpenseClaimItem(request.value.claimId, item.id, itemPayload)
|
||||
applyLocalExpenseItemPatch(item.id, {
|
||||
itemDate: expenseEditor.itemDate,
|
||||
itemDate: expenseEditor.itemDate || item.itemDate,
|
||||
itemType: expenseEditor.itemType,
|
||||
itemReason: expenseEditor.itemReason.trim(),
|
||||
itemLocation: preservedLocation,
|
||||
itemAmount: Number(expenseEditor.itemAmount),
|
||||
itemAmount: nextAmount,
|
||||
invoiceId: nextInvoiceId
|
||||
})
|
||||
let riskNotice = ''
|
||||
@@ -1713,7 +1805,6 @@ export default {
|
||||
triggerExpenseUpload,
|
||||
uploadedExpenseCount,
|
||||
uploadingExpenseId,
|
||||
cancelExpenseEdit,
|
||||
saveExpenseEdit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,13 +288,7 @@ export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] }
|
||||
summary: 'AI判断当前草稿已具备提交条件,可以直接发起审批。',
|
||||
items,
|
||||
riskCards: [],
|
||||
sections: [
|
||||
{
|
||||
kind: 'completion',
|
||||
title: '建议补充字段',
|
||||
items
|
||||
}
|
||||
]
|
||||
sections: []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
buildLocalExtractionProgressMessages,
|
||||
buildLocalIntentPreview,
|
||||
inferLocalFlowCandidates,
|
||||
shouldRequestExpenseIntentConfirmation,
|
||||
shouldRequestExpenseSceneSelection,
|
||||
summarizeSemanticIntentDetail
|
||||
} 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)
|
||||
})
|
||||
|
||||
@@ -153,6 +153,10 @@ test('AI advice view model exposes grouped completion and risk sections', () =>
|
||||
})
|
||||
|
||||
test('AI advice view model omits empty sections', () => {
|
||||
const readyAdvice = buildAiAdviceViewModel({
|
||||
completionItems: [],
|
||||
riskCards: []
|
||||
})
|
||||
const completionOnlyAdvice = buildAiAdviceViewModel({
|
||||
completionItems: ['补充业务地点'],
|
||||
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(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(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\.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;/)
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
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\.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', () => {
|
||||
@@ -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', () => {
|
||||
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, /return isRouteDescriptionExpenseType\(itemType\) \? '始发地-目的地,例如:广州南-北京南' : '输入费用说明'/)
|
||||
assert.match(detailViewScript, /return isRouteDescriptionExpenseType\(itemType\) \? '始发地-目的地' : '业务报销说明'/)
|
||||
assert.match(detailViewScript, /return '起始地-目的地,例如:广州南-北京南'/)
|
||||
assert.match(detailViewScript, /return '起始地-目的地'/)
|
||||
assert.match(detailViewScript, /return '目的地酒店,例如:北京中心酒店'/)
|
||||
assert.match(detailViewScript, /return '目的地酒店'/)
|
||||
assert.match(detailViewScript, /isSyntheticLocationDisplay\(item\.detail, item\.itemType\)/)
|
||||
assert.match(
|
||||
detailViewScript,
|
||||
/isRouteDescriptionExpenseType\(item\.itemType\) && !isValidRouteDescription\(item\.itemReason\)[\s\S]*issues\.push\('行程说明格式错误'\)/
|
||||
)
|
||||
assert.match(
|
||||
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(
|
||||
detailViewScript,
|
||||
/fieldText === '行程说明格式错误'[\s\S]*return `\$\{labelPrefix\}的行程说明,格式应为“始发地-目的地”。`/
|
||||
/fieldText === '行程说明格式错误'[\s\S]*return `\$\{labelPrefix\}的行程说明,格式应为“起始地-目的地”。`/
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user