Files
X-Financial/server/src/app/services/budget.py
caoxiaozhu 678f64d772 feat: 统一后端分页查询与前端服务层适配
后端新增通用分页模块,为报销单、员工、预算、agent 资产等
端点统一接入分页参数和游标查询,优化 repository 层分页实
现,前端服务层适配分页响应结构,完善预算图表和全局样式,
优化侧边栏和企业选择器组件,引入 Element Plus 插件注册。
2026-05-29 14:11:06 +08:00

781 lines
32 KiB
Python

from __future__ import annotations
from datetime import UTC, datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.base import Base
from app.models.budget import BudgetAllocation, BudgetReservation
from app.models.financial_record import ExpenseClaim
from app.schemas.budget import (
BudgetAllocationCreate,
BudgetAllocationRead,
BudgetCheckRead,
BudgetCheckRequest,
BudgetOperationRequest,
BudgetOperationRead,
BudgetSummaryRead,
BudgetTransactionRead,
)
from app.services.budget_expense_control import BudgetExpenseControlModel
from app.services.budget_pagination import BudgetPaginationMixin
from app.services.budget_support import BudgetSupportMixin
from app.services.budget_types import BudgetControlError
class BudgetService(BudgetPaginationMixin, BudgetSupportMixin):
def __init__(self, db: Session) -> None:
self.db = db
def ensure_budget_ready(self) -> None:
Base.metadata.create_all(bind=self.db.get_bind())
exists = self.db.scalar(select(BudgetAllocation.id).limit(1))
if exists:
return
self._seed_default_allocations()
def list_allocations(
self,
*,
fiscal_year: int | None = None,
period_key: str | None = None,
department_id: str | None = None,
department_name: str | None = None,
cost_center: str | None = None,
) -> list[BudgetAllocationRead]:
self.ensure_budget_ready()
stmt = self.build_allocation_stmt(
fiscal_year=fiscal_year,
period_key=period_key,
department_id=department_id,
department_name=department_name,
cost_center=cost_center,
)
return [self.serialize_allocation(row) for row in self.db.scalars(stmt).all()]
def get_summary(
self,
*,
fiscal_year: int | None = None,
period_key: str | None = None,
department_id: str | None = None,
department_name: str | None = None,
cost_center: str | None = None,
) -> BudgetSummaryRead:
allocations = self.list_allocations(
fiscal_year=fiscal_year,
period_key=period_key,
department_id=department_id,
department_name=department_name,
cost_center=cost_center,
)
total_amount = sum((item.balance.total_amount for item in allocations), Decimal("0.00"))
reserved_amount = sum((item.balance.reserved_amount for item in allocations), Decimal("0.00"))
consumed_amount = sum((item.balance.consumed_amount for item in allocations), Decimal("0.00"))
available_amount = sum((item.balance.available_amount for item in allocations), Decimal("0.00"))
warning_count = sum(
1
for item in allocations
if item.balance.usage_rate >= item.warning_threshold
and item.balance.available_amount >= Decimal("0.00")
)
over_budget_count = sum(
1 for item in allocations if item.balance.available_amount < Decimal("0.00")
)
warnings = self.build_summary_warnings(allocations)
return BudgetSummaryRead(
fiscal_year=fiscal_year,
period_key=period_key,
total_amount=total_amount,
reserved_amount=reserved_amount,
consumed_amount=consumed_amount,
available_amount=available_amount,
warning_count=warning_count,
over_budget_count=over_budget_count,
allocations=allocations,
trend=self.build_summary_trend(
fiscal_year=fiscal_year,
department_id=department_id,
department_name=department_name,
cost_center=cost_center,
),
warnings=warnings,
)
def analyze_claim_budget(self, claim: ExpenseClaim) -> dict[str, Any]:
return BudgetExpenseControlModel().assess(self.build_claim_budget_context(claim), claim)
def create_or_update_allocation(
self,
payload: BudgetAllocationCreate,
*,
operator: str,
) -> BudgetAllocationRead:
self.ensure_budget_ready()
subject_code = self._normalize_subject_code(payload.subject_code)
if not self._is_supported_budget_subject(subject_code):
raise ValueError("demo 阶段预算中心只维护差旅、通信、招待费、办公用品四类预算。")
period_key = self._normalize_period_key(payload.fiscal_year, payload.period_key)
existing = self._find_exact_allocation(
fiscal_year=payload.fiscal_year,
period_key=period_key,
department_id=payload.department_id,
department_name=payload.department_name,
cost_center=payload.cost_center,
project_code=payload.project_code,
subject_code=subject_code,
)
if existing is None:
allocation = BudgetAllocation(
budget_no=self._make_no("BUD"),
fiscal_year=payload.fiscal_year,
period_type=self._normalize_period_type(payload.period_type),
period_key=period_key,
department_id=self._blank_to_none(payload.department_id),
department_name=payload.department_name.strip(),
cost_center=self._blank_to_none(payload.cost_center),
project_code=self._blank_to_none(payload.project_code),
subject_code=subject_code,
subject_name=payload.subject_name.strip(),
original_amount=self._money(payload.original_amount),
adjusted_amount=Decimal("0.00"),
status="active",
warning_threshold=self._percent(payload.warning_threshold),
control_action=self._normalize_control_action(payload.control_action),
description=self._blank_to_none(payload.description),
created_by=operator,
updated_by=operator,
)
self.db.add(allocation)
self.db.flush()
self._record_transaction(
allocation=allocation,
transaction_type="init",
amount=allocation.original_amount,
before_available=Decimal("0.00"),
after_available=self.get_balance(allocation).available_amount,
source_type="budget_allocation",
source_id=allocation.id,
source_no=allocation.budget_no,
operator=operator,
reason="初始化预算额度",
)
self.db.flush()
return self.serialize_allocation(allocation)
before_balance = self.get_balance(existing)
original_before = self._money(existing.original_amount)
existing.period_type = self._normalize_period_type(payload.period_type)
existing.department_name = payload.department_name.strip()
existing.cost_center = self._blank_to_none(payload.cost_center)
existing.project_code = self._blank_to_none(payload.project_code)
existing.subject_name = payload.subject_name.strip()
existing.original_amount = self._money(payload.original_amount)
existing.warning_threshold = self._percent(payload.warning_threshold)
existing.control_action = self._normalize_control_action(payload.control_action)
existing.description = self._blank_to_none(payload.description)
existing.updated_by = operator
self.db.flush()
amount_delta = self._money(existing.original_amount) - original_before
if amount_delta:
self._record_transaction(
allocation=existing,
transaction_type="adjust",
amount=amount_delta,
before_available=before_balance.available_amount,
after_available=self.get_balance(existing).available_amount,
source_type="budget_allocation",
source_id=existing.id,
source_no=existing.budget_no,
operator=operator,
reason="调整预算额度",
)
self.db.flush()
return self.serialize_allocation(existing)
def check(self, payload: BudgetCheckRequest) -> BudgetCheckRead:
self.ensure_budget_ready()
subject_code = self._normalize_subject_code(payload.subject_code)
if not self._is_supported_budget_subject(subject_code):
flag = self._build_budget_flag(
event_type="budget_control_skipped",
severity="low",
label="预算暂不管控",
message="demo 阶段该费用类型暂不纳入预算计算。",
amount=self._money(payload.amount),
extra={"subject_code": subject_code},
)
return BudgetCheckRead(passed=True, flags=[flag])
allocation = self._find_allocation_for_dimension(
fiscal_year=payload.fiscal_year,
period_key=payload.period_key,
department_id=payload.department_id,
department_name=payload.department_name,
cost_center=payload.cost_center,
project_code=payload.project_code,
subject_code=subject_code,
)
amount = self._money(payload.amount)
if allocation is None:
flag = self._build_budget_flag(
event_type="budget_missing",
severity="high",
label="预算归属缺失",
message="未找到匹配的预算额度,当前单据不能进入费用控制闭环。",
amount=amount,
)
return BudgetCheckRead(passed=False, blocking_reasons=[flag["message"]], flags=[flag])
review = self._review_allocation_amount(allocation, amount)
return BudgetCheckRead(
passed=not review["blocking_reasons"],
blocking_reasons=review["blocking_reasons"],
flags=review["flags"],
allocation=self.serialize_allocation(allocation),
)
def review_claim_budget(self, claim: ExpenseClaim) -> dict[str, list[Any]]:
self.ensure_budget_ready()
if not self._claim_uses_budget_control(claim):
return {"flags": [], "blocking_reasons": []}
allocation = self._find_allocation_for_claim(claim)
amount = self._money(claim.amount or Decimal("0.00"))
if allocation is None:
if self._budget_table_empty():
allocation = self._create_fallback_allocation_for_claim(claim)
else:
flag = self._build_budget_flag(
event_type="budget_missing",
severity="high",
label="预算归属缺失",
message=f"单据 {claim.claim_no} 未找到预算池额度,请先在预算中心建立预算。",
amount=amount,
)
return {"flags": [flag], "blocking_reasons": [flag["message"]]}
review = self._review_allocation_amount(allocation, amount)
return {"flags": review["flags"], "blocking_reasons": review["blocking_reasons"]}
def reserve_for_claim(
self,
claim: ExpenseClaim,
*,
source_type: str,
operator: str,
) -> list[dict[str, Any]]:
self.ensure_budget_ready()
if not self._claim_uses_budget_control(claim):
return []
existing = self._find_active_reservation(source_type=source_type, source_id=claim.id)
if existing is not None:
return [
self._build_operation_flag(
existing.allocation,
event_type="budget_reserve_reused",
label="预算预占已存在",
message=f"单据 {claim.claim_no} 已存在有效预算预占,本次提交复用原预占。",
amount=existing.amount,
reservation_id=existing.id,
)
]
allocation = self._find_allocation_for_claim(claim)
if allocation is None and self._budget_table_empty():
allocation = self._create_fallback_allocation_for_claim(claim)
if allocation is None:
raise BudgetControlError([f"单据 {claim.claim_no} 未找到预算池额度,请先建立预算。"])
amount = self._money(claim.amount or Decimal("0.00"))
review = self._review_allocation_amount(allocation, amount)
if review["blocking_reasons"]:
raise BudgetControlError(review["blocking_reasons"], flags=review["flags"])
before_balance = self.get_balance(allocation)
reservation = BudgetReservation(
reservation_no=self._make_no("BRS"),
allocation_id=allocation.id,
source_type=source_type,
source_id=claim.id,
source_no=claim.claim_no,
source_status="active",
amount=amount,
context_json=self._claim_context(claim),
)
self.db.add(reservation)
self.db.flush()
after_balance = self.get_balance(allocation)
transaction = self._record_transaction(
allocation=allocation,
reservation=reservation,
transaction_type="reserve",
amount=amount,
before_available=before_balance.available_amount,
after_available=after_balance.available_amount,
source_type=source_type,
source_id=claim.id,
source_no=claim.claim_no,
operator=operator,
reason="单据提交预算预占",
)
self.db.flush()
flag = self._build_operation_flag(
allocation,
event_type="budget_reserved",
label="预算已预占",
message=f"已为单据 {claim.claim_no} 预占预算 {amount} 元。",
amount=amount,
reservation_id=reservation.id,
transaction_id=transaction.id,
)
return [*review["flags"], flag]
def release_for_claim(
self,
claim: ExpenseClaim,
*,
source_type: str,
operator: str,
reason: str,
) -> list[dict[str, Any]]:
self.ensure_budget_ready()
reservations = self._find_active_reservations(source_type=source_type, source_id=claim.id)
flags: list[dict[str, Any]] = []
for reservation in reservations:
allocation = reservation.allocation
before_balance = self.get_balance(allocation)
amount = self._money(reservation.amount)
reservation.source_status = "released"
reservation.released_amount = amount
reservation.released_at = datetime.now(UTC)
self.db.flush()
after_balance = self.get_balance(allocation)
transaction = self._record_transaction(
allocation=allocation,
reservation=reservation,
transaction_type="release",
amount=amount,
before_available=before_balance.available_amount,
after_available=after_balance.available_amount,
source_type=source_type,
source_id=claim.id,
source_no=claim.claim_no,
operator=operator,
reason=reason,
)
flags.append(
self._build_operation_flag(
allocation,
event_type="budget_released",
label="预算预占已释放",
message=f"单据 {claim.claim_no} 已释放预算预占 {amount} 元。",
amount=amount,
reservation_id=reservation.id,
transaction_id=transaction.id,
)
)
self.db.flush()
return flags
def consume_for_claim(
self,
claim: ExpenseClaim,
*,
operator: str,
reason: str,
) -> dict[str, Any] | None:
self.ensure_budget_ready()
if not self._claim_uses_budget_control(claim):
return None
reservations = self._find_active_reservations(source_type="claim", source_id=claim.id)
amount = self._money(claim.amount or Decimal("0.00"))
if reservations:
reservation = reservations[0]
allocation = reservation.allocation
before_balance = self.get_balance(allocation)
reserved_amount = self._money(reservation.amount)
consume_amount = min(amount, reserved_amount)
release_amount = max(reserved_amount - consume_amount, Decimal("0.00"))
reservation.source_status = "consumed"
reservation.consumed_amount = consume_amount
reservation.released_amount = release_amount
reservation.consumed_at = datetime.now(UTC)
self.db.flush()
after_balance = self.get_balance(allocation)
transaction = self._record_transaction(
allocation=allocation,
reservation=reservation,
transaction_type="consume",
amount=consume_amount,
before_available=before_balance.available_amount,
after_available=after_balance.available_amount,
source_type="claim",
source_id=claim.id,
source_no=claim.claim_no,
operator=operator,
reason=reason,
)
self.db.flush()
return self._build_operation_flag(
allocation,
event_type="budget_consumed",
label="预算已核销",
message=f"单据 {claim.claim_no} 已核销预算 {consume_amount} 元。",
amount=consume_amount,
reservation_id=reservation.id,
transaction_id=transaction.id,
)
allocation = self._find_allocation_for_claim(claim)
if allocation is None:
return self._build_budget_flag(
event_type="budget_consume_skipped",
severity="low",
label="预算未核销",
message=f"单据 {claim.claim_no} 未找到预算池额度,按存量单据兼容放行。",
amount=amount,
)
review = self._review_allocation_amount(allocation, amount)
if review["blocking_reasons"]:
raise BudgetControlError(review["blocking_reasons"], flags=review["flags"])
before_balance = self.get_balance(allocation)
transaction = self._record_transaction(
allocation=allocation,
transaction_type="consume",
amount=amount,
before_available=before_balance.available_amount,
after_available=before_balance.available_amount - amount,
source_type="claim",
source_id=claim.id,
source_no=claim.claim_no,
operator=operator,
reason=reason,
)
self.db.flush()
return self._build_operation_flag(
allocation,
event_type="budget_consumed",
label="预算已核销",
message=f"单据 {claim.claim_no} 已核销预算 {amount} 元。",
amount=amount,
transaction_id=transaction.id,
)
def transfer_application_reservation(
self,
*,
application_claim: ExpenseClaim,
draft_claim: ExpenseClaim,
operator: str,
) -> dict[str, Any] | None:
self.ensure_budget_ready()
reservation = self._find_active_reservation(
source_type="application",
source_id=application_claim.id,
)
if reservation is None:
return None
allocation = reservation.allocation
before_balance = self.get_balance(allocation)
reservation.source_type = "claim"
reservation.source_id = draft_claim.id
reservation.source_no = draft_claim.claim_no
context = dict(reservation.context_json or {})
context["application_claim_id"] = application_claim.id
context["application_claim_no"] = application_claim.claim_no
context["draft_claim_id"] = draft_claim.id
context["draft_claim_no"] = draft_claim.claim_no
reservation.context_json = context
self.db.flush()
transaction = self._record_transaction(
allocation=allocation,
reservation=reservation,
transaction_type="transfer",
amount=Decimal("0.00"),
before_available=before_balance.available_amount,
after_available=self.get_balance(allocation).available_amount,
source_type="claim",
source_id=draft_claim.id,
source_no=draft_claim.claim_no,
operator=operator,
reason="申请审批通过后预算预占转入报销草稿",
)
self.db.flush()
return self._build_operation_flag(
allocation,
event_type="budget_reservation_transferred",
label="预算预占已转入报销",
message=f"申请 {application_claim.claim_no} 的预算预占已转入报销草稿 {draft_claim.claim_no}",
amount=reservation.amount,
reservation_id=reservation.id,
transaction_id=transaction.id,
)
def execute_operation(
self,
payload: BudgetOperationRequest,
*,
transaction_type: str,
operator: str,
) -> BudgetOperationRead:
self.ensure_budget_ready()
normalized_type = str(transaction_type or "").strip().lower()
subject_code = self._normalize_subject_code(payload.subject_code)
if not self._is_supported_budget_subject(subject_code):
raise BudgetControlError(["demo 阶段该费用类型暂不纳入预算计算。"])
allocation = self._find_allocation_for_dimension(
fiscal_year=payload.fiscal_year,
period_key=payload.period_key,
department_id=payload.department_id,
department_name=payload.department_name,
cost_center=payload.cost_center,
project_code=payload.project_code,
subject_code=subject_code,
)
if allocation is None:
raise BudgetControlError(["未找到匹配的预算额度。"])
amount = self._money(payload.amount)
if normalized_type == "reserve":
return self._execute_manual_reserve_operation(payload, allocation, amount, operator)
if normalized_type == "release":
return self._execute_manual_release_operation(payload, operator)
if normalized_type == "consume":
return self._execute_manual_consume_operation(payload, allocation, amount, operator)
before_balance = self.get_balance(allocation)
transaction = self._record_transaction(
allocation=allocation,
transaction_type=normalized_type,
amount=amount,
before_available=before_balance.available_amount,
after_available=before_balance.available_amount - amount,
source_type=payload.source_type,
source_id=payload.source_id,
source_no=payload.source_no,
operator=operator,
reason=payload.reason,
)
self.db.flush()
return BudgetOperationRead(
ok=True,
message="预算操作已记录。",
allocation=self.serialize_allocation(allocation),
transaction=BudgetTransactionRead.model_validate(transaction),
)
def _execute_manual_reserve_operation(
self,
payload: BudgetOperationRequest,
allocation: BudgetAllocation,
amount: Decimal,
operator: str,
) -> BudgetOperationRead:
existing = self._find_active_reservation(
source_type=payload.source_type,
source_id=payload.source_id,
)
if existing is not None:
flag = self._build_operation_flag(
existing.allocation,
event_type="budget_reserve_reused",
label="预算预占已存在",
message=f"来源 {payload.source_no} 已存在有效预算预占。",
amount=existing.amount,
reservation_id=existing.id,
)
return BudgetOperationRead(
ok=True,
message="预算预占已存在。",
reservation_id=existing.id,
allocation=self.serialize_allocation(existing.allocation),
flags=[flag],
)
review = self._review_allocation_amount(allocation, amount)
if review["blocking_reasons"]:
raise BudgetControlError(review["blocking_reasons"], flags=review["flags"])
before_balance = self.get_balance(allocation)
reservation = BudgetReservation(
reservation_no=self._make_no("BRS"),
allocation_id=allocation.id,
source_type=payload.source_type,
source_id=payload.source_id,
source_no=payload.source_no,
source_status="active",
amount=amount,
context_json={"manual_operation": True},
)
self.db.add(reservation)
self.db.flush()
after_balance = self.get_balance(allocation)
transaction = self._record_transaction(
allocation=allocation,
reservation=reservation,
transaction_type="reserve",
amount=amount,
before_available=before_balance.available_amount,
after_available=after_balance.available_amount,
source_type=payload.source_type,
source_id=payload.source_id,
source_no=payload.source_no,
operator=operator,
reason=payload.reason,
)
self.db.flush()
flag = self._build_operation_flag(
allocation,
event_type="budget_reserved",
label="预算已预占",
message=f"来源 {payload.source_no} 已预占预算 {amount} 元。",
amount=amount,
reservation_id=reservation.id,
transaction_id=transaction.id,
)
return BudgetOperationRead(
ok=True,
message="预算预占已记录。",
reservation_id=reservation.id,
allocation=self.serialize_allocation(allocation),
transaction=BudgetTransactionRead.model_validate(transaction),
flags=[*review["flags"], flag],
)
def _execute_manual_release_operation(
self,
payload: BudgetOperationRequest,
operator: str,
) -> BudgetOperationRead:
reservation = self._find_active_reservation(
source_type=payload.source_type,
source_id=payload.source_id,
)
if reservation is None:
raise BudgetControlError(["未找到可释放的预算预占。"])
allocation = reservation.allocation
before_balance = self.get_balance(allocation)
amount = self._money(reservation.amount)
reservation.source_status = "released"
reservation.released_amount = amount
reservation.released_at = datetime.now(UTC)
self.db.flush()
after_balance = self.get_balance(allocation)
transaction = self._record_transaction(
allocation=allocation,
reservation=reservation,
transaction_type="release",
amount=amount,
before_available=before_balance.available_amount,
after_available=after_balance.available_amount,
source_type=payload.source_type,
source_id=payload.source_id,
source_no=payload.source_no,
operator=operator,
reason=payload.reason,
)
self.db.flush()
flag = self._build_operation_flag(
allocation,
event_type="budget_released",
label="预算预占已释放",
message=f"来源 {payload.source_no} 已释放预算预占 {amount} 元。",
amount=amount,
reservation_id=reservation.id,
transaction_id=transaction.id,
)
return BudgetOperationRead(
ok=True,
message="预算释放已记录。",
reservation_id=reservation.id,
allocation=self.serialize_allocation(allocation),
transaction=BudgetTransactionRead.model_validate(transaction),
flags=[flag],
)
def _execute_manual_consume_operation(
self,
payload: BudgetOperationRequest,
allocation: BudgetAllocation,
amount: Decimal,
operator: str,
) -> BudgetOperationRead:
reservation = self._find_active_reservation(
source_type=payload.source_type,
source_id=payload.source_id,
)
if reservation is None:
return self._record_direct_operation(payload, allocation, "consume", amount, operator)
before_balance = self.get_balance(reservation.allocation)
reserved_amount = self._money(reservation.amount)
consume_amount = min(amount, reserved_amount)
reservation.source_status = "consumed"
reservation.consumed_amount = consume_amount
reservation.released_amount = max(reserved_amount - consume_amount, Decimal("0.00"))
reservation.consumed_at = datetime.now(UTC)
self.db.flush()
after_balance = self.get_balance(reservation.allocation)
transaction = self._record_transaction(
allocation=reservation.allocation,
reservation=reservation,
transaction_type="consume",
amount=consume_amount,
before_available=before_balance.available_amount,
after_available=after_balance.available_amount,
source_type=payload.source_type,
source_id=payload.source_id,
source_no=payload.source_no,
operator=operator,
reason=payload.reason,
)
self.db.flush()
flag = self._build_operation_flag(
reservation.allocation,
event_type="budget_consumed",
label="预算已核销",
message=f"来源 {payload.source_no} 已核销预算 {consume_amount} 元。",
amount=consume_amount,
reservation_id=reservation.id,
transaction_id=transaction.id,
)
return BudgetOperationRead(
ok=True,
message="预算核销已记录。",
reservation_id=reservation.id,
allocation=self.serialize_allocation(reservation.allocation),
transaction=BudgetTransactionRead.model_validate(transaction),
flags=[flag],
)
def _record_direct_operation(
self,
payload: BudgetOperationRequest,
allocation: BudgetAllocation,
transaction_type: str,
amount: Decimal,
operator: str,
) -> BudgetOperationRead:
before_balance = self.get_balance(allocation)
transaction = self._record_transaction(
allocation=allocation,
transaction_type=transaction_type,
amount=amount,
before_available=before_balance.available_amount,
after_available=before_balance.available_amount - amount,
source_type=payload.source_type,
source_id=payload.source_id,
source_no=payload.source_no,
operator=operator,
reason=payload.reason,
)
self.db.flush()
return BudgetOperationRead(
ok=True,
message="预算操作已记录。",
allocation=self.serialize_allocation(allocation),
transaction=BudgetTransactionRead.model_validate(transaction),
)