from __future__ import annotations import json import re import shutil import uuid from collections import defaultdict from datetime import UTC, date, datetime, timedelta from decimal import Decimal, InvalidOperation from pathlib import Path from types import SimpleNamespace from typing import Any from sqlalchemy import func, or_, select from sqlalchemy import inspect as sqlalchemy_inspect from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, selectinload from app.api.deps import CurrentUserContext from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType from app.models.agent_asset import AgentAsset from app.models.employee import Employee from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.schemas.ontology import OntologyEntity, OntologyParseResult from app.schemas.reimbursement import ( ExpenseClaimItemCreate, ExpenseClaimItemUpdate, ExpenseClaimUpdate, TravelReimbursementCalculatorRequest, ) from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY from app.services.agent_foundation import AgentFoundationService from app.services.audit import AuditLogService from app.services.document_intelligence import build_document_insight from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage from app.services.expense_claim_errors import ExpenseClaimSubmissionBlockedError from app.services.expense_claim_constants import ( EXPENSE_TYPE_LABELS, MAX_DRAFT_CLAIMS_PER_USER, EDITABLE_CLAIM_STATUSES, SYSTEM_GENERATED_ITEM_TYPES, TRAVEL_DETAIL_ITEM_TYPES, TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES, DOCUMENT_TYPE_ITEM_TYPE_MAP, DOCUMENT_TYPE_SCENE_MAP, DOCUMENT_FACT_ITEM_TYPES, ROUTE_DESCRIPTION_ITEM_TYPES, DOCUMENT_TRIP_DATE_LABELS, DOCUMENT_TRIP_DATE_REQUIREMENT_LABELS, DOCUMENT_TRIP_DATE_KEYS, DOCUMENT_GENERIC_DATE_KEYS, DOCUMENT_INVOICE_DATE_KEYS, DOCUMENT_TRIP_DATE_LABEL_TOKENS, DOCUMENT_GENERIC_DATE_LABEL_TOKENS, DOCUMENT_INVOICE_DATE_LABEL_TOKENS, DOCUMENT_ROUTE_FORMAT_PATTERN, DOCUMENT_ROUTE_TEXT_PATTERN, DOCUMENT_ROUTE_ORIGIN_LABELS, DOCUMENT_ROUTE_DESTINATION_LABELS, GENERIC_ATTACHMENT_BACKFILL_ITEM_TYPES, LOCATION_REQUIRED_EXPENSE_TYPES, EXPENSE_SCENE_KEYWORDS, EXPENSE_TYPE_ALLOWED_DOCUMENT_SCENES, DOCUMENT_SCENE_LABELS, DOCUMENT_ASSOCIATION_REVIEW_ACTIONS, PERSISTENT_EXPENSE_REVIEW_ACTIONS, RETURN_REASON_OPTIONS, MAX_CLAIM_NO_RETRY_ATTEMPTS, DOCUMENT_DATE_PATTERN, SYSTEM_GENERATED_REASON_PREFIXES, LEADING_REASON_TIME_PATTERNS, AI_REVIEW_LOOKBACK_DAYS, AI_REVIEW_REPEAT_RISK_WARNING_COUNT, AI_REVIEW_REPEAT_RISK_BLOCK_COUNT, TRAVEL_REVIEW_RELEVANT_EXPENSE_TYPES, TRAVEL_REVIEW_LONG_DISTANCE_DOCUMENT_TYPES, TRAVEL_POLICY_CITY_TIERS, TRAVEL_POLICY_CITY_MATCH_ORDER, TRAVEL_POLICY_BAND_LABELS, TRAVEL_POLICY_HOTEL_LIMITS, TRAVEL_POLICY_ALLOWED_TRANSPORT_LEVELS, TRAVEL_POLICY_ROUTE_EXCEPTION_KEYWORDS, TRAVEL_POLICY_STANDARD_EXCEPTION_KEYWORDS, TRAVEL_POLICY_FLIGHT_CLASS_PATTERNS, TRAVEL_POLICY_TRAIN_CLASS_PATTERNS, TRAVEL_POLICY_HOTEL_NIGHT_PATTERN, ) from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin from app.services.expense_amounts import ( extract_amount_candidates, format_decimal_amount, is_amount_match_date_fragment, is_date_like_amount_candidate, is_probable_year_amount, parse_document_amount_value, parse_plain_document_amount_value, resolve_document_field_amount, resolve_document_item_amount, resolve_document_text_amount, ) from app.services.expense_rule_runtime import ( DEFAULT_SCENE_RULE_ASSET_CODE, ExpenseRuleRuntimeService, RuntimeTravelPolicy, build_default_expense_rule_catalog, resolve_document_type_label, ) from app.services.ocr import OcrService class ExpenseClaimReviewPreviewMixin: 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]: 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( user_id=user_id, message=message, ontology=ontology, context_json=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"): return result current_user = CurrentUserContext( username=str(user_id or context_json.get("name") or "anonymous").strip() or "anonymous", name=str(context_json.get("name") or user_id or "anonymous").strip() or "anonymous", role_codes=[ str(item).strip() for item in list(context_json.get("role_codes") or []) if str(item).strip() ], is_admin=bool(context_json.get("is_admin")), department_name=str(context_json.get("department_name") or context_json.get("department") or "").strip(), ) try: claim = self.submit_claim(claim_id, current_user) except ExpenseClaimSubmissionBlockedError as exc: return { **result, "message": self._format_submission_blocked_message(exc.issues), "submission_blocked": True, "submission_blocked_reasons": exc.issues, "missing_fields": exc.issues, "draft_only": False, } except ValueError as exc: message = str(exc) return { **result, "message": message, "submission_blocked": True, "submission_blocked_reasons": [message] if message else [], "missing_fields": [message] if message else [], "draft_only": False, } if claim is None: return { **result, "message": "未找到可提交的报销单,请刷新后重试。", "submission_blocked": True, "draft_only": False, } if str(claim.status or "").strip().lower() != "submitted": review_message = "" for flag in list(claim.risk_flags_json or []): if not isinstance(flag, dict): continue if str(flag.get("source") or "").strip() != "submission_review": continue review_message = str(flag.get("message") or "").strip() if review_message: break return { "message": review_message or f"报销单 {claim.claim_no} 经自动检测后转为待补充,请先修正后再提交。", "submission_blocked": True, "draft_only": False, "claim_id": claim.id, "claim_no": claim.claim_no, "status": claim.status, "approval_stage": claim.approval_stage, "amount": float(claim.amount), "invoice_count": int(claim.invoice_count or 0), } return { "message": ( f"报销单 {claim.claim_no} 已完成自动检测," f"当前节点为 {claim.approval_stage or '审批中'}。" ), "draft_only": False, "claim_id": claim.id, "claim_no": claim.claim_no, "status": claim.status, "approval_stage": claim.approval_stage, "amount": float(claim.amount), "invoice_count": int(claim.invoice_count or 0), } def _build_expense_review_preview_result( self, *, user_id: str | None, message: str, ontology: OntologyParseResult, context_json: dict[str, Any], ) -> dict[str, Any]: attachment_count = self._resolve_attachment_count(context_json) calculation_copy = self._build_expense_review_preview_calculation_copy( user_id=user_id, message=message, ontology=ontology, context_json=context_json, ) return { "message": "\n\n".join( item for item in [ "我已先整理出本次报销的待核对信息。下面是基于当前信息的制度测算,票据补齐后会按真实金额重新复核。", calculation_copy, ] if item ), "draft_only": True, "preview_only": True, "status": "preview", "invoice_count": attachment_count, } def _build_expense_review_preview_calculation_copy( self, *, user_id: str | None, message: str, ontology: OntologyParseResult, context_json: dict[str, Any], ) -> str: expense_type = self._resolve_explicit_review_expense_type(context_json) or self._resolve_expense_type( ontology.entities, context_json=context_json, ) if expense_type == "travel" or ( (not expense_type or expense_type == "other") and self._should_preview_as_travel(message=message, context_json=context_json) ): return self._build_travel_review_preview_calculation_copy( user_id=user_id, message=message, ontology=ontology, context_json=context_json, ) amount = self._resolve_amount(ontology.entities, context_json=context_json) or Decimal("0.00") expense_label = EXPENSE_TYPE_LABELS.get(str(expense_type or "").strip(), "当前费用") return "\n".join( [ "报销测算参考:", "", "| 项目 | 当前信息 | 复核口径 |", "| --- | --- | --- |", f"| 费用类型 | {expense_label} | 匹配规则中心对应费用标准 |", f"| 票据金额 | {self._format_decimal_amount(amount)} 元 | 以真实票据识别金额和用户确认金额为准 |", "| 规则校验 | 待票据和关键信息补齐 | 按费用类型、发生地点、业务事由和审批口径复核 |", ] ) def _build_travel_review_preview_calculation_copy( self, *, user_id: str | None, message: str, ontology: OntologyParseResult, context_json: dict[str, Any], ) -> str: location = self._resolve_location(message=message, context_json=context_json) or "待确认" occurred_at = self._resolve_occurred_at(ontology, context_json=context_json) or datetime.now(UTC) days, _, _ = self._resolve_travel_allowance_days( context_json=context_json, occurred_at=occurred_at, ) amount = self._resolve_amount(ontology.entities, context_json=context_json) or Decimal("0.00") employee = self._resolve_employee( ontology=ontology, context_json=context_json, user_id=user_id, ) grade = str( context_json.get("employee_grade") or context_json.get("grade") or context_json.get("user_grade") or (employee.grade if employee is not None else "") or "" ).strip() if location == "待确认" or not grade: return "\n".join( [ "报销测算参考:", "", "| 项目 | 当前信息 | 测算说明 |", "| --- | --- | --- |", f"| 出差地点 | {location} | 用于匹配城市住宿标准和补贴区域 |", f"| 出差天数 | {days} 天 | 来自业务发生时间或用户描述 |", f"| 职级 | {grade or '待确认'} | 补齐后才能匹配住宿标准和补贴档位 |", f"| 交通票据 | {self._format_decimal_amount(amount)} 元 | 上传票据后按真实金额重新复核 |", ] ) try: from app.services.travel_reimbursement_calculator import ( TravelReimbursementCalculatorService, ) result = TravelReimbursementCalculatorService(self.db).calculate( TravelReimbursementCalculatorRequest(days=days, location=location, grade=grade), CurrentUserContext( username=str(user_id or context_json.get("name") or "anonymous").strip() or "anonymous", name=str(context_json.get("name") or user_id or "anonymous").strip() or "anonymous", role_codes=[], is_admin=False, ), ) except ValueError: return "\n".join( [ "报销测算参考:", "", "| 项目 | 当前信息 | 测算说明 |", "| --- | --- | --- |", f"| 出差地点 | {location} | 暂时未能匹配规则中心地点 |", f"| 出差天数 | {days} 天 | 来自业务发生时间或用户描述 |", f"| 职级 | {grade} | 暂时无法自动匹配差旅标准 |", f"| 交通票据 | {self._format_decimal_amount(amount)} 元 | 上传票据后按真实金额重新复核 |", ] ) ticket_amount = amount.quantize(Decimal("0.01")) total_amount = ( ticket_amount + Decimal(result.hotel_amount or Decimal("0.00")) + Decimal(result.allowance_amount or Decimal("0.00")) ).quantize(Decimal("0.01")) ticket_basis = "当前未上传交通票据,先按 0.00 元占位" if ticket_amount <= Decimal("0.00") else "已识别或填写的交通票据金额" return "\n".join( [ "报销测算参考:", "", f"职级 {grade},目的地 {location},匹配城市 {result.matched_city};补齐交通、酒店等票据后,我会按真实票据金额和规则中心标准重新复核。", "", "| 项目 | 测算口径 | 金额 |", "| --- | --- | ---: |", f"| 交通票据 | {ticket_basis} | {self._format_decimal_amount(ticket_amount)} 元 |", f"| 住宿标准 | {self._format_decimal_amount(result.hotel_rate)} 元/天 × {days} 天 | {self._format_decimal_amount(result.hotel_amount)} 元 |", f"| 出差补贴 | {self._format_decimal_amount(result.total_allowance_rate)} 元/天 × {days} 天 | {self._format_decimal_amount(result.allowance_amount)} 元 |", f"| 参考合计 | 交通票据 + 住宿标准 + 出差补贴 | {self._format_decimal_amount(total_amount)} 元 |", ] ) @staticmethod def _should_preview_as_travel(*, message: str, context_json: dict[str, Any]) -> bool: text_parts = [message] review_form_values = context_json.get("review_form_values") if isinstance(review_form_values, dict): text_parts.extend(str(value or "") for value in review_form_values.values()) text_parts.extend(str(context_json.get(key) or "") for key in ("user_input_text", "raw_text", "ocr_summary")) compact = "".join(text_parts) return any(keyword in compact for keyword in ("差旅", "出差", "火车票", "机票", "酒店", "住宿票"))