from __future__ import annotations import uuid from datetime import UTC, datetime from decimal import Decimal from typing import Any from sqlalchemy import or_, select from app.models.financial_record import ExpenseClaim from app.services.expense_claim_risk_stage import with_risk_business_stage from app.services.expense_claim_workflow_constants import APPLICATION_ARCHIVE_STAGE APPLICATION_REIMBURSEMENT_TYPE_MAP = { "travel_application": "travel", "purchase_application": "office", "meeting_application": "meeting", "expense_application": "other", "application": "other", } APPLICATION_LINK_FLAG_SOURCES = {"application_handoff", "application_link"} class ExpenseClaimApplicationHandoffMixin: @staticmethod def _resolve_reimbursement_type_from_application(expense_type: str | None) -> str: normalized = str(expense_type or "").strip().lower() if normalized in APPLICATION_REIMBURSEMENT_TYPE_MAP: return APPLICATION_REIMBURSEMENT_TYPE_MAP[normalized] if normalized.endswith("_application"): return normalized.removesuffix("_application") or "other" return normalized or "other" @staticmethod def _resolve_application_detail(application_claim: ExpenseClaim) -> dict[str, str]: for flag in list(application_claim.risk_flags_json or []): if not isinstance(flag, dict) or str(flag.get("source") or "").strip() != "application_detail": continue detail = flag.get("application_detail") or flag.get("applicationDetail") or {} if isinstance(detail, dict): return {str(key): str(value or "").strip() for key, value in detail.items()} return {} @staticmethod def _build_application_handoff_detail(application_claim: ExpenseClaim) -> dict[str, str]: detail = ExpenseClaimApplicationHandoffMixin._resolve_application_detail(application_claim) application_time = str(detail.get("time") or "").strip() if not application_time and application_claim.occurred_at is not None: application_time = application_claim.occurred_at.isoformat() application_amount = str(detail.get("amount") or "").strip() if not application_amount: application_amount = str(application_claim.amount or Decimal("0.00")) return { "application_type": str(detail.get("application_type") or application_claim.expense_type or "").strip(), "application_content": " / ".join( item for item in [ str(detail.get("application_type") or application_claim.expense_type or "").strip(), str(detail.get("location") or application_claim.location or "").strip(), ] if item ), "application_reason": str(detail.get("reason") or application_claim.reason or "").strip(), "application_days": str(detail.get("days") or "").strip(), "application_location": str(detail.get("location") or application_claim.location or "").strip(), "application_amount": application_amount, "application_time": application_time, "application_transport_mode": str(detail.get("transport_mode") or "").strip(), "application_lodging_daily_cap": str(detail.get("lodging_daily_cap") or "").strip(), "application_subsidy_daily_cap": str(detail.get("subsidy_daily_cap") or "").strip(), "application_transport_policy": str(detail.get("transport_policy") or "").strip(), "application_policy_estimate": str(detail.get("policy_estimate") or "").strip(), "application_rule_name": str(detail.get("rule_name") or "").strip(), "application_rule_version": str(detail.get("rule_version") or "").strip(), } def _create_reimbursement_draft_from_application( self, *, application_claim: ExpenseClaim, approval_flag: dict[str, Any], operator: str, ) -> ExpenseClaim: occurred_at = application_claim.occurred_at or datetime.now(UTC) created_at = datetime.now(UTC) draft_claim = ExpenseClaim( claim_no=self._generate_claim_no(occurred_at), employee_id=application_claim.employee_id, employee_name=application_claim.employee_name, department_id=application_claim.department_id, department_name=application_claim.department_name, project_code=application_claim.project_code, expense_type=self._resolve_reimbursement_type_from_application(application_claim.expense_type), reason=application_claim.reason, location=application_claim.location, amount=application_claim.amount or Decimal("0.00"), currency=application_claim.currency or "CNY", invoice_count=0, occurred_at=occurred_at, submitted_at=None, status="draft", approval_stage="待提交", risk_flags_json=[ { "source": "application_handoff", "event_type": "expense_application_to_reimbursement_draft", "handoff_event_id": str(uuid.uuid4()), "severity": "info", "label": "申请转报销草稿", "message": ( f"费用申请 {application_claim.claim_no} 已由 {operator} 确认审核," "系统已生成报销草稿。" ), "application_claim_id": application_claim.id, "application_claim_no": application_claim.claim_no, "application_budget_amount": str(application_claim.amount or Decimal("0.00")), "application_detail": self._build_application_handoff_detail(application_claim), "application_approval_event_id": str(approval_flag.get("approval_event_id") or ""), "leader_opinion": str( approval_flag.get("leader_opinion") or approval_flag.get("opinion") or "" ).strip(), "budget_opinion": str(approval_flag.get("budget_opinion") or "").strip(), "created_at": created_at.isoformat(), } ], ) self.db.add(draft_claim) self.db.flush() approval_flag["generated_draft_claim_id"] = draft_claim.id approval_flag["generated_draft_claim_no"] = draft_claim.claim_no approval_flag["handoff_event_type"] = "expense_application_to_reimbursement_draft" approval_flag["handoff_message"] = f"已生成报销草稿 {draft_claim.claim_no}。" return draft_claim @staticmethod def _collect_application_references_from_reimbursement(claim: ExpenseClaim) -> tuple[set[str], set[str]]: application_ids: set[str] = set() application_nos: set[str] = set() for flag in list(claim.risk_flags_json or []): if not isinstance(flag, dict): continue source = str(flag.get("source") or "").strip() has_application_reference = any( str(flag.get(key) or "").strip() for key in ( "application_claim_id", "applicationClaimId", "application_claim_no", "applicationClaimNo", ) ) if source not in APPLICATION_LINK_FLAG_SOURCES and not has_application_reference: continue application_id = str(flag.get("application_claim_id") or flag.get("applicationClaimId") or "").strip() application_no = str(flag.get("application_claim_no") or flag.get("applicationClaimNo") or "").strip() if application_id: application_ids.add(application_id) if application_no: application_nos.add(application_no) return application_ids, application_nos def _find_linked_application_claims(self, reimbursement_claim: ExpenseClaim) -> list[ExpenseClaim]: application_ids, application_nos = self._collect_application_references_from_reimbursement(reimbursement_claim) conditions = [] if application_ids: conditions.append(ExpenseClaim.id.in_(application_ids)) if application_nos: conditions.append(ExpenseClaim.claim_no.in_(application_nos)) if not conditions: return [] claims = list(self.db.scalars(select(ExpenseClaim).where(or_(*conditions))).all()) return [claim for claim in claims if self._is_expense_application_claim(claim)] def _archive_linked_applications_after_reimbursement_paid( self, *, reimbursement_claim: ExpenseClaim, payment_flag: dict[str, Any], operator: str, current_user: Any, ) -> list[dict[str, str]]: archived_applications: list[dict[str, str]] = [] payment_event_id = str(payment_flag.get("payment_event_id") or "").strip() for application_claim in self._find_linked_application_claims(reimbursement_claim): previous_status = str(application_claim.status or "").strip() previous_stage = str(application_claim.approval_stage or "").strip() if previous_stage == APPLICATION_ARCHIVE_STAGE: continue normalized_status = previous_status.lower() if normalized_status not in {"approved", "completed"}: continue before_json = self._serialize_claim(application_claim) archive_flag = with_risk_business_stage( { "source": "application_archive_sync", "event_type": "expense_application_archived_by_reimbursement", "archive_event_id": str(uuid.uuid4()), "severity": "info", "label": "申请归档", "message": ( f"关联报销单 {reimbursement_claim.claim_no} 已完成付款," "系统同步将申请单归档。" ), "operator": operator, "operator_username": getattr(current_user, "username", ""), "operator_role_codes": [ str(item).strip().lower() for item in getattr(current_user, "role_codes", []) if str(item).strip() ], "application_claim_id": application_claim.id, "application_claim_no": application_claim.claim_no, "reimbursement_claim_id": reimbursement_claim.id, "reimbursement_claim_no": reimbursement_claim.claim_no, "payment_event_id": payment_event_id, "previous_status": previous_status, "previous_approval_stage": previous_stage, "next_status": "approved", "next_approval_stage": APPLICATION_ARCHIVE_STAGE, "created_at": datetime.now(UTC).isoformat(), }, "expense_application", ) application_claim.status = "approved" application_claim.approval_stage = APPLICATION_ARCHIVE_STAGE application_claim.risk_flags_json = [*list(application_claim.risk_flags_json or []), archive_flag] archived_applications.append( { "application_claim_id": application_claim.id, "application_claim_no": str(application_claim.claim_no or "").strip(), "next_approval_stage": APPLICATION_ARCHIVE_STAGE, } ) self.audit_service.log_action( actor=operator, action="expense_application.archive_by_reimbursement", resource_type="expense_claim", resource_id=application_claim.id, before_json=before_json, after_json=self._serialize_claim(application_claim), ) return archived_applications