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