feat: 数字员工财务报告体系与定时提醒及看板快照调度
- 新增数字员工财务报告生成、邮件投递与渲染调度器 - 引入员工画像扫描调度与定时提醒任务 - 完善财务看板快照、排行口径与部门人员占比计算 - 优化数字员工工作看板仪表盘与技能目录 - 增强前端总览页图表、工作台摘要与顶部导航栏交互 - 新增差旅申请规划推动提醒与报销创建会话状态管理 - 补充财务报告、看板调度、数字员工工作记录测试覆盖
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user