772 lines
33 KiB
Python
772 lines
33 KiB
Python
from __future__ import annotations
|
|
|
|
import uuid
|
|
from collections.abc import Callable
|
|
from datetime import UTC, datetime
|
|
from decimal import Decimal
|
|
from typing import Any
|
|
|
|
from sqlalchemy import delete, select
|
|
from sqlalchemy.orm import Session, selectinload
|
|
|
|
from app.api.deps import CurrentUserContext
|
|
from app.models.employee import Employee
|
|
from app.models.expense_case import BusinessEvent
|
|
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.reimbursement import (
|
|
ExpenseClaimItemCreate,
|
|
ExpenseClaimItemUpdate,
|
|
ExpenseClaimUpdate,
|
|
)
|
|
from app.services.audit import AuditLogService
|
|
from app.services.budget_types import BudgetControlError
|
|
from app.services.document_numbering import is_application_claim_no
|
|
from app.services.expense_cases import ExpenseCaseService
|
|
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
|
from app.services.expense_claim_application_handoff import ExpenseClaimApplicationHandoffMixin
|
|
from app.services.expense_claim_approval_flow import ExpenseClaimApprovalFlowMixin
|
|
from app.services.expense_claim_approval_routing import ExpenseClaimApprovalRoutingMixin
|
|
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_attachment_presentation import ExpenseClaimAttachmentPresentation
|
|
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
|
from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin
|
|
from app.services.expense_claim_constants import RETURN_REASON_OPTIONS
|
|
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 (
|
|
ExpenseClaimPreReviewBlockedError,
|
|
ExpenseClaimSubmissionBlockedError,
|
|
)
|
|
from app.services.expense_claim_ontology_resolvers import ExpenseClaimOntologyResolverMixin
|
|
from app.services.expense_claim_pagination import ExpenseClaimPaginationMixin
|
|
from app.services.expense_claim_pre_review import ExpenseClaimPreReviewMixin
|
|
from app.services.expense_claim_pre_review_decision import (
|
|
pre_review_identity_matches,
|
|
pre_review_public_payload,
|
|
)
|
|
from app.services.expense_claim_read_model import ExpenseClaimReadModelMixin
|
|
from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin
|
|
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
|
|
from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin
|
|
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
|
from app.services.expense_claim_standard_adjustment import (
|
|
ExpenseClaimStandardAdjustmentMixin,
|
|
)
|
|
from app.services.expense_claim_workflow_constants import DIRECT_MANAGER_APPROVAL_STAGE
|
|
from app.services.expense_claim_workflow_repair import ExpenseClaimWorkflowRepairMixin
|
|
from app.services.receipt_folder import ReceiptFolderService
|
|
|
|
|
|
class ExpenseClaimItemActionMixin:
|
|
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,
|
|
commit: bool = True,
|
|
write_audit: bool = True,
|
|
refresh_pre_review: bool = True,
|
|
) -> 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)
|
|
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),
|
|
)
|
|
|
|
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,
|
|
*,
|
|
correlation_id: str | None = None,
|
|
pre_review_id: str = "",
|
|
pre_review_input_fingerprint: str = "",
|
|
before_commit: Callable[[BusinessEvent], None] | None = None,
|
|
) -> 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)
|
|
|
|
before_json = self._serialize_claim(claim)
|
|
pre_review_flag = self.refresh_claim_pre_review_state(
|
|
claim,
|
|
is_application_claim=is_application_claim,
|
|
tenant_id=current_user.tenant_id,
|
|
)
|
|
if pre_review_flag is None:
|
|
raise RuntimeError("无法生成提交前预审结果。")
|
|
|
|
client_review_provided = bool(
|
|
str(pre_review_id or "").strip()
|
|
or str(pre_review_input_fingerprint or "").strip()
|
|
)
|
|
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,
|
|
)
|
|
|
|
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 [])
|
|
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",
|
|
)
|
|
|
|
claim.status = "submitted"
|
|
claim.approval_stage = DIRECT_MANAGER_APPROVAL_STAGE
|
|
claim.submitted_at = datetime.now(UTC)
|
|
|
|
claim.risk_flags_json = dedupe_claim_risk_flags(claim.risk_flags_json)
|
|
|
|
_, submission_event = self._expense_cases.record_claim_event(
|
|
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),
|
|
correlation_id=correlation_id,
|
|
causation_id=pre_review_event.id if pre_review_event is not None else None,
|
|
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 ""),
|
|
)
|
|
if before_commit is not None:
|
|
before_commit(submission_event)
|
|
|
|
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:
|
|
candidate_stmt = (
|
|
select(ExpenseClaim)
|
|
.options(
|
|
selectinload(ExpenseClaim.items),
|
|
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
|
|
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
|
|
)
|
|
.where(ExpenseClaim.id == claim_id)
|
|
)
|
|
candidate_claim = self.db.scalar(
|
|
self._access_policy.apply_tenant_scope(candidate_stmt, current_user)
|
|
)
|
|
if candidate_claim is not None:
|
|
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
|
|
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,
|
|
)
|
|
|
|
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._expense_cases.record_claim_event(
|
|
claim,
|
|
event_type=("application_returned" if is_application_claim else "claim_returned"),
|
|
actor_id=current_user.username,
|
|
tenant_id=getattr(current_user, "tenant_id", None),
|
|
idempotency_key=str(return_flag.get("return_event_id") or ""),
|
|
previous_status=previous_status,
|
|
previous_approval_stage=previous_stage,
|
|
extra_payload={
|
|
"reason": message,
|
|
"reason_codes": normalized_reason_codes,
|
|
},
|
|
)
|
|
|
|
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
|
|
|
|
|
|
class ExpenseClaimService(ExpenseClaimStandardAdjustmentMixin, ExpenseClaimItemActionMixin, ExpenseClaimPaginationMixin, ExpenseClaimApprovalFlowMixin, ExpenseClaimApprovalRoutingMixin, ExpenseClaimApplicationHandoffMixin, ExpenseClaimPreReviewMixin, ExpenseClaimBudgetFlowMixin, ExpenseClaimAttachmentOperationsMixin, ExpenseClaimReviewPreviewMixin, ExpenseClaimDraftFlowMixin, ExpenseClaimDraftPersistenceMixin, ExpenseClaimDocumentItemBuilderMixin, ExpenseClaimDocumentParsingMixin, ExpenseClaimOntologyResolverMixin, ExpenseClaimAttachmentDocumentMixin, ExpenseClaimAttachmentAnalysisMixin, ExpenseClaimReadModelMixin, ExpenseClaimRiskReviewMixin, ExpenseClaimWorkflowRepairMixin):
|
|
def __init__(self, db: Session) -> None:
|
|
self.db = db
|
|
self.audit_service = AuditLogService(db)
|
|
self._expense_cases = ExpenseCaseService(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.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())
|
|
self._repair_duplicate_budget_approval_stages(claims)
|
|
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())
|
|
self._repair_duplicate_budget_approval_stages(claims)
|
|
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),
|
|
)
|
|
.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.organization_unit),
|
|
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)
|
|
claim = self.db.scalar(stmt)
|
|
if claim is not None:
|
|
self._repair_duplicate_budget_approval_stages([claim])
|
|
return self._access_policy.attach_approval_snapshot(claim)
|
|
|
|
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.has_privileged_claim_access(current_user)
|
|
and not self._access_policy.is_claim_owned_by_current_user(claim, current_user)
|
|
):
|
|
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:
|
|
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
|