From df49103f238cf5956acf1a7ca6e46c30141b602b Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Tue, 26 May 2026 20:07:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E9=A2=84=E7=AE=97?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=E5=9B=BE=E8=A1=A8=E4=B8=8E=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E6=A1=86=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端预算服务增加汇总查询和辅助计算,前端预算中心优化趋 势图组件和数据展示,增强确认对话框通用性和样式,完善预 算编辑对话框布局,补充预算端点单元测试。 --- server/src/app/schemas/budget.py | 33 ++++ server/src/app/services/budget.py | 8 + server/src/app/services/budget_support.py | 135 +++++++++++++++- server/tests/test_budget_endpoints.py | 86 +++++++++- .../styles/views/budget-center-dialog.css | 152 +++++++++++++++++- .../styles/views/budget-center-view.css | 52 +++++- .../components/charts/BudgetTrendChart.vue | 124 +++++++++----- web/src/components/shared/ConfirmDialog.vue | 105 ++++++++---- web/src/views/BudgetCenterView.vue | 55 +++++-- web/src/views/scripts/BudgetCenterView.js | 119 +++++++------- 10 files changed, 716 insertions(+), 153 deletions(-) diff --git a/server/src/app/schemas/budget.py b/server/src/app/schemas/budget.py index ecbebf0..c8d3702 100644 --- a/server/src/app/schemas/budget.py +++ b/server/src/app/schemas/budget.py @@ -92,6 +92,37 @@ class BudgetTransactionRead(BaseModel): created_at: datetime +class BudgetTrendPointRead(BaseModel): + period_key: str + label: str + total_amount: Decimal + reserved_amount: Decimal + consumed_amount: Decimal + used_amount: Decimal + available_amount: Decimal + usage_rate: Decimal + + +class BudgetWarningRead(BaseModel): + allocation_id: str + budget_no: str + fiscal_year: int + period_key: str + department_name: str + cost_center: str | None = None + subject_code: str + subject_name: str + total_amount: Decimal + reserved_amount: Decimal + consumed_amount: Decimal + available_amount: Decimal + usage_rate: Decimal + warning_threshold: Decimal + severity: str + message: str + occurred_at: datetime | None = None + + class BudgetSummaryRead(BaseModel): fiscal_year: int | None = None period_key: str | None = None @@ -102,6 +133,8 @@ class BudgetSummaryRead(BaseModel): warning_count: int over_budget_count: int allocations: list[BudgetAllocationRead] = Field(default_factory=list) + trend: list[BudgetTrendPointRead] = Field(default_factory=list) + warnings: list[BudgetWarningRead] = Field(default_factory=list) class BudgetCheckRead(BaseModel): diff --git a/server/src/app/services/budget.py b/server/src/app/services/budget.py index 23004bf..9150612 100644 --- a/server/src/app/services/budget.py +++ b/server/src/app/services/budget.py @@ -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( diff --git a/server/src/app/services/budget_support.py b/server/src/app/services/budget_support.py index f909f1c..4acc809 100644 --- a/server/src/app/services/budget_support.py +++ b/server/src/app/services/budget_support.py @@ -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) diff --git a/server/tests/test_budget_endpoints.py b/server/tests/test_budget_endpoints.py index 616668d..46e65dd 100644 --- a/server/tests/test_budget_endpoints.py +++ b/server/tests/test_budget_endpoints.py @@ -12,7 +12,7 @@ from sqlalchemy.pool import StaticPool from app.api.deps import get_db from app.db.base import Base from app.main import create_app -from app.models.budget import BudgetAllocation +from app.models.budget import BudgetAllocation, BudgetTransaction def build_session_factory() -> sessionmaker[Session]: @@ -123,6 +123,90 @@ def test_admin_can_view_all_budget_allocations_without_is_admin_header() -> None payload = response.json() assert {item["department_name"] for item in payload["allocations"]} == {"市场部", "财务部"} assert {item["subject_code"] for item in payload["allocations"]} == {"travel", "office"} + assert payload["warnings"] == [] + assert [item["period_key"] for item in payload["trend"]] == ["2026Q2"] + assert Decimal(payload["trend"][0]["total_amount"]) == Decimal("80000.00") + + +def test_budget_summary_returns_real_trend_and_warnings() -> None: + client, session_factory = build_client() + now = datetime.now(UTC) + with session_factory() as db: + seed_budget_allocations(db) + db.add( + BudgetAllocation( + id="budget-market-travel-q1", + budget_no="BUD-MARKET-TRAVEL-Q1", + fiscal_year=2026, + period_type="quarter", + period_key="2026Q1", + department_id="dept-market", + department_name="市场部", + cost_center="CC-4100", + project_code=None, + subject_code="travel", + subject_name="差旅费", + original_amount=Decimal("40000.00"), + adjusted_amount=Decimal("0.00"), + status="active", + warning_threshold=Decimal("80.00"), + control_action="block", + created_at=now, + updated_at=now, + ) + ) + db.add_all( + [ + BudgetTransaction( + transaction_no="BTX-MARKET-TRAVEL-Q1", + allocation_id="budget-market-travel-q1", + source_type="claim", + source_id="claim-q1", + source_no="CLM-Q1", + transaction_type="consume", + amount=Decimal("12000.00"), + before_available_amount=Decimal("40000.00"), + after_available_amount=Decimal("28000.00"), + operator="tester", + reason="Q1 真实发生额", + created_at=now, + ), + BudgetTransaction( + transaction_no="BTX-MARKET-TRAVEL-Q2", + allocation_id="budget-market-travel", + source_type="claim", + source_id="claim-q2", + source_no="CLM-Q2", + transaction_type="consume", + amount=Decimal("41000.00"), + before_available_amount=Decimal("50000.00"), + after_available_amount=Decimal("9000.00"), + operator="tester", + reason="Q2 真实发生额", + created_at=now, + ), + ] + ) + db.commit() + + response = client.get( + "/api/v1/budgets/summary?year=2026&period=2026Q2&cost_center=CC-4100", + headers={"x-auth-username": "admin", "x-auth-role-codes": "manager"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert [item["subject_code"] for item in payload["allocations"]] == ["travel"] + assert [item["period_key"] for item in payload["trend"]] == ["2026Q1", "2026Q2"] + assert Decimal(payload["trend"][0]["used_amount"]) == Decimal("12000.00") + assert Decimal(payload["trend"][1]["used_amount"]) == Decimal("41000.00") + assert payload["warning_count"] == 1 + assert payload["over_budget_count"] == 0 + assert len(payload["warnings"]) == 1 + warning = payload["warnings"][0] + assert warning["subject_code"] == "travel" + assert warning["severity"] == "warn" + assert Decimal(warning["usage_rate"]) == Decimal("82.00") def test_budget_monitor_is_limited_to_own_department_scope() -> None: diff --git a/web/src/assets/styles/views/budget-center-dialog.css b/web/src/assets/styles/views/budget-center-dialog.css index bb8acf0..2ea0bb1 100644 --- a/web/src/assets/styles/views/budget-center-dialog.css +++ b/web/src/assets/styles/views/budget-center-dialog.css @@ -290,20 +290,20 @@ } .budget-edit-foot { - padding: 18px 24px 20px; + padding: 14px 24px 16px; display: flex; align-items: center; - justify-content: center; - gap: 18px; + justify-content: flex-end; + gap: 10px; border-top: 1px solid #edf1f6; background: #fff; } .budget-edit-foot button { - height: 40px; - min-width: 156px; - border-radius: 7px; - font-size: 14px; + height: 36px; + min-width: 96px; + border-radius: 8px; + font-size: 13px; font-weight: 800; cursor: pointer; } @@ -372,3 +372,141 @@ width: 100%; } } + +@media (max-width: 1366px), (max-height: 820px) { + .budget-dialog-backdrop { + padding: 14px; + } + + .budget-edit-dialog { + width: min(920px, calc(100vw - 28px)); + max-height: calc(100vh - 28px); + } + + .budget-edit-head { + min-height: 48px; + padding: 0 18px; + } + + .budget-edit-head strong { + font-size: 16px; + } + + .budget-dialog-close { + width: 28px; + height: 28px; + font-size: 18px; + } + + .budget-edit-body { + padding: 12px 18px 10px; + } + + .budget-edit-section + .budget-edit-section { + margin-top: 10px; + } + + .budget-edit-section h3 { + margin-bottom: 8px; + font-size: 14px; + } + + .budget-edit-form-grid { + gap: 10px 16px; + } + + .budget-edit-form-grid label, + .budget-edit-textarea { + gap: 5px; + font-size: 12px; + } + + .budget-edit-form-grid select { + height: 34px; + } + + .budget-edit-textarea { + margin-top: 8px; + } + + .budget-edit-textarea textarea { + min-height: 58px; + padding: 8px 12px 20px; + line-height: 1.45; + } + + .budget-edit-table th, + .budget-edit-table td { + height: 40px; + padding: 6px 8px; + } + + .budget-edit-table input, + .budget-edit-table select { + height: 30px; + padding: 0 8px; + } + + .budget-row-delete { + width: 28px; + height: 28px; + font-size: 16px; + } + + .budget-add-row-btn { + height: 26px; + margin-top: 6px; + font-size: 12px; + } + + .budget-edit-total { + height: 34px; + margin-top: 6px; + } + + .budget-edit-total span, + .budget-edit-foot button { + font-size: 13px; + } + + .budget-edit-total strong { + font-size: 15px; + } + + .budget-edit-foot { + padding: 10px 18px 12px; + gap: 8px; + } + + .budget-edit-foot button { + height: 32px; + min-width: 88px; + } +} + +@media (max-height: 700px) { + .budget-dialog-backdrop { + padding: 8px; + } + + .budget-edit-dialog { + max-height: calc(100vh - 16px); + } + + .budget-edit-body { + padding: 10px 16px 8px; + } + + .budget-edit-textarea textarea { + min-height: 46px; + } + + .budget-edit-table th, + .budget-edit-table td { + height: 36px; + } + + .budget-edit-foot { + padding: 8px 16px; + } +} diff --git a/web/src/assets/styles/views/budget-center-view.css b/web/src/assets/styles/views/budget-center-view.css index 94d1813..c9f7bb6 100644 --- a/web/src/assets/styles/views/budget-center-view.css +++ b/web/src/assets/styles/views/budget-center-view.css @@ -546,14 +546,19 @@ } .legend-line { - width: 18px; - height: 0; - border-top: 2px dashed #2f7fd7; + width: 10px; + height: 10px; + border-radius: 3px; + background: #13a66b; } -.legend-line.used { - border-top-style: solid; - border-top-color: #13a66b; +.legend-line.occupied { + background: #f59e0b; +} + +.legend-line.available { + background: #e5edf3; + border: 1px solid #cbd5e1; } .budget-chart-panel { @@ -569,6 +574,41 @@ padding: 12px 20px 18px; } +.budget-alert-empty { + min-height: 220px; + padding: 28px 24px 30px; + display: grid; + place-items: center; + align-content: center; + gap: 10px; + text-align: center; +} + +.budget-alert-empty-icon { + width: 44px; + height: 44px; + border-radius: 12px; + display: grid; + place-items: center; + background: #e9f7f1; + color: #059669; + font-size: 24px; +} + +.budget-alert-empty strong { + color: #111827; + font-size: 16px; + font-weight: 800; +} + +.budget-alert-empty p { + max-width: 260px; + margin: 0; + color: #64748b; + font-size: 13px; + line-height: 1.6; +} + .budget-alert-row { min-height: 46px; display: grid; diff --git a/web/src/components/charts/BudgetTrendChart.vue b/web/src/components/charts/BudgetTrendChart.vue index a153c1c..67740a6 100644 --- a/web/src/components/charts/BudgetTrendChart.vue +++ b/web/src/components/charts/BudgetTrendChart.vue @@ -1,77 +1,110 @@