from datetime import date from hashlib import sha256 import json from typing import Any from fastapi import HTTPException, status from app.core.constants import ActorValue from app.modules.audit.constants import AuditAction, AuditSource, AuditTargetType from app.modules.audit.schemas import AuditLogCreate from app.modules.audit.service import AuditService from app.modules.business.models import ( PerformanceMetric, ) from app.modules.events.constants import ( EventAggregateType, EventPayloadKey, EventSource, EventType, ) from app.modules.events.services import EventService from app.modules.reports.constants import ( EnterpriseAnalyticsKey, LifecycleResponseKey, LifecycleSection, MetricKey, ReportStatus, ReportText, ReportTitle, ) from app.modules.reports.services.common import _json_safe, _money, _rate class ReportEnterpriseAnalyticsMixin: def enterprise_analytics( self, project_code: str | None = None, owner: str | None = None, period_start: date | None = None, period_end: date | None = None, actor: str = ActorValue.API, ) -> dict[str, Any]: """Build V3 read-only finance, procurement, performance, and operations analytics.""" if period_start and period_end and period_start > period_end: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="period_start must be before or equal to period_end", ) include_global_metrics = not any( value is not None for value in (project_code, owner, period_start, period_end) ) lifecycle = self.project_lifecycle_report( project_code=project_code, owner=owner, period_start=period_start, period_end=period_end, include_ai=False, actor=actor, ) metrics = lifecycle[LifecycleResponseKey.METRICS] projects = metrics[LifecycleSection.PROJECTS] procurements = metrics[LifecycleSection.PROCUREMENTS] expenses = metrics[LifecycleSection.EXPENSES] funds = metrics[LifecycleSection.FUNDS] tasks = metrics[LifecycleSection.TASKS] risks = metrics[LifecycleSection.RISKS] health = metrics[LifecycleSection.HEALTH] finance = { MetricKey.BUDGET_TOTAL: projects[MetricKey.BUDGET_TOTAL], MetricKey.ACTUAL_TOTAL: projects[MetricKey.ACTUAL_TOTAL], MetricKey.BUDGET_USAGE_RATE: projects[MetricKey.BUDGET_USAGE_RATE], MetricKey.CURRENT_BALANCE_TOTAL: ( funds[MetricKey.CURRENT_BALANCE_TOTAL] if include_global_metrics else 0 ), MetricKey.NET_POSITION: ( funds[MetricKey.NET_POSITION] if include_global_metrics else 0 ), MetricKey.RISK_ACCOUNTS: ( funds[MetricKey.RISK_ACCOUNTS] if include_global_metrics else 0 ), MetricKey.PAYMENT_EXPOSURE: ( procurements[MetricKey.ACTUAL_TOTAL] + expenses[MetricKey.AMOUNT_TOTAL] ), } procurement = { MetricKey.TOTAL: procurements[MetricKey.TOTAL], MetricKey.PENDING_APPROVAL: procurements[MetricKey.PENDING_APPROVAL], MetricKey.PENDING_DELIVERY: procurements[MetricKey.PENDING_DELIVERY], MetricKey.UNPAID: procurements[MetricKey.UNPAID], MetricKey.EXPECTED_TOTAL: procurements[MetricKey.EXPECTED_TOTAL], MetricKey.ACTUAL_TOTAL: procurements[MetricKey.ACTUAL_TOTAL], MetricKey.DELIVERY_RISK: procurements[MetricKey.PENDING_DELIVERY], } performance = self._enterprise_performance_stats(include_global_metrics) operations = { MetricKey.READINESS_SCORE: health[MetricKey.SCORE], MetricKey.LEVEL: health[MetricKey.LEVEL], MetricKey.COMPLETION_RATE: tasks[MetricKey.COMPLETION_RATE], MetricKey.OVERDUE_TASKS: risks[MetricKey.OVERDUE_TASKS], MetricKey.DELAYED_PROJECTS: risks[MetricKey.DELAYED_PROJECTS], MetricKey.OVER_BUDGET_PROJECTS: risks[MetricKey.OVER_BUDGET_PROJECTS], MetricKey.OPEN_EVENTS: risks[MetricKey.OPEN_EVENTS], MetricKey.HIGH_EVENTS: risks[MetricKey.HIGH_EVENTS], } recommendations = lifecycle[LifecycleResponseKey.RECOMMENDATIONS] lines = self._enterprise_analytics_lines( lifecycle[LifecycleResponseKey.FILTERS], finance, procurement, performance, operations, recommendations, ) snapshot = _json_safe( { EnterpriseAnalyticsKey.TITLE: ReportTitle.ENTERPRISE_ANALYTICS, EnterpriseAnalyticsKey.FILTERS: lifecycle[LifecycleResponseKey.FILTERS], EnterpriseAnalyticsKey.FINANCE: finance, EnterpriseAnalyticsKey.PROCUREMENT: procurement, EnterpriseAnalyticsKey.PERFORMANCE: performance, EnterpriseAnalyticsKey.OPERATIONS: operations, EnterpriseAnalyticsKey.RECOMMENDATIONS: recommendations, EnterpriseAnalyticsKey.LINES: lines, EnterpriseAnalyticsKey.CONTENT: "\n".join(lines), } ) canonical_snapshot = json.dumps( snapshot, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str, ) digest = sha256(canonical_snapshot.encode("utf-8")).hexdigest()[:24].upper() code = f"ANALYTICS-{digest}" report = {EnterpriseAnalyticsKey.CODE: code, **snapshot} AuditService(self.db).record( AuditLogCreate( actor=actor, source=AuditSource.REPORTS, action=AuditAction.ENTERPRISE_ANALYTICS, target_type=AuditTargetType.ENTERPRISE_ANALYTICS, target_id=code, response_payload=report, ) ) EventService(self.db).enqueue( event_type=EventType.ENTERPRISE_ANALYTICS_GENERATED, source=EventSource.ANALYTICS, aggregate_type=EventAggregateType.ENTERPRISE_ANALYTICS, aggregate_id=code, actor=actor, payload={ EventPayloadKey.CODE: code, EventPayloadKey.STATUS: ReportStatus.GENERATED, }, idempotency_key=f"enterprise-analytics:{code}", ) self.db.commit() return report def _enterprise_performance_stats(self, include_global: bool) -> dict[str, Any]: if not include_global: return { MetricKey.TOTAL: 0, MetricKey.CONFIRMED: 0, MetricKey.CONFIRMED_RATE: 0.0, MetricKey.AVERAGE_AUTO_SCORE: 0.0, MetricKey.AVERAGE_CONFIRMED_SCORE: 0.0, MetricKey.WEIGHT_TOTAL: 0, MetricKey.BY_STATUS: {}, } total = self._count(PerformanceMetric) confirmed = self._count(PerformanceMetric, PerformanceMetric.confirmed_score.is_not(None)) return { MetricKey.TOTAL: total, MetricKey.CONFIRMED: confirmed, MetricKey.CONFIRMED_RATE: _rate(confirmed, total), MetricKey.AVERAGE_AUTO_SCORE: self._avg(PerformanceMetric.auto_score), MetricKey.AVERAGE_CONFIRMED_SCORE: self._avg(PerformanceMetric.confirmed_score), MetricKey.WEIGHT_TOTAL: self._sum(PerformanceMetric.weight), MetricKey.BY_STATUS: self._group_counts(PerformanceMetric, PerformanceMetric.status), } def _enterprise_analytics_lines( self, filters: dict[str, Any], finance: dict[str, Any], procurement: dict[str, Any], performance: dict[str, Any], operations: dict[str, Any], recommendations: list[str], ) -> list[str]: scope = ( "、".join(f"{key}={value}" for key, value in filters.items() if value) or ReportText.DEFAULT_SCOPE ) lines = [ f"- 范围:{scope}", ( f"- 财务:预算 {_money(finance[MetricKey.BUDGET_TOTAL])}," f"实际 {_money(finance[MetricKey.ACTUAL_TOTAL])}," f"净头寸 {_money(finance[MetricKey.NET_POSITION])}," f"支付暴露 {_money(finance[MetricKey.PAYMENT_EXPOSURE])}" ), ( f"- 采购:总数 {procurement[MetricKey.TOTAL]}," f"待审批 {procurement[MetricKey.PENDING_APPROVAL]}," f"待交付 {procurement[MetricKey.PENDING_DELIVERY]}," f"未付款 {procurement[MetricKey.UNPAID]}" ), ( f"- 绩效:指标 {performance[MetricKey.TOTAL]}," f"已确认 {performance[MetricKey.CONFIRMED]}," f"确认率 {performance[MetricKey.CONFIRMED_RATE]}%," f"平均自动分 {performance[MetricKey.AVERAGE_AUTO_SCORE]}" ), ( f"- 运营:准备度 {operations[MetricKey.READINESS_SCORE]}," f"任务完成率 {operations[MetricKey.COMPLETION_RATE]}%," f"逾期任务 {operations[MetricKey.OVERDUE_TASKS]}," f"打开风险 {operations[MetricKey.OPEN_EVENTS]}" ), ReportText.ACTION_HEADER, ] lines.extend(f" - {item}" for item in recommendations) return lines