2026-05-27 17:31:27 +08:00
|
|
|
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 (
|
2026-06-06 17:19:07 +08:00
|
|
|
APPLICATION_LINK_STATUS_STAGE,
|
2026-05-27 17:31:27 +08:00
|
|
|
BUDGET_MANAGER_APPROVAL_STAGE,
|
|
|
|
|
DIRECT_MANAGER_APPROVAL_STAGE,
|
|
|
|
|
FINANCE_APPROVAL_STAGE,
|
2026-05-28 12:09:49 +08:00
|
|
|
PAYMENT_PAID_STAGE,
|
|
|
|
|
PAYMENT_PAID_STATUS,
|
|
|
|
|
PAYMENT_PENDING_STAGE,
|
|
|
|
|
PAYMENT_PENDING_STATUS,
|
2026-05-27 17:31:27 +08:00
|
|
|
)
|
2026-06-01 17:07:14 +08:00
|
|
|
from app.services.expense_claim_risk_stage import (
|
|
|
|
|
risk_business_stage_for_claim,
|
|
|
|
|
with_risk_business_stage,
|
|
|
|
|
)
|
2026-05-27 17:31:27 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-06-01 17:07:14 +08:00
|
|
|
business_stage = risk_business_stage_for_claim(is_application_claim=is_application_claim)
|
2026-05-27 17:31:27 +08:00
|
|
|
next_budget_manager = None
|
2026-05-30 15:46:51 +08:00
|
|
|
merged_budget_approval = False
|
2026-06-01 17:07:14 +08:00
|
|
|
route_decision_flag: dict[str, Any] | None = None
|
2026-05-27 17:31:27 +08:00
|
|
|
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 = "领导审批通过"
|
2026-06-01 17:07:14 +08:00
|
|
|
route_decision_flag = self._build_approval_route_decision(
|
|
|
|
|
claim,
|
|
|
|
|
is_application_claim=is_application_claim,
|
|
|
|
|
)
|
|
|
|
|
requires_budget_review = bool(route_decision_flag.get("requires_budget_review"))
|
2026-05-27 17:31:27 +08:00
|
|
|
if is_application_claim:
|
2026-06-01 17:07:14 +08:00
|
|
|
merged_budget_approval = (
|
|
|
|
|
requires_budget_review
|
|
|
|
|
and self._access_policy.is_department_p8_budget_monitor(current_user, claim)
|
|
|
|
|
)
|
2026-05-30 15:46:51 +08:00
|
|
|
if merged_budget_approval:
|
|
|
|
|
label = "领导及预算审核通过"
|
|
|
|
|
next_status = "approved"
|
2026-06-06 17:19:07 +08:00
|
|
|
next_stage = APPLICATION_LINK_STATUS_STAGE
|
2026-05-30 15:46:51 +08:00
|
|
|
default_message = "{operator} 已完成直属领导和预算管理者审核,申请流程完成并生成报销草稿。"
|
2026-06-01 17:07:14 +08:00
|
|
|
elif requires_budget_review:
|
2026-05-30 15:46:51 +08:00
|
|
|
next_budget_manager = self._access_policy.resolve_department_budget_manager(claim)
|
2026-06-01 17:07:14 +08:00
|
|
|
if next_budget_manager is None:
|
|
|
|
|
raise ValueError("未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。")
|
2026-05-30 15:46:51 +08:00
|
|
|
next_status = "submitted"
|
|
|
|
|
next_stage = BUDGET_MANAGER_APPROVAL_STAGE
|
2026-06-01 17:07:14 +08:00
|
|
|
default_message = "{operator} 已确认直属领导审核,因预算或风险关注项流转至预算管理者审批。"
|
|
|
|
|
else:
|
|
|
|
|
next_status = "approved"
|
2026-06-06 17:19:07 +08:00
|
|
|
next_stage = APPLICATION_LINK_STATUS_STAGE
|
2026-06-01 17:07:14 +08:00
|
|
|
default_message = "{operator} 已确认直属领导审核,系统判断预算充足且无风险,申请流程完成并生成报销草稿。"
|
2026-05-27 17:31:27 +08:00
|
|
|
else:
|
2026-06-01 17:07:14 +08:00
|
|
|
if requires_budget_review:
|
|
|
|
|
next_budget_manager = self._access_policy.resolve_department_budget_manager(claim)
|
|
|
|
|
if next_budget_manager is None:
|
|
|
|
|
raise ValueError("未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。")
|
|
|
|
|
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}。"
|
2026-05-27 17:31:27 +08:00
|
|
|
elif previous_stage == BUDGET_MANAGER_APPROVAL_STAGE:
|
|
|
|
|
if not self._access_policy.can_approve_claim(current_user, claim):
|
2026-06-01 17:07:14 +08:00
|
|
|
raise ValueError("只有当前预算管理者可以审批通过该单据。")
|
2026-05-27 17:31:27 +08:00
|
|
|
approval_source = "budget_approval"
|
2026-06-01 17:07:14 +08:00
|
|
|
event_type = (
|
|
|
|
|
"expense_application_budget_approval"
|
|
|
|
|
if is_application_claim
|
|
|
|
|
else "expense_claim_budget_approval"
|
|
|
|
|
)
|
2026-05-27 17:31:27 +08:00
|
|
|
label = "预算管理者审核通过"
|
2026-06-01 17:07:14 +08:00
|
|
|
if is_application_claim:
|
|
|
|
|
next_status = "approved"
|
2026-06-06 17:19:07 +08:00
|
|
|
next_stage = APPLICATION_LINK_STATUS_STAGE
|
2026-06-01 17:07:14 +08:00
|
|
|
default_message = "{operator} 已完成预算审核,申请流程完成并生成报销草稿。"
|
|
|
|
|
else:
|
|
|
|
|
next_status = "submitted"
|
|
|
|
|
next_stage = FINANCE_APPROVAL_STAGE
|
|
|
|
|
default_message = "{operator} 已完成预算审核,流转至{next_stage}。"
|
2026-05-27 17:31:27 +08:00
|
|
|
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 = "财务审核通过"
|
2026-05-28 12:09:49 +08:00
|
|
|
next_status = PAYMENT_PENDING_STATUS
|
|
|
|
|
next_stage = PAYMENT_PENDING_STAGE
|
|
|
|
|
default_message = "{operator} 已完成财务审核,进入待付款。"
|
2026-05-27 17:31:27 +08:00
|
|
|
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)
|
2026-06-01 17:07:14 +08:00
|
|
|
approval_flag = with_risk_business_stage(
|
|
|
|
|
{
|
|
|
|
|
"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(),
|
|
|
|
|
},
|
|
|
|
|
business_stage,
|
|
|
|
|
)
|
2026-05-30 15:46:51 +08:00
|
|
|
if merged_budget_approval:
|
|
|
|
|
approval_flag.update(
|
|
|
|
|
{
|
|
|
|
|
"budget_approval_merged": True,
|
2026-06-01 17:07:14 +08:00
|
|
|
"budget_approval_merged_reason": "direct_manager_is_department_budget_approver",
|
2026-05-30 15:46:51 +08:00
|
|
|
}
|
|
|
|
|
)
|
2026-05-27 17:31:27 +08:00
|
|
|
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(),
|
2026-06-01 17:07:14 +08:00
|
|
|
"next_approver_role_code": self._access_policy.resolve_budget_approval_role_code(
|
|
|
|
|
next_budget_manager,
|
|
|
|
|
),
|
2026-05-27 17:31:27 +08:00
|
|
|
}
|
|
|
|
|
)
|
2026-06-01 17:07:14 +08:00
|
|
|
if route_decision_flag is not None:
|
|
|
|
|
approval_flag["route_decision"] = {
|
|
|
|
|
"requires_budget_review": route_decision_flag.get("requires_budget_review"),
|
|
|
|
|
"route": route_decision_flag.get("route"),
|
|
|
|
|
"reasons": route_decision_flag.get("reasons", []),
|
|
|
|
|
"budget_result": route_decision_flag.get("budget_result", {}),
|
|
|
|
|
"current_risk_count": route_decision_flag.get("current_risk_count", 0),
|
|
|
|
|
"historical_risk_count": route_decision_flag.get("historical_risk_count", 0),
|
|
|
|
|
}
|
2026-05-27 17:31:27 +08:00
|
|
|
|
|
|
|
|
claim.status = next_status
|
|
|
|
|
claim.approval_stage = next_stage
|
|
|
|
|
if claim.submitted_at is None:
|
|
|
|
|
claim.submitted_at = datetime.now(UTC)
|
2026-06-06 17:19:07 +08:00
|
|
|
if is_application_claim and next_stage == APPLICATION_LINK_STATUS_STAGE:
|
2026-05-30 15:46:51 +08:00
|
|
|
if 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
|
|
|
|
|
elif merged_budget_approval:
|
|
|
|
|
approval_flag["leader_opinion"] = approval_opinion
|
|
|
|
|
approval_flag["budget_opinion"] = approval_opinion
|
2026-06-01 17:07:14 +08:00
|
|
|
elif (
|
|
|
|
|
previous_stage == DIRECT_MANAGER_APPROVAL_STAGE
|
|
|
|
|
and route_decision_flag is not None
|
|
|
|
|
and not route_decision_flag.get("requires_budget_review")
|
|
|
|
|
):
|
|
|
|
|
approval_flag["leader_opinion"] = approval_opinion
|
|
|
|
|
approval_flag["budget_opinion"] = "系统动态路由跳过预算复核"
|
2026-05-27 17:31:27 +08:00
|
|
|
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,
|
2026-06-01 17:07:14 +08:00
|
|
|
business_stage="reimbursement",
|
2026-05-27 17:31:27 +08:00
|
|
|
)
|
2026-06-01 17:07:14 +08:00
|
|
|
approval_flags: list[Any] = list(claim.risk_flags_json or [])
|
|
|
|
|
if route_decision_flag is not None:
|
|
|
|
|
approval_flags.append(route_decision_flag)
|
|
|
|
|
approval_flags.append(approval_flag)
|
2026-05-27 17:31:27 +08:00
|
|
|
claim.risk_flags_json = self._append_budget_flags(
|
2026-06-01 17:07:14 +08:00
|
|
|
approval_flags,
|
2026-05-27 17:31:27 +08:00
|
|
|
budget_flags,
|
2026-06-01 17:07:14 +08:00
|
|
|
business_stage=business_stage,
|
2026-05-27 17:31:27 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self.db.commit()
|
|
|
|
|
self.db.refresh(claim)
|
2026-06-01 17:07:14 +08:00
|
|
|
self._access_policy.attach_budget_approval_snapshot(claim)
|
2026-05-27 17:31:27 +08:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2026-05-28 12:09:49 +08:00
|
|
|
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()
|
2026-06-01 17:07:14 +08:00
|
|
|
payment_flag = with_risk_business_stage(
|
|
|
|
|
{
|
|
|
|
|
"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(),
|
|
|
|
|
},
|
|
|
|
|
"reimbursement",
|
|
|
|
|
)
|
2026-05-28 12:09:49 +08:00
|
|
|
|
2026-06-06 17:19:07 +08:00
|
|
|
archived_applications = self._archive_linked_applications_after_reimbursement_paid(
|
|
|
|
|
reimbursement_claim=claim,
|
|
|
|
|
payment_flag=payment_flag,
|
|
|
|
|
operator=operator,
|
|
|
|
|
current_user=current_user,
|
|
|
|
|
)
|
|
|
|
|
if archived_applications:
|
|
|
|
|
payment_flag["archived_application_claims"] = archived_applications
|
|
|
|
|
|
2026-05-28 12:09:49 +08:00
|
|
|
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
|
|
|
|
|
|
2026-05-27 17:31:27 +08:00
|
|
|
@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 ""
|