Files
X-Financial/server/src/app/services/finance_report_context.py

320 lines
12 KiB
Python
Raw Normal View History

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