feat: 数字员工财务报告体系与定时提醒及看板快照调度
- 新增数字员工财务报告生成、邮件投递与渲染调度器 - 引入员工画像扫描调度与定时提醒任务 - 完善财务看板快照、排行口径与部门人员占比计算 - 优化数字员工工作看板仪表盘与技能目录 - 增强前端总览页图表、工作台摘要与顶部导航栏交互 - 新增差旅申请规划推动提醒与报销创建会话状态管理 - 补充财务报告、看板调度、数字员工工作记录测试覆盖
This commit is contained in:
319
server/src/app/services/finance_report_context.py
Normal file
319
server/src/app/services/finance_report_context.py
Normal file
@@ -0,0 +1,319 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any, Literal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.agent_run import AgentRun
|
||||
from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot
|
||||
from app.models.risk_observation import RiskObservation
|
||||
from app.services.finance_dashboard import FinanceDashboardService
|
||||
|
||||
FinanceReportType = Literal["weekly", "quarterly", "annual"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FinanceReportPeriod:
|
||||
report_type: FinanceReportType
|
||||
start_date: date
|
||||
end_date: date
|
||||
label: str
|
||||
title: str
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
payload = asdict(self)
|
||||
payload["start_date"] = self.start_date.isoformat()
|
||||
payload["end_date"] = self.end_date.isoformat()
|
||||
return payload
|
||||
|
||||
|
||||
class FinanceReportContextService:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def build_context(
|
||||
self,
|
||||
*,
|
||||
report_type: FinanceReportType = "weekly",
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
generated_at = now or datetime.now(UTC)
|
||||
period = self.resolve_period(
|
||||
report_type=report_type,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
now=generated_at,
|
||||
)
|
||||
dashboard = FinanceDashboardService(self.db).build_dashboard(
|
||||
range_key="自定义",
|
||||
start_date=period.start_date,
|
||||
end_date=period.end_date,
|
||||
trend_range="近12天" if report_type == "weekly" else "近30天",
|
||||
department_range="本季度" if report_type != "weekly" else "本月",
|
||||
)
|
||||
dashboard_payload = dashboard.model_dump(mode="json")
|
||||
risk_summary = self._risk_summary(period)
|
||||
profile_summary = self._profile_summary(period)
|
||||
digital_employee_summary = self._digital_employee_summary(period)
|
||||
actions = self._action_items(dashboard_payload, risk_summary)
|
||||
insights = self._insights(dashboard_payload, risk_summary, profile_summary, actions)
|
||||
|
||||
return {
|
||||
"report_type": report_type,
|
||||
"period": period.to_dict(),
|
||||
"generated_at": generated_at.isoformat(),
|
||||
"dashboard": dashboard_payload,
|
||||
"risk_summary": risk_summary,
|
||||
"profile_summary": profile_summary,
|
||||
"digital_employee_summary": digital_employee_summary,
|
||||
"insights": insights,
|
||||
"action_items": actions,
|
||||
"summary": self._summary(dashboard_payload, risk_summary, actions),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def resolve_period(
|
||||
*,
|
||||
report_type: FinanceReportType,
|
||||
start_date: date | None,
|
||||
end_date: date | None,
|
||||
now: datetime,
|
||||
) -> FinanceReportPeriod:
|
||||
if start_date and end_date:
|
||||
begin = min(start_date, end_date)
|
||||
finish = max(start_date, end_date)
|
||||
return FinanceReportPeriod(
|
||||
report_type=report_type,
|
||||
start_date=begin,
|
||||
end_date=finish,
|
||||
label=f"{begin.isoformat()} 至 {finish.isoformat()}",
|
||||
title=_report_title(report_type),
|
||||
)
|
||||
|
||||
local_now = now.astimezone(ZoneInfo("Asia/Shanghai")) if now.tzinfo else now
|
||||
today = local_now.date()
|
||||
if report_type == "annual":
|
||||
year = today.year - 1
|
||||
begin = date(year, 1, 1)
|
||||
finish = date(year, 12, 31)
|
||||
label = f"{year} 年"
|
||||
elif report_type == "quarterly":
|
||||
current_quarter = (today.month - 1) // 3 + 1
|
||||
year = today.year
|
||||
quarter = current_quarter - 1
|
||||
if quarter <= 0:
|
||||
quarter = 4
|
||||
year -= 1
|
||||
month = (quarter - 1) * 3 + 1
|
||||
begin = date(year, month, 1)
|
||||
finish = _month_end(year, month + 2)
|
||||
label = f"{year} 年 Q{quarter}"
|
||||
else:
|
||||
current_week_start = today - timedelta(days=today.weekday())
|
||||
begin = current_week_start - timedelta(days=7)
|
||||
finish = current_week_start - timedelta(days=1)
|
||||
label = f"{begin.isoformat()} 至 {finish.isoformat()}"
|
||||
|
||||
return FinanceReportPeriod(
|
||||
report_type=report_type,
|
||||
start_date=begin,
|
||||
end_date=finish,
|
||||
label=label,
|
||||
title=_report_title(report_type),
|
||||
)
|
||||
|
||||
def _risk_summary(self, period: FinanceReportPeriod) -> dict[str, Any]:
|
||||
start_dt = _day_start(period.start_date)
|
||||
end_dt = _day_after(period.end_date)
|
||||
rows = list(
|
||||
self.db.scalars(
|
||||
select(RiskObservation).where(
|
||||
RiskObservation.created_at >= start_dt,
|
||||
RiskObservation.created_at < end_dt,
|
||||
)
|
||||
).all()
|
||||
)
|
||||
high_rows = [row for row in rows if str(row.risk_level or "").lower() == "high"]
|
||||
pending_rows = [
|
||||
row for row in rows if str(row.status or "").lower() in {"pending_review", "open"}
|
||||
]
|
||||
top_signals: dict[str, int] = {}
|
||||
for row in rows:
|
||||
label = str(row.title or row.risk_signal or "风险观察").strip()
|
||||
top_signals[label] = top_signals.get(label, 0) + 1
|
||||
return {
|
||||
"risk_count": len(rows),
|
||||
"high_risk_count": len(high_rows),
|
||||
"pending_review_count": len(pending_rows),
|
||||
"top_signals": [
|
||||
{"name": name, "count": count}
|
||||
for name, count in sorted(
|
||||
top_signals.items(),
|
||||
key=lambda item: item[1],
|
||||
reverse=True,
|
||||
)[:5]
|
||||
],
|
||||
}
|
||||
|
||||
def _profile_summary(self, period: FinanceReportPeriod) -> dict[str, Any]:
|
||||
start_dt = _day_start(period.start_date)
|
||||
end_dt = _day_after(period.end_date)
|
||||
rows = list(
|
||||
self.db.scalars(
|
||||
select(EmployeeBehaviorProfileSnapshot).where(
|
||||
EmployeeBehaviorProfileSnapshot.calculated_at >= start_dt,
|
||||
EmployeeBehaviorProfileSnapshot.calculated_at < end_dt,
|
||||
)
|
||||
).all()
|
||||
)
|
||||
attention_rows = [
|
||||
row
|
||||
for row in rows
|
||||
if str(row.profile_level or "").lower() in {"attention", "high", "warning"}
|
||||
or int(row.profile_score or 0) >= 80
|
||||
]
|
||||
return {
|
||||
"snapshot_count": len(rows),
|
||||
"attention_profile_count": len(attention_rows),
|
||||
"top_profiles": [
|
||||
{
|
||||
"name": row.subject_name,
|
||||
"department": row.department_name or "",
|
||||
"score": row.profile_score,
|
||||
"level": row.profile_level,
|
||||
}
|
||||
for row in sorted(
|
||||
rows,
|
||||
key=lambda item: int(item.profile_score or 0),
|
||||
reverse=True,
|
||||
)[:5]
|
||||
],
|
||||
}
|
||||
|
||||
def _digital_employee_summary(self, period: FinanceReportPeriod) -> dict[str, Any]:
|
||||
start_dt = _day_start(period.start_date)
|
||||
end_dt = _day_after(period.end_date)
|
||||
rows = list(
|
||||
self.db.scalars(
|
||||
select(AgentRun).where(
|
||||
AgentRun.agent == "hermes",
|
||||
AgentRun.started_at >= start_dt,
|
||||
AgentRun.started_at < end_dt,
|
||||
)
|
||||
).all()
|
||||
)
|
||||
succeeded = [row for row in rows if row.status == "succeeded"]
|
||||
reports = [
|
||||
row
|
||||
for row in rows
|
||||
if "finance_report" in str((row.route_json or {}).get("task_type") or "")
|
||||
]
|
||||
return {
|
||||
"run_count": len(rows),
|
||||
"succeeded_count": len(succeeded),
|
||||
"report_count": len(reports),
|
||||
}
|
||||
|
||||
def _action_items(
|
||||
self,
|
||||
dashboard: dict[str, Any],
|
||||
risk: dict[str, Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
actions: list[dict[str, Any]] = []
|
||||
for item in list(dashboard.get("bottlenecks") or [])[:4]:
|
||||
name = str(item.get("name") or "财务关注项").strip()
|
||||
tone = str(item.get("tone") or "neutral").strip()
|
||||
if tone in {"warning", "danger"}:
|
||||
actions.append(
|
||||
{
|
||||
"title": name,
|
||||
"owner": str(item.get("role") or "财务运营组"),
|
||||
"priority": "high" if tone == "danger" else "medium",
|
||||
"suggestion": (
|
||||
f"请跟进{name}:"
|
||||
f"{item.get('duration') or ''} {item.get('status') or ''}"
|
||||
).strip(),
|
||||
}
|
||||
)
|
||||
if int(risk.get("pending_review_count") or 0) > 0:
|
||||
actions.append(
|
||||
{
|
||||
"title": "风险观察待复核",
|
||||
"owner": "风控与审计部",
|
||||
"priority": "high",
|
||||
"suggestion": f"当前有 {risk['pending_review_count']} 条风险观察待复核。",
|
||||
}
|
||||
)
|
||||
return actions[:6]
|
||||
|
||||
def _insights(
|
||||
self,
|
||||
dashboard: dict[str, Any],
|
||||
risk: dict[str, Any],
|
||||
profile: dict[str, Any],
|
||||
actions: list[dict[str, Any]],
|
||||
) -> list[str]:
|
||||
totals = dashboard.get("totals") if isinstance(dashboard.get("totals"), dict) else {}
|
||||
amount = float(totals.get("reimbursementAmount") or 0)
|
||||
count = int(totals.get("reimbursementCount") or 0)
|
||||
budget_rate = float(totals.get("budgetUsageRate") or 0)
|
||||
insights = [
|
||||
f"本周期报销 {count} 单,费用金额 {_money(amount)}。",
|
||||
f"预算使用率 {budget_rate:.1f}%,需关注预算预警和预占释放。",
|
||||
(
|
||||
f"风险观察 {risk.get('risk_count', 0)} 条,"
|
||||
f"其中高风险 {risk.get('high_risk_count', 0)} 条。"
|
||||
),
|
||||
]
|
||||
if int(profile.get("attention_profile_count") or 0) > 0:
|
||||
insights.append(f"员工画像中有 {profile['attention_profile_count']} 个高关注样本。")
|
||||
if actions:
|
||||
insights.append(f"数字员工整理出 {len(actions)} 项管理动作,建议纳入本周跟进。")
|
||||
return insights[:5]
|
||||
|
||||
@staticmethod
|
||||
def _summary(
|
||||
dashboard: dict[str, Any],
|
||||
risk: dict[str, Any],
|
||||
actions: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
totals = dashboard.get("totals") if isinstance(dashboard.get("totals"), dict) else {}
|
||||
return {
|
||||
"reimbursement_count": int(totals.get("reimbursementCount") or 0),
|
||||
"reimbursement_amount": float(totals.get("reimbursementAmount") or 0),
|
||||
"pending_payment_amount": float(totals.get("pendingPaymentAmount") or 0),
|
||||
"risk_count": int(risk.get("risk_count") or 0),
|
||||
"action_count": len(actions),
|
||||
}
|
||||
|
||||
|
||||
def _report_title(report_type: str) -> str:
|
||||
return {
|
||||
"weekly": "财务经营周报",
|
||||
"quarterly": "财务经营季报",
|
||||
"annual": "财务经营年报",
|
||||
}.get(report_type, "财务经营报告")
|
||||
|
||||
|
||||
def _month_end(year: int, month: int) -> date:
|
||||
next_month = date(year + (month // 12), (month % 12) + 1, 1)
|
||||
return next_month - timedelta(days=1)
|
||||
|
||||
|
||||
def _day_start(value: date) -> datetime:
|
||||
return datetime.combine(value, datetime.min.time(), tzinfo=UTC)
|
||||
|
||||
|
||||
def _day_after(value: date) -> datetime:
|
||||
return datetime.combine(value + timedelta(days=1), datetime.min.time(), tzinfo=UTC)
|
||||
|
||||
|
||||
def _money(value: float | Decimal) -> str:
|
||||
return f"¥{float(value):,.0f}"
|
||||
Reference in New Issue
Block a user