diff --git a/server/src/app/schemas/user_agent.py b/server/src/app/schemas/user_agent.py index 8be367e..a7badc7 100644 --- a/server/src/app/schemas/user_agent.py +++ b/server/src/app/schemas/user_agent.py @@ -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): diff --git a/server/src/app/services/agent_conversations.py b/server/src/app/services/agent_conversations.py index 6ed04aa..7af40f8 100644 --- a/server/src/app/services/agent_conversations.py +++ b/server/src/app/services/agent_conversations.py @@ -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 diff --git a/server/src/app/services/document_intelligence.py b/server/src/app/services/document_intelligence.py index d5b33da..4c562fc 100644 --- a/server/src/app/services/document_intelligence.py +++ b/server/src/app/services/document_intelligence.py @@ -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: diff --git a/server/src/app/services/expense_claims.py b/server/src/app/services/expense_claims.py index 9424fbe..0e0fbb5 100644 --- a/server/src/app/services/expense_claims.py +++ b/server/src/app/services/expense_claims.py @@ -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"(? 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"(? 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"(? 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 diff --git a/server/src/app/services/ontology.py b/server/src/app/services/ontology.py index a13cfbc..03b44b7 100644 --- a/server/src/app/services/ontology.py +++ b/server/src/app/services/ontology.py @@ -62,11 +62,13 @@ AMOUNT_PATTERN = re.compile( TOP_N_PATTERN = re.compile(r"(?:top|TOP|前|最高的?|最低的?)\s*(?P\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, diff --git a/server/src/app/services/orchestrator.py b/server/src/app/services/orchestrator.py index 5c55497..d1e0c62 100644 --- a/server/src/app/services/orchestrator.py +++ b/server/src/app/services/orchestrator.py @@ -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], diff --git a/server/src/app/services/user_agent.py b/server/src/app/services/user_agent.py index 6439bf7..80858ce 100644 --- a/server/src/app/services/user_agent.py +++ b/server/src/app/services/user_agent.py @@ -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 ( diff --git a/server/storage/expense_claims/f8bb5866-1a52-4371-a0e5-257493a8a491/52702496-29fa-40e8-beab-662abd302aae/2月23_上海-武汉.pdf b/server/storage/expense_claims/f8bb5866-1a52-4371-a0e5-257493a8a491/52702496-29fa-40e8-beab-662abd302aae/2月23_上海-武汉.pdf new file mode 100644 index 0000000..d516ecb Binary files /dev/null and b/server/storage/expense_claims/f8bb5866-1a52-4371-a0e5-257493a8a491/52702496-29fa-40e8-beab-662abd302aae/2月23_上海-武汉.pdf differ diff --git a/server/storage/expense_claims/f8bb5866-1a52-4371-a0e5-257493a8a491/52702496-29fa-40e8-beab-662abd302aae/2月23_上海-武汉.pdf.meta.json b/server/storage/expense_claims/f8bb5866-1a52-4371-a0e5-257493a8a491/52702496-29fa-40e8-beab-662abd302aae/2月23_上海-武汉.pdf.meta.json new file mode 100644 index 0000000..09e4048 --- /dev/null +++ b/server/storage/expense_claims/f8bb5866-1a52-4371-a0e5-257493a8a491/52702496-29fa-40e8-beab-662abd302aae/2月23_上海-武汉.pdf.meta.json @@ -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": [] +} \ No newline at end of file diff --git a/server/storage/expense_claims/f8bb5866-1a52-4371-a0e5-257493a8a491/52702496-29fa-40e8-beab-662abd302aae/2月23_上海-武汉.preview.png b/server/storage/expense_claims/f8bb5866-1a52-4371-a0e5-257493a8a491/52702496-29fa-40e8-beab-662abd302aae/2月23_上海-武汉.preview.png new file mode 100644 index 0000000..099413e Binary files /dev/null and b/server/storage/expense_claims/f8bb5866-1a52-4371-a0e5-257493a8a491/52702496-29fa-40e8-beab-662abd302aae/2月23_上海-武汉.preview.png differ diff --git a/server/storage/expense_claims/f8bb5866-1a52-4371-a0e5-257493a8a491/cfe0fe24-f482-4cf1-aee9-cb3acc26dbe4/2月20_武汉-上海.pdf b/server/storage/expense_claims/f8bb5866-1a52-4371-a0e5-257493a8a491/cfe0fe24-f482-4cf1-aee9-cb3acc26dbe4/2月20_武汉-上海.pdf new file mode 100644 index 0000000..b2207b8 Binary files /dev/null and b/server/storage/expense_claims/f8bb5866-1a52-4371-a0e5-257493a8a491/cfe0fe24-f482-4cf1-aee9-cb3acc26dbe4/2月20_武汉-上海.pdf differ diff --git a/server/storage/expense_claims/f8bb5866-1a52-4371-a0e5-257493a8a491/cfe0fe24-f482-4cf1-aee9-cb3acc26dbe4/2月20_武汉-上海.pdf.meta.json b/server/storage/expense_claims/f8bb5866-1a52-4371-a0e5-257493a8a491/cfe0fe24-f482-4cf1-aee9-cb3acc26dbe4/2月20_武汉-上海.pdf.meta.json new file mode 100644 index 0000000..db1cecb --- /dev/null +++ b/server/storage/expense_claims/f8bb5866-1a52-4371-a0e5-257493a8a491/cfe0fe24-f482-4cf1-aee9-cb3acc26dbe4/2月20_武汉-上海.pdf.meta.json @@ -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": [] +} \ No newline at end of file diff --git a/server/storage/expense_claims/f8bb5866-1a52-4371-a0e5-257493a8a491/cfe0fe24-f482-4cf1-aee9-cb3acc26dbe4/2月20_武汉-上海.preview.png b/server/storage/expense_claims/f8bb5866-1a52-4371-a0e5-257493a8a491/cfe0fe24-f482-4cf1-aee9-cb3acc26dbe4/2月20_武汉-上海.preview.png new file mode 100644 index 0000000..0bdfb91 Binary files /dev/null and b/server/storage/expense_claims/f8bb5866-1a52-4371-a0e5-257493a8a491/cfe0fe24-f482-4cf1-aee9-cb3acc26dbe4/2月20_武汉-上海.preview.png differ diff --git a/server/storage/expense_claims/f8bb5866-1a52-4371-a0e5-257493a8a491/f4d818ec-8a17-44e3-98f4-99c1e97b63b1/酒店1.jpg b/server/storage/expense_claims/f8bb5866-1a52-4371-a0e5-257493a8a491/f4d818ec-8a17-44e3-98f4-99c1e97b63b1/酒店1.jpg new file mode 100644 index 0000000..2427401 Binary files /dev/null and b/server/storage/expense_claims/f8bb5866-1a52-4371-a0e5-257493a8a491/f4d818ec-8a17-44e3-98f4-99c1e97b63b1/酒店1.jpg differ diff --git a/server/storage/expense_claims/f8bb5866-1a52-4371-a0e5-257493a8a491/f4d818ec-8a17-44e3-98f4-99c1e97b63b1/酒店1.jpg.meta.json b/server/storage/expense_claims/f8bb5866-1a52-4371-a0e5-257493a8a491/f4d818ec-8a17-44e3-98f4-99c1e97b63b1/酒店1.jpg.meta.json new file mode 100644 index 0000000..9d900fa --- /dev/null +++ b/server/storage/expense_claims/f8bb5866-1a52-4371-a0e5-257493a8a491/f4d818ec-8a17-44e3-98f4-99c1e97b63b1/酒店1.jpg.meta.json @@ -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": [] +} \ No newline at end of file diff --git a/server/storage/expense_claims/f8bb5866-1a52-4371-a0e5-257493a8a491/f4d818ec-8a17-44e3-98f4-99c1e97b63b1/酒店1.preview.jpg b/server/storage/expense_claims/f8bb5866-1a52-4371-a0e5-257493a8a491/f4d818ec-8a17-44e3-98f4-99c1e97b63b1/酒店1.preview.jpg new file mode 100644 index 0000000..2427401 Binary files /dev/null and b/server/storage/expense_claims/f8bb5866-1a52-4371-a0e5-257493a8a491/f4d818ec-8a17-44e3-98f4-99c1e97b63b1/酒店1.preview.jpg differ diff --git a/server/tests/test_document_intelligence.py b/server/tests/test_document_intelligence.py index a0e0e5d..e927301 100644 --- a/server/tests/test_document_intelligence.py +++ b/server/tests/test_document_intelligence.py @@ -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", diff --git a/server/tests/test_expense_claim_service.py b/server/tests/test_expense_claim_service.py index 85a1f36..5fcf596 100644 --- a/server/tests/test_expense_claim_service.py +++ b/server/tests/test_expense_claim_service.py @@ -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: diff --git a/server/tests/test_ontology_service.py b/server/tests/test_ontology_service.py index f6e956e..df55553 100644 --- a/server/tests/test_ontology_service.py +++ b/server/tests/test_ontology_service.py @@ -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: diff --git a/server/tests/test_orchestrator_review_flow.py b/server/tests/test_orchestrator_review_flow.py index 74fc9d6..dcf576f 100644 --- a/server/tests/test_orchestrator_review_flow.py +++ b/server/tests/test_orchestrator_review_flow.py @@ -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]] == ["差旅费", "交通费", "住宿费"] diff --git a/server/tests/test_user_agent_service.py b/server/tests/test_user_agent_service.py index 0228629..c22da2a 100644 --- a/server/tests/test_user_agent_service.py +++ b/server/tests/test_user_agent_service.py @@ -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: diff --git a/web/src/assets/styles/views/travel-reimbursement-create-view.css b/web/src/assets/styles/views/travel-reimbursement-create-view.css index 5788ccb..b0b99d1 100644 --- a/web/src/assets/styles/views/travel-reimbursement-create-view.css +++ b/web/src/assets/styles/views/travel-reimbursement-create-view.css @@ -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; } diff --git a/web/src/assets/styles/views/travel-request-detail-view.css b/web/src/assets/styles/views/travel-request-detail-view.css index 741c20e..be61191 100644 --- a/web/src/assets/styles/views/travel-request-detail-view.css +++ b/web/src/assets/styles/views/travel-request-detail-view.css @@ -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; diff --git a/web/src/components/business/PersonalWorkbench.vue b/web/src/components/business/PersonalWorkbench.vue index 640e419..c29bbc0 100644 --- a/web/src/components/business/PersonalWorkbench.vue +++ b/web/src/components/business/PersonalWorkbench.vue @@ -143,13 +143,17 @@