From 22442bfd2e9fa03c249f7eaa99c9602652de9ec4 Mon Sep 17 00:00:00 2001 From: k1nq Date: Sat, 29 Nov 2025 09:14:29 +0500 Subject: [PATCH] feat: add AnalyticsService and repository dependencies for deal analytics --- app/api/deps.py | 12 +++ app/services/analytics_service.py | 139 ++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 app/services/analytics_service.py diff --git a/app/api/deps.py b/app/api/deps.py index b0f573a..d55d4a0 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -11,11 +11,13 @@ from app.core.database import get_session from app.core.security import jwt_service, password_hasher from app.models.user import User from app.repositories.activity_repo import ActivityRepository +from app.repositories.analytics_repo import AnalyticsRepository from app.repositories.contact_repo import ContactRepository from app.repositories.deal_repo import DealRepository from app.repositories.org_repo import OrganizationRepository from app.repositories.task_repo import TaskRepository from app.repositories.user_repo import UserRepository +from app.services.analytics_service import AnalyticsService from app.services.auth_service import AuthService from app.services.activity_service import ActivityService from app.services.contact_service import ContactService @@ -61,6 +63,10 @@ def get_activity_repository(session: AsyncSession = Depends(get_db_session)) -> return ActivityRepository(session=session) +def get_analytics_repository(session: AsyncSession = Depends(get_db_session)) -> AnalyticsRepository: + return AnalyticsRepository(session=session) + + def get_deal_service(repo: DealRepository = Depends(get_deal_repository)) -> DealService: return DealService(repository=repo) @@ -87,6 +93,12 @@ def get_activity_service( return ActivityService(repository=repo) +def get_analytics_service( + repo: AnalyticsRepository = Depends(get_analytics_repository), +) -> AnalyticsService: + return AnalyticsService(repository=repo) + + def get_contact_service( repo: ContactRepository = Depends(get_contact_repository), ) -> ContactService: diff --git a/app/services/analytics_service.py b/app/services/analytics_service.py new file mode 100644 index 0000000..8fc9e46 --- /dev/null +++ b/app/services/analytics_service.py @@ -0,0 +1,139 @@ +"""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