dev #11
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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