- 重构报销状态注册表、审批流路由与平台风险标记 - 完善管家意图规划器与模型计划构建器全链路 - 新增 OCR Worker 脚本、数据库会话管理与通知状态 - 优化文档中心、日志视图、预算中心与员工管理交互 - 增强工作台摘要、图标资源与全局主题样式 - 补充审批路由、状态注册、OCR 服务与管家规划器测试覆盖
1024 lines
41 KiB
Python
1024 lines
41 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 delete, 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.models.hermes_report import HermesRiskReport
|
|
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
|
|
from app.schemas.ontology import OntologyEntity, OntologyParseResult
|
|
from app.schemas.reimbursement import (
|
|
ExpenseClaimItemCreate,
|
|
ExpenseClaimItemUpdate,
|
|
ExpenseClaimStandardAdjustmentPayload,
|
|
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.document_numbering import is_application_claim_no
|
|
from app.services.budget_types import BudgetControlError
|
|
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
|
from app.services.expense_claim_approval_flow import ExpenseClaimApprovalFlowMixin
|
|
from app.services.expense_claim_approval_routing import ExpenseClaimApprovalRoutingMixin
|
|
from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation
|
|
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
|
from app.services.expense_claim_application_handoff import ExpenseClaimApplicationHandoffMixin
|
|
from app.services.expense_claim_attachment_analysis import ExpenseClaimAttachmentAnalysisMixin
|
|
from app.services.expense_claim_attachment_document import ExpenseClaimAttachmentDocumentMixin
|
|
from app.services.expense_claim_attachment_operations import ExpenseClaimAttachmentOperationsMixin
|
|
from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin
|
|
from app.services.expense_claim_workflow_constants import DIRECT_MANAGER_APPROVAL_STAGE
|
|
from app.services.expense_claim_document_item_builder import ExpenseClaimDocumentItemBuilderMixin
|
|
from app.services.expense_claim_document_parsing import ExpenseClaimDocumentParsingMixin
|
|
from app.services.expense_claim_draft_flow import ExpenseClaimDraftFlowMixin
|
|
from app.services.expense_claim_draft_persistence import ExpenseClaimDraftPersistenceMixin
|
|
from app.services.expense_claim_errors import ExpenseClaimSubmissionBlockedError
|
|
from app.services.expense_claim_pagination import ExpenseClaimPaginationMixin
|
|
from app.services.expense_claim_pre_review import ExpenseClaimPreReviewMixin
|
|
from app.services.expense_claim_ontology_resolvers import ExpenseClaimOntologyResolverMixin
|
|
from app.services.expense_claim_read_model import ExpenseClaimReadModelMixin
|
|
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
|
from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin
|
|
from app.services.receipt_folder import ReceiptFolderService
|
|
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,
|
|
STANDARD_ADJUSTMENT_RISK_SOURCE,
|
|
)
|
|
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 ExpenseClaimService(
|
|
ExpenseClaimPaginationMixin,
|
|
ExpenseClaimApprovalFlowMixin,
|
|
ExpenseClaimApprovalRoutingMixin,
|
|
ExpenseClaimApplicationHandoffMixin,
|
|
ExpenseClaimPreReviewMixin,
|
|
ExpenseClaimBudgetFlowMixin,
|
|
ExpenseClaimAttachmentOperationsMixin,
|
|
ExpenseClaimReviewPreviewMixin,
|
|
ExpenseClaimDraftFlowMixin,
|
|
ExpenseClaimDraftPersistenceMixin,
|
|
ExpenseClaimDocumentItemBuilderMixin,
|
|
ExpenseClaimDocumentParsingMixin,
|
|
ExpenseClaimOntologyResolverMixin,
|
|
ExpenseClaimAttachmentDocumentMixin,
|
|
ExpenseClaimAttachmentAnalysisMixin,
|
|
ExpenseClaimReadModelMixin,
|
|
ExpenseClaimRiskReviewMixin,
|
|
):
|
|
def __init__(self, db: Session) -> None:
|
|
self.db = db
|
|
self.audit_service = AuditLogService(db)
|
|
self._access_policy = ExpenseClaimAccessPolicy(db)
|
|
self._attachment_storage = ExpenseClaimAttachmentStorage()
|
|
self._attachment_presentation = ExpenseClaimAttachmentPresentation(self._attachment_storage)
|
|
|
|
@staticmethod
|
|
def _is_expense_application_claim(claim: ExpenseClaim) -> bool:
|
|
claim_no = str(getattr(claim, "claim_no", "") or "").strip().upper()
|
|
expense_type = str(getattr(claim, "expense_type", "") or "").strip().lower()
|
|
document_type = str(
|
|
getattr(claim, "document_type_code", "")
|
|
or getattr(claim, "document_type", "")
|
|
or ""
|
|
).strip().lower()
|
|
return (
|
|
is_application_claim_no(claim_no)
|
|
or expense_type == "application"
|
|
or expense_type.endswith("_application")
|
|
or document_type in {"application", "expense_application"}
|
|
)
|
|
|
|
def _validate_application_claim_for_submission(self, claim: ExpenseClaim) -> list[str]:
|
|
issues: list[str] = []
|
|
if self._is_missing_value(claim.employee_name):
|
|
issues.append("申请人未完善")
|
|
if self._is_missing_value(claim.department_name):
|
|
issues.append("所属部门未完善")
|
|
if self._is_missing_value(claim.expense_type):
|
|
issues.append("申请类型未完善")
|
|
if self._is_missing_value(claim.reason):
|
|
issues.append("申请事由未完善")
|
|
if self._is_missing_value(claim.location):
|
|
issues.append("业务地点未完善")
|
|
if claim.amount is None or claim.amount <= Decimal("0.00"):
|
|
issues.append("预计总费用未完善")
|
|
if claim.occurred_at is None:
|
|
issues.append("申请时间未完善")
|
|
return issues
|
|
|
|
def list_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
|
|
stmt = (
|
|
select(ExpenseClaim)
|
|
.options(
|
|
selectinload(ExpenseClaim.items),
|
|
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
|
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
|
)
|
|
.order_by(ExpenseClaim.created_at.desc(), ExpenseClaim.occurred_at.desc())
|
|
)
|
|
stmt = self._access_policy.apply_claim_scope(stmt, current_user)
|
|
return self._access_policy.attach_budget_approval_snapshots(list(self.db.scalars(stmt).all()))
|
|
|
|
def list_approval_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
|
|
stmt = (
|
|
select(ExpenseClaim)
|
|
.options(
|
|
selectinload(ExpenseClaim.items),
|
|
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
|
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
|
)
|
|
.order_by(ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc())
|
|
)
|
|
stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user)
|
|
return self._access_policy.attach_budget_approval_snapshots(list(self.db.scalars(stmt).all()))
|
|
|
|
def list_archived_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
|
|
stmt = (
|
|
select(ExpenseClaim)
|
|
.options(
|
|
selectinload(ExpenseClaim.items),
|
|
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
|
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
|
)
|
|
.order_by(ExpenseClaim.updated_at.desc(), ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc())
|
|
)
|
|
stmt = self._access_policy.apply_archived_claim_scope(stmt, current_user)
|
|
return list(self.db.scalars(stmt).all())
|
|
|
|
def get_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
|
|
stmt = (
|
|
select(ExpenseClaim)
|
|
.options(
|
|
selectinload(ExpenseClaim.items),
|
|
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
|
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
|
)
|
|
.where(ExpenseClaim.id == claim_id)
|
|
)
|
|
stmt = self._access_policy.apply_claim_scope(stmt, current_user, include_approval_scope=True)
|
|
return self._access_policy.attach_budget_approval_snapshot(self.db.scalar(stmt))
|
|
|
|
def can_view_budget_analysis(self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None) -> bool:
|
|
if claim is None:
|
|
return self._access_policy.is_budget_manager_user(current_user)
|
|
if current_user.is_admin:
|
|
return True
|
|
role_codes = self._access_policy.normalize_role_codes(current_user)
|
|
if "executive" in role_codes:
|
|
return True
|
|
if self._access_policy.is_claim_owned_by_current_user(claim, current_user):
|
|
return False
|
|
return self._access_policy.is_department_p8_budget_monitor(current_user, claim)
|
|
|
|
def update_claim(
|
|
self,
|
|
*,
|
|
claim_id: str,
|
|
payload: ExpenseClaimUpdate,
|
|
current_user: CurrentUserContext,
|
|
) -> ExpenseClaim | None:
|
|
claim = self.get_claim(claim_id, current_user)
|
|
if claim is None:
|
|
return None
|
|
|
|
self._ensure_draft_pending_claim(claim)
|
|
before_json = self._serialize_claim(claim)
|
|
|
|
if payload.reason is not None:
|
|
claim.reason = self._normalize_optional_text(payload.reason, allow_empty=True) or "待补充"
|
|
|
|
if not self._is_expense_application_claim(claim):
|
|
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
|
|
|
|
self.db.commit()
|
|
self.db.refresh(claim)
|
|
|
|
self.audit_service.log_action(
|
|
actor=current_user.name or current_user.username,
|
|
action="expense_claim.update",
|
|
resource_type="expense_claim",
|
|
resource_id=claim.id,
|
|
before_json=before_json,
|
|
after_json=self._serialize_claim(claim),
|
|
)
|
|
|
|
return claim
|
|
|
|
@staticmethod
|
|
def _normalize_standard_adjustment_amount(value: Any) -> Decimal | None:
|
|
try:
|
|
raw_value = "" if value is None else value
|
|
amount = Decimal(str(raw_value)).quantize(Decimal("0.01"))
|
|
except (InvalidOperation, ValueError):
|
|
return None
|
|
return amount if amount >= Decimal("0.00") else None
|
|
|
|
@staticmethod
|
|
def _format_adjustment_money(value: Decimal) -> str:
|
|
normalized = Decimal(value or Decimal("0.00")).quantize(Decimal("0.01"))
|
|
return f"{normalized:.2f}"
|
|
|
|
@staticmethod
|
|
def _normalize_standard_adjustment_days(value: Any) -> int | None:
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, int):
|
|
return value if 1 <= value <= 365 else None
|
|
text = str(value or "").strip()
|
|
if not text:
|
|
return None
|
|
match = re.search(r"\d{1,3}", text)
|
|
if not match:
|
|
return None
|
|
days = int(match.group(0))
|
|
return days if 1 <= days <= 365 else None
|
|
|
|
@staticmethod
|
|
def _normalize_standard_adjustment_text(value: Any) -> str:
|
|
text = str(value or "").strip()
|
|
if not text or text in {"-", "N/A", "n/a"}:
|
|
return ""
|
|
if text in {"待补充", "未知", "暂无", "非必填"}:
|
|
return ""
|
|
return text
|
|
|
|
def _iter_standard_adjustment_application_details(self, claim: ExpenseClaim) -> list[dict[str, Any]]:
|
|
details: list[dict[str, Any]] = []
|
|
for flag in list(claim.risk_flags_json or []):
|
|
if not isinstance(flag, dict):
|
|
continue
|
|
detail = flag.get("application_detail") or flag.get("applicationDetail")
|
|
if isinstance(detail, dict):
|
|
details.append(detail)
|
|
related = flag.get("related_application") or flag.get("relatedApplication")
|
|
if isinstance(related, dict):
|
|
details.append(related)
|
|
return details
|
|
|
|
def _resolve_standard_adjustment_days(
|
|
self,
|
|
claim: ExpenseClaim,
|
|
item: ExpenseClaimItem,
|
|
entry: Any,
|
|
) -> int:
|
|
direct_days = self._normalize_standard_adjustment_days(getattr(entry, "application_days", None))
|
|
if direct_days is not None:
|
|
return direct_days
|
|
|
|
for detail in self._iter_standard_adjustment_application_details(claim):
|
|
for key in ("application_days", "applicationDays", "days"):
|
|
detail_days = self._normalize_standard_adjustment_days(detail.get(key))
|
|
if detail_days is not None:
|
|
return detail_days
|
|
|
|
candidates = [
|
|
getattr(entry, "risk", None),
|
|
getattr(entry, "title", None),
|
|
item.item_reason,
|
|
claim.reason,
|
|
]
|
|
for text in candidates:
|
|
match = re.search(r"(\d{1,3})\s*(?:天|晚|夜)", str(text or ""))
|
|
if match:
|
|
days = self._normalize_standard_adjustment_days(match.group(1))
|
|
if days is not None:
|
|
return days
|
|
return 1
|
|
|
|
def _resolve_standard_adjustment_location(
|
|
self,
|
|
claim: ExpenseClaim,
|
|
item: ExpenseClaimItem,
|
|
) -> str:
|
|
for value in (item.item_location, claim.location):
|
|
text = self._normalize_standard_adjustment_text(value)
|
|
if text:
|
|
return text
|
|
|
|
for detail in self._iter_standard_adjustment_application_details(claim):
|
|
for key in ("application_location", "applicationLocation", "location", "city"):
|
|
text = self._normalize_standard_adjustment_text(detail.get(key))
|
|
if text:
|
|
return text
|
|
return ""
|
|
|
|
def _resolve_policy_standard_reimbursable_amount(
|
|
self,
|
|
*,
|
|
claim: ExpenseClaim,
|
|
item: ExpenseClaimItem,
|
|
entry: Any,
|
|
current_user: CurrentUserContext,
|
|
) -> Decimal | None:
|
|
item_type = str(item.item_type or "").strip().lower()
|
|
if item_type not in {"hotel", "hotel_ticket"}:
|
|
return None
|
|
|
|
location = self._resolve_standard_adjustment_location(claim, item)
|
|
grade = str(claim.employee_grade or current_user.grade or "").strip()
|
|
if not location or not grade:
|
|
return None
|
|
|
|
try:
|
|
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
|
|
|
result = TravelReimbursementCalculatorService(self.db).calculate(
|
|
TravelReimbursementCalculatorRequest(
|
|
days=self._resolve_standard_adjustment_days(claim, item, entry),
|
|
location=location,
|
|
grade=grade,
|
|
),
|
|
current_user,
|
|
)
|
|
except Exception:
|
|
return None
|
|
|
|
return self._normalize_standard_adjustment_amount(result.hotel_amount)
|
|
|
|
def _resolve_standard_adjustment_reimbursable_amount(
|
|
self,
|
|
*,
|
|
claim: ExpenseClaim,
|
|
item: ExpenseClaimItem,
|
|
entry: Any,
|
|
original_amount: Decimal,
|
|
current_user: CurrentUserContext,
|
|
) -> Decimal:
|
|
policy_amount = self._resolve_policy_standard_reimbursable_amount(
|
|
claim=claim,
|
|
item=item,
|
|
entry=entry,
|
|
current_user=current_user,
|
|
)
|
|
if policy_amount is not None:
|
|
return min(max(policy_amount, Decimal("0.00")), original_amount)
|
|
|
|
entry_amount = self._normalize_standard_adjustment_amount(entry.reimbursable_amount)
|
|
if entry_amount is not None:
|
|
return min(max(entry_amount, Decimal("0.00")), original_amount)
|
|
return original_amount
|
|
|
|
def accept_standard_adjustment(
|
|
self,
|
|
*,
|
|
claim_id: str,
|
|
payload: ExpenseClaimStandardAdjustmentPayload,
|
|
current_user: CurrentUserContext,
|
|
) -> ExpenseClaim | None:
|
|
claim = self.get_claim(claim_id, current_user)
|
|
if claim is None:
|
|
return None
|
|
|
|
self._ensure_draft_claim(claim)
|
|
if self._is_expense_application_claim(claim):
|
|
raise ValueError("费用申请单不支持按报销标准重算。")
|
|
|
|
risk_entries = list(payload.risks or [])
|
|
if not risk_entries:
|
|
raise ValueError("请至少选择一条需要按职级标准重算的风险。")
|
|
|
|
before_json = self._serialize_claim(claim)
|
|
item_map = {str(item.id or "").strip(): item for item in list(claim.items or [])}
|
|
now_text = datetime.now(UTC).isoformat()
|
|
adjustment_flags: list[dict[str, Any]] = []
|
|
|
|
for index, entry in enumerate(risk_entries, start=1):
|
|
item_id = str(entry.item_id or "").strip()
|
|
item = item_map.get(item_id)
|
|
if item is None:
|
|
continue
|
|
|
|
original_amount = (
|
|
self._normalize_standard_adjustment_amount(entry.original_amount)
|
|
or Decimal(item.item_amount or Decimal("0.00")).quantize(Decimal("0.01"))
|
|
)
|
|
reimbursable_amount = self._resolve_standard_adjustment_reimbursable_amount(
|
|
claim=claim,
|
|
item=item,
|
|
entry=entry,
|
|
original_amount=original_amount,
|
|
current_user=current_user,
|
|
)
|
|
employee_absorbed_amount = (original_amount - reimbursable_amount).quantize(Decimal("0.01"))
|
|
item_label = (
|
|
str(item.item_reason or "").strip()
|
|
or str(entry.title or "").strip()
|
|
or f"费用明细第 {index} 条"
|
|
)
|
|
source_risk = str(entry.risk or entry.title or "原风险未补充异常说明").strip()
|
|
message = (
|
|
f"提交人已选择按职级最高报销标准审核:{item_label} 原票据金额 "
|
|
f"{self._format_adjustment_money(original_amount)} 元,实际报销金额 "
|
|
f"{self._format_adjustment_money(reimbursable_amount)} 元,超出 "
|
|
f"{self._format_adjustment_money(employee_absorbed_amount)} 元由员工自行承担。"
|
|
)
|
|
adjustment_flags.append(
|
|
with_risk_business_stage(
|
|
{
|
|
"source": STANDARD_ADJUSTMENT_RISK_SOURCE,
|
|
"event_type": "standard_adjustment_accepted",
|
|
"severity": "medium",
|
|
"label": "接受职级标准审核",
|
|
"title": "提交人接受职级最高报销标准",
|
|
"message": message,
|
|
"summary": "提交人未补充异常说明,已选择按职级最高报销标准重算实际报销金额。",
|
|
"suggestion": "领导和财务审批时请确认该差额由员工自行承担,并按实际报销金额入账。",
|
|
"risk_id": str(entry.risk_id or "").strip(),
|
|
"source_risk": source_risk,
|
|
"item_id": item_id,
|
|
"original_amount": self._format_adjustment_money(original_amount),
|
|
"reimbursable_amount": self._format_adjustment_money(reimbursable_amount),
|
|
"employee_absorbed_amount": self._format_adjustment_money(employee_absorbed_amount),
|
|
"risk_domain": "amount",
|
|
"actionability": "review_decision",
|
|
"visibility_scope": "leader",
|
|
"created_at": now_text,
|
|
},
|
|
"reimbursement",
|
|
)
|
|
)
|
|
|
|
if not adjustment_flags:
|
|
raise ValueError("未找到可按职级标准重算的费用明细。")
|
|
|
|
preserved_flags = [
|
|
flag
|
|
for flag in list(claim.risk_flags_json or [])
|
|
if not (
|
|
isinstance(flag, dict)
|
|
and str(flag.get("source") or "").strip() == STANDARD_ADJUSTMENT_RISK_SOURCE
|
|
)
|
|
]
|
|
claim.risk_flags_json = [*preserved_flags, *adjustment_flags]
|
|
self._sync_claim_from_items(claim)
|
|
|
|
self.db.commit()
|
|
self.db.refresh(claim)
|
|
|
|
self.audit_service.log_action(
|
|
actor=current_user.name or current_user.username,
|
|
action="expense_claim.standard_adjustment_accept",
|
|
resource_type="expense_claim",
|
|
resource_id=claim.id,
|
|
before_json=before_json,
|
|
after_json=self._serialize_claim(claim),
|
|
)
|
|
|
|
return claim
|
|
|
|
def update_claim_item(
|
|
self,
|
|
*,
|
|
claim_id: str,
|
|
item_id: str,
|
|
payload: ExpenseClaimItemUpdate,
|
|
current_user: CurrentUserContext,
|
|
) -> ExpenseClaim | None:
|
|
claim = self.get_claim(claim_id, current_user)
|
|
if claim is None:
|
|
return None
|
|
|
|
self._ensure_draft_claim(claim)
|
|
item = next((entry for entry in claim.items if entry.id == item_id), None)
|
|
if item is None:
|
|
raise LookupError("Item not found")
|
|
self._ensure_mutable_claim_item(item)
|
|
|
|
before_json = self._serialize_claim(claim)
|
|
|
|
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, 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_note is not None:
|
|
item.item_note = self._normalize_optional_text(payload.item_note, 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)
|
|
|
|
self._refresh_item_attachment_analysis(item)
|
|
self._sync_claim_from_items(claim)
|
|
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
|
|
self.db.commit()
|
|
self.db.refresh(claim)
|
|
|
|
self.audit_service.log_action(
|
|
actor=current_user.name or current_user.username,
|
|
action="expense_claim.item_update",
|
|
resource_type="expense_claim",
|
|
resource_id=claim.id,
|
|
before_json=before_json,
|
|
after_json=self._serialize_claim(claim),
|
|
)
|
|
|
|
return claim
|
|
|
|
def create_claim_item(
|
|
self,
|
|
*,
|
|
claim_id: str,
|
|
payload: ExpenseClaimItemCreate | None,
|
|
current_user: CurrentUserContext,
|
|
) -> ExpenseClaim | None:
|
|
claim = self.get_claim(claim_id, current_user)
|
|
if claim is None:
|
|
return None
|
|
|
|
self._ensure_draft_claim(claim)
|
|
before_json = self._serialize_claim(claim)
|
|
payload = payload or ExpenseClaimItemCreate()
|
|
|
|
occurred_at = claim.occurred_at if claim.occurred_at is not None else datetime.now(UTC)
|
|
item_amount = Decimal("0.00")
|
|
if payload.item_amount is not None:
|
|
item_amount = payload.item_amount.quantize(Decimal("0.01"))
|
|
if item_amount < Decimal("0.00"):
|
|
raise ValueError("费用金额不能小于 0。")
|
|
|
|
item = ExpenseClaimItem(
|
|
claim_id=claim.id,
|
|
item_date=payload.item_date or occurred_at.date(),
|
|
item_type=self._normalize_optional_text(
|
|
payload.item_type,
|
|
fallback=str(claim.expense_type or "").strip() or "other",
|
|
)
|
|
or "other",
|
|
item_reason=self._normalize_optional_text(payload.item_reason, fallback="") or "",
|
|
item_location=self._normalize_optional_text(payload.item_location, fallback="") or "",
|
|
item_note=self._normalize_optional_text(payload.item_note, allow_empty=True) or "",
|
|
item_amount=item_amount,
|
|
invoice_id=self._normalize_optional_text(payload.invoice_id, allow_empty=True),
|
|
)
|
|
claim.items.append(item)
|
|
self.db.add(item)
|
|
|
|
self._sync_claim_from_items(claim)
|
|
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
|
|
self.db.commit()
|
|
self.db.refresh(claim)
|
|
|
|
self.audit_service.log_action(
|
|
actor=current_user.name or current_user.username,
|
|
action="expense_claim.item_create",
|
|
resource_type="expense_claim",
|
|
resource_id=claim.id,
|
|
before_json=before_json,
|
|
after_json=self._serialize_claim(claim),
|
|
)
|
|
|
|
return claim
|
|
|
|
def delete_claim_item(
|
|
self,
|
|
*,
|
|
claim_id: str,
|
|
item_id: str,
|
|
current_user: CurrentUserContext,
|
|
) -> dict[str, Any] | None:
|
|
claim, item = self._get_claim_item_or_raise(
|
|
claim_id=claim_id,
|
|
item_id=item_id,
|
|
current_user=current_user,
|
|
)
|
|
if claim is None:
|
|
return None
|
|
|
|
self._ensure_draft_claim(claim)
|
|
before_json = self._serialize_claim(claim)
|
|
item_label = str(item.item_reason or "").strip() or self._resolve_expense_type_label(item.item_type)
|
|
|
|
self._attachment_storage.delete_item_files(item)
|
|
claim.items = [entry for entry in claim.items if entry.id != item.id]
|
|
self.db.delete(item)
|
|
|
|
self._sync_claim_from_items(claim)
|
|
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
|
|
self.db.commit()
|
|
self.db.refresh(claim)
|
|
|
|
self.audit_service.log_action(
|
|
actor=current_user.name or current_user.username,
|
|
action="expense_claim.item_delete",
|
|
resource_type="expense_claim",
|
|
resource_id=claim.id,
|
|
before_json=before_json,
|
|
after_json=self._serialize_claim(claim),
|
|
)
|
|
|
|
return {
|
|
"message": f"费用明细“{item_label}”已删除。",
|
|
"claim_id": claim.id,
|
|
"item_id": item.id,
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def submit_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
|
|
claim = self.get_claim(claim_id, current_user)
|
|
if claim is None:
|
|
return None
|
|
|
|
self._ensure_draft_claim(claim)
|
|
self._access_policy.backfill_claim_identity_from_current_user(claim, current_user)
|
|
is_application_claim = self._is_expense_application_claim(claim)
|
|
if not is_application_claim:
|
|
self._sync_claim_from_items(claim)
|
|
missing_fields = (
|
|
self._validate_application_claim_for_submission(claim)
|
|
if is_application_claim
|
|
else self._validate_claim_for_submission(claim)
|
|
)
|
|
if missing_fields:
|
|
raise ExpenseClaimSubmissionBlockedError(missing_fields)
|
|
|
|
try:
|
|
budget_flags = self._reserve_budget_for_submission(
|
|
claim,
|
|
current_user,
|
|
is_application_claim=is_application_claim,
|
|
)
|
|
except BudgetControlError as exc:
|
|
if is_application_claim:
|
|
raise
|
|
budget_flags = list(exc.flags or [])
|
|
before_json = self._serialize_claim(claim)
|
|
if is_application_claim:
|
|
submitted_at = datetime.now(UTC)
|
|
preserved_flags = [
|
|
flag
|
|
for flag in list(claim.risk_flags_json or [])
|
|
if not (
|
|
isinstance(flag, dict)
|
|
and str(flag.get("source") or "").strip()
|
|
in {"submission_review", "attachment_analysis"}
|
|
)
|
|
]
|
|
platform_review = self.evaluate_platform_risk_rules(
|
|
claim,
|
|
business_stage="expense_application",
|
|
)
|
|
platform_flags = list(platform_review.get("flags") or [])
|
|
submit_flag = with_risk_business_stage(
|
|
{
|
|
"source": "application_submission",
|
|
"event_type": "expense_application_submission",
|
|
"severity": "info",
|
|
"label": "申请提交",
|
|
"message": "费用申请已提交至直属领导审批,请等待审核结果。",
|
|
"previous_status": str(claim.status or "").strip(),
|
|
"previous_approval_stage": str(claim.approval_stage or "").strip(),
|
|
"next_status": "submitted",
|
|
"next_approval_stage": "直属领导审批",
|
|
"created_at": submitted_at.isoformat(),
|
|
},
|
|
"expense_application",
|
|
)
|
|
claim.status = "submitted"
|
|
claim.approval_stage = "直属领导审批"
|
|
claim.risk_flags_json = self._append_budget_flags(
|
|
[*preserved_flags, submit_flag, *platform_flags],
|
|
budget_flags,
|
|
business_stage="expense_application",
|
|
)
|
|
claim.submitted_at = submitted_at
|
|
else:
|
|
claim.risk_flags_json = self._append_budget_flags(
|
|
claim.risk_flags_json,
|
|
budget_flags,
|
|
business_stage="reimbursement",
|
|
)
|
|
if not self._has_ai_pre_review_flag(claim):
|
|
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
|
|
|
|
claim.status = "submitted"
|
|
claim.approval_stage = DIRECT_MANAGER_APPROVAL_STAGE
|
|
claim.submitted_at = datetime.now(UTC)
|
|
|
|
|
|
self.db.commit()
|
|
self.db.refresh(claim)
|
|
|
|
self.audit_service.log_action(
|
|
actor=current_user.name or current_user.username,
|
|
action="expense_claim.submit",
|
|
resource_type="expense_claim",
|
|
resource_id=claim.id,
|
|
before_json=before_json,
|
|
after_json=self._serialize_claim(claim),
|
|
)
|
|
if str(claim.status or "").strip().lower() == "submitted":
|
|
self._delete_claim_assistant_sessions(claim.id)
|
|
|
|
return claim
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def delete_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
|
|
claim = self.get_claim(claim_id, current_user)
|
|
if claim is None and (
|
|
current_user.is_admin or self._access_policy.has_archive_center_access(current_user)
|
|
):
|
|
candidate_claim = self.db.scalar(
|
|
select(ExpenseClaim)
|
|
.options(
|
|
selectinload(ExpenseClaim.items),
|
|
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
|
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
|
)
|
|
.where(ExpenseClaim.id == claim_id)
|
|
)
|
|
if candidate_claim is not None and (
|
|
current_user.is_admin or self._access_policy.is_archived_claim(candidate_claim)
|
|
):
|
|
claim = candidate_claim
|
|
if claim is None:
|
|
return None
|
|
|
|
if self._access_policy.is_archived_claim(claim) and not current_user.is_admin:
|
|
raise ValueError("已归档单据不能删除,只有高级管理员可以执行删除。")
|
|
|
|
if not self._access_policy.has_claim_delete_access(current_user):
|
|
self._ensure_draft_claim(claim)
|
|
if not self._access_policy.is_claim_owned_by_current_user(claim, current_user):
|
|
raise ValueError("只有高级财务人员可以删除非本人单据,申请人仅可删除自己的草稿、待补充或退回单据。")
|
|
|
|
before_json = self._serialize_claim(claim)
|
|
resource_id = claim.id
|
|
|
|
self._release_budget_for_delete(claim, current_user)
|
|
self._delete_claim_analysis_records(resource_id)
|
|
self._attachment_storage.delete_claim_files(claim)
|
|
ReceiptFolderService().unlink_receipts_for_claim(resource_id)
|
|
self.db.delete(claim)
|
|
self.db.commit()
|
|
|
|
self.audit_service.log_action(
|
|
actor=current_user.name or current_user.username,
|
|
action="expense_claim.delete",
|
|
resource_type="expense_claim",
|
|
resource_id=resource_id,
|
|
before_json=before_json,
|
|
after_json=None,
|
|
)
|
|
self._delete_claim_assistant_sessions(resource_id)
|
|
|
|
return claim
|
|
|
|
def _delete_claim_analysis_records(self, claim_id: str) -> None:
|
|
observation_ids = select(RiskObservation.id).where(RiskObservation.claim_id == claim_id)
|
|
self.db.execute(
|
|
delete(RiskObservationFeedback).where(
|
|
RiskObservationFeedback.observation_id.in_(observation_ids)
|
|
)
|
|
)
|
|
self.db.execute(delete(RiskObservation).where(RiskObservation.claim_id == claim_id))
|
|
self.db.execute(delete(HermesRiskReport).where(HermesRiskReport.claim_id == claim_id))
|
|
|
|
def return_claim(
|
|
self,
|
|
claim_id: str,
|
|
current_user: CurrentUserContext,
|
|
*,
|
|
reason: str | None = None,
|
|
reason_codes: list[str] | None = None,
|
|
) -> ExpenseClaim | None:
|
|
claim = self.get_claim(claim_id, current_user)
|
|
if claim is None:
|
|
return None
|
|
|
|
normalized_status = str(claim.status or "").strip().lower()
|
|
if normalized_status == "draft":
|
|
raise ValueError("草稿状态无需退回。")
|
|
if normalized_status == "returned":
|
|
raise ValueError("该单据已处于退回待提交状态,无需重复退回。")
|
|
if normalized_status in {"approved", "completed", "paid"}:
|
|
raise ValueError("已完成单据不允许退回。")
|
|
|
|
if not self._access_policy.can_return_claim(current_user, claim):
|
|
raise ValueError("只有财务人员、高级财务人员或当前审批人可以退回报销单。")
|
|
|
|
before_json = self._serialize_claim(claim)
|
|
operator = self._access_policy.resolve_current_user_display_name(current_user)
|
|
previous_status = str(claim.status or "").strip()
|
|
previous_stage = str(claim.approval_stage or "").strip() or "未标记审批环节"
|
|
previous_stage_key = self._normalize_return_stage_key(previous_stage)
|
|
is_application_claim = self._is_expense_application_claim(claim)
|
|
is_direct_manager_return = previous_stage_key == "direct_manager"
|
|
is_budget_return = previous_stage_key == "budget"
|
|
is_application_return = is_application_claim and (is_direct_manager_return or is_budget_return)
|
|
return_event_type = (
|
|
"expense_application_return"
|
|
if is_application_return
|
|
else "expense_claim_return"
|
|
)
|
|
return_label = (
|
|
"领导退回"
|
|
if is_application_claim and is_direct_manager_return
|
|
else "预算退回"
|
|
if is_application_claim and is_budget_return
|
|
else "人工退回"
|
|
)
|
|
return_reason = str(reason or "").strip()
|
|
reason_code_payload = self._normalize_return_reason_code_payload(reason_codes)
|
|
normalized_reason_codes = reason_code_payload["reason_codes"]
|
|
unknown_reason_codes = reason_code_payload["unknown_reason_codes"]
|
|
if is_application_return and not any(
|
|
code.startswith("application_") for code in normalized_reason_codes
|
|
):
|
|
raise ValueError("申请单退回必须选择至少一个退单类型。")
|
|
risk_points = [RETURN_REASON_OPTIONS[code] for code in normalized_reason_codes]
|
|
existing_return_flags = self._collect_return_flags(claim.risk_flags_json)
|
|
return_count = len(existing_return_flags) + 1
|
|
stage_return_count = (
|
|
sum(
|
|
1
|
|
for flag in existing_return_flags
|
|
if (
|
|
str(flag.get("return_stage_key") or "").strip()
|
|
or self._normalize_return_stage_key(str(flag.get("return_stage") or "").strip())
|
|
)
|
|
== previous_stage_key
|
|
)
|
|
+ 1
|
|
)
|
|
message = return_reason or self._build_default_return_message(operator=operator, risk_points=risk_points)
|
|
return_flag = {
|
|
"source": "manual_return",
|
|
"event_type": return_event_type,
|
|
"return_event_id": str(uuid.uuid4()),
|
|
"severity": "medium",
|
|
"label": return_label,
|
|
"node_key": "returned",
|
|
"node_label": "退回",
|
|
"approval_node": "退回",
|
|
"message": message,
|
|
"reason": return_reason,
|
|
"opinion": message,
|
|
"leader_opinion": message if is_application_claim and is_direct_manager_return else "",
|
|
"budget_opinion": message if is_application_claim and is_budget_return else "",
|
|
"reason_codes": normalized_reason_codes,
|
|
"risk_points": risk_points,
|
|
"operator": operator,
|
|
"operator_username": current_user.username,
|
|
"operator_role_codes": [
|
|
str(item).strip().lower()
|
|
for item in current_user.role_codes
|
|
if str(item).strip()
|
|
],
|
|
"previous_status": previous_status,
|
|
"previous_approval_stage": previous_stage,
|
|
"return_stage": previous_stage,
|
|
"return_stage_key": previous_stage_key,
|
|
"next_status": "returned",
|
|
"next_approval_stage": "待提交",
|
|
"return_count": return_count,
|
|
"stage_return_count": stage_return_count,
|
|
"created_at": datetime.now(UTC).isoformat(),
|
|
}
|
|
if unknown_reason_codes:
|
|
return_flag["unknown_reason_codes"] = unknown_reason_codes
|
|
|
|
budget_flags = self._release_budget_for_return(
|
|
claim,
|
|
current_user,
|
|
reason=message,
|
|
)
|
|
claim.status = "returned"
|
|
claim.approval_stage = "待提交"
|
|
claim.submitted_at = None
|
|
claim.risk_flags_json = self._append_budget_flags(
|
|
[*list(claim.risk_flags_json or []), return_flag],
|
|
budget_flags,
|
|
business_stage="expense_application" if is_application_claim else "reimbursement",
|
|
)
|
|
|
|
self.db.commit()
|
|
self.db.refresh(claim)
|
|
|
|
self.audit_service.log_action(
|
|
actor=operator,
|
|
action="expense_claim.return",
|
|
resource_type="expense_claim",
|
|
resource_id=claim.id,
|
|
before_json=before_json,
|
|
after_json=self._serialize_claim(claim),
|
|
)
|
|
|
|
return claim
|
|
|
|
|
|
|
|
|
|
|
|
|