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