feat: 数字员工财务报告体系与定时提醒及看板快照调度

- 新增数字员工财务报告生成、邮件投递与渲染调度器
- 引入员工画像扫描调度与定时提醒任务
- 完善财务看板快照、排行口径与部门人员占比计算
- 优化数字员工工作看板仪表盘与技能目录
- 增强前端总览页图表、工作台摘要与顶部导航栏交互
- 新增差旅申请规划推动提醒与报销创建会话状态管理
- 补充财务报告、看板调度、数字员工工作记录测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-03 09:25:23 +08:00
parent 0c74b4ab4a
commit 15006a05a7
114 changed files with 7356 additions and 650 deletions

View File

@@ -9,75 +9,23 @@ 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
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")
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"}]
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)",
"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": "缺少事前申请",
"missing_material": "材料不完整",
"budget_pressure": "预算压力偏高",
"budget_overrun": "预算超支",
"budget_warning": "预算预警",
"over_budget": "预算超支",
"invoice_abnormal": "发票异常",
"invoice_missing": "缺少发票",
"missing_invoice": "缺少发票",
"policy_violation": "政策不符",
"abnormal_frequency": "频次异常",
"manual_review": "人工复核",
}
from app.services.finance_dashboard_constants import (
CHART_COLORS,
EMPTY_DONUT,
EXCLUDED_SPEND_STATUSES,
EXPENSE_TYPE_ALIASES,
PENDING_STATUSES,
RISK_SIGNAL_LABELS,
SLA_TARGET_HOURS,
STAGE_LABELS,
SUCCESS_STATUSES,
)
class FinanceDashboardService(BudgetSupportMixin):
@@ -93,7 +41,6 @@ class FinanceDashboardService(BudgetSupportMixin):
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,
@@ -103,7 +50,7 @@ class FinanceDashboardService(BudgetSupportMixin):
)
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)
ranking_start, ranking_end = self._resolve_ranking_scope(department_range, now)
claims = [
claim for claim in self._fetch_claims() if is_finance_reimbursement_claim(claim)
@@ -111,7 +58,7 @@ class FinanceDashboardService(BudgetSupportMixin):
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)
ranking_claims = self._claims_between(claims, ranking_start, ranking_end)
totals = self._totals(scope_claims)
previous_totals = self._totals(previous_claims)
@@ -127,17 +74,15 @@ class FinanceDashboardService(BudgetSupportMixin):
trend=self._trend(trend_labels, trend_claims, now),
spend_by_category=self._spend_by_category(scope_claims),
exception_mix=self._payment_status_mix(scope_claims),
department_ranking=self._department_ranking(department_claims),
employee_ranking=self._employee_ranking(department_claims),
top_claims=self._top_claims(department_claims),
department_ranking=self._department_ranking(ranking_claims),
department_employee_mix=self._department_employee_mix(ranking_claims),
employee_ranking=self._employee_ranking(ranking_claims),
top_claims=self._top_claims(ranking_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:
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())
@@ -189,18 +134,20 @@ class FinanceDashboardService(BudgetSupportMixin):
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(
def _resolve_ranking_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 == "本季度":
if key == "全部":
return datetime(1970, 1, 1, tzinfo=UTC), self._day_after(today)
if key == "本季度":
quarter_month = ((today.month - 1) // 3) * 3 + 1
start_day = today.replace(month=quarter_month, day=1)
elif key == "本年":
start_day = today.replace(month=1, day=1)
else:
start_day = today.replace(day=1)
return self._day_start(start_day), self._day_after(today)
@@ -347,6 +294,7 @@ class FinanceDashboardService(BudgetSupportMixin):
buckets: dict[str, Decimal] = defaultdict(Decimal)
counts: dict[str, int] = defaultdict(int)
pending_amounts: dict[str, Decimal] = defaultdict(Decimal)
employees: dict[str, set[str]] = defaultdict(set)
for claim in claims:
status = self._status(claim)
if status in EXCLUDED_SPEND_STATUSES:
@@ -357,6 +305,9 @@ class FinanceDashboardService(BudgetSupportMixin):
amount = self._claim_amount(claim)
buckets[department_name] += amount
counts[department_name] += 1
employee_name = str(claim.employee_name or "").strip()
if not self._is_missing_finance_dimension(employee_name):
employees[department_name].add(employee_name)
if status in PENDING_STATUSES:
pending_amounts[department_name] += amount
@@ -366,6 +317,7 @@ class FinanceDashboardService(BudgetSupportMixin):
"amount": self._decimal_number(amount),
"value": self._decimal_number(amount),
"count": counts[name],
"employeeCount": len(employees[name]),
"pendingAmount": self._decimal_number(pending_amounts[name]),
"color": CHART_COLORS[index % len(CHART_COLORS)],
}
@@ -375,6 +327,34 @@ class FinanceDashboardService(BudgetSupportMixin):
]
return rows
def _department_employee_mix(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
buckets: dict[tuple[str, str], Decimal] = defaultdict(Decimal)
for claim in claims:
if self._status(claim) in EXCLUDED_SPEND_STATUSES:
continue
department_name = str(claim.department_name or "").strip()
employee_name = str(claim.employee_name or "").strip()
if self._is_missing_finance_dimension(department_name):
continue
if self._is_missing_finance_dimension(employee_name):
continue
buckets[(department_name, employee_name)] += self._claim_amount(claim)
rows = [
{
"name": f"{department_name} · {employee_name}",
"department": department_name,
"employee": employee_name,
"value": self._decimal_number(amount),
"amount": self._decimal_number(amount),
"color": CHART_COLORS[index % len(CHART_COLORS)],
}
for index, ((department_name, employee_name), amount) in enumerate(
sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6]
)
]
return rows or EMPTY_DONUT
def _employee_ranking(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
buckets: dict[str, Decimal] = defaultdict(Decimal)
counts: dict[str, int] = defaultdict(int)