feat: 完善预算中心图表与确认对话框交互

后端预算服务增加汇总查询和辅助计算,前端预算中心优化趋
势图组件和数据展示,增强确认对话框通用性和样式,完善预
算编辑对话框布局,补充预算端点单元测试。
This commit is contained in:
caoxiaozhu
2026-05-26 20:07:56 +08:00
parent e7bef0883d
commit df49103f23
10 changed files with 716 additions and 153 deletions

View File

@@ -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(

View File

@@ -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)