Files
X-Financial/server/src/app/services/expense_claim_review_preview.py
caoxiaozhu ca691f3ee0 feat: 优化差旅报销预审流程与个人工作台 UI 体系
- 完善 user_agent_application 申请差旅报销预审槽位与消息组装
- 增强预算助理报告与风险建议卡片交互
- 重构登录页视觉样式与移动端响应式适配
- 优化个人工作台、文档中心、政策中心、员工管理等页面布局
- 拆分 travelRequestDetailPreReviewModel 为 advice/submit 模型
- 补充报销草稿、风险复核、Item Sync 与模板执行器测试覆盖
2026-06-02 14:01:51 +08:00

394 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ("差旅", "出差", "火车票", "机票", "酒店", "住宿票"))