"""Analytics-related business logic.""" from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timedelta, timezone from decimal import Decimal from typing import Iterable from app.models.deal import DealStage, DealStatus from app.repositories.analytics_repo import AnalyticsRepository, StageStatusRollup _STAGE_ORDER: list[DealStage] = [ DealStage.QUALIFICATION, DealStage.PROPOSAL, DealStage.NEGOTIATION, DealStage.CLOSED, ] @dataclass(slots=True, frozen=True) class StatusSummary: status: DealStatus count: int amount_sum: Decimal @dataclass(slots=True, frozen=True) class WonStatistics: count: int amount_sum: Decimal average_amount: Decimal @dataclass(slots=True, frozen=True) class NewDealsWindow: days: int count: int @dataclass(slots=True, frozen=True) class DealSummary: by_status: list[StatusSummary] won: WonStatistics new_deals: NewDealsWindow total_deals: int @dataclass(slots=True, frozen=True) class StageBreakdown: stage: DealStage total: int by_status: dict[DealStatus, int] conversion_to_next: float | None class AnalyticsService: """Provides aggregated analytics for deals.""" def __init__(self, repository: AnalyticsRepository) -> None: self._repository = repository async def get_deal_summary(self, organization_id: int, *, days: int) -> DealSummary: status_rollup = await self._repository.fetch_status_rollup(organization_id) status_map = {item.status: item for item in status_rollup} summaries: list[StatusSummary] = [] total_deals = 0 won_amount_sum = Decimal("0") won_amount_count = 0 won_count = 0 for status in DealStatus: row = status_map.get(status) count = row.deal_count if row else 0 amount_sum = row.amount_sum if row else Decimal("0") summaries.append(StatusSummary(status=status, count=count, amount_sum=amount_sum)) total_deals += count if status is DealStatus.WON and row: won_amount_sum = row.amount_sum won_amount_count = row.amount_count won_count = row.deal_count won_average = ( (won_amount_sum / won_amount_count) if won_amount_count > 0 else Decimal("0") ) window_threshold = _threshold_from_days(days) new_deals = await self._repository.count_new_deals_since(organization_id, window_threshold) return DealSummary( by_status=summaries, won=WonStatistics( count=won_count, amount_sum=won_amount_sum, average_amount=won_average, ), new_deals=NewDealsWindow(days=days, count=new_deals), total_deals=total_deals, ) async def get_deal_funnel(self, organization_id: int) -> list[StageBreakdown]: rollup = await self._repository.fetch_stage_status_rollup(organization_id) stage_map = _build_stage_map(rollup) breakdowns: list[StageBreakdown] = [] totals = {stage: sum(by_status.values()) for stage, by_status in stage_map.items()} for index, stage in enumerate(_STAGE_ORDER): by_status = stage_map.get(stage, {status: 0 for status in DealStatus}) total = totals.get(stage, 0) conversion = None if index < len(_STAGE_ORDER) - 1: next_stage = _STAGE_ORDER[index + 1] next_total = totals.get(next_stage, 0) if total > 0: conversion = float(round((next_total / total) * 100, 2)) breakdowns.append( StageBreakdown( stage=stage, total=total, by_status=by_status, conversion_to_next=conversion, ) ) return breakdowns def _threshold_from_days(days: int) -> datetime: return datetime.now(timezone.utc) - timedelta(days=days) def _build_stage_map(rollup: Iterable[StageStatusRollup]) -> dict[DealStage, dict[DealStatus, int]]: stage_map: dict[DealStage, dict[DealStatus, int]] = { stage: {status: 0 for status in DealStatus} for stage in _STAGE_ORDER } for item in rollup: stage_map.setdefault(item.stage, {status: 0 for status in DealStatus}) stage_map[item.stage][item.status] = item.deal_count return stage_map