2026-05-22 08:58:59 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-07-14 11:10:55 +08:00
|
|
|
from collections.abc import Callable
|
2026-07-16 10:23:23 +08:00
|
|
|
from datetime import UTC, datetime
|
2026-07-16 11:42:08 +08:00
|
|
|
from decimal import Decimal
|
2026-05-22 08:58:59 +08:00
|
|
|
from typing import Any
|
|
|
|
|
|
2026-07-16 10:23:23 +08:00
|
|
|
from sqlalchemy import delete, select
|
2026-05-22 08:58:59 +08:00
|
|
|
from sqlalchemy.orm import Session, selectinload
|
|
|
|
|
|
|
|
|
|
from app.api.deps import CurrentUserContext
|
|
|
|
|
from app.models.employee import Employee
|
2026-07-14 11:10:55 +08:00
|
|
|
from app.models.expense_case import BusinessEvent
|
2026-05-22 08:58:59 +08:00
|
|
|
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
2026-06-02 14:01:51 +08:00
|
|
|
from app.models.hermes_report import HermesRiskReport
|
|
|
|
|
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
|
2026-05-22 08:58:59 +08:00
|
|
|
from app.schemas.reimbursement import (
|
|
|
|
|
ExpenseClaimItemCreate,
|
|
|
|
|
ExpenseClaimItemUpdate,
|
|
|
|
|
ExpenseClaimUpdate,
|
|
|
|
|
)
|
|
|
|
|
from app.services.audit import AuditLogService
|
2026-06-03 15:46:56 +08:00
|
|
|
from app.services.budget_types import BudgetControlError
|
2026-07-16 10:23:23 +08:00
|
|
|
from app.services.document_numbering import is_application_claim_no
|
|
|
|
|
from app.services.expense_cases import ExpenseCaseService
|
2026-05-22 10:42:31 +08:00
|
|
|
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
2026-07-16 15:34:58 +08:00
|
|
|
from app.services.expense_claim_action_protocol import ExpenseClaimActionProtocolMixin
|
2026-07-16 10:23:23 +08:00
|
|
|
from app.services.expense_claim_application_handoff import ExpenseClaimApplicationHandoffMixin
|
2026-05-27 17:31:27 +08:00
|
|
|
from app.services.expense_claim_approval_flow import ExpenseClaimApprovalFlowMixin
|
2026-06-01 17:07:14 +08:00
|
|
|
from app.services.expense_claim_approval_routing import ExpenseClaimApprovalRoutingMixin
|
2026-05-22 10:42:31 +08:00
|
|
|
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
|
2026-07-16 10:23:23 +08:00
|
|
|
from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation
|
|
|
|
|
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
2026-05-26 17:29:35 +08:00
|
|
|
from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin
|
2026-05-22 10:42:31 +08:00
|
|
|
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
|
2026-07-16 11:42:08 +08:00
|
|
|
from app.services.expense_claim_errors import (
|
|
|
|
|
ExpenseClaimPreReviewBlockedError,
|
|
|
|
|
ExpenseClaimSubmissionBlockedError,
|
|
|
|
|
)
|
2026-07-16 10:23:23 +08:00
|
|
|
from app.services.expense_claim_ontology_resolvers import ExpenseClaimOntologyResolverMixin
|
2026-05-29 14:11:06 +08:00
|
|
|
from app.services.expense_claim_pagination import ExpenseClaimPaginationMixin
|
2026-06-01 17:07:14 +08:00
|
|
|
from app.services.expense_claim_pre_review import ExpenseClaimPreReviewMixin
|
2026-07-16 11:42:08 +08:00
|
|
|
from app.services.expense_claim_pre_review_decision import (
|
|
|
|
|
pre_review_identity_matches,
|
|
|
|
|
pre_review_public_payload,
|
|
|
|
|
)
|
2026-05-22 10:42:31 +08:00
|
|
|
from app.services.expense_claim_read_model import ExpenseClaimReadModelMixin
|
2026-07-16 15:34:58 +08:00
|
|
|
from app.services.expense_claim_return_flow import ExpenseClaimReturnFlowMixin
|
2026-07-16 10:23:23 +08:00
|
|
|
from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin
|
2026-06-17 14:38:07 +08:00
|
|
|
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
|
2026-07-16 10:23:23 +08:00
|
|
|
from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin
|
2026-06-01 17:07:14 +08:00
|
|
|
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
2026-07-16 11:42:08 +08:00
|
|
|
from app.services.expense_claim_standard_adjustment import (
|
|
|
|
|
ExpenseClaimStandardAdjustmentMixin,
|
|
|
|
|
)
|
2026-07-16 10:23:23 +08:00
|
|
|
from app.services.expense_claim_workflow_constants import DIRECT_MANAGER_APPROVAL_STAGE
|
|
|
|
|
from app.services.expense_claim_workflow_repair import ExpenseClaimWorkflowRepairMixin
|
2026-06-03 15:46:56 +08:00
|
|
|
from app.services.receipt_folder import ReceiptFolderService
|
2026-05-22 08:58:59 +08:00
|
|
|
|
2026-05-22 10:42:31 +08:00
|
|
|
|
2026-06-22 11:58:53 +08:00
|
|
|
class ExpenseClaimItemActionMixin:
|
2026-05-21 10:57:06 +08:00
|
|
|
def update_claim_item(
|
|
|
|
|
self,
|
|
|
|
|
*,
|
2026-05-22 08:58:59 +08:00
|
|
|
claim_id: str,
|
|
|
|
|
item_id: str,
|
|
|
|
|
payload: ExpenseClaimItemUpdate,
|
|
|
|
|
current_user: CurrentUserContext,
|
|
|
|
|
) -> ExpenseClaim | None:
|
|
|
|
|
claim = self.get_claim(claim_id, current_user)
|
2026-05-21 10:57:06 +08:00
|
|
|
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)
|
2026-05-22 08:58:59 +08:00
|
|
|
|
|
|
|
|
before_json = self._serialize_claim(claim)
|
|
|
|
|
|
|
|
|
|
if payload.item_date is not None:
|
|
|
|
|
item.item_date = payload.item_date
|
2026-05-21 16:09:47 +08:00
|
|
|
if payload.item_type is not None:
|
2026-07-16 15:34:58 +08:00
|
|
|
item.item_type = (
|
|
|
|
|
self._normalize_optional_text(payload.item_type, fallback=item.item_type)
|
|
|
|
|
or item.item_type
|
|
|
|
|
)
|
2026-05-21 16:09:47 +08:00
|
|
|
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 ""
|
|
|
|
|
)
|
2026-06-03 15:46:56 +08:00
|
|
|
if payload.item_note is not None:
|
2026-07-16 15:34:58 +08:00
|
|
|
item.item_note = (
|
|
|
|
|
self._normalize_optional_text(payload.item_note, allow_empty=True) or ""
|
|
|
|
|
)
|
2026-05-21 16:09:47 +08:00
|
|
|
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
|
2026-05-22 08:58:59 +08:00
|
|
|
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)
|
2026-06-04 11:03:29 +08:00
|
|
|
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
|
2026-05-22 08:58:59 +08:00
|
|
|
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,
|
2026-07-16 10:23:23 +08:00
|
|
|
commit: bool = True,
|
|
|
|
|
write_audit: bool = True,
|
|
|
|
|
refresh_pre_review: bool = True,
|
2026-05-22 08:58:59 +08:00
|
|
|
) -> ExpenseClaim | None:
|
|
|
|
|
claim = self.get_claim(claim_id, current_user)
|
2026-05-21 10:57:06 +08:00
|
|
|
if claim is None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
self._ensure_draft_claim(claim)
|
|
|
|
|
before_json = self._serialize_claim(claim)
|
|
|
|
|
payload = payload or ExpenseClaimItemCreate()
|
2026-05-22 08:58:59 +08:00
|
|
|
|
|
|
|
|
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 "",
|
2026-06-03 15:46:56 +08:00
|
|
|
item_note=self._normalize_optional_text(payload.item_note, allow_empty=True) or "",
|
2026-05-22 08:58:59 +08:00
|
|
|
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)
|
2026-07-16 10:23:23 +08:00
|
|
|
if refresh_pre_review:
|
|
|
|
|
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
|
|
|
|
|
if commit:
|
|
|
|
|
self.db.commit()
|
|
|
|
|
self.db.refresh(claim)
|
|
|
|
|
else:
|
|
|
|
|
self.db.flush()
|
|
|
|
|
|
|
|
|
|
if write_audit:
|
|
|
|
|
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),
|
|
|
|
|
)
|
2026-05-22 08:58:59 +08:00
|
|
|
|
|
|
|
|
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)
|
2026-07-16 15:34:58 +08:00
|
|
|
item_label = str(item.item_reason or "").strip() or self._resolve_expense_type_label(
|
|
|
|
|
item.item_type
|
|
|
|
|
)
|
2026-05-22 08:58:59 +08:00
|
|
|
|
2026-05-22 10:42:31 +08:00
|
|
|
self._attachment_storage.delete_item_files(item)
|
2026-05-22 08:58:59 +08:00
|
|
|
claim.items = [entry for entry in claim.items if entry.id != item.id]
|
|
|
|
|
self.db.delete(item)
|
|
|
|
|
|
|
|
|
|
self._sync_claim_from_items(claim)
|
2026-06-04 11:03:29 +08:00
|
|
|
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
|
2026-05-22 08:58:59 +08:00
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 11:10:55 +08:00
|
|
|
def submit_claim(
|
|
|
|
|
self,
|
|
|
|
|
claim_id: str,
|
|
|
|
|
current_user: CurrentUserContext,
|
|
|
|
|
*,
|
|
|
|
|
correlation_id: str | None = None,
|
2026-07-16 11:42:08 +08:00
|
|
|
pre_review_id: str = "",
|
|
|
|
|
pre_review_input_fingerprint: str = "",
|
2026-07-14 11:10:55 +08:00
|
|
|
before_commit: Callable[[BusinessEvent], None] | None = None,
|
|
|
|
|
) -> ExpenseClaim | None:
|
2026-05-22 08:58:59 +08:00
|
|
|
claim = self.get_claim(claim_id, current_user)
|
|
|
|
|
if claim is None:
|
|
|
|
|
return None
|
|
|
|
|
|
2026-05-20 09:36:01 +08:00
|
|
|
self._ensure_draft_claim(claim)
|
2026-05-22 10:42:31 +08:00
|
|
|
self._access_policy.backfill_claim_identity_from_current_user(claim, current_user)
|
2026-05-25 13:35:39 +08:00
|
|
|
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)
|
|
|
|
|
)
|
2026-05-20 09:36:01 +08:00
|
|
|
if missing_fields:
|
|
|
|
|
raise ExpenseClaimSubmissionBlockedError(missing_fields)
|
2026-05-22 08:58:59 +08:00
|
|
|
|
2026-07-16 11:42:08 +08:00
|
|
|
before_json = self._serialize_claim(claim)
|
|
|
|
|
pre_review_flag = self.refresh_claim_pre_review_state(
|
|
|
|
|
claim,
|
|
|
|
|
is_application_claim=is_application_claim,
|
2026-07-16 14:30:41 +08:00
|
|
|
tenant_id=current_user.tenant_id,
|
2026-07-16 11:42:08 +08:00
|
|
|
)
|
|
|
|
|
if pre_review_flag is None:
|
|
|
|
|
raise RuntimeError("无法生成提交前预审结果。")
|
|
|
|
|
|
|
|
|
|
client_review_provided = bool(
|
2026-07-16 15:34:58 +08:00
|
|
|
str(pre_review_id or "").strip() or str(pre_review_input_fingerprint or "").strip()
|
2026-07-16 11:42:08 +08:00
|
|
|
)
|
|
|
|
|
client_review_matches = pre_review_identity_matches(
|
|
|
|
|
pre_review_flag,
|
|
|
|
|
review_id=pre_review_id,
|
|
|
|
|
input_fingerprint=pre_review_input_fingerprint,
|
|
|
|
|
)
|
|
|
|
|
correlation_id = self._expense_cases.normalize_correlation_id(
|
|
|
|
|
correlation_id or str(pre_review_flag.get("review_id") or "")
|
|
|
|
|
)
|
|
|
|
|
_, pre_review_event = self._record_pre_review_event(
|
|
|
|
|
claim,
|
|
|
|
|
pre_review_flag=pre_review_flag,
|
|
|
|
|
current_user=current_user,
|
|
|
|
|
is_application_claim=is_application_claim,
|
|
|
|
|
correlation_id=correlation_id,
|
|
|
|
|
)
|
|
|
|
|
correlation_id = pre_review_event.correlation_id
|
|
|
|
|
decision = str(pre_review_flag.get("decision") or "")
|
|
|
|
|
review_error_code = (
|
|
|
|
|
"PRE_REVIEW_NEEDS_FIX"
|
|
|
|
|
if decision == "needs_fix"
|
|
|
|
|
else "PRE_REVIEW_CHANGED"
|
|
|
|
|
if client_review_provided and not client_review_matches
|
|
|
|
|
else ""
|
|
|
|
|
)
|
|
|
|
|
if review_error_code:
|
|
|
|
|
self.db.commit()
|
|
|
|
|
self.db.refresh(claim)
|
|
|
|
|
raise ExpenseClaimPreReviewBlockedError(
|
|
|
|
|
pre_review_public_payload(pre_review_flag) or {},
|
|
|
|
|
code=review_error_code,
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-03 15:46:56 +08:00
|
|
|
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 [])
|
2026-05-25 13:35:39 +08:00
|
|
|
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)
|
2026-06-01 17:07:14 +08:00
|
|
|
and str(flag.get("source") or "").strip()
|
|
|
|
|
in {"submission_review", "attachment_analysis"}
|
2026-05-25 13:35:39 +08:00
|
|
|
)
|
|
|
|
|
]
|
2026-06-01 17:07:14 +08:00
|
|
|
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",
|
|
|
|
|
)
|
2026-05-25 13:35:39 +08:00
|
|
|
claim.status = "submitted"
|
|
|
|
|
claim.approval_stage = "直属领导审批"
|
2026-06-01 17:07:14 +08:00
|
|
|
claim.risk_flags_json = self._append_budget_flags(
|
|
|
|
|
[*preserved_flags, submit_flag, *platform_flags],
|
|
|
|
|
budget_flags,
|
|
|
|
|
business_stage="expense_application",
|
|
|
|
|
)
|
2026-05-25 13:35:39 +08:00
|
|
|
claim.submitted_at = submitted_at
|
|
|
|
|
else:
|
2026-06-01 17:07:14 +08:00
|
|
|
claim.risk_flags_json = self._append_budget_flags(
|
|
|
|
|
claim.risk_flags_json,
|
|
|
|
|
budget_flags,
|
|
|
|
|
business_stage="reimbursement",
|
|
|
|
|
)
|
2026-06-04 11:03:29 +08:00
|
|
|
|
|
|
|
|
claim.status = "submitted"
|
|
|
|
|
claim.approval_stage = DIRECT_MANAGER_APPROVAL_STAGE
|
|
|
|
|
claim.submitted_at = datetime.now(UTC)
|
2026-05-20 09:36:01 +08:00
|
|
|
|
2026-06-17 14:38:07 +08:00
|
|
|
claim.risk_flags_json = dedupe_claim_risk_flags(claim.risk_flags_json)
|
2026-05-20 09:36:01 +08:00
|
|
|
|
2026-07-14 11:10:55 +08:00
|
|
|
_, submission_event = self._expense_cases.record_claim_event(
|
2026-07-13 11:58:48 +08:00
|
|
|
claim,
|
|
|
|
|
event_type=("application_submitted" if is_application_claim else "claim_submitted"),
|
|
|
|
|
actor_id=current_user.username,
|
|
|
|
|
tenant_id=getattr(current_user, "tenant_id", None),
|
2026-07-14 11:10:55 +08:00
|
|
|
correlation_id=correlation_id,
|
2026-07-16 11:42:08 +08:00
|
|
|
causation_id=pre_review_event.id if pre_review_event is not None else None,
|
2026-07-13 11:58:48 +08:00
|
|
|
idempotency_key=(
|
|
|
|
|
f"submit:{claim.id}:{claim.submitted_at.isoformat()}"
|
|
|
|
|
if claim.submitted_at is not None
|
|
|
|
|
else f"submit:{claim.id}:{before_json.get('status') or 'draft'}"
|
|
|
|
|
),
|
|
|
|
|
previous_status=str(before_json.get("status") or ""),
|
|
|
|
|
previous_approval_stage=str(before_json.get("approval_stage") or ""),
|
|
|
|
|
)
|
2026-07-14 11:10:55 +08:00
|
|
|
if before_commit is not None:
|
|
|
|
|
before_commit(submission_event)
|
2026-07-13 11:58:48 +08:00
|
|
|
|
2026-05-20 09:36:01 +08:00
|
|
|
self.db.commit()
|
|
|
|
|
self.db.refresh(claim)
|
2026-05-22 08:58:59 +08:00
|
|
|
|
2026-05-21 09:28:33 +08:00
|
|
|
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":
|
2026-05-21 23:52:34 +08:00
|
|
|
self._delete_claim_assistant_sessions(claim.id)
|
2026-05-21 09:28:33 +08:00
|
|
|
|
|
|
|
|
return claim
|
2026-05-22 08:58:59 +08:00
|
|
|
|
2026-05-20 14:21:56 +08:00
|
|
|
def delete_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
|
|
|
|
|
claim = self.get_claim(claim_id, current_user)
|
2026-06-17 14:38:07 +08:00
|
|
|
if claim is None and current_user.is_admin:
|
2026-07-16 11:42:08 +08:00
|
|
|
candidate_stmt = (
|
2026-05-26 09:15:14 +08:00
|
|
|
select(ExpenseClaim)
|
|
|
|
|
.options(
|
|
|
|
|
selectinload(ExpenseClaim.items),
|
|
|
|
|
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
|
|
|
|
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
|
|
|
|
)
|
|
|
|
|
.where(ExpenseClaim.id == claim_id)
|
|
|
|
|
)
|
2026-07-16 11:42:08 +08:00
|
|
|
candidate_claim = self.db.scalar(
|
|
|
|
|
self._access_policy.apply_tenant_scope(candidate_stmt, current_user)
|
|
|
|
|
)
|
2026-06-17 14:38:07 +08:00
|
|
|
if candidate_claim is not None:
|
2026-05-26 09:15:14 +08:00
|
|
|
claim = candidate_claim
|
2026-05-20 14:21:56 +08:00
|
|
|
if claim is None:
|
|
|
|
|
return None
|
|
|
|
|
|
2026-05-26 09:15:14 +08:00
|
|
|
if self._access_policy.is_archived_claim(claim) and not current_user.is_admin:
|
|
|
|
|
raise ValueError("已归档单据不能删除,只有高级管理员可以执行删除。")
|
|
|
|
|
|
2026-05-22 10:42:31 +08:00
|
|
|
if not self._access_policy.has_claim_delete_access(current_user):
|
2026-05-20 14:21:56 +08:00
|
|
|
self._ensure_draft_claim(claim)
|
2026-05-22 10:42:31 +08:00
|
|
|
if not self._access_policy.is_claim_owned_by_current_user(claim, current_user):
|
2026-07-16 15:34:58 +08:00
|
|
|
raise ValueError(
|
|
|
|
|
"只有系统管理员或草稿、待补充、退回待提交阶段的申请人本人可以删除单据。"
|
|
|
|
|
)
|
2026-05-20 14:21:56 +08:00
|
|
|
|
|
|
|
|
before_json = self._serialize_claim(claim)
|
|
|
|
|
resource_id = claim.id
|
2026-06-24 10:42:24 +08:00
|
|
|
operator = self._access_policy.resolve_current_user_display_name(current_user)
|
|
|
|
|
if not self._is_expense_application_claim(claim):
|
|
|
|
|
self._sync_linked_applications_after_reimbursement_deleted(
|
|
|
|
|
reimbursement_claim=claim,
|
|
|
|
|
operator=operator,
|
|
|
|
|
current_user=current_user,
|
|
|
|
|
)
|
2026-05-20 14:21:56 +08:00
|
|
|
|
2026-05-26 17:29:35 +08:00
|
|
|
self._release_budget_for_delete(claim, current_user)
|
2026-06-02 14:01:51 +08:00
|
|
|
self._delete_claim_analysis_records(resource_id)
|
2026-05-22 10:42:31 +08:00
|
|
|
self._attachment_storage.delete_claim_files(claim)
|
2026-06-06 17:19:07 +08:00
|
|
|
ReceiptFolderService().unlink_receipts_for_claim(resource_id)
|
2026-05-20 14:21:56 +08:00
|
|
|
self.db.delete(claim)
|
2026-05-21 23:52:34 +08:00
|
|
|
self.db.commit()
|
2026-05-22 08:58:59 +08:00
|
|
|
|
2026-05-21 23:52:34 +08:00
|
|
|
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)
|
2026-05-20 14:21:56 +08:00
|
|
|
|
|
|
|
|
return claim
|
|
|
|
|
|
2026-06-02 14:01:51 +08:00
|
|
|
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))
|
|
|
|
|
|
2026-05-22 08:58:59 +08:00
|
|
|
|
2026-07-16 15:34:58 +08:00
|
|
|
class ExpenseClaimService(
|
|
|
|
|
ExpenseClaimActionProtocolMixin,
|
|
|
|
|
ExpenseClaimReturnFlowMixin,
|
|
|
|
|
ExpenseClaimStandardAdjustmentMixin,
|
|
|
|
|
ExpenseClaimItemActionMixin,
|
|
|
|
|
ExpenseClaimPaginationMixin,
|
|
|
|
|
ExpenseClaimApprovalFlowMixin,
|
|
|
|
|
ExpenseClaimApprovalRoutingMixin,
|
|
|
|
|
ExpenseClaimApplicationHandoffMixin,
|
|
|
|
|
ExpenseClaimPreReviewMixin,
|
|
|
|
|
ExpenseClaimBudgetFlowMixin,
|
|
|
|
|
ExpenseClaimAttachmentOperationsMixin,
|
|
|
|
|
ExpenseClaimReviewPreviewMixin,
|
|
|
|
|
ExpenseClaimDraftFlowMixin,
|
|
|
|
|
ExpenseClaimDraftPersistenceMixin,
|
|
|
|
|
ExpenseClaimDocumentItemBuilderMixin,
|
|
|
|
|
ExpenseClaimDocumentParsingMixin,
|
|
|
|
|
ExpenseClaimOntologyResolverMixin,
|
|
|
|
|
ExpenseClaimAttachmentDocumentMixin,
|
|
|
|
|
ExpenseClaimAttachmentAnalysisMixin,
|
|
|
|
|
ExpenseClaimReadModelMixin,
|
|
|
|
|
ExpenseClaimRiskReviewMixin,
|
|
|
|
|
ExpenseClaimWorkflowRepairMixin,
|
|
|
|
|
):
|
2026-06-22 11:58:53 +08:00
|
|
|
def __init__(self, db: Session) -> None:
|
|
|
|
|
self.db = db
|
|
|
|
|
self.audit_service = AuditLogService(db)
|
2026-07-13 11:58:48 +08:00
|
|
|
self._expense_cases = ExpenseCaseService(db)
|
2026-06-22 11:58:53 +08:00
|
|
|
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()
|
2026-07-16 15:34:58 +08:00
|
|
|
document_type = (
|
|
|
|
|
str(
|
|
|
|
|
getattr(claim, "document_type_code", "")
|
|
|
|
|
or getattr(claim, "document_type", "")
|
|
|
|
|
or ""
|
|
|
|
|
)
|
|
|
|
|
.strip()
|
|
|
|
|
.lower()
|
|
|
|
|
)
|
2026-06-22 11:58:53 +08:00
|
|
|
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.organization_unit),
|
|
|
|
|
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)
|
|
|
|
|
claims = list(self.db.scalars(stmt).all())
|
|
|
|
|
return self._access_policy.attach_budget_approval_snapshots(claims)
|
|
|
|
|
|
|
|
|
|
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.organization_unit),
|
|
|
|
|
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)
|
|
|
|
|
claims = list(self.db.scalars(stmt).all())
|
|
|
|
|
return self._access_policy.attach_budget_approval_snapshots(claims)
|
|
|
|
|
|
|
|
|
|
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.organization_unit),
|
|
|
|
|
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
|
|
|
|
)
|
2026-07-16 15:34:58 +08:00
|
|
|
.order_by(
|
|
|
|
|
ExpenseClaim.updated_at.desc(),
|
|
|
|
|
ExpenseClaim.submitted_at.desc(),
|
|
|
|
|
ExpenseClaim.created_at.desc(),
|
|
|
|
|
)
|
2026-06-22 11:58:53 +08:00
|
|
|
)
|
|
|
|
|
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.organization_unit),
|
|
|
|
|
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
|
|
|
|
)
|
|
|
|
|
.where(ExpenseClaim.id == claim_id)
|
|
|
|
|
)
|
2026-07-16 15:34:58 +08:00
|
|
|
stmt = self._access_policy.apply_claim_scope(
|
|
|
|
|
stmt, current_user, include_approval_scope=True
|
|
|
|
|
)
|
2026-06-22 11:58:53 +08:00
|
|
|
claim = self.db.scalar(stmt)
|
|
|
|
|
return self._access_policy.attach_approval_snapshot(claim)
|
|
|
|
|
|
2026-07-16 15:34:58 +08:00
|
|
|
def can_view_budget_analysis(
|
|
|
|
|
self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None
|
|
|
|
|
) -> bool:
|
2026-06-22 11:58:53 +08:00
|
|
|
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
|
2026-07-16 15:34:58 +08:00
|
|
|
if self._access_policy.has_privileged_claim_access(
|
|
|
|
|
current_user
|
|
|
|
|
) and not self._access_policy.is_claim_owned_by_current_user(claim, current_user):
|
2026-06-22 11:58:53 +08:00
|
|
|
return True
|
|
|
|
|
if self._access_policy.can_approve_claim(current_user, claim):
|
|
|
|
|
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:
|
2026-07-16 15:34:58 +08:00
|
|
|
claim.reason = (
|
|
|
|
|
self._normalize_optional_text(payload.reason, allow_empty=True) or "待补充"
|
|
|
|
|
)
|
2026-06-22 11:58:53 +08:00
|
|
|
|
|
|
|
|
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
|