2026-05-30 15:46:51 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
from collections import defaultdict
|
|
|
|
|
from datetime import UTC, date, datetime, time, timedelta
|
|
|
|
|
from decimal import Decimal
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
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.schemas.finance_dashboard import FinanceDashboardRead
|
|
|
|
|
from app.services.budget_support import BudgetSupportMixin
|
2026-06-02 16:22:59 +08:00
|
|
|
from app.services.demo_company_simulation_filters import is_finance_reimbursement_claim
|
2026-05-30 15:46:51 +08:00
|
|
|
from app.services.expense_claim_constants import EXPENSE_TYPE_LABELS
|
|
|
|
|
|
|
|
|
|
SLA_TARGET_HOURS = Decimal("8.0")
|
|
|
|
|
PENDING_STATUSES = {
|
|
|
|
|
"submitted",
|
|
|
|
|
"review",
|
|
|
|
|
"pending_review",
|
|
|
|
|
"manager_review",
|
|
|
|
|
"budget_review",
|
|
|
|
|
"finance_review",
|
|
|
|
|
"approving",
|
|
|
|
|
}
|
|
|
|
|
SUCCESS_STATUSES = {"approved", "pending_payment", "paid", "completed"}
|
|
|
|
|
EXCLUDED_SPEND_STATUSES = {"draft", "rejected", "returned", "supplement", "deleted"}
|
|
|
|
|
EMPTY_DONUT = [{"name": "暂无数据", "value": 0, "color": "#cbd5e1"}]
|
2026-06-02 16:22:59 +08:00
|
|
|
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",
|
|
|
|
|
}
|
2026-05-30 15:46:51 +08:00
|
|
|
CHART_COLORS = [
|
|
|
|
|
"var(--theme-primary)",
|
|
|
|
|
"var(--chart-blue)",
|
|
|
|
|
"var(--chart-amber)",
|
|
|
|
|
"var(--chart-purple)",
|
|
|
|
|
"var(--success)",
|
|
|
|
|
"var(--danger)",
|
|
|
|
|
]
|
|
|
|
|
STAGE_LABELS = {
|
|
|
|
|
"manager": "直属经理",
|
|
|
|
|
"manager_review": "直属经理",
|
|
|
|
|
"budget": "预算复核",
|
|
|
|
|
"budget_review": "预算复核",
|
|
|
|
|
"finance": "财务审核",
|
|
|
|
|
"finance_review": "财务审核",
|
|
|
|
|
"payment": "付款确认",
|
|
|
|
|
"pending_payment": "付款确认",
|
|
|
|
|
}
|
|
|
|
|
RISK_SIGNAL_LABELS = {
|
|
|
|
|
"duplicate_invoice": "重复发票",
|
|
|
|
|
"split_billing": "拆分报销",
|
|
|
|
|
"frequent_small_claims": "高频小额",
|
|
|
|
|
"location_mismatch": "地点不一致",
|
|
|
|
|
"amount_outlier": "金额异常",
|
|
|
|
|
"preapproval_absent": "缺少事前申请",
|
2026-06-02 16:22:59 +08:00
|
|
|
"missing_material": "材料不完整",
|
|
|
|
|
"budget_pressure": "预算压力偏高",
|
|
|
|
|
"budget_overrun": "预算超支",
|
|
|
|
|
"budget_warning": "预算预警",
|
|
|
|
|
"over_budget": "预算超支",
|
|
|
|
|
"invoice_abnormal": "发票异常",
|
|
|
|
|
"invoice_missing": "缺少发票",
|
|
|
|
|
"missing_invoice": "缺少发票",
|
|
|
|
|
"policy_violation": "政策不符",
|
|
|
|
|
"abnormal_frequency": "频次异常",
|
|
|
|
|
"manual_review": "人工复核",
|
2026-05-30 15:46:51 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FinanceDashboardService(BudgetSupportMixin):
|
|
|
|
|
def __init__(self, db: Session) -> None:
|
|
|
|
|
self.db = db
|
|
|
|
|
|
|
|
|
|
def build_dashboard(
|
|
|
|
|
self,
|
|
|
|
|
*,
|
|
|
|
|
range_key: str = "近10日",
|
|
|
|
|
start_date: date | None = None,
|
|
|
|
|
end_date: date | None = None,
|
|
|
|
|
trend_range: str = "近12天",
|
|
|
|
|
department_range: str = "本月",
|
|
|
|
|
) -> FinanceDashboardRead:
|
|
|
|
|
self._ensure_storage_ready()
|
|
|
|
|
now = datetime.now(UTC)
|
|
|
|
|
start, end, resolved_key = self._resolve_scope(
|
|
|
|
|
range_key=range_key,
|
|
|
|
|
start_date=start_date,
|
|
|
|
|
end_date=end_date,
|
|
|
|
|
now=now,
|
|
|
|
|
)
|
|
|
|
|
previous_start = start - (end - start)
|
|
|
|
|
trend_start, trend_end, trend_labels = self._resolve_trend_scope(trend_range, now)
|
|
|
|
|
department_start, department_end = self._resolve_department_scope(department_range, now)
|
|
|
|
|
|
2026-06-02 16:22:59 +08:00
|
|
|
claims = [
|
|
|
|
|
claim for claim in self._fetch_claims() if is_finance_reimbursement_claim(claim)
|
|
|
|
|
]
|
2026-05-30 15:46:51 +08:00
|
|
|
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)
|
|
|
|
|
|
2026-06-02 16:22:59 +08:00
|
|
|
totals = self._totals(scope_claims)
|
|
|
|
|
previous_totals = self._totals(previous_claims)
|
2026-05-30 15:46:51 +08:00
|
|
|
|
|
|
|
|
return FinanceDashboardRead(
|
|
|
|
|
range_key=resolved_key,
|
|
|
|
|
start_date=start.date().isoformat(),
|
|
|
|
|
end_date=(end - timedelta(days=1)).date().isoformat(),
|
|
|
|
|
generated_at=now.isoformat(),
|
2026-06-02 16:22:59 +08:00
|
|
|
has_real_data=bool(claims or self._fetch_budget_allocations(now.year)),
|
2026-05-30 15:46:51 +08:00
|
|
|
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),
|
2026-06-02 16:22:59 +08:00
|
|
|
exception_mix=self._payment_status_mix(scope_claims),
|
2026-05-30 15:46:51 +08:00
|
|
|
department_ranking=self._department_ranking(department_claims),
|
2026-06-02 16:22:59 +08:00
|
|
|
employee_ranking=self._employee_ranking(department_claims),
|
|
|
|
|
top_claims=self._top_claims(department_claims),
|
|
|
|
|
bottlenecks=self._bottlenecks(scope_claims),
|
2026-05-30 15:46:51 +08:00
|
|
|
budget_summary=self._budget_summary(now.year),
|
2026-06-02 16:22:59 +08:00
|
|
|
budget_metrics=self._budget_metrics(now.year),
|
2026-05-30 15:46:51 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _ensure_storage_ready(self) -> None:
|
|
|
|
|
Base.metadata.create_all(bind=self.db.get_bind())
|
|
|
|
|
|
|
|
|
|
def _fetch_claims(self) -> list[ExpenseClaim]:
|
|
|
|
|
stmt = select(ExpenseClaim).order_by(ExpenseClaim.created_at.asc())
|
|
|
|
|
return list(self.db.scalars(stmt).all())
|
|
|
|
|
|
|
|
|
|
def _fetch_budget_allocations(self, fiscal_year: int) -> list[BudgetAllocation]:
|
|
|
|
|
stmt = (
|
|
|
|
|
select(BudgetAllocation)
|
|
|
|
|
.where(BudgetAllocation.fiscal_year == fiscal_year)
|
|
|
|
|
.order_by(BudgetAllocation.period_key.asc())
|
|
|
|
|
)
|
|
|
|
|
return list(self.db.scalars(stmt).all())
|
|
|
|
|
|
|
|
|
|
def _resolve_scope(
|
|
|
|
|
self,
|
|
|
|
|
*,
|
|
|
|
|
range_key: str,
|
|
|
|
|
start_date: date | None,
|
|
|
|
|
end_date: date | None,
|
|
|
|
|
now: datetime,
|
|
|
|
|
) -> tuple[datetime, datetime, str]:
|
|
|
|
|
today = now.date()
|
|
|
|
|
normalized_key = str(range_key or "").strip() or "近10日"
|
|
|
|
|
|
|
|
|
|
if start_date and end_date:
|
|
|
|
|
start_day = min(start_date, end_date)
|
|
|
|
|
end_day = max(start_date, end_date)
|
|
|
|
|
return self._day_start(start_day), self._day_after(end_day), "自定义"
|
|
|
|
|
|
|
|
|
|
if normalized_key == "今日":
|
|
|
|
|
start_day = today
|
|
|
|
|
elif normalized_key == "本周":
|
|
|
|
|
start_day = today - timedelta(days=today.weekday())
|
|
|
|
|
elif normalized_key == "本月":
|
|
|
|
|
start_day = today.replace(day=1)
|
|
|
|
|
else:
|
|
|
|
|
days = self._days_from_label(normalized_key, default=10)
|
|
|
|
|
start_day = today - timedelta(days=days - 1)
|
|
|
|
|
|
|
|
|
|
return self._day_start(start_day), self._day_after(today), normalized_key
|
|
|
|
|
|
|
|
|
|
def _resolve_trend_scope(
|
|
|
|
|
self,
|
|
|
|
|
trend_range: str,
|
|
|
|
|
now: datetime,
|
|
|
|
|
) -> tuple[datetime, datetime, list[str]]:
|
|
|
|
|
days = self._days_from_label(trend_range, default=12)
|
|
|
|
|
end_day = now.date()
|
|
|
|
|
start_day = end_day - timedelta(days=days - 1)
|
|
|
|
|
labels = [self._date_label(start_day + timedelta(days=index)) for index in range(days)]
|
|
|
|
|
return self._day_start(start_day), self._day_after(end_day), labels
|
|
|
|
|
|
|
|
|
|
def _resolve_department_scope(
|
|
|
|
|
self,
|
|
|
|
|
department_range: str,
|
|
|
|
|
now: datetime,
|
|
|
|
|
) -> tuple[datetime, datetime]:
|
|
|
|
|
today = now.date()
|
|
|
|
|
key = str(department_range or "").strip()
|
|
|
|
|
if key == "本周":
|
|
|
|
|
start_day = today - timedelta(days=today.weekday())
|
|
|
|
|
elif key == "本季度":
|
|
|
|
|
quarter_month = ((today.month - 1) // 3) * 3 + 1
|
|
|
|
|
start_day = today.replace(month=quarter_month, day=1)
|
|
|
|
|
else:
|
|
|
|
|
start_day = today.replace(day=1)
|
|
|
|
|
return self._day_start(start_day), self._day_after(today)
|
|
|
|
|
|
|
|
|
|
def _claims_between(
|
|
|
|
|
self,
|
|
|
|
|
claims: list[ExpenseClaim],
|
|
|
|
|
start: datetime,
|
|
|
|
|
end: datetime,
|
|
|
|
|
) -> list[ExpenseClaim]:
|
|
|
|
|
return [claim for claim in claims if start <= self._claim_time(claim) < end]
|
|
|
|
|
|
|
|
|
|
def _totals(
|
|
|
|
|
self,
|
|
|
|
|
claims: list[ExpenseClaim],
|
|
|
|
|
) -> dict[str, Any]:
|
2026-06-02 16:22:59 +08:00
|
|
|
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")
|
|
|
|
|
)
|
2026-05-30 15:46:51 +08:00
|
|
|
|
|
|
|
|
return {
|
2026-06-02 16:22:59 +08:00
|
|
|
"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)),
|
2026-05-30 15:46:51 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def _metric_meta(self, current: dict[str, Any], previous: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
unit_by_key = {
|
2026-06-02 16:22:59 +08:00
|
|
|
"reimbursementAmount": "元",
|
|
|
|
|
"reimbursementCount": "单",
|
|
|
|
|
"pendingPaymentAmount": "元",
|
|
|
|
|
"avgClaimAmount": "元",
|
|
|
|
|
"budgetUsageRate": "%",
|
|
|
|
|
"paymentClearanceRate": "%",
|
2026-05-30 15:46:51 +08:00
|
|
|
}
|
|
|
|
|
meta: dict[str, Any] = {}
|
|
|
|
|
for key, current_value in current.items():
|
|
|
|
|
previous_value = Decimal(str(previous.get(key, 0) or 0))
|
|
|
|
|
value = Decimal(str(current_value or 0))
|
|
|
|
|
diff = value - previous_value
|
|
|
|
|
change = self._change_percent(value, previous_value)
|
|
|
|
|
unit = unit_by_key.get(key, "")
|
|
|
|
|
meta[key] = {
|
|
|
|
|
"changeText": f"{'+' if change >= 0 else ''}{change:.1f}%",
|
|
|
|
|
"delta": f"较上一周期 {'+' if diff >= 0 else ''}{self._format_delta(diff, unit)}",
|
|
|
|
|
"trend": "up" if diff >= 0 else "down",
|
|
|
|
|
}
|
|
|
|
|
return meta
|
|
|
|
|
|
|
|
|
|
def _trend(
|
|
|
|
|
self,
|
|
|
|
|
labels: list[str],
|
|
|
|
|
claims: list[ExpenseClaim],
|
|
|
|
|
now: datetime,
|
|
|
|
|
) -> dict[str, Any]:
|
2026-06-02 16:22:59 +08:00
|
|
|
claim_count = [0 for _ in labels]
|
|
|
|
|
claim_amount = [Decimal("0.00") for _ in labels]
|
|
|
|
|
success_count = [0 for _ in labels]
|
2026-05-30 15:46:51 +08:00
|
|
|
hours: list[list[Decimal]] = [[] for _ in labels]
|
|
|
|
|
index = {label: idx for idx, label in enumerate(labels)}
|
|
|
|
|
|
|
|
|
|
for claim in claims:
|
2026-06-02 16:22:59 +08:00
|
|
|
if self._status(claim) in EXCLUDED_SPEND_STATUSES:
|
2026-05-30 15:46:51 +08:00
|
|
|
continue
|
|
|
|
|
label = self._date_label(self._claim_time(claim).date())
|
|
|
|
|
if label not in index:
|
|
|
|
|
continue
|
|
|
|
|
bucket = index[label]
|
2026-06-02 16:22:59 +08:00
|
|
|
claim_count[bucket] += 1
|
|
|
|
|
claim_amount[bucket] += self._claim_amount(claim)
|
2026-05-30 15:46:51 +08:00
|
|
|
if self._status(claim) in SUCCESS_STATUSES:
|
2026-06-02 16:22:59 +08:00
|
|
|
success_count[bucket] += 1
|
2026-05-30 15:46:51 +08:00
|
|
|
if claim.submitted_at:
|
|
|
|
|
hours[bucket].append(self._claim_sla_hours(claim, now))
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"labels": labels,
|
2026-06-02 16:22:59 +08:00
|
|
|
"claimCount": claim_count,
|
|
|
|
|
"claimAmount": [self._decimal_number(value) for value in claim_amount],
|
|
|
|
|
"successCount": success_count,
|
|
|
|
|
# 兼容旧前端字段;新财务看板不再使用审批趋势语义。
|
|
|
|
|
"applications": claim_count,
|
|
|
|
|
"approved": success_count,
|
2026-05-30 15:46:51 +08:00
|
|
|
"avgHours": [self._decimal_number(self._average(row)) for row in hours],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def _spend_by_category(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
|
|
|
|
|
buckets: dict[str, Decimal] = defaultdict(Decimal)
|
|
|
|
|
for claim in claims:
|
|
|
|
|
if self._status(claim) in EXCLUDED_SPEND_STATUSES:
|
|
|
|
|
continue
|
2026-06-02 16:22:59 +08:00
|
|
|
buckets[self._expense_type_label(claim.expense_type)] += self._claim_amount(claim)
|
2026-05-30 15:46:51 +08:00
|
|
|
|
|
|
|
|
rows = [
|
2026-06-02 16:22:59 +08:00
|
|
|
{
|
|
|
|
|
"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]
|
|
|
|
|
)
|
2026-05-30 15:46:51 +08:00
|
|
|
]
|
|
|
|
|
return rows or EMPTY_DONUT
|
|
|
|
|
|
2026-06-02 16:22:59 +08:00
|
|
|
def _payment_status_mix(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
|
2026-05-30 15:46:51 +08:00
|
|
|
buckets: dict[str, int] = defaultdict(int)
|
2026-06-02 16:22:59 +08:00
|
|
|
for claim in claims:
|
|
|
|
|
status = self._status(claim)
|
|
|
|
|
if status in {"draft", "deleted"}:
|
|
|
|
|
continue
|
|
|
|
|
buckets[self._finance_status_label(status)] += 1
|
2026-05-30 15:46:51 +08:00
|
|
|
|
|
|
|
|
rows = [
|
|
|
|
|
{"name": name, "value": count, "color": CHART_COLORS[index % len(CHART_COLORS)]}
|
2026-06-02 16:22:59 +08:00
|
|
|
for index, (name, count) in enumerate(
|
|
|
|
|
sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6]
|
|
|
|
|
)
|
2026-05-30 15:46:51 +08:00
|
|
|
]
|
|
|
|
|
return rows or EMPTY_DONUT
|
|
|
|
|
|
|
|
|
|
def _department_ranking(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
|
|
|
|
|
buckets: dict[str, Decimal] = defaultdict(Decimal)
|
2026-06-02 16:22:59 +08:00
|
|
|
counts: dict[str, int] = defaultdict(int)
|
|
|
|
|
pending_amounts: dict[str, Decimal] = defaultdict(Decimal)
|
2026-05-30 15:46:51 +08:00
|
|
|
for claim in claims:
|
2026-06-02 16:22:59 +08:00
|
|
|
status = self._status(claim)
|
|
|
|
|
if status in EXCLUDED_SPEND_STATUSES:
|
|
|
|
|
continue
|
|
|
|
|
department_name = str(claim.department_name or "").strip()
|
|
|
|
|
if self._is_missing_finance_dimension(department_name):
|
2026-05-30 15:46:51 +08:00
|
|
|
continue
|
2026-06-02 16:22:59 +08:00
|
|
|
amount = self._claim_amount(claim)
|
|
|
|
|
buckets[department_name] += amount
|
|
|
|
|
counts[department_name] += 1
|
|
|
|
|
if status in PENDING_STATUSES:
|
|
|
|
|
pending_amounts[department_name] += amount
|
2026-05-30 15:46:51 +08:00
|
|
|
|
|
|
|
|
rows = [
|
|
|
|
|
{
|
|
|
|
|
"name": name,
|
|
|
|
|
"amount": self._decimal_number(amount),
|
|
|
|
|
"value": self._decimal_number(amount),
|
2026-06-02 16:22:59 +08:00
|
|
|
"count": counts[name],
|
|
|
|
|
"pendingAmount": self._decimal_number(pending_amounts[name]),
|
2026-05-30 15:46:51 +08:00
|
|
|
"color": CHART_COLORS[index % len(CHART_COLORS)],
|
|
|
|
|
}
|
2026-06-02 16:22:59 +08:00
|
|
|
for index, (name, amount) in enumerate(
|
|
|
|
|
sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6]
|
|
|
|
|
)
|
2026-05-30 15:46:51 +08:00
|
|
|
]
|
|
|
|
|
return rows
|
|
|
|
|
|
2026-06-02 16:22:59 +08:00
|
|
|
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] = {}
|
2026-05-30 15:46:51 +08:00
|
|
|
for claim in claims:
|
2026-06-02 16:22:59 +08:00
|
|
|
if self._status(claim) in EXCLUDED_SPEND_STATUSES:
|
2026-05-30 15:46:51 +08:00
|
|
|
continue
|
2026-06-02 16:22:59 +08:00
|
|
|
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())
|
|
|
|
|
|
|
|
|
|
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]
|
2026-05-30 15:46:51 +08:00
|
|
|
)
|
2026-06-02 16:22:59 +08:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
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]
|
2026-05-30 15:46:51 +08:00
|
|
|
|
|
|
|
|
def _budget_summary(self, fiscal_year: int) -> dict[str, Any]:
|
|
|
|
|
allocations = self._fetch_budget_allocations(fiscal_year)
|
|
|
|
|
total = Decimal("0.00")
|
|
|
|
|
used = Decimal("0.00")
|
|
|
|
|
available = Decimal("0.00")
|
|
|
|
|
|
|
|
|
|
for allocation in allocations:
|
|
|
|
|
balance = self.get_balance(allocation)
|
|
|
|
|
total += balance.total_amount
|
|
|
|
|
used += balance.reserved_amount + balance.consumed_amount
|
|
|
|
|
available += balance.available_amount
|
|
|
|
|
|
|
|
|
|
ratio = Decimal("0.00")
|
|
|
|
|
if total > Decimal("0.00"):
|
|
|
|
|
ratio = (used / total) * Decimal("100")
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"ratio": self._decimal_number(ratio),
|
|
|
|
|
"total": self._currency(total),
|
|
|
|
|
"used": self._currency(used),
|
|
|
|
|
"left": self._currency(available),
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 16:22:59 +08:00
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 15:46:51 +08:00
|
|
|
def _claim_time(self, claim: ExpenseClaim) -> datetime:
|
|
|
|
|
return self._as_utc(claim.submitted_at or claim.occurred_at or claim.created_at)
|
|
|
|
|
|
|
|
|
|
def _claim_sla_hours(self, claim: ExpenseClaim, now: datetime) -> Decimal:
|
|
|
|
|
start = self._as_utc(claim.submitted_at or claim.created_at or claim.occurred_at)
|
|
|
|
|
end = now
|
|
|
|
|
if self._status(claim) in SUCCESS_STATUSES | {"rejected", "returned"} and claim.updated_at:
|
|
|
|
|
end = self._as_utc(claim.updated_at)
|
|
|
|
|
hours = Decimal(str(max((end - start).total_seconds(), 0))) / Decimal("3600")
|
|
|
|
|
return hours.quantize(Decimal("0.1"))
|
|
|
|
|
|
|
|
|
|
def _claim_amount(self, claim: ExpenseClaim) -> Decimal:
|
|
|
|
|
return Decimal(str(claim.amount or 0))
|
|
|
|
|
|
|
|
|
|
def _claim_key(self, claim: ExpenseClaim) -> str:
|
|
|
|
|
return str(claim.claim_no or claim.id or "").strip()
|
|
|
|
|
|
|
|
|
|
def _has_claim_risk(self, claim: ExpenseClaim) -> bool:
|
|
|
|
|
return bool(claim.hermes_risk_flag or self._risk_flags(claim))
|
|
|
|
|
|
|
|
|
|
def _claim_risk_labels(self, claim: ExpenseClaim) -> list[str]:
|
|
|
|
|
labels: list[str] = []
|
|
|
|
|
if claim.hermes_risk_flag:
|
|
|
|
|
labels.append("风险扫描命中")
|
|
|
|
|
for flag in self._risk_flags(claim):
|
|
|
|
|
if isinstance(flag, dict):
|
2026-06-02 16:22:59 +08:00
|
|
|
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 ""
|
|
|
|
|
)
|
2026-05-30 15:46:51 +08:00
|
|
|
else:
|
2026-06-02 16:22:59 +08:00
|
|
|
label = self._risk_signal_label(flag)
|
|
|
|
|
labels.append(self._display_risk_label(label))
|
2026-05-30 15:46:51 +08:00
|
|
|
return labels
|
|
|
|
|
|
|
|
|
|
def _risk_flags(self, claim: ExpenseClaim) -> list[Any]:
|
|
|
|
|
flags = claim.risk_flags_json or []
|
|
|
|
|
return flags if isinstance(flags, list) else []
|
|
|
|
|
|
|
|
|
|
def _stage_label(self, claim: ExpenseClaim) -> str:
|
|
|
|
|
stage = str(claim.approval_stage or self._status(claim) or "").strip().lower()
|
|
|
|
|
return STAGE_LABELS.get(stage, stage.replace("_", " ").strip() or "待审批")
|
|
|
|
|
|
2026-06-02 16:22:59 +08:00
|
|
|
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)
|
|
|
|
|
|
2026-05-30 15:46:51 +08:00
|
|
|
def _status(self, claim: ExpenseClaim) -> str:
|
|
|
|
|
return str(claim.status or "").strip().lower()
|
|
|
|
|
|
|
|
|
|
def _as_utc(self, value: datetime | None) -> datetime:
|
|
|
|
|
if value is None:
|
|
|
|
|
return datetime.now(UTC)
|
|
|
|
|
if value.tzinfo is None:
|
|
|
|
|
return value.replace(tzinfo=UTC)
|
|
|
|
|
return value.astimezone(UTC)
|
|
|
|
|
|
|
|
|
|
def _day_start(self, value: date) -> datetime:
|
|
|
|
|
return datetime.combine(value, time.min, tzinfo=UTC)
|
|
|
|
|
|
|
|
|
|
def _day_after(self, value: date) -> datetime:
|
|
|
|
|
return datetime.combine(value + timedelta(days=1), time.min, tzinfo=UTC)
|
|
|
|
|
|
|
|
|
|
def _date_label(self, value: date) -> str:
|
|
|
|
|
return value.strftime("%m-%d")
|
|
|
|
|
|
|
|
|
|
def _days_from_label(self, value: str, *, default: int) -> int:
|
|
|
|
|
match = re.search(r"\d+", str(value or ""))
|
|
|
|
|
if not match:
|
|
|
|
|
return default
|
|
|
|
|
return max(1, min(int(match.group(0)), 90))
|
|
|
|
|
|
|
|
|
|
def _duration_status(self, hours: Decimal) -> str:
|
|
|
|
|
if hours >= Decimal("12"):
|
|
|
|
|
return "较慢"
|
|
|
|
|
if hours >= SLA_TARGET_HOURS:
|
|
|
|
|
return "偏慢"
|
|
|
|
|
return "正常"
|
|
|
|
|
|
|
|
|
|
def _duration_tone(self, hours: Decimal) -> str:
|
|
|
|
|
if hours >= Decimal("12"):
|
|
|
|
|
return "danger"
|
|
|
|
|
if hours >= SLA_TARGET_HOURS:
|
|
|
|
|
return "warning"
|
|
|
|
|
return "success"
|
|
|
|
|
|
|
|
|
|
def _average(self, values: list[Decimal]) -> Decimal:
|
|
|
|
|
if not values:
|
|
|
|
|
return Decimal("0.00")
|
|
|
|
|
return sum(values, Decimal("0.00")) / Decimal(str(len(values)))
|
|
|
|
|
|
|
|
|
|
def _percent(self, part: int | Decimal, total: int | Decimal) -> float:
|
|
|
|
|
total_decimal = Decimal(str(total or 0))
|
|
|
|
|
if total_decimal <= Decimal("0"):
|
|
|
|
|
return 0.0
|
|
|
|
|
return self._decimal_number((Decimal(str(part or 0)) / total_decimal) * Decimal("100"))
|
|
|
|
|
|
|
|
|
|
def _change_percent(self, current: Decimal, previous: Decimal) -> float:
|
|
|
|
|
if previous == Decimal("0"):
|
|
|
|
|
return 0.0 if current == Decimal("0") else 100.0
|
|
|
|
|
return self._decimal_number(((current - previous) / previous) * Decimal("100"))
|
|
|
|
|
|
|
|
|
|
def _decimal_number(self, value: Decimal) -> float:
|
|
|
|
|
return float(value.quantize(Decimal("0.1")))
|
|
|
|
|
|
|
|
|
|
def _format_delta(self, value: Decimal, unit: str) -> str:
|
|
|
|
|
if unit == "元":
|
|
|
|
|
return self._currency(value)
|
|
|
|
|
if unit == "h":
|
|
|
|
|
return f"{self._decimal_number(value):.1f}h"
|
|
|
|
|
if unit == "%":
|
|
|
|
|
return f"{self._decimal_number(value):.1f}%"
|
|
|
|
|
return f"{int(value)}{unit}"
|
|
|
|
|
|
|
|
|
|
def _currency(self, value: Decimal) -> str:
|
|
|
|
|
prefix = "-¥" if value < Decimal("0") else "¥"
|
|
|
|
|
amount = abs(value)
|
|
|
|
|
return f"{prefix}{amount:,.0f}"
|