后端新增通用分页模块,为报销单、员工、预算、agent 资产等 端点统一接入分页参数和游标查询,优化 repository 层分页实 现,前端服务层适配分页响应结构,完善预算图表和全局样式, 优化侧边栏和企业选择器组件,引入 Element Plus 插件注册。
781 lines
32 KiB
Python
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),
|
|
)
|