116 lines
3.3 KiB
Python
116 lines
3.3 KiB
Python
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:
|
|
await self.ensure_workspace_access(input.workspace_id, input.user_id)
|
|
|
|
if input.target_channel_id:
|
|
target_channel = await self.database.get_target_channel(input.workspace_id, input.target_channel_id)
|
|
if not target_channel:
|
|
raise domain.TargetChannelNotFound(input.target_channel_id)
|
|
|
|
placements = await self.database.get_workspace_placements(
|
|
input.workspace_id, input.target_channel_id, include_archived=False
|
|
)
|
|
|
|
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
|
|
|
|
# Группировка по периодам
|
|
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
|
|
|
|
total_subs += p.subscriptions_count
|
|
pd.subscriptions += p.subscriptions_count
|
|
|
|
if p.views_count is not None:
|
|
total_views += p.views_count
|
|
pd.views += p.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,
|
|
)
|