feat: 财务看板口径重构与半年模拟数据及报销状态注册表
- 重构 finance_dashboard 口径计算,新增模拟公司画像数据生成与筛选 - 引入 expense_claim_status_registry 统一报销状态流转 - 完善报销草稿流程、Item Sync 与本体解析器 - 优化总览页趋势图、分页组件与请求进度步骤 - 增强报销申请快速预览、本体工具与详情展示 - 新增半年报销模拟数据种子脚本与状态审计工具 - 补充财务看板、报销状态注册与模拟数据测试覆盖
This commit is contained in:
@@ -12,9 +12,9 @@ from sqlalchemy.orm import Session
|
||||
from app.db.base import Base
|
||||
from app.models.budget import BudgetAllocation
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.risk_observation import RiskObservation
|
||||
from app.schemas.finance_dashboard import FinanceDashboardRead
|
||||
from app.services.budget_support import BudgetSupportMixin
|
||||
from app.services.demo_company_simulation_filters import is_finance_reimbursement_claim
|
||||
from app.services.expense_claim_constants import EXPENSE_TYPE_LABELS
|
||||
|
||||
SLA_TARGET_HOURS = Decimal("8.0")
|
||||
@@ -30,6 +30,17 @@ PENDING_STATUSES = {
|
||||
SUCCESS_STATUSES = {"approved", "pending_payment", "paid", "completed"}
|
||||
EXCLUDED_SPEND_STATUSES = {"draft", "rejected", "returned", "supplement", "deleted"}
|
||||
EMPTY_DONUT = [{"name": "暂无数据", "value": 0, "color": "#cbd5e1"}]
|
||||
EXPENSE_TYPE_ALIASES = {
|
||||
"travel_application": "travel",
|
||||
"business_travel": "travel",
|
||||
"trip": "travel",
|
||||
"traffic": "travel",
|
||||
"transportation": "travel",
|
||||
"hotel": "travel",
|
||||
"accommodation": "travel",
|
||||
"business_meal": "meal",
|
||||
"communication_fee": "communication",
|
||||
}
|
||||
CHART_COLORS = [
|
||||
"var(--theme-primary)",
|
||||
"var(--chart-blue)",
|
||||
@@ -55,6 +66,17 @@ RISK_SIGNAL_LABELS = {
|
||||
"location_mismatch": "地点不一致",
|
||||
"amount_outlier": "金额异常",
|
||||
"preapproval_absent": "缺少事前申请",
|
||||
"missing_material": "材料不完整",
|
||||
"budget_pressure": "预算压力偏高",
|
||||
"budget_overrun": "预算超支",
|
||||
"budget_warning": "预算预警",
|
||||
"over_budget": "预算超支",
|
||||
"invoice_abnormal": "发票异常",
|
||||
"invoice_missing": "缺少发票",
|
||||
"missing_invoice": "缺少发票",
|
||||
"policy_violation": "政策不符",
|
||||
"abnormal_frequency": "频次异常",
|
||||
"manual_review": "人工复核",
|
||||
}
|
||||
|
||||
|
||||
@@ -83,31 +105,34 @@ class FinanceDashboardService(BudgetSupportMixin):
|
||||
trend_start, trend_end, trend_labels = self._resolve_trend_scope(trend_range, now)
|
||||
department_start, department_end = self._resolve_department_scope(department_range, now)
|
||||
|
||||
claims = self._fetch_claims()
|
||||
observations = self._fetch_risk_observations()
|
||||
claims = [
|
||||
claim for claim in self._fetch_claims() if is_finance_reimbursement_claim(claim)
|
||||
]
|
||||
scope_claims = self._claims_between(claims, start, end)
|
||||
previous_claims = self._claims_between(claims, previous_start, start)
|
||||
trend_claims = self._claims_between(claims, trend_start, trend_end)
|
||||
department_claims = self._claims_between(claims, department_start, department_end)
|
||||
scope_observations = self._observations_between(observations, start, end)
|
||||
|
||||
totals = self._totals(scope_claims, scope_observations, now)
|
||||
previous_totals = self._totals(previous_claims, [], now)
|
||||
totals = self._totals(scope_claims)
|
||||
previous_totals = self._totals(previous_claims)
|
||||
|
||||
return FinanceDashboardRead(
|
||||
range_key=resolved_key,
|
||||
start_date=start.date().isoformat(),
|
||||
end_date=(end - timedelta(days=1)).date().isoformat(),
|
||||
generated_at=now.isoformat(),
|
||||
has_real_data=bool(claims or observations or self._fetch_budget_allocations(now.year)),
|
||||
has_real_data=bool(claims or self._fetch_budget_allocations(now.year)),
|
||||
totals=totals,
|
||||
metric_meta=self._metric_meta(totals, previous_totals),
|
||||
trend=self._trend(trend_labels, trend_claims, now),
|
||||
spend_by_category=self._spend_by_category(scope_claims),
|
||||
exception_mix=self._exception_mix(scope_claims, scope_observations),
|
||||
exception_mix=self._payment_status_mix(scope_claims),
|
||||
department_ranking=self._department_ranking(department_claims),
|
||||
bottlenecks=self._bottlenecks(scope_claims, now),
|
||||
employee_ranking=self._employee_ranking(department_claims),
|
||||
top_claims=self._top_claims(department_claims),
|
||||
bottlenecks=self._bottlenecks(scope_claims),
|
||||
budget_summary=self._budget_summary(now.year),
|
||||
budget_metrics=self._budget_metrics(now.year),
|
||||
)
|
||||
|
||||
def _ensure_storage_ready(self) -> None:
|
||||
@@ -117,10 +142,6 @@ class FinanceDashboardService(BudgetSupportMixin):
|
||||
stmt = select(ExpenseClaim).order_by(ExpenseClaim.created_at.asc())
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
def _fetch_risk_observations(self) -> list[RiskObservation]:
|
||||
stmt = select(RiskObservation).order_by(RiskObservation.created_at.asc())
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
def _fetch_budget_allocations(self, fiscal_year: int) -> list[BudgetAllocation]:
|
||||
stmt = (
|
||||
select(BudgetAllocation)
|
||||
@@ -192,50 +213,49 @@ class FinanceDashboardService(BudgetSupportMixin):
|
||||
) -> list[ExpenseClaim]:
|
||||
return [claim for claim in claims if start <= self._claim_time(claim) < end]
|
||||
|
||||
def _observations_between(
|
||||
self,
|
||||
observations: list[RiskObservation],
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
) -> list[RiskObservation]:
|
||||
return [item for item in observations if start <= self._as_utc(item.created_at) < end]
|
||||
|
||||
def _totals(
|
||||
self,
|
||||
claims: list[ExpenseClaim],
|
||||
observations: list[RiskObservation],
|
||||
now: datetime,
|
||||
) -> dict[str, Any]:
|
||||
active_claims = [claim for claim in claims if self._status(claim) not in {"draft", "deleted"}]
|
||||
pending_claims = [claim for claim in active_claims if self._status(claim) in PENDING_STATUSES]
|
||||
success_claims = [claim for claim in active_claims if self._status(claim) in SUCCESS_STATUSES]
|
||||
risk_claim_keys = {self._claim_key(claim) for claim in active_claims if self._has_claim_risk(claim)}
|
||||
observation_keys = {
|
||||
str(item.claim_no or item.subject_key or item.id).strip()
|
||||
for item in observations
|
||||
if str(item.status or "").strip().lower() != "false_positive"
|
||||
}
|
||||
sla_hours = [self._claim_sla_hours(claim, now) for claim in active_claims if claim.submitted_at]
|
||||
sla_met = sum(1 for hours in sla_hours if hours <= SLA_TARGET_HOURS)
|
||||
clean_success = sum(1 for claim in success_claims if not self._has_claim_risk(claim))
|
||||
active_claims = [
|
||||
claim for claim in claims if self._status(claim) not in {"draft", "deleted"}
|
||||
]
|
||||
spend_claims = [
|
||||
claim for claim in active_claims if self._status(claim) not in EXCLUDED_SPEND_STATUSES
|
||||
]
|
||||
pending_payment_claims = [
|
||||
claim for claim in spend_claims if self._status(claim) == "pending_payment"
|
||||
]
|
||||
paid_claims = [claim for claim in spend_claims if self._status(claim) == "paid"]
|
||||
total_amount = sum((self._claim_amount(claim) for claim in spend_claims), Decimal("0.00"))
|
||||
pending_payment_amount = sum(
|
||||
(self._claim_amount(claim) for claim in pending_payment_claims),
|
||||
Decimal("0.00"),
|
||||
)
|
||||
budget_summary = self._budget_summary(datetime.now(UTC).year)
|
||||
avg_amount = (
|
||||
total_amount / Decimal(str(len(spend_claims)))
|
||||
if spend_claims
|
||||
else Decimal("0.00")
|
||||
)
|
||||
|
||||
return {
|
||||
"pendingCount": len(pending_claims),
|
||||
"pendingAmount": self._decimal_number(sum((self._claim_amount(claim) for claim in pending_claims), Decimal("0.00"))),
|
||||
"avgSla": self._decimal_number(self._average(sla_hours)),
|
||||
"autoPassRate": self._percent(clean_success, len(active_claims)),
|
||||
"riskCount": len({key for key in risk_claim_keys | observation_keys if key}),
|
||||
"slaRate": self._percent(sla_met, len(sla_hours)),
|
||||
"reimbursementAmount": self._decimal_number(total_amount),
|
||||
"reimbursementCount": len(spend_claims),
|
||||
"pendingPaymentAmount": self._decimal_number(pending_payment_amount),
|
||||
"avgClaimAmount": self._decimal_number(avg_amount),
|
||||
"budgetUsageRate": float(budget_summary.get("ratio") or 0),
|
||||
"paymentClearanceRate": self._percent(len(paid_claims), len(spend_claims)),
|
||||
}
|
||||
|
||||
def _metric_meta(self, current: dict[str, Any], previous: dict[str, Any]) -> dict[str, Any]:
|
||||
unit_by_key = {
|
||||
"pendingCount": "单",
|
||||
"pendingAmount": "元",
|
||||
"avgSla": "h",
|
||||
"autoPassRate": "%",
|
||||
"riskCount": "单",
|
||||
"slaRate": "%",
|
||||
"reimbursementAmount": "元",
|
||||
"reimbursementCount": "单",
|
||||
"pendingPaymentAmount": "元",
|
||||
"avgClaimAmount": "元",
|
||||
"budgetUsageRate": "%",
|
||||
"paymentClearanceRate": "%",
|
||||
}
|
||||
meta: dict[str, Any] = {}
|
||||
for key, current_value in current.items():
|
||||
@@ -257,28 +277,34 @@ class FinanceDashboardService(BudgetSupportMixin):
|
||||
claims: list[ExpenseClaim],
|
||||
now: datetime,
|
||||
) -> dict[str, Any]:
|
||||
applications = [0 for _ in labels]
|
||||
approved = [0 for _ in labels]
|
||||
claim_count = [0 for _ in labels]
|
||||
claim_amount = [Decimal("0.00") for _ in labels]
|
||||
success_count = [0 for _ in labels]
|
||||
hours: list[list[Decimal]] = [[] for _ in labels]
|
||||
index = {label: idx for idx, label in enumerate(labels)}
|
||||
|
||||
for claim in claims:
|
||||
if self._status(claim) == "draft":
|
||||
if self._status(claim) in EXCLUDED_SPEND_STATUSES:
|
||||
continue
|
||||
label = self._date_label(self._claim_time(claim).date())
|
||||
if label not in index:
|
||||
continue
|
||||
bucket = index[label]
|
||||
applications[bucket] += 1
|
||||
claim_count[bucket] += 1
|
||||
claim_amount[bucket] += self._claim_amount(claim)
|
||||
if self._status(claim) in SUCCESS_STATUSES:
|
||||
approved[bucket] += 1
|
||||
success_count[bucket] += 1
|
||||
if claim.submitted_at:
|
||||
hours[bucket].append(self._claim_sla_hours(claim, now))
|
||||
|
||||
return {
|
||||
"labels": labels,
|
||||
"applications": applications,
|
||||
"approved": approved,
|
||||
"claimCount": claim_count,
|
||||
"claimAmount": [self._decimal_number(value) for value in claim_amount],
|
||||
"successCount": success_count,
|
||||
# 兼容旧前端字段;新财务看板不再使用审批趋势语义。
|
||||
"applications": claim_count,
|
||||
"approved": success_count,
|
||||
"avgHours": [self._decimal_number(self._average(row)) for row in hours],
|
||||
}
|
||||
|
||||
@@ -287,79 +313,178 @@ class FinanceDashboardService(BudgetSupportMixin):
|
||||
for claim in claims:
|
||||
if self._status(claim) in EXCLUDED_SPEND_STATUSES:
|
||||
continue
|
||||
label = EXPENSE_TYPE_LABELS.get(str(claim.expense_type or "").strip(), claim.expense_type)
|
||||
buckets[str(label or "其他费用")] += self._claim_amount(claim)
|
||||
buckets[self._expense_type_label(claim.expense_type)] += self._claim_amount(claim)
|
||||
|
||||
rows = [
|
||||
{"name": name, "value": self._decimal_number(value), "color": CHART_COLORS[index % len(CHART_COLORS)]}
|
||||
for index, (name, value) in enumerate(sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6])
|
||||
{
|
||||
"name": name,
|
||||
"value": self._decimal_number(value),
|
||||
"color": CHART_COLORS[index % len(CHART_COLORS)],
|
||||
}
|
||||
for index, (name, value) in enumerate(
|
||||
sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6]
|
||||
)
|
||||
]
|
||||
return rows or EMPTY_DONUT
|
||||
|
||||
def _exception_mix(
|
||||
self,
|
||||
claims: list[ExpenseClaim],
|
||||
observations: list[RiskObservation],
|
||||
) -> list[dict[str, Any]]:
|
||||
def _payment_status_mix(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
|
||||
buckets: dict[str, int] = defaultdict(int)
|
||||
|
||||
for observation in observations:
|
||||
key = str(observation.risk_signal or observation.risk_type or "").strip()
|
||||
buckets[RISK_SIGNAL_LABELS.get(key, key.replace("_", " ") or "风险观察")] += 1
|
||||
|
||||
if not buckets:
|
||||
for claim in claims:
|
||||
if self._status(claim) in {"draft", "deleted"}:
|
||||
continue
|
||||
for label in self._claim_risk_labels(claim):
|
||||
buckets[label] += 1
|
||||
for claim in claims:
|
||||
status = self._status(claim)
|
||||
if status in {"draft", "deleted"}:
|
||||
continue
|
||||
buckets[self._finance_status_label(status)] += 1
|
||||
|
||||
rows = [
|
||||
{"name": name, "value": count, "color": CHART_COLORS[index % len(CHART_COLORS)]}
|
||||
for index, (name, count) in enumerate(sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6])
|
||||
for index, (name, count) in enumerate(
|
||||
sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6]
|
||||
)
|
||||
]
|
||||
return rows or EMPTY_DONUT
|
||||
|
||||
def _department_ranking(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
|
||||
buckets: dict[str, Decimal] = defaultdict(Decimal)
|
||||
counts: dict[str, int] = defaultdict(int)
|
||||
pending_amounts: dict[str, Decimal] = defaultdict(Decimal)
|
||||
for claim in claims:
|
||||
if self._status(claim) not in PENDING_STATUSES:
|
||||
status = self._status(claim)
|
||||
if status in EXCLUDED_SPEND_STATUSES:
|
||||
continue
|
||||
buckets[str(claim.department_name or "未归属部门")] += self._claim_amount(claim)
|
||||
department_name = str(claim.department_name or "").strip()
|
||||
if self._is_missing_finance_dimension(department_name):
|
||||
continue
|
||||
amount = self._claim_amount(claim)
|
||||
buckets[department_name] += amount
|
||||
counts[department_name] += 1
|
||||
if status in PENDING_STATUSES:
|
||||
pending_amounts[department_name] += amount
|
||||
|
||||
rows = [
|
||||
{
|
||||
"name": name,
|
||||
"amount": self._decimal_number(amount),
|
||||
"value": self._decimal_number(amount),
|
||||
"count": counts[name],
|
||||
"pendingAmount": self._decimal_number(pending_amounts[name]),
|
||||
"color": CHART_COLORS[index % len(CHART_COLORS)],
|
||||
}
|
||||
for index, (name, amount) in enumerate(sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:5])
|
||||
for index, (name, amount) in enumerate(
|
||||
sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6]
|
||||
)
|
||||
]
|
||||
return rows
|
||||
|
||||
def _bottlenecks(self, claims: list[ExpenseClaim], now: datetime) -> list[dict[str, Any]]:
|
||||
buckets: dict[str, list[Decimal]] = defaultdict(list)
|
||||
def _employee_ranking(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
|
||||
buckets: dict[str, Decimal] = defaultdict(Decimal)
|
||||
counts: dict[str, int] = defaultdict(int)
|
||||
departments: dict[str, str] = {}
|
||||
for claim in claims:
|
||||
if self._status(claim) not in PENDING_STATUSES:
|
||||
if self._status(claim) in EXCLUDED_SPEND_STATUSES:
|
||||
continue
|
||||
stage = self._stage_label(claim)
|
||||
buckets[stage].append(self._claim_sla_hours(claim, now))
|
||||
employee_name = str(claim.employee_name or "").strip()
|
||||
if self._is_missing_finance_dimension(employee_name):
|
||||
continue
|
||||
amount = self._claim_amount(claim)
|
||||
buckets[employee_name] += amount
|
||||
counts[employee_name] += 1
|
||||
departments.setdefault(employee_name, str(claim.department_name or "").strip())
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for index, (stage, values) in enumerate(sorted(buckets.items(), key=lambda item: self._average(item[1]), reverse=True)[:3]):
|
||||
avg_hours = self._average(values)
|
||||
rows.append(
|
||||
{
|
||||
"name": stage,
|
||||
"role": "审批节点",
|
||||
"duration": f"{self._decimal_number(avg_hours):.1f} h",
|
||||
"status": self._duration_status(avg_hours),
|
||||
"tone": self._duration_tone(avg_hours),
|
||||
"avatar": stage[:1] or str(index + 1),
|
||||
}
|
||||
return [
|
||||
{
|
||||
"name": name,
|
||||
"department": departments.get(name, ""),
|
||||
"amount": self._decimal_number(amount),
|
||||
"value": self._decimal_number(amount),
|
||||
"count": counts[name],
|
||||
"avgAmount": self._decimal_number(amount / Decimal(str(counts[name]))),
|
||||
"color": CHART_COLORS[index % len(CHART_COLORS)],
|
||||
}
|
||||
for index, (name, amount) in enumerate(
|
||||
sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6]
|
||||
)
|
||||
return rows
|
||||
]
|
||||
|
||||
def _top_claims(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
|
||||
spend_claims = [
|
||||
claim for claim in claims if self._status(claim) not in EXCLUDED_SPEND_STATUSES
|
||||
]
|
||||
return [
|
||||
{
|
||||
"claimNo": claim.claim_no,
|
||||
"employeeName": claim.employee_name,
|
||||
"departmentName": self._display_finance_dimension(
|
||||
claim.department_name,
|
||||
fallback="未归属部门",
|
||||
),
|
||||
"expenseTypeLabel": self._expense_type_label(claim.expense_type),
|
||||
"amount": self._decimal_number(self._claim_amount(claim)),
|
||||
"amountLabel": self._currency(self._claim_amount(claim)),
|
||||
"statusLabel": self._finance_status_label(self._status(claim)),
|
||||
}
|
||||
for claim in sorted(spend_claims, key=self._claim_amount, reverse=True)[:6]
|
||||
]
|
||||
|
||||
def _bottlenecks(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
|
||||
active_claims = [
|
||||
claim for claim in claims if self._status(claim) not in EXCLUDED_SPEND_STATUSES
|
||||
]
|
||||
pending_payment_claims = [
|
||||
claim for claim in active_claims if self._status(claim) == "pending_payment"
|
||||
]
|
||||
paid_claims = [claim for claim in active_claims if self._status(claim) == "paid"]
|
||||
submitted_claims = [
|
||||
claim for claim in active_claims if self._status(claim) in PENDING_STATUSES
|
||||
]
|
||||
budget_rows = self._budget_focus_rows()
|
||||
|
||||
pending_payment_amount = sum(
|
||||
(self._claim_amount(claim) for claim in pending_payment_claims),
|
||||
Decimal("0.00"),
|
||||
)
|
||||
high_claim = max(
|
||||
(self._claim_amount(claim) for claim in active_claims),
|
||||
default=Decimal("0.00"),
|
||||
)
|
||||
payment_clearance = self._percent(len(paid_claims), len(active_claims))
|
||||
|
||||
rows = [
|
||||
*budget_rows,
|
||||
self._focus_item(
|
||||
name="待付款",
|
||||
role="资金计划",
|
||||
duration=self._currency(pending_payment_amount),
|
||||
status=f"{len(pending_payment_claims)} 单",
|
||||
tone="warning" if pending_payment_claims else "success",
|
||||
avatar="付",
|
||||
),
|
||||
self._focus_item(
|
||||
name="高额单据",
|
||||
role="费用集中度",
|
||||
duration=self._currency(high_claim),
|
||||
status="本期最高",
|
||||
tone="warning" if high_claim >= Decimal("10000") else "success",
|
||||
avatar="高",
|
||||
),
|
||||
self._focus_item(
|
||||
name="待入账",
|
||||
role="月结准备",
|
||||
duration=f"{len(submitted_claims)} 单",
|
||||
status="待流转" if submitted_claims else "已清理",
|
||||
tone="warning" if submitted_claims else "success",
|
||||
avatar="账",
|
||||
),
|
||||
self._focus_item(
|
||||
name="付款完成率",
|
||||
role="付款执行",
|
||||
duration=f"{payment_clearance:.1f}%",
|
||||
status=f"{len(paid_claims)} 单已付",
|
||||
tone="success" if payment_clearance >= 80 else "warning",
|
||||
avatar="率",
|
||||
),
|
||||
]
|
||||
priority = {"danger": 0, "warning": 1, "success": 2}
|
||||
return sorted(rows, key=lambda item: priority.get(str(item.get("tone")), 3))[:6]
|
||||
|
||||
def _budget_summary(self, fiscal_year: int) -> dict[str, Any]:
|
||||
allocations = self._fetch_budget_allocations(fiscal_year)
|
||||
@@ -384,6 +509,149 @@ class FinanceDashboardService(BudgetSupportMixin):
|
||||
"left": self._currency(available),
|
||||
}
|
||||
|
||||
def _budget_metrics(self, fiscal_year: int) -> list[dict[str, Any]]:
|
||||
allocations = self._fetch_budget_allocations(fiscal_year)
|
||||
total = Decimal("0.00")
|
||||
consumed = Decimal("0.00")
|
||||
reserved = Decimal("0.00")
|
||||
available = Decimal("0.00")
|
||||
over_count = 0
|
||||
warning_count = 0
|
||||
|
||||
for allocation in allocations:
|
||||
balance = self.get_balance(allocation)
|
||||
total += balance.total_amount
|
||||
consumed += balance.consumed_amount
|
||||
reserved += balance.reserved_amount
|
||||
available += balance.available_amount
|
||||
if balance.available_amount < Decimal("0.00"):
|
||||
over_count += 1
|
||||
continue
|
||||
if balance.usage_rate >= Decimal(str(allocation.warning_threshold or 80)):
|
||||
warning_count += 1
|
||||
|
||||
used = consumed + reserved
|
||||
usage_rate = Decimal("0.00")
|
||||
if total > Decimal("0.00"):
|
||||
usage_rate = (used / total) * Decimal("100")
|
||||
|
||||
return [
|
||||
self._budget_metric(
|
||||
label="预算池数量",
|
||||
value=f"{len(allocations)} 个",
|
||||
detail="年度有效预算池",
|
||||
tone="neutral",
|
||||
icon="mdi mdi-database-outline",
|
||||
),
|
||||
self._budget_metric(
|
||||
label="总预算",
|
||||
value=self._currency(total),
|
||||
detail="原始预算 + 调整",
|
||||
tone="neutral",
|
||||
icon="mdi mdi-cash-register",
|
||||
),
|
||||
self._budget_metric(
|
||||
label="已用预算",
|
||||
value=self._currency(used),
|
||||
detail=f"使用率 {self._decimal_number(usage_rate):.1f}%",
|
||||
tone="warning" if usage_rate >= Decimal("80") else "success",
|
||||
icon="mdi mdi-chart-arc",
|
||||
),
|
||||
self._budget_metric(
|
||||
label="预占预算",
|
||||
value=self._currency(reserved),
|
||||
detail="待流转单据占用",
|
||||
tone="warning" if reserved > Decimal("0.00") else "success",
|
||||
icon="mdi mdi-lock-outline",
|
||||
),
|
||||
self._budget_metric(
|
||||
label="可用预算",
|
||||
value=self._currency(available),
|
||||
detail="可继续使用额度",
|
||||
tone="danger" if available < Decimal("0.00") else "success",
|
||||
icon="mdi mdi-wallet-outline",
|
||||
),
|
||||
self._budget_metric(
|
||||
label="预警预算池",
|
||||
value=f"{warning_count} 个",
|
||||
detail=f"超支 {over_count} 个",
|
||||
tone="danger" if over_count else "warning" if warning_count else "success",
|
||||
icon="mdi mdi-alert-outline",
|
||||
),
|
||||
]
|
||||
|
||||
def _budget_metric(
|
||||
self,
|
||||
*,
|
||||
label: str,
|
||||
value: str,
|
||||
detail: str,
|
||||
tone: str,
|
||||
icon: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"label": label,
|
||||
"value": value,
|
||||
"detail": detail,
|
||||
"tone": tone,
|
||||
"icon": icon,
|
||||
}
|
||||
|
||||
def _budget_focus_rows(self) -> list[dict[str, Any]]:
|
||||
allocations = self._fetch_budget_allocations(datetime.now(UTC).year)
|
||||
over_count = 0
|
||||
warning_count = 0
|
||||
over_amount = Decimal("0.00")
|
||||
warning_used = Decimal("0.00")
|
||||
|
||||
for allocation in allocations:
|
||||
balance = self.get_balance(allocation)
|
||||
if balance.available_amount < Decimal("0.00"):
|
||||
over_count += 1
|
||||
over_amount += abs(balance.available_amount)
|
||||
continue
|
||||
if balance.usage_rate >= Decimal(str(allocation.warning_threshold or 80)):
|
||||
warning_count += 1
|
||||
warning_used += balance.reserved_amount + balance.consumed_amount
|
||||
|
||||
return [
|
||||
self._focus_item(
|
||||
name="预算超支",
|
||||
role="预算控制",
|
||||
duration=f"{over_count} 个池",
|
||||
status=self._currency(over_amount),
|
||||
tone="danger" if over_count else "success",
|
||||
avatar="超",
|
||||
),
|
||||
self._focus_item(
|
||||
name="预算预警",
|
||||
role="预算控制",
|
||||
duration=f"{warning_count} 个池",
|
||||
status=self._currency(warning_used),
|
||||
tone="warning" if warning_count else "success",
|
||||
avatar="预",
|
||||
),
|
||||
]
|
||||
|
||||
def _focus_item(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
role: str,
|
||||
duration: str,
|
||||
status: str,
|
||||
tone: str,
|
||||
avatar: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"name": name,
|
||||
"role": role,
|
||||
"duration": duration,
|
||||
"status": status,
|
||||
"tone": tone,
|
||||
"avatar": avatar,
|
||||
}
|
||||
|
||||
def _claim_time(self, claim: ExpenseClaim) -> datetime:
|
||||
return self._as_utc(claim.submitted_at or claim.occurred_at or claim.created_at)
|
||||
|
||||
@@ -410,10 +678,14 @@ class FinanceDashboardService(BudgetSupportMixin):
|
||||
labels.append("风险扫描命中")
|
||||
for flag in self._risk_flags(claim):
|
||||
if isinstance(flag, dict):
|
||||
label = str(flag.get("label") or flag.get("message") or flag.get("type") or "").strip()
|
||||
label = str(flag.get("label") or flag.get("message") or "").strip()
|
||||
if not label:
|
||||
label = self._risk_signal_label(
|
||||
flag.get("type") or flag.get("risk_signal") or ""
|
||||
)
|
||||
else:
|
||||
label = str(flag or "").strip()
|
||||
labels.append(label or "规则异常")
|
||||
label = self._risk_signal_label(flag)
|
||||
labels.append(self._display_risk_label(label))
|
||||
return labels
|
||||
|
||||
def _risk_flags(self, claim: ExpenseClaim) -> list[Any]:
|
||||
@@ -424,6 +696,70 @@ class FinanceDashboardService(BudgetSupportMixin):
|
||||
stage = str(claim.approval_stage or self._status(claim) or "").strip().lower()
|
||||
return STAGE_LABELS.get(stage, stage.replace("_", " ").strip() or "待审批")
|
||||
|
||||
def _finance_status_label(self, status: str) -> str:
|
||||
labels = {
|
||||
"submitted": "审批中",
|
||||
"review": "审批中",
|
||||
"pending_review": "审批中",
|
||||
"manager_review": "审批中",
|
||||
"budget_review": "审批中",
|
||||
"finance_review": "审批中",
|
||||
"approving": "审批中",
|
||||
"approved": "已入账",
|
||||
"pending_payment": "待付款",
|
||||
"paid": "已付款",
|
||||
"returned": "待补充",
|
||||
"rejected": "已驳回",
|
||||
}
|
||||
return labels.get(str(status or "").strip().lower(), "其他")
|
||||
|
||||
def _expense_type_label(self, value: str | None) -> str:
|
||||
raw = str(value or "").strip()
|
||||
normalized = raw.lower().replace(" ", "_").replace("-", "_")
|
||||
normalized = EXPENSE_TYPE_ALIASES.get(normalized, normalized)
|
||||
if normalized.endswith("_application"):
|
||||
normalized = normalized.removesuffix("_application")
|
||||
return EXPENSE_TYPE_LABELS.get(normalized, "其他费用")
|
||||
|
||||
def _is_missing_finance_dimension(self, value: str | None) -> bool:
|
||||
normalized = str(value or "").strip()
|
||||
return not normalized or normalized in {
|
||||
"待补充",
|
||||
"待确认",
|
||||
"未归属部门",
|
||||
"未归属",
|
||||
"N/A",
|
||||
"n/a",
|
||||
"-",
|
||||
}
|
||||
|
||||
def _display_finance_dimension(self, value: str | None, *, fallback: str) -> str:
|
||||
text = str(value or "").strip()
|
||||
return fallback if self._is_missing_finance_dimension(text) else text
|
||||
|
||||
def _risk_signal_label(self, value: Any) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
return "风险观察"
|
||||
key = normalized.lower().replace(" ", "_").replace("-", "_")
|
||||
if key in RISK_SIGNAL_LABELS:
|
||||
return RISK_SIGNAL_LABELS[key]
|
||||
return self._display_risk_label(normalized)
|
||||
|
||||
def _display_risk_label(self, value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return "风险观察"
|
||||
key = text.lower().replace(" ", "_").replace("-", "_")
|
||||
if key in RISK_SIGNAL_LABELS:
|
||||
return RISK_SIGNAL_LABELS[key]
|
||||
if self._contains_cjk(text):
|
||||
return text
|
||||
return "风险观察"
|
||||
|
||||
def _contains_cjk(self, value: str) -> bool:
|
||||
return any("\u4e00" <= char <= "\u9fff" for char in value)
|
||||
|
||||
def _status(self, claim: ExpenseClaim) -> str:
|
||||
return str(claim.status or "").strip().lower()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user