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_support import BudgetSupportMixin from app.services.budget_types import BudgetControlError, SUPPORTED_BUDGET_SUBJECT_CODES class BudgetService(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 = select(BudgetAllocation).order_by( BudgetAllocation.fiscal_year.desc(), BudgetAllocation.period_key.asc(), BudgetAllocation.department_name.asc(), BudgetAllocation.subject_code.asc(), ).where(BudgetAllocation.subject_code.in_(SUPPORTED_BUDGET_SUBJECT_CODES)) if fiscal_year is not None: stmt = stmt.where(BudgetAllocation.fiscal_year == fiscal_year) if period_key: stmt = stmt.where(BudgetAllocation.period_key == period_key) if department_id: stmt = stmt.where(BudgetAllocation.department_id == department_id) if department_name: stmt = stmt.where(BudgetAllocation.department_name == department_name) if cost_center: stmt = stmt.where(BudgetAllocation.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") ) 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, ) 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), )