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