feat: 完善预算中心图表与确认对话框交互
后端预算服务增加汇总查询和辅助计算,前端预算中心优化趋 势图组件和数据展示,增强确认对话框通用性和样式,完善预 算编辑对话框布局,补充预算端点单元测试。
This commit is contained in:
@@ -92,6 +92,7 @@ class BudgetService(BudgetSupportMixin):
|
||||
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,
|
||||
@@ -102,6 +103,13 @@ class BudgetService(BudgetSupportMixin):
|
||||
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 create_or_update_allocation(
|
||||
|
||||
@@ -10,7 +10,12 @@ from sqlalchemy import select
|
||||
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.schemas.budget import BudgetAllocationRead, BudgetTransactionRead
|
||||
from app.schemas.budget import (
|
||||
BudgetAllocationRead,
|
||||
BudgetTransactionRead,
|
||||
BudgetTrendPointRead,
|
||||
BudgetWarningRead,
|
||||
)
|
||||
from app.services.budget_types import (
|
||||
BUDGET_SUBJECT_LABELS,
|
||||
BudgetBalance,
|
||||
@@ -89,6 +94,120 @@ class BudgetSupportMixin:
|
||||
).all()
|
||||
return [BudgetTransactionRead.model_validate(row) for row in rows]
|
||||
|
||||
def build_summary_trend(
|
||||
self,
|
||||
*,
|
||||
fiscal_year: int | None = None,
|
||||
department_id: str | None = None,
|
||||
department_name: str | None = None,
|
||||
cost_center: str | None = None,
|
||||
) -> list[BudgetTrendPointRead]:
|
||||
stmt = (
|
||||
select(BudgetAllocation)
|
||||
.where(BudgetAllocation.subject_code.in_(SUPPORTED_BUDGET_SUBJECT_CODES))
|
||||
.order_by(
|
||||
BudgetAllocation.fiscal_year.asc(),
|
||||
BudgetAllocation.period_key.asc(),
|
||||
)
|
||||
)
|
||||
if fiscal_year is not None:
|
||||
stmt = stmt.where(BudgetAllocation.fiscal_year == fiscal_year)
|
||||
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)
|
||||
|
||||
buckets: dict[str, dict[str, Decimal]] = {}
|
||||
for allocation in self.db.scalars(stmt).all():
|
||||
balance = self.get_balance(allocation)
|
||||
bucket = buckets.setdefault(
|
||||
allocation.period_key,
|
||||
{
|
||||
"total_amount": Decimal("0.00"),
|
||||
"reserved_amount": Decimal("0.00"),
|
||||
"consumed_amount": Decimal("0.00"),
|
||||
"available_amount": Decimal("0.00"),
|
||||
},
|
||||
)
|
||||
bucket["total_amount"] += balance.total_amount
|
||||
bucket["reserved_amount"] += balance.reserved_amount
|
||||
bucket["consumed_amount"] += balance.consumed_amount
|
||||
bucket["available_amount"] += balance.available_amount
|
||||
|
||||
trend: list[BudgetTrendPointRead] = []
|
||||
for period_key in sorted(buckets, key=self._period_sort_key):
|
||||
bucket = buckets[period_key]
|
||||
used_amount = bucket["reserved_amount"] + bucket["consumed_amount"]
|
||||
usage_rate = Decimal("0.00")
|
||||
if bucket["total_amount"] > Decimal("0.00"):
|
||||
usage_rate = (
|
||||
(used_amount / bucket["total_amount"]) * Decimal("100")
|
||||
).quantize(Decimal("0.01"))
|
||||
trend.append(
|
||||
BudgetTrendPointRead(
|
||||
period_key=period_key,
|
||||
label=self._period_label(period_key),
|
||||
total_amount=self._money(bucket["total_amount"]),
|
||||
reserved_amount=self._money(bucket["reserved_amount"]),
|
||||
consumed_amount=self._money(bucket["consumed_amount"]),
|
||||
used_amount=self._money(used_amount),
|
||||
available_amount=self._money(bucket["available_amount"]),
|
||||
usage_rate=usage_rate,
|
||||
)
|
||||
)
|
||||
return trend
|
||||
|
||||
def build_summary_warnings(self, allocations: list[BudgetAllocationRead]) -> list[BudgetWarningRead]:
|
||||
warnings: list[BudgetWarningRead] = []
|
||||
for allocation in allocations:
|
||||
balance = allocation.balance
|
||||
if balance.available_amount < Decimal("0.00"):
|
||||
severity = "danger"
|
||||
message = (
|
||||
f"{allocation.department_name} {allocation.subject_name} 可用预算为 "
|
||||
f"{balance.available_amount} 元,已超出预算。"
|
||||
)
|
||||
elif balance.usage_rate >= allocation.warning_threshold:
|
||||
severity = "warn"
|
||||
message = (
|
||||
f"{allocation.department_name} {allocation.subject_name} 使用率已达 "
|
||||
f"{balance.usage_rate}%,达到预警线 {allocation.warning_threshold}%。"
|
||||
)
|
||||
else:
|
||||
continue
|
||||
warnings.append(
|
||||
BudgetWarningRead(
|
||||
allocation_id=allocation.id,
|
||||
budget_no=allocation.budget_no,
|
||||
fiscal_year=allocation.fiscal_year,
|
||||
period_key=allocation.period_key,
|
||||
department_name=allocation.department_name,
|
||||
cost_center=allocation.cost_center,
|
||||
subject_code=allocation.subject_code,
|
||||
subject_name=allocation.subject_name,
|
||||
total_amount=balance.total_amount,
|
||||
reserved_amount=balance.reserved_amount,
|
||||
consumed_amount=balance.consumed_amount,
|
||||
available_amount=balance.available_amount,
|
||||
usage_rate=balance.usage_rate,
|
||||
warning_threshold=allocation.warning_threshold,
|
||||
severity=severity,
|
||||
message=message,
|
||||
occurred_at=allocation.updated_at,
|
||||
)
|
||||
)
|
||||
return sorted(
|
||||
warnings,
|
||||
key=lambda item: (
|
||||
0 if item.severity == "danger" else 1,
|
||||
-float(item.usage_rate),
|
||||
item.department_name,
|
||||
item.subject_name,
|
||||
),
|
||||
)
|
||||
|
||||
def get_allocation_row(self, allocation_id: str) -> BudgetAllocation | None:
|
||||
self.ensure_budget_ready()
|
||||
return self.db.get(BudgetAllocation, allocation_id)
|
||||
@@ -543,6 +662,20 @@ class BudgetSupportMixin:
|
||||
quarter = ((max(1, min(month, 12)) - 1) // 3) + 1
|
||||
return f"{year}Q{quarter}"
|
||||
|
||||
@staticmethod
|
||||
def _period_label(period_key: str) -> str:
|
||||
text = str(period_key or "").strip().upper()
|
||||
if len(text) >= 6 and text[:4].isdigit() and text[4] == "Q":
|
||||
return f"{text[:4]}年{text[4:]}"
|
||||
return text or "未分期"
|
||||
|
||||
@staticmethod
|
||||
def _period_sort_key(period_key: str) -> tuple[int, int, str]:
|
||||
text = str(period_key or "").strip().upper()
|
||||
year = int(text[:4]) if len(text) >= 4 and text[:4].isdigit() else 0
|
||||
quarter = int(text[5:]) if len(text) >= 6 and text[4] == "Q" and text[5:].isdigit() else 0
|
||||
return year, quarter, text
|
||||
|
||||
def _period_from_claim(self, claim: ExpenseClaim) -> tuple[int, str]:
|
||||
occurred_at = claim.occurred_at or claim.submitted_at or datetime.now(UTC)
|
||||
return occurred_at.year, self._quarter_key(occurred_at.year, occurred_at.month)
|
||||
|
||||
Reference in New Issue
Block a user