- 新增本体字段注册表与字段治理审计脚本 - 重构风险规则模板执行器、DSL 验证与清单分类器 - 完善票据夹服务与差旅请求详情页交互 - 优化趋势图表与总览页数据展示 - 增强报销平台风险分级与模拟公司筛选 - 补充本体字段、风险规则生成与票据夹服务测试覆盖
389 lines
15 KiB
Python
389 lines
15 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_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_type_keywords import resolve_expense_type_code_from_text
|
|
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.ontology_field_registry import normalize_ontology_form_values
|
|
from app.services.ocr import OcrService
|
|
|
|
|
|
class ExpenseClaimOntologyResolverMixin:
|
|
def _resolve_employee(
|
|
self,
|
|
*,
|
|
ontology: OntologyParseResult,
|
|
context_json: dict[str, Any],
|
|
user_id: str | None,
|
|
) -> Employee | None:
|
|
normalized_user_id = str(user_id or "").strip()
|
|
if normalized_user_id:
|
|
stmt = (
|
|
select(Employee)
|
|
.options(selectinload(Employee.organization_unit), selectinload(Employee.manager))
|
|
.where(func.lower(Employee.email) == normalized_user_id.lower())
|
|
.limit(1)
|
|
)
|
|
employee = self.db.scalar(stmt)
|
|
if employee is not None:
|
|
return employee
|
|
|
|
employee_name = self._resolve_employee_name(
|
|
ontology=ontology,
|
|
context_json=context_json,
|
|
user_id=None,
|
|
)
|
|
if not employee_name:
|
|
return None
|
|
|
|
stmt = (
|
|
select(Employee)
|
|
.options(selectinload(Employee.organization_unit), selectinload(Employee.manager))
|
|
.where(Employee.name == employee_name)
|
|
.limit(1)
|
|
)
|
|
return self.db.scalar(stmt)
|
|
|
|
@staticmethod
|
|
def _resolve_employee_name(
|
|
*,
|
|
ontology: OntologyParseResult,
|
|
context_json: dict[str, Any],
|
|
user_id: str | None,
|
|
fallback: str = "待补充",
|
|
) -> str:
|
|
review_form_values = context_json.get("review_form_values")
|
|
if isinstance(review_form_values, dict):
|
|
for key in ("reporter_name", "employee_name", "claimant_name"):
|
|
value = str(review_form_values.get(key) or "").strip()
|
|
if value:
|
|
return value
|
|
for item in ontology.entities:
|
|
if item.type == "employee" and item.value.strip():
|
|
return item.value.strip()
|
|
for key in ("name", "user_name", "employee_name"):
|
|
value = str(context_json.get(key) or "").strip()
|
|
if value:
|
|
return value
|
|
return str(user_id or fallback).strip() or fallback
|
|
|
|
@staticmethod
|
|
def _resolve_department_name(
|
|
*,
|
|
employee: Employee | None,
|
|
context_json: dict[str, Any],
|
|
fallback: str = "待补充",
|
|
) -> str:
|
|
if employee is not None and employee.organization_unit is not None:
|
|
return employee.organization_unit.name
|
|
|
|
request_context = context_json.get("request_context")
|
|
if isinstance(request_context, dict):
|
|
for key in ("department", "department_name", "deptName"):
|
|
value = str(request_context.get(key) or "").strip()
|
|
if value:
|
|
return value
|
|
|
|
for key in ("department_name", "department"):
|
|
value = str(context_json.get(key) or "").strip()
|
|
if value:
|
|
return value
|
|
return fallback
|
|
|
|
@staticmethod
|
|
def _resolve_project_code(entities: list[OntologyEntity]) -> str | None:
|
|
for item in entities:
|
|
if item.type == "project" and item.normalized_value.strip():
|
|
return item.normalized_value.strip()
|
|
return None
|
|
|
|
@staticmethod
|
|
def _resolve_explicit_review_expense_type(context_json: dict[str, Any]) -> str | None:
|
|
review_form_values = context_json.get("review_form_values")
|
|
if isinstance(review_form_values, dict):
|
|
review_form_values = normalize_ontology_form_values(review_form_values)
|
|
compact = str(review_form_values.get("expense_type") or "").replace(" ", "")
|
|
if compact:
|
|
return resolve_expense_type_code_from_text(compact)
|
|
return None
|
|
|
|
@staticmethod
|
|
def _resolve_expense_type(
|
|
entities: list[OntologyEntity],
|
|
*,
|
|
context_json: dict[str, Any],
|
|
) -> str | None:
|
|
explicit_expense_type = ExpenseClaimOntologyResolverMixin._resolve_explicit_review_expense_type(context_json)
|
|
if explicit_expense_type:
|
|
return explicit_expense_type
|
|
for item in entities:
|
|
if item.type == "expense_type":
|
|
normalized = item.normalized_value.strip()
|
|
if normalized:
|
|
return normalized
|
|
return None
|
|
|
|
@staticmethod
|
|
def _resolve_reason(
|
|
*,
|
|
message: str,
|
|
context_json: dict[str, Any],
|
|
allow_message_fallback: bool,
|
|
) -> str | None:
|
|
review_form_values = context_json.get("review_form_values")
|
|
if isinstance(review_form_values, dict):
|
|
review_form_values = normalize_ontology_form_values(review_form_values)
|
|
value = str(review_form_values.get("reason") or "").strip()
|
|
if value:
|
|
return ExpenseClaimOntologyResolverMixin._strip_leading_time_from_reason(value)
|
|
|
|
explicit_text = context_json.get("user_input_text")
|
|
if isinstance(explicit_text, str):
|
|
normalized_explicit_text = explicit_text.strip()
|
|
if normalized_explicit_text:
|
|
return ExpenseClaimOntologyResolverMixin._strip_leading_time_from_reason(normalized_explicit_text)[:500] or None
|
|
return None
|
|
|
|
request_context = context_json.get("request_context")
|
|
if (
|
|
isinstance(request_context, dict)
|
|
and str(context_json.get("entry_source") or "").strip() == "detail"
|
|
):
|
|
for key in ("reason", "title"):
|
|
value = str(request_context.get(key) or "").strip()
|
|
if value:
|
|
return value
|
|
if not allow_message_fallback:
|
|
return None
|
|
|
|
normalized_message = str(message or "").strip()
|
|
compact_message = re.sub(r"\s+", "", normalized_message)
|
|
if compact_message.startswith(SYSTEM_GENERATED_REASON_PREFIXES):
|
|
return None
|
|
return ExpenseClaimOntologyResolverMixin._strip_leading_time_from_reason(normalized_message)[:500] or None
|
|
|
|
@staticmethod
|
|
def _strip_leading_time_from_reason(value: str) -> str:
|
|
reason = str(value or "").strip()
|
|
for pattern in LEADING_REASON_TIME_PATTERNS:
|
|
next_reason = pattern.sub("", reason).strip()
|
|
if next_reason != reason:
|
|
return next_reason
|
|
return reason
|
|
|
|
@staticmethod
|
|
def _resolve_location(*, message: str, context_json: dict[str, Any]) -> str | None:
|
|
review_form_values = context_json.get("review_form_values")
|
|
if isinstance(review_form_values, dict):
|
|
review_form_values = normalize_ontology_form_values(review_form_values)
|
|
value = str(review_form_values.get("location") or "").strip()
|
|
if value:
|
|
return value
|
|
|
|
request_context = context_json.get("request_context")
|
|
if (
|
|
isinstance(request_context, dict)
|
|
and str(context_json.get("entry_source") or "").strip() == "detail"
|
|
):
|
|
for key in ("city", "location"):
|
|
value = str(request_context.get(key) or "").strip()
|
|
if value:
|
|
return value
|
|
compact = str(message or "").replace(" ", "")
|
|
city_match = re.search(
|
|
r"去(?P<city>[\u4e00-\u9fa5]{2,8}?)(?:出差|拜访|参会|见客户|客户现场|支撑|支持|部署|实施|处理|协助)",
|
|
compact,
|
|
)
|
|
if city_match:
|
|
return city_match.group("city").strip()
|
|
if "客户现场" in compact:
|
|
return "客户现场"
|
|
return None
|
|
|
|
@staticmethod
|
|
def _resolve_occurred_at(
|
|
ontology: OntologyParseResult,
|
|
*,
|
|
context_json: dict[str, Any],
|
|
) -> datetime | None:
|
|
review_form_values = context_json.get("review_form_values")
|
|
if isinstance(review_form_values, dict):
|
|
review_form_values = normalize_ontology_form_values(review_form_values)
|
|
value = str(review_form_values.get("time_range") or "").strip()
|
|
if value:
|
|
try:
|
|
parsed = date.fromisoformat(value)
|
|
return datetime(parsed.year, parsed.month, parsed.day, tzinfo=UTC)
|
|
except ValueError:
|
|
parsed = ExpenseClaimOntologyResolverMixin._resolve_first_date_from_text(value)
|
|
if parsed is not None:
|
|
return datetime(parsed.year, parsed.month, parsed.day, tzinfo=UTC)
|
|
|
|
start_date = ontology.time_range.start_date
|
|
if start_date:
|
|
try:
|
|
parsed = date.fromisoformat(start_date)
|
|
return datetime(parsed.year, parsed.month, parsed.day, tzinfo=UTC)
|
|
except ValueError:
|
|
pass
|
|
return None
|
|
|
|
@staticmethod
|
|
def _resolve_first_date_from_text(value: str) -> date | None:
|
|
match = re.search(r"20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}", str(value or ""))
|
|
if not match:
|
|
return None
|
|
normalized = match.group(0).replace("/", "-").replace(".", "-")
|
|
parts = [part for part in normalized.split("-") if part]
|
|
if len(parts) != 3:
|
|
return None
|
|
try:
|
|
year, month, day = (int(part) for part in parts)
|
|
return date(year, month, day)
|
|
except ValueError:
|
|
return None
|
|
|
|
@staticmethod
|
|
def _resolve_amount(
|
|
entities: list[OntologyEntity],
|
|
*,
|
|
context_json: dict[str, Any],
|
|
) -> Decimal | None:
|
|
review_form_values = context_json.get("review_form_values")
|
|
if isinstance(review_form_values, dict):
|
|
raw_value = str(review_form_values.get("amount") or "").strip()
|
|
if raw_value:
|
|
compact = raw_value.replace("元", "").replace(",", "").strip()
|
|
try:
|
|
return Decimal(compact).quantize(Decimal("0.01"))
|
|
except (InvalidOperation, ValueError):
|
|
pass
|
|
for item in entities:
|
|
if item.type != "amount" or item.role == "threshold":
|
|
continue
|
|
try:
|
|
return Decimal(item.normalized_value).quantize(Decimal("0.01"))
|
|
except (InvalidOperation, ValueError):
|
|
continue
|
|
return None
|
|
|
|
@staticmethod
|
|
def _resolve_attachment_names(context_json: dict[str, Any]) -> list[str]:
|
|
names = context_json.get("attachment_names")
|
|
if not isinstance(names, list):
|
|
return []
|
|
return [str(name).strip() for name in names if str(name).strip()]
|
|
|
|
def _resolve_attachment_count(self, context_json: dict[str, Any]) -> int:
|
|
names = self._resolve_attachment_names(context_json)
|
|
if names:
|
|
return len(names)
|
|
try:
|
|
return max(0, int(context_json.get("attachment_count") or 0))
|
|
except (TypeError, ValueError):
|
|
return 0
|