from datetime import date from typing import Any 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, _next_code, _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.""" code = _next_code("ANALYTICS") 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], MetricKey.NET_POSITION: funds[MetricKey.NET_POSITION], MetricKey.RISK_ACCOUNTS: funds[MetricKey.RISK_ACCOUNTS], 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() 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, ) report = _json_safe( { EnterpriseAnalyticsKey.CODE: code, 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), } ) 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) -> dict[str, Any]: 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