feat: 完善预算中心图表与确认对话框交互
后端预算服务增加汇总查询和辅助计算,前端预算中心优化趋 势图组件和数据展示,增强确认对话框通用性和样式,完善预 算编辑对话框布局,补充预算端点单元测试。
This commit is contained in:
@@ -92,6 +92,37 @@ class BudgetTransactionRead(BaseModel):
|
|||||||
created_at: datetime
|
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):
|
class BudgetSummaryRead(BaseModel):
|
||||||
fiscal_year: int | None = None
|
fiscal_year: int | None = None
|
||||||
period_key: str | None = None
|
period_key: str | None = None
|
||||||
@@ -102,6 +133,8 @@ class BudgetSummaryRead(BaseModel):
|
|||||||
warning_count: int
|
warning_count: int
|
||||||
over_budget_count: int
|
over_budget_count: int
|
||||||
allocations: list[BudgetAllocationRead] = Field(default_factory=list)
|
allocations: list[BudgetAllocationRead] = Field(default_factory=list)
|
||||||
|
trend: list[BudgetTrendPointRead] = Field(default_factory=list)
|
||||||
|
warnings: list[BudgetWarningRead] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class BudgetCheckRead(BaseModel):
|
class BudgetCheckRead(BaseModel):
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ class BudgetService(BudgetSupportMixin):
|
|||||||
over_budget_count = sum(
|
over_budget_count = sum(
|
||||||
1 for item in allocations if item.balance.available_amount < Decimal("0.00")
|
1 for item in allocations if item.balance.available_amount < Decimal("0.00")
|
||||||
)
|
)
|
||||||
|
warnings = self.build_summary_warnings(allocations)
|
||||||
return BudgetSummaryRead(
|
return BudgetSummaryRead(
|
||||||
fiscal_year=fiscal_year,
|
fiscal_year=fiscal_year,
|
||||||
period_key=period_key,
|
period_key=period_key,
|
||||||
@@ -102,6 +103,13 @@ class BudgetService(BudgetSupportMixin):
|
|||||||
warning_count=warning_count,
|
warning_count=warning_count,
|
||||||
over_budget_count=over_budget_count,
|
over_budget_count=over_budget_count,
|
||||||
allocations=allocations,
|
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(
|
def create_or_update_allocation(
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ from sqlalchemy import select
|
|||||||
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
|
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
|
||||||
from app.models.financial_record import ExpenseClaim
|
from app.models.financial_record import ExpenseClaim
|
||||||
from app.models.organization import OrganizationUnit
|
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 (
|
from app.services.budget_types import (
|
||||||
BUDGET_SUBJECT_LABELS,
|
BUDGET_SUBJECT_LABELS,
|
||||||
BudgetBalance,
|
BudgetBalance,
|
||||||
@@ -89,6 +94,120 @@ class BudgetSupportMixin:
|
|||||||
).all()
|
).all()
|
||||||
return [BudgetTransactionRead.model_validate(row) for row in rows]
|
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:
|
def get_allocation_row(self, allocation_id: str) -> BudgetAllocation | None:
|
||||||
self.ensure_budget_ready()
|
self.ensure_budget_ready()
|
||||||
return self.db.get(BudgetAllocation, allocation_id)
|
return self.db.get(BudgetAllocation, allocation_id)
|
||||||
@@ -543,6 +662,20 @@ class BudgetSupportMixin:
|
|||||||
quarter = ((max(1, min(month, 12)) - 1) // 3) + 1
|
quarter = ((max(1, min(month, 12)) - 1) // 3) + 1
|
||||||
return f"{year}Q{quarter}"
|
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]:
|
def _period_from_claim(self, claim: ExpenseClaim) -> tuple[int, str]:
|
||||||
occurred_at = claim.occurred_at or claim.submitted_at or datetime.now(UTC)
|
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)
|
return occurred_at.year, self._quarter_key(occurred_at.year, occurred_at.month)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from sqlalchemy.pool import StaticPool
|
|||||||
from app.api.deps import get_db
|
from app.api.deps import get_db
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
from app.main import create_app
|
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]:
|
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()
|
payload = response.json()
|
||||||
assert {item["department_name"] for item in payload["allocations"]} == {"市场部", "财务部"}
|
assert {item["department_name"] for item in payload["allocations"]} == {"市场部", "财务部"}
|
||||||
assert {item["subject_code"] for item in payload["allocations"]} == {"travel", "office"}
|
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:
|
def test_budget_monitor_is_limited_to_own_department_scope() -> None:
|
||||||
|
|||||||
@@ -290,20 +290,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.budget-edit-foot {
|
.budget-edit-foot {
|
||||||
padding: 18px 24px 20px;
|
padding: 14px 24px 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: flex-end;
|
||||||
gap: 18px;
|
gap: 10px;
|
||||||
border-top: 1px solid #edf1f6;
|
border-top: 1px solid #edf1f6;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.budget-edit-foot button {
|
.budget-edit-foot button {
|
||||||
height: 40px;
|
height: 36px;
|
||||||
min-width: 156px;
|
min-width: 96px;
|
||||||
border-radius: 7px;
|
border-radius: 8px;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -372,3 +372,141 @@
|
|||||||
width: 100%;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -546,14 +546,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.legend-line {
|
.legend-line {
|
||||||
width: 18px;
|
width: 10px;
|
||||||
height: 0;
|
height: 10px;
|
||||||
border-top: 2px dashed #2f7fd7;
|
border-radius: 3px;
|
||||||
|
background: #13a66b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-line.used {
|
.legend-line.occupied {
|
||||||
border-top-style: solid;
|
background: #f59e0b;
|
||||||
border-top-color: #13a66b;
|
}
|
||||||
|
|
||||||
|
.legend-line.available {
|
||||||
|
background: #e5edf3;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.budget-chart-panel {
|
.budget-chart-panel {
|
||||||
@@ -569,6 +574,41 @@
|
|||||||
padding: 12px 20px 18px;
|
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 {
|
.budget-alert-row {
|
||||||
min-height: 46px;
|
min-height: 46px;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -1,77 +1,110 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="budget-trend-chart">
|
<div class="budget-trend-chart">
|
||||||
<Line :data="chartData" :options="chartOptions" />
|
<Bar :data="chartData" :options="chartOptions" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { Line } from 'vue-chartjs'
|
import { Bar } from 'vue-chartjs'
|
||||||
import {
|
import {
|
||||||
Chart as ChartJS,
|
Chart as ChartJS,
|
||||||
|
BarElement,
|
||||||
CategoryScale,
|
CategoryScale,
|
||||||
Filler,
|
|
||||||
Legend,
|
Legend,
|
||||||
LinearScale,
|
LinearScale,
|
||||||
LineElement,
|
|
||||||
PointElement,
|
|
||||||
Tooltip
|
Tooltip
|
||||||
} from 'chart.js'
|
} from 'chart.js'
|
||||||
import { useAnimationProgress } from '../../composables/useAnimationProgress.js'
|
import { useAnimationProgress } from '../../composables/useAnimationProgress.js'
|
||||||
|
|
||||||
ChartJS.register(CategoryScale, LinearScale, LineElement, PointElement, Filler, Tooltip, Legend)
|
ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip, Legend)
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
labels: { type: Array, required: true },
|
labels: { type: Array, required: true },
|
||||||
budget: { type: Array, required: true },
|
budget: { type: Array, required: true },
|
||||||
used: { type: Array, required: true }
|
used: { type: Array, required: true },
|
||||||
|
occupied: { type: Array, default: () => [] },
|
||||||
|
available: { type: Array, default: () => [] }
|
||||||
})
|
})
|
||||||
|
|
||||||
const progress = useAnimationProgress([
|
const progress = useAnimationProgress([
|
||||||
() => props.labels,
|
() => props.labels,
|
||||||
() => props.budget,
|
() => props.budget,
|
||||||
() => props.used
|
() => props.used,
|
||||||
|
() => props.occupied,
|
||||||
|
() => props.available
|
||||||
], 1000)
|
], 1000)
|
||||||
|
|
||||||
|
const currency = (value) =>
|
||||||
|
Number(value || 0).toLocaleString('zh-CN', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
})
|
||||||
|
|
||||||
|
const percent = (value, total) => {
|
||||||
|
const denominator = Number(total || 0)
|
||||||
|
if (!denominator) return 0
|
||||||
|
return Number(((Number(value || 0) / denominator) * 100).toFixed(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
const percentSeries = (series) =>
|
||||||
|
props.budget.map((total, index) => percent(series[index], total))
|
||||||
|
|
||||||
const scaleSeries = (series) =>
|
const scaleSeries = (series) =>
|
||||||
series.map((value) => Number((Number(value || 0) * progress.value).toFixed(2)))
|
series.map((value) => Number((Number(value || 0) * progress.value).toFixed(2)))
|
||||||
|
|
||||||
|
const usedPercent = computed(() => percentSeries(props.used))
|
||||||
|
const occupiedPercent = computed(() => percentSeries(props.occupied))
|
||||||
|
const availablePercent = computed(() =>
|
||||||
|
props.budget.map((total, index) => {
|
||||||
|
const usedValue = Number(props.used[index] || 0)
|
||||||
|
const occupiedValue = Number(props.occupied[index] || 0)
|
||||||
|
return percent(Math.max(Number(total || 0) - usedValue - occupiedValue, 0), total)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const yAxisMax = computed(() => {
|
||||||
|
const maxUsage = Math.max(
|
||||||
|
100,
|
||||||
|
...usedPercent.value.map((value, index) => value + Number(occupiedPercent.value[index] || 0))
|
||||||
|
)
|
||||||
|
return Math.ceil(maxUsage / 20) * 20
|
||||||
|
})
|
||||||
|
|
||||||
const chartData = computed(() => ({
|
const chartData = computed(() => ({
|
||||||
labels: props.labels,
|
labels: props.labels,
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: '预算',
|
label: '已使用',
|
||||||
data: scaleSeries(props.budget),
|
data: scaleSeries(usedPercent.value),
|
||||||
borderColor: '#2f7fd7',
|
backgroundColor: '#13a66b',
|
||||||
backgroundColor: 'rgba(47, 127, 215, 0.08)',
|
borderRadius: 5,
|
||||||
borderDash: [7, 5],
|
borderSkipped: false,
|
||||||
borderWidth: 2,
|
stack: 'budgetUsage',
|
||||||
pointRadius: 3,
|
amounts: props.used
|
||||||
pointHoverRadius: 5,
|
|
||||||
pointBackgroundColor: '#ffffff',
|
|
||||||
pointBorderColor: '#2f7fd7',
|
|
||||||
pointBorderWidth: 2,
|
|
||||||
tension: 0.34,
|
|
||||||
fill: false
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '已发生',
|
label: '已占用',
|
||||||
data: scaleSeries(props.used),
|
data: scaleSeries(occupiedPercent.value),
|
||||||
borderColor: '#13a66b',
|
backgroundColor: '#f59e0b',
|
||||||
backgroundColor: 'rgba(19, 166, 107, 0.12)',
|
borderRadius: 5,
|
||||||
borderWidth: 2,
|
borderSkipped: false,
|
||||||
pointRadius: 3,
|
stack: 'budgetUsage',
|
||||||
pointHoverRadius: 5,
|
amounts: props.occupied
|
||||||
pointBackgroundColor: '#ffffff',
|
},
|
||||||
pointBorderColor: '#13a66b',
|
{
|
||||||
pointBorderWidth: 2,
|
label: '剩余可用',
|
||||||
tension: 0.34,
|
data: scaleSeries(availablePercent.value),
|
||||||
fill: false
|
backgroundColor: '#e5edf3',
|
||||||
|
borderRadius: 5,
|
||||||
|
borderSkipped: false,
|
||||||
|
stack: 'budgetUsage',
|
||||||
|
amounts: props.available
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const chartOptions = {
|
const chartOptions = computed(() => ({
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
interaction: {
|
interaction: {
|
||||||
@@ -97,7 +130,12 @@ const chartOptions = {
|
|||||||
callbacks: {
|
callbacks: {
|
||||||
label(context) {
|
label(context) {
|
||||||
const value = Number(context.parsed.y || 0)
|
const value = Number(context.parsed.y || 0)
|
||||||
return `${context.dataset.label}: ${value.toLocaleString('zh-CN')} 元`
|
const amount = Number(context.dataset.amounts?.[context.dataIndex] || 0)
|
||||||
|
return `${context.dataset.label}: ${value.toFixed(2)}%(¥${currency(amount)})`
|
||||||
|
},
|
||||||
|
afterBody(items) {
|
||||||
|
const index = items[0]?.dataIndex ?? 0
|
||||||
|
return `预算总额: ¥${currency(props.budget[index])}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,7 +151,8 @@ const chartOptions = {
|
|||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
beginAtZero: true,
|
beginAtZero: true,
|
||||||
max: 12000000,
|
max: yAxisMax.value,
|
||||||
|
stacked: true,
|
||||||
grid: {
|
grid: {
|
||||||
color: '#edf2f7',
|
color: '#edf2f7',
|
||||||
drawTicks: false
|
drawTicks: false
|
||||||
@@ -122,15 +161,20 @@ const chartOptions = {
|
|||||||
ticks: {
|
ticks: {
|
||||||
color: '#64748b',
|
color: '#64748b',
|
||||||
font: { size: 12 },
|
font: { size: 12 },
|
||||||
stepSize: 3000000,
|
stepSize: 20,
|
||||||
callback(value) {
|
callback(value) {
|
||||||
if (value === 0) return '0'
|
return `${Number(value)}%`
|
||||||
return `${Number(value) / 10000}万`
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
datasets: {
|
||||||
|
bar: {
|
||||||
|
categoryPercentage: 0.58,
|
||||||
|
barPercentage: 0.72
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
class="shared-confirm-card"
|
class="shared-confirm-card"
|
||||||
|
:class="cardClasses"
|
||||||
role="alertdialog"
|
role="alertdialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
:aria-labelledby="titleId"
|
:aria-labelledby="titleId"
|
||||||
@@ -65,13 +66,19 @@ const props = defineProps({
|
|||||||
confirmTone: { type: String, default: 'primary' },
|
confirmTone: { type: String, default: 'primary' },
|
||||||
confirmIcon: { type: String, default: '' },
|
confirmIcon: { type: String, default: '' },
|
||||||
busy: { type: Boolean, default: false },
|
busy: { type: Boolean, default: false },
|
||||||
closeOnMask: { type: Boolean, default: true }
|
closeOnMask: { type: Boolean, default: true },
|
||||||
|
size: { type: String, default: 'default' },
|
||||||
|
actionsAlign: { type: String, default: 'end' }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'cancel', 'confirm'])
|
const emit = defineEmits(['close', 'cancel', 'confirm'])
|
||||||
const instance = getCurrentInstance()
|
const instance = getCurrentInstance()
|
||||||
|
|
||||||
const titleId = computed(() => `shared-confirm-title-${instance?.uid || 'dialog'}`)
|
const titleId = computed(() => `shared-confirm-title-${instance?.uid || 'dialog'}`)
|
||||||
|
const cardClasses = computed(() => [
|
||||||
|
`shared-confirm-card--${props.size}`,
|
||||||
|
`shared-confirm-actions-${props.actionsAlign}`
|
||||||
|
])
|
||||||
|
|
||||||
function handleMaskClose() {
|
function handleMaskClose() {
|
||||||
if (!props.closeOnMask || props.busy) {
|
if (!props.closeOnMask || props.busy) {
|
||||||
@@ -170,6 +177,18 @@ function handleCancel() {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shared-confirm-card.shared-confirm-actions-start .shared-confirm-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-confirm-card.shared-confirm-actions-center .shared-confirm-actions {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-confirm-card.shared-confirm-actions-end .shared-confirm-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.shared-confirm-btn {
|
.shared-confirm-btn {
|
||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
@@ -236,6 +255,36 @@ function handleCancel() {
|
|||||||
transform: translateY(8px) scale(0.98);
|
transform: translateY(8px) scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shared-confirm-card--compact {
|
||||||
|
width: min(360px, 100%);
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-confirm-card--compact h4 {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-confirm-card--compact p {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-confirm-card--compact .shared-confirm-actions {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-confirm-card--compact .shared-confirm-btn {
|
||||||
|
min-width: 76px;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.shared-confirm-mask {
|
.shared-confirm-mask {
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
|
|||||||
@@ -176,32 +176,42 @@
|
|||||||
<section class="budget-bottom-grid">
|
<section class="budget-bottom-grid">
|
||||||
<article class="budget-chart-panel">
|
<article class="budget-chart-panel">
|
||||||
<header class="budget-card-head">
|
<header class="budget-card-head">
|
||||||
<strong>预算使用趋势</strong>
|
<strong>费用预算使用占比</strong>
|
||||||
<div class="budget-chart-legend">
|
<div class="budget-chart-legend">
|
||||||
<span><i class="legend-line budget"></i>预算</span>
|
<span><i class="legend-line used"></i>已使用</span>
|
||||||
<span><i class="legend-line used"></i>已发生</span>
|
<span><i class="legend-line occupied"></i>已占用</span>
|
||||||
|
<span><i class="legend-line available"></i>剩余可用</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<BudgetTrendChart
|
<BudgetTrendChart
|
||||||
:labels="trendData.labels"
|
:labels="budgetUsageData.labels"
|
||||||
:budget="trendData.budget"
|
:budget="budgetUsageData.budget"
|
||||||
:used="trendData.used"
|
:used="budgetUsageData.used"
|
||||||
|
:occupied="budgetUsageData.occupied"
|
||||||
|
:available="budgetUsageData.available"
|
||||||
/>
|
/>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="budget-alert-panel">
|
<article class="budget-alert-panel">
|
||||||
<header class="budget-card-head">
|
<header class="budget-card-head">
|
||||||
<strong>预算预警</strong>
|
<strong>预算预警</strong>
|
||||||
<button type="button">查看全部</button>
|
<button v-if="warnings.length" type="button">查看全部</button>
|
||||||
</header>
|
</header>
|
||||||
<div class="budget-alert-list">
|
<div v-if="warnings.length" class="budget-alert-list">
|
||||||
<div v-for="alert in warnings" :key="alert.title" class="budget-alert-row">
|
<div v-for="alert in warnings" :key="alert.id" class="budget-alert-row">
|
||||||
<i :class="alert.tone"></i>
|
<i :class="alert.tone"></i>
|
||||||
<strong>{{ alert.title }}</strong>
|
<strong>{{ alert.title }}</strong>
|
||||||
<span>{{ alert.desc }}</span>
|
<span>{{ alert.desc }}</span>
|
||||||
<time>{{ alert.date }}</time>
|
<time v-if="alert.date">{{ alert.date }}</time>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="budget-alert-empty">
|
||||||
|
<span class="budget-alert-empty-icon">
|
||||||
|
<i class="mdi mdi-shield-check-outline"></i>
|
||||||
|
</span>
|
||||||
|
<strong>暂无预算预警</strong>
|
||||||
|
<p>当前范围内预算使用率未达到预警线。</p>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -318,15 +328,36 @@
|
|||||||
|
|
||||||
<footer class="budget-edit-foot">
|
<footer class="budget-edit-foot">
|
||||||
<button class="budget-edit-cancel" type="button" @click="closeBudgetEditDialog">取消</button>
|
<button class="budget-edit-cancel" type="button" @click="closeBudgetEditDialog">取消</button>
|
||||||
<button class="budget-edit-publish" type="button" @click="publishBudget">保存并发布</button>
|
<button
|
||||||
|
class="budget-edit-publish"
|
||||||
|
type="button"
|
||||||
|
:disabled="budgetSaving"
|
||||||
|
@click="requestSaveBudget"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
<ConfirmDialog
|
||||||
|
:open="confirmSaveOpen"
|
||||||
|
title="确认保存预算"
|
||||||
|
description="保存后将更新当前部门和季度的预算额度。"
|
||||||
|
cancel-text="取消"
|
||||||
|
confirm-text="保存"
|
||||||
|
busy-text="保存中..."
|
||||||
|
confirm-icon="mdi mdi-content-save-outline"
|
||||||
|
:busy="budgetSaving"
|
||||||
|
size="compact"
|
||||||
|
actions-align="end"
|
||||||
|
@close="cancelSaveBudget"
|
||||||
|
@confirm="confirmSaveBudget"
|
||||||
|
/>
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
:open="confirmDeleteOpen"
|
:open="confirmDeleteOpen"
|
||||||
title="确认删除"
|
title="确认删除"
|
||||||
content="确定要删除当前预算明细行吗?删除后不可恢复。"
|
description="确定要删除当前预算明细行吗?删除后不可恢复。"
|
||||||
confirm-text="确认删除"
|
confirm-text="确认删除"
|
||||||
confirm-tone="danger"
|
confirm-tone="danger"
|
||||||
confirm-icon="mdi mdi-delete-outline"
|
confirm-icon="mdi mdi-delete-outline"
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ const comparison = (value, direction) => ({
|
|||||||
const parseBudgetAmount = (value) => Number(String(value || '').replace(/[^\d.-]/g, '')) || 0
|
const parseBudgetAmount = (value) => Number(String(value || '').replace(/[^\d.-]/g, '')) || 0
|
||||||
const makeBudgetRowId = () => `budget-row-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
const makeBudgetRowId = () => `budget-row-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||||
const BUDGET_PAGE_SIZE_OPTIONS = [5, 10]
|
const BUDGET_PAGE_SIZE_OPTIONS = [5, 10]
|
||||||
|
const ALERT_DATE_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit'
|
||||||
|
})
|
||||||
|
|
||||||
const normalizePeriodKey = (year, quarter) => {
|
const normalizePeriodKey = (year, quarter) => {
|
||||||
const normalizedYear = String(year || '').replace(/[^\d]/g, '') || '2026'
|
const normalizedYear = String(year || '').replace(/[^\d]/g, '') || '2026'
|
||||||
@@ -130,52 +134,36 @@ function normalizeBudgetAllocationRow(item) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDepartmentRows(departmentCode) {
|
function normalizeBudgetUsageData(rows) {
|
||||||
const seed = Array.from(String(departmentCode || '')).reduce(
|
const source = Array.isArray(rows) ? rows : []
|
||||||
(sum, char) => sum + char.charCodeAt(0),
|
return {
|
||||||
0
|
labels: source.map((item) => item.expenseType || '未分类'),
|
||||||
)
|
budget: source.map((item) => Number(item.totalAmount || 0)),
|
||||||
const factor = 0.88 + (seed % 18) / 100
|
used: source.map((item) => Number(item.usedAmount || 0)),
|
||||||
|
occupied: source.map((item) => Number(item.occupiedAmount || 0)),
|
||||||
return EXPENSE_BLUEPRINTS.map((item, index) => {
|
available: source.map((item) => Math.max(Number(item.leftAmount || 0), 0))
|
||||||
const totalAmount = Math.round(item.total * factor)
|
}
|
||||||
const usedAmount = Math.round(item.used * (0.9 + ((seed + index) % 12) / 100))
|
|
||||||
const occupiedAmount = Math.round(
|
|
||||||
item.occupied * (0.92 + ((seed + index * 3) % 10) / 100)
|
|
||||||
)
|
|
||||||
const leftAmount = Math.max(totalAmount - usedAmount - occupiedAmount, 0)
|
|
||||||
const rate = Number((((usedAmount + occupiedAmount) / totalAmount) * 100).toFixed(2))
|
|
||||||
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
totalAmount,
|
|
||||||
usedAmount,
|
|
||||||
occupiedAmount,
|
|
||||||
leftAmount,
|
|
||||||
rate,
|
|
||||||
rateTone: rate >= item.warning ? 'danger' : rate >= item.warning - 12 ? 'warn' : 'ok',
|
|
||||||
warningTone: item.warning >= 80 ? 'budget-warning-red' : 'budget-warning-yellow',
|
|
||||||
warningLine: `${item.warning}%`,
|
|
||||||
total: currency(totalAmount),
|
|
||||||
used: currency(usedAmount),
|
|
||||||
occupied: currency(occupiedAmount),
|
|
||||||
left: currency(leftAmount)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTrendData(rows) {
|
function formatAlertDate(value) {
|
||||||
const total = rows.reduce((sum, item) => sum + item.totalAmount, 0)
|
if (!value) return ''
|
||||||
const used = rows.reduce((sum, item) => sum + item.usedAmount + item.occupiedAmount, 0)
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return ''
|
||||||
|
return ALERT_DATE_FORMATTER.format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBudgetWarning(item) {
|
||||||
|
const subjectName = item?.subject_name || resolveBudgetExpenseTypeLabel(item?.subject_code, item?.subject_code)
|
||||||
|
const departmentName = item?.department_name || ''
|
||||||
|
const usageRate = Number(item?.usage_rate || 0)
|
||||||
|
const warningThreshold = Number(item?.warning_threshold || 0)
|
||||||
|
const tone = item?.severity === 'danger' ? 'danger' : 'warn'
|
||||||
return {
|
return {
|
||||||
labels: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
|
id: item?.allocation_id || `${departmentName}-${subjectName}-${item?.period_key || ''}`,
|
||||||
budget: [0.05, 0.18, 0.25, 0.34, 0.45, 0.52, 0.68, 0.76, 0.84, 0.91, 0.96, 1].map((ratio) =>
|
title: departmentName ? `${departmentName} · ${subjectName}` : subjectName,
|
||||||
Math.round(total * ratio)
|
desc: item?.message || `使用率已达 ${usageRate}%,达到预警线 ${warningThreshold}%。`,
|
||||||
),
|
date: formatAlertDate(item?.occurred_at),
|
||||||
used: [0.03, 0.1, 0.13, 0.22, 0.3, 0.37, 0.51, 0.59, 0.69, 0.73, 0.86, 0.96].map((ratio) =>
|
tone
|
||||||
Math.round(used * ratio)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,10 +192,12 @@ export default {
|
|||||||
const budgetPage = ref(1)
|
const budgetPage = ref(1)
|
||||||
const budgetPageSize = ref(5)
|
const budgetPageSize = ref(5)
|
||||||
const budgetRows = ref([])
|
const budgetRows = ref([])
|
||||||
|
const budgetSummary = ref(null)
|
||||||
const budgetLoading = ref(false)
|
const budgetLoading = ref(false)
|
||||||
const budgetError = ref('')
|
const budgetError = ref('')
|
||||||
const budgetSaving = ref(false)
|
const budgetSaving = ref(false)
|
||||||
const budgetEditOpen = ref(false)
|
const budgetEditOpen = ref(false)
|
||||||
|
const confirmSaveOpen = ref(false)
|
||||||
const budgetEditForm = ref({
|
const budgetEditForm = ref({
|
||||||
budgetYear: '2026',
|
budgetYear: '2026',
|
||||||
budgetQuarter: 'Q1',
|
budgetQuarter: 'Q1',
|
||||||
@@ -323,19 +313,13 @@ export default {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const warnings = computed(() =>
|
const warnings = computed(() =>
|
||||||
departmentRows.value
|
(Array.isArray(budgetSummary.value?.warnings) ? budgetSummary.value.warnings : [])
|
||||||
.slice()
|
.map(normalizeBudgetWarning)
|
||||||
.sort((a, b) => b.rate - a.rate)
|
|
||||||
.slice(0, 4)
|
|
||||||
.map((row, index) => ({
|
|
||||||
title: row.expenseType,
|
|
||||||
desc: `使用率已达 ${row.rate}%,${row.rate >= row.warning ? '已超过预警线' : '接近预警线'}(${row.warningLine})`,
|
|
||||||
date: index < 2 ? '2026-05-12' : '2026-05-10',
|
|
||||||
tone: row.rate >= row.warning ? 'danger' : row.rate >= row.warning - 12 ? 'warn' : 'ok'
|
|
||||||
}))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const trendData = computed(() => buildTrendData(departmentRows.value))
|
const budgetUsageData = computed(() =>
|
||||||
|
normalizeBudgetUsageData(departmentRows.value)
|
||||||
|
)
|
||||||
const budgetEditTotal = computed(() =>
|
const budgetEditTotal = computed(() =>
|
||||||
currency(
|
currency(
|
||||||
budgetEditRows.value.reduce(
|
budgetEditRows.value.reduce(
|
||||||
@@ -402,6 +386,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeBudgetEditDialog() {
|
function closeBudgetEditDialog() {
|
||||||
|
confirmSaveOpen.value = false
|
||||||
budgetEditOpen.value = false
|
budgetEditOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,6 +425,16 @@ export default {
|
|||||||
confirmDeleteOpen.value = false
|
confirmDeleteOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestSaveBudget() {
|
||||||
|
if (!canEditBudget.value || budgetSaving.value) return
|
||||||
|
confirmSaveOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelSaveBudget() {
|
||||||
|
if (budgetSaving.value) return
|
||||||
|
confirmSaveOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
function goToBudgetPage(page) {
|
function goToBudgetPage(page) {
|
||||||
budgetPage.value = Math.min(Math.max(Number(page) || 1, 1), totalBudgetPages.value)
|
budgetPage.value = Math.min(Math.max(Number(page) || 1, 1), totalBudgetPages.value)
|
||||||
}
|
}
|
||||||
@@ -551,9 +546,11 @@ export default {
|
|||||||
cost_center: department.costCenter || ''
|
cost_center: department.costCenter || ''
|
||||||
})
|
})
|
||||||
const allocations = Array.isArray(payload?.allocations) ? payload.allocations : []
|
const allocations = Array.isArray(payload?.allocations) ? payload.allocations : []
|
||||||
|
budgetSummary.value = payload || null
|
||||||
budgetRows.value = allocations.map(normalizeBudgetAllocationRow)
|
budgetRows.value = allocations.map(normalizeBudgetAllocationRow)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
budgetError.value = error?.message || 'Failed to load budget data'
|
budgetError.value = error?.message || 'Failed to load budget data'
|
||||||
|
budgetSummary.value = null
|
||||||
budgetRows.value = []
|
budgetRows.value = []
|
||||||
console.warn('Failed to load budget data:', error)
|
console.warn('Failed to load budget data:', error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -561,9 +558,11 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function publishBudgetAction() {
|
async function confirmSaveBudget() {
|
||||||
budgetEditForm.value.budgetStatus = BUDGET_STATUS_OPTIONS[1]
|
if (!canEditBudget.value || budgetSaving.value) return
|
||||||
await saveBudgetRows('published')
|
budgetEditForm.value.budgetStatus = BUDGET_STATUS_OPTIONS[0]
|
||||||
|
await saveBudgetRows('saved')
|
||||||
|
confirmSaveOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -609,6 +608,7 @@ export default {
|
|||||||
budgetLoading,
|
budgetLoading,
|
||||||
budgetMetrics,
|
budgetMetrics,
|
||||||
budgetOntologyContext,
|
budgetOntologyContext,
|
||||||
|
budgetSaving,
|
||||||
budgetPage: currentBudgetPage,
|
budgetPage: currentBudgetPage,
|
||||||
budgetPageNumbers,
|
budgetPageNumbers,
|
||||||
budgetPageSize,
|
budgetPageSize,
|
||||||
@@ -618,6 +618,8 @@ export default {
|
|||||||
closeBudgetEditDialog,
|
closeBudgetEditDialog,
|
||||||
controlActionOptions: BUDGET_CONTROL_ACTION_OPTIONS,
|
controlActionOptions: BUDGET_CONTROL_ACTION_OPTIONS,
|
||||||
changeBudgetPage,
|
changeBudgetPage,
|
||||||
|
confirmSaveBudget,
|
||||||
|
confirmSaveOpen,
|
||||||
departmentKeyword,
|
departmentKeyword,
|
||||||
departments,
|
departments,
|
||||||
expenseTypeOptions: BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS,
|
expenseTypeOptions: BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS,
|
||||||
@@ -630,14 +632,15 @@ export default {
|
|||||||
confirmDeleteOpen,
|
confirmDeleteOpen,
|
||||||
confirmDeleteRow,
|
confirmDeleteRow,
|
||||||
cancelDeleteRow,
|
cancelDeleteRow,
|
||||||
publishBudget: publishBudgetAction,
|
cancelSaveBudget,
|
||||||
|
requestSaveBudget,
|
||||||
statusOptions: BUDGET_STATUS_OPTIONS,
|
statusOptions: BUDGET_STATUS_OPTIONS,
|
||||||
statuses: ['全部', '正常', '预警', '管控'],
|
statuses: ['全部', '正常', '预警', '管控'],
|
||||||
syncBudgetRowSubject,
|
syncBudgetRowSubject,
|
||||||
goToBudgetPage,
|
goToBudgetPage,
|
||||||
totalBudgetPages,
|
totalBudgetPages,
|
||||||
totalBudgetRows,
|
totalBudgetRows,
|
||||||
trendData,
|
budgetUsageData,
|
||||||
visibleBudgetRows,
|
visibleBudgetRows,
|
||||||
visibleDepartments,
|
visibleDepartments,
|
||||||
warningOptions: BUDGET_WARNING_OPTIONS,
|
warningOptions: BUDGET_WARNING_OPTIONS,
|
||||||
|
|||||||
Reference in New Issue
Block a user