feat: add AnalyticsService and repository dependencies for deal analytics
This commit is contained in:
parent
65a8307a2e
commit
22442bfd2e
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue