feat: 新增预算后端服务与差旅风险规则库
后端新增预算模型、端点和服务模块,支持预算 CRUD 和余额 查询,清理旧生成规则文件并替换为按严重等级分类的差旅风 险规则库,优化认证权限和报销单访问策略,新增财务规则目 录和演示数据构建脚本,前端预算中心增加对话框交互,完善 审计页面运行时模型和元数据展示,补充单元测试。
This commit is contained in:
776
server/src/app/services/budget.py
Normal file
776
server/src/app/services/budget.py
Normal file
@@ -0,0 +1,776 @@
|
||||
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),
|
||||
)
|
||||
Reference in New Issue
Block a user