feat: add AnalyticsService and repository dependencies for deal analytics

This commit is contained in:
k1nq 2025-11-29 09:14:29 +05:00
parent 65a8307a2e
commit 22442bfd2e
2 changed files with 151 additions and 0 deletions

View File

@ -11,11 +11,13 @@ from app.core.database import get_session
from app.core.security import jwt_service, password_hasher from app.core.security import jwt_service, password_hasher
from app.models.user import User from app.models.user import User
from app.repositories.activity_repo import ActivityRepository from app.repositories.activity_repo import ActivityRepository
from app.repositories.analytics_repo import AnalyticsRepository
from app.repositories.contact_repo import ContactRepository from app.repositories.contact_repo import ContactRepository
from app.repositories.deal_repo import DealRepository from app.repositories.deal_repo import DealRepository
from app.repositories.org_repo import OrganizationRepository from app.repositories.org_repo import OrganizationRepository
from app.repositories.task_repo import TaskRepository from app.repositories.task_repo import TaskRepository
from app.repositories.user_repo import UserRepository from app.repositories.user_repo import UserRepository
from app.services.analytics_service import AnalyticsService
from app.services.auth_service import AuthService from app.services.auth_service import AuthService
from app.services.activity_service import ActivityService from app.services.activity_service import ActivityService
from app.services.contact_service import ContactService 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) 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: def get_deal_service(repo: DealRepository = Depends(get_deal_repository)) -> DealService:
return DealService(repository=repo) return DealService(repository=repo)
@ -87,6 +93,12 @@ def get_activity_service(
return ActivityService(repository=repo) return ActivityService(repository=repo)
def get_analytics_service(
repo: AnalyticsRepository = Depends(get_analytics_repository),
) -> AnalyticsService:
return AnalyticsService(repository=repo)
def get_contact_service( def get_contact_service(
repo: ContactRepository = Depends(get_contact_repository), repo: ContactRepository = Depends(get_contact_repository),
) -> ContactService: ) -> ContactService:

View File

@ -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