import datetime import logging from collections import defaultdict from dataclasses import dataclass from typing import TYPE_CHECKING from src import domain, dto if TYPE_CHECKING: from .. import Usecase log = logging.getLogger(__name__) def _format_period(dt: 'datetime.datetime', grouping: dto.DateGrouping) -> str: match grouping: case dto.DateGrouping.DAY: return str(dt.strftime('%Y-%m-%d')) case dto.DateGrouping.WEEK: # ISO week return str(dt.strftime('%Y-W%W')) case dto.DateGrouping.MONTH: return str(dt.strftime('%Y-%m')) case dto.DateGrouping.QUARTER: quarter = (dt.month - 1) // 3 + 1 return f'{dt.year}-Q{quarter}' case dto.DateGrouping.YEAR: return str(dt.year) raise ValueError('Invalid date grouping') async def get_spending_analytics( self: 'Usecase', input: dto.GetSpendingAnalyticsInput ) -> dto.GetSpendingAnalyticsOutput: context = await self.ensure_workspace_permission( input.workspace_id, input.user_id, domain.PermissionKey.ANALYTICS_READ ) allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ) if input.project_id: project = await self.database.get_project(input.workspace_id, input.project_id) if not project: raise domain.ProjectNotFound(input.project_id) context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id) allowed_project_ids = None placements = await self.database.get_workspace_placements( input.workspace_id, input.project_id, include_archived=False, allowed_project_ids=allowed_project_ids, ) filtered = [ p for p in placements if (not input.date_from or p.placement_date >= input.date_from) and (not input.date_to or p.placement_date <= input.date_to) ] total_cost = 0.0 total_subs = 0 total_views = 0 @dataclass class PeriodData: cost: float = 0.0 subscriptions: int = 0 views: int = 0 # Batch fetch views data for all posts post_ids = [p.post.id for p in filtered if p.post] views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {} # Batch fetch subscriptions counts placement_ids = [p.id for p in filtered] subscriptions_counts = await self.database.count_subscriptions_by_placement_batch(placement_ids) # Группировка по периодам period_data: dict[str, PeriodData] = defaultdict(PeriodData) for p in filtered: period = _format_period(p.placement_date, input.grouping) pd = period_data[period] if p.cost is not None: total_cost += p.cost pd.cost += p.cost subs_count = subscriptions_counts.get(p.id, 0) total_subs += subs_count pd.subscriptions += subs_count # Get views from batch data if p.post and p.post.id in views_map: views_count = views_map[p.post.id][0] total_views += views_count pd.views += views_count avg_cpf = total_cost / total_subs if total_subs > 0 and total_cost > 0 else None avg_cpm = (total_cost / total_views * 1000) if total_views > 0 and total_cost > 0 else None # Данные для графика chart_data = [] for period in sorted(period_data.keys()): d = period_data[period] cost = d.cost subs = d.subscriptions views = d.views cpf = cost / subs if subs > 0 and cost > 0 else None cpm = (cost / views * 1000) if views > 0 and cost > 0 else None chart_data.append( dto.SpendingDataPoint( period=period, cost=cost, subscriptions=subs, views=views, cpf=cpf, cpm=cpm, ) ) return dto.GetSpendingAnalyticsOutput( total_cost=total_cost, total_subscriptions=total_subs, total_views=total_views, avg_cpf=avg_cpf, avg_cpm=avg_cpm, chart_data=chart_data, )