from __future__ import annotations import uuid from datetime import UTC, datetime from typing import Any from app.api.deps import CurrentUserContext from app.services.expense_claim_workflow_constants import ( APPROVAL_DONE_STAGE, BUDGET_MANAGER_APPROVAL_STAGE, DIRECT_MANAGER_APPROVAL_STAGE, FINANCE_APPROVAL_STAGE, PAYMENT_PAID_STAGE, PAYMENT_PAID_STATUS, PAYMENT_PENDING_STAGE, PAYMENT_PENDING_STATUS, ) class ExpenseClaimApprovalFlowMixin: def approve_claim( self, claim_id: str, current_user: CurrentUserContext, *, opinion: str | None = 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 != "submitted": raise ValueError("只有审批中的单据可以审批通过。") previous_stage = str(claim.approval_stage or "").strip() is_application_claim = self._is_expense_application_claim(claim) next_budget_manager = None if previous_stage == DIRECT_MANAGER_APPROVAL_STAGE: if not self._access_policy.can_approve_claim(current_user, claim): raise ValueError("只有当前直属领导审批人可以审批通过该单据。") approval_source = "manual_approval" event_type = "expense_application_approval" if is_application_claim else "expense_claim_approval" label = "领导审批通过" if is_application_claim: next_budget_manager = self._access_policy.resolve_department_budget_manager(claim) next_status = "submitted" next_stage = BUDGET_MANAGER_APPROVAL_STAGE default_message = "{operator} 已确认直属领导审核,流转至预算管理者审批。" else: next_status = "submitted" next_stage = FINANCE_APPROVAL_STAGE default_message = "{operator} 已审批通过,流转至{next_stage}。" elif previous_stage == BUDGET_MANAGER_APPROVAL_STAGE: if not is_application_claim: raise ValueError("只有费用申请需要预算管理者审批。") if not self._access_policy.can_approve_claim(current_user, claim): raise ValueError("只有当前预算管理者可以审批通过该费用申请。") approval_source = "budget_approval" event_type = "expense_application_budget_approval" label = "预算管理者审核通过" next_status = "approved" next_stage = APPROVAL_DONE_STAGE default_message = "{operator} 已完成预算审核,申请流程完成并生成报销草稿。" elif previous_stage == FINANCE_APPROVAL_STAGE: if is_application_claim: raise ValueError("费用申请需先完成预算管理者审批。") if not self._access_policy.can_approve_claim(current_user, claim): raise ValueError("只有财务人员可以完成财务终审。") approval_source = "finance_approval" event_type = "expense_claim_finance_approval" label = "财务审核通过" next_status = PAYMENT_PENDING_STATUS next_stage = PAYMENT_PENDING_STAGE default_message = "{operator} 已完成财务审核,进入待付款。" else: raise ValueError("当前节点不支持审批通过。") approval_opinion = str(opinion or "").strip() if previous_stage in {DIRECT_MANAGER_APPROVAL_STAGE, BUDGET_MANAGER_APPROVAL_STAGE} and not approval_opinion: approval_opinion = "同意" before_json = self._serialize_claim(claim) operator = self._access_policy.resolve_current_user_display_name(current_user) budget_flags: list[dict[str, Any]] = [] if approval_source == "finance_approval" and not is_application_claim: consumed_budget_flag = self._consume_budget_for_finance_approval(claim, current_user) if consumed_budget_flag is not None: budget_flags.append(consumed_budget_flag) approval_flag = { "source": approval_source, "event_type": event_type, "approval_event_id": str(uuid.uuid4()), "severity": "info", "label": label, "message": approval_opinion or default_message.format(operator=operator, next_stage=next_stage), "opinion": approval_opinion, "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": str(claim.status or "").strip(), "previous_approval_stage": previous_stage, "next_status": next_status, "next_approval_stage": next_stage, "created_at": datetime.now(UTC).isoformat(), } if next_budget_manager is not None: approval_flag.update( { "next_approver_name": str(next_budget_manager.name or "").strip(), "next_approver_employee_id": next_budget_manager.id, "next_approver_grade": str(next_budget_manager.grade or "").strip(), "next_approver_role_code": "budget_monitor", } ) claim.status = next_status claim.approval_stage = next_stage if claim.submitted_at is None: claim.submitted_at = datetime.now(UTC) if is_application_claim and previous_stage == BUDGET_MANAGER_APPROVAL_STAGE: approval_flag["leader_opinion"] = self._resolve_latest_approval_opinion( claim, source="manual_approval", ) approval_flag["budget_opinion"] = approval_opinion generated_draft = self._create_reimbursement_draft_from_application( application_claim=claim, approval_flag=approval_flag, operator=operator, ) transferred_budget_flag = self._transfer_application_budget_to_reimbursement( application_claim=claim, draft_claim=generated_draft, current_user=current_user, ) if transferred_budget_flag is not None: budget_flags.append(transferred_budget_flag) generated_draft.risk_flags_json = self._append_budget_flags( generated_draft.risk_flags_json, transferred_budget_flag, ) claim.risk_flags_json = self._append_budget_flags( [*list(claim.risk_flags_json or []), approval_flag], budget_flags, ) self.db.commit() self.db.refresh(claim) self.audit_service.log_action( actor=operator, action="expense_claim.approve", resource_type="expense_claim", resource_id=claim.id, before_json=before_json, after_json=self._serialize_claim(claim), ) return claim def mark_claim_paid( self, claim_id: str, current_user: CurrentUserContext, ): 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 == PAYMENT_PAID_STATUS: raise ValueError("该报销单已付款,无需重复确认。") if normalized_status != PAYMENT_PENDING_STATUS: raise ValueError("只有待付款状态的报销单可以确认已付款。") if not self._access_policy.can_mark_claim_paid(current_user, claim): raise ValueError("只有财务人员或高级财务人员可以确认付款,且不能处理本人单据。") before_json = self._serialize_claim(claim) operator = self._access_policy.resolve_current_user_display_name(current_user) previous_stage = str(claim.approval_stage or "").strip() payment_flag = { "source": "payment", "event_type": "expense_claim_payment_completed", "payment_event_id": str(uuid.uuid4()), "severity": "info", "label": "付款完成", "message": f"{operator} 已确认付款,报销单进入已付款。", "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": str(claim.status or "").strip(), "previous_approval_stage": previous_stage, "next_status": PAYMENT_PAID_STATUS, "next_approval_stage": PAYMENT_PAID_STAGE, "created_at": datetime.now(UTC).isoformat(), } claim.status = PAYMENT_PAID_STATUS claim.approval_stage = PAYMENT_PAID_STAGE claim.risk_flags_json = [*list(claim.risk_flags_json or []), payment_flag] self.db.commit() self.db.refresh(claim) self.audit_service.log_action( actor=operator, action="expense_claim.mark_paid", resource_type="expense_claim", resource_id=claim.id, before_json=before_json, after_json=self._serialize_claim(claim), ) return claim @staticmethod def _resolve_latest_approval_opinion(claim, *, source: str) -> str: for flag in reversed(list(claim.risk_flags_json or [])): if not isinstance(flag, dict): continue if str(flag.get("source") or "").strip() != source: continue opinion = str(flag.get("opinion") or flag.get("message") or "").strip() if opinion: return opinion return ""