Files
X-Financial/server/src/app/services/finance_report_context.py
caoxiaozhu 15006a05a7 feat: 数字员工财务报告体系与定时提醒及看板快照调度
- 新增数字员工财务报告生成、邮件投递与渲染调度器
- 引入员工画像扫描调度与定时提醒任务
- 完善财务看板快照、排行口径与部门人员占比计算
- 优化数字员工工作看板仪表盘与技能目录
- 增强前端总览页图表、工作台摘要与顶部导航栏交互
- 新增差旅申请规划推动提醒与报销创建会话状态管理
- 补充财务报告、看板调度、数字员工工作记录测试覆盖
2026-06-03 09:25:23 +08:00

320 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}"