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}"
|