- 完善 user_agent_application 申请差旅报销预审槽位与消息组装 - 增强预算助理报告与风险建议卡片交互 - 重构登录页视觉样式与移动端响应式适配 - 优化个人工作台、文档中心、政策中心、员工管理等页面布局 - 拆分 travelRequestDetailPreReviewModel 为 advice/submit 模型 - 补充报销草稿、风险复核、Item Sync 与模板执行器测试覆盖
394 lines
16 KiB
Python
394 lines
16 KiB
Python
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 ("差旅", "出差", "火车票", "机票", "酒店", "住宿票"))
|