Урок курса
Метрики продаж: выручка, число заказов и AOV с учётом статусов и возвратов
Практическая аналитика данныхВ предыдущих уроках фильтр status='paid' и агрегация по order_id уже встречались при построении временных рядов. Здесь эти же операции получают точное управленческое определение: они становятся частью формулы, по которой считаются выручка, число заказов и средний чек.
Управленческое определение выручки: фильтр по статусу и корректировка на возвраты
В таблице orders каждый заказ имеет статус paid или cancelled. В выбранном учебном управленческом определении в выручку включаются только paid-заказы — это явный методологический выбор. Cancelled-заказы исключаются независимо от того, заполнено ли у них поле gross_revenue положительным числом. При этом статус сам по себе не доказывает историю платежа, отгрузки или возврата конкретного заказа: cancelled лишь означает, что данный заказ не попадает в метрику paid-продаж по принятому определению.
После фильтрации по статусу выручка считается в два шага.
Шаг 1 — gross_revenue paid-заказов: gross_revenue_paid = SUM(gross_revenue) по строкам orders, где status = 'paid' и order_date попадает в нужный период.
Шаг 2 — вычет возвратов: net_revenue = gross_revenue_paid − SUM(refund_amount по тем же order_id, где refund_date ≤ дата среза)
Дата среза фиксируется явно. В учебном наборе «Маяка» возвраты считаются известными на конец 2025-08-31. Это означает: для отчёта за январь 2024 берутся все возвраты по январским paid-заказам, зарегистрированные до этой даты, — не только те, что поступили в январе.
Для января 2024: gross_revenue paid-заказов составляет 933 000 руб., после вычета возвратов net_revenue равен 911 612 руб. Разница в 21 388 руб. — это суммарные refund_amount по январским paid-заказам на дату среза.
Что происходит при включении cancelled-заказов? Их gross_revenue прибавляется к числителю в нарушение принятого определения. Если cancelled-заказы за январь имеют ненулевые суммы, итоговая выручка завышается — и пропорционально завышается AOV, если знаменатель остаётся корректным. Направление ошибки однозначное: cancelled-заказы добавляют к числителю положительные суммы.
Важная граница: возврат уменьшает числитель (выручку), но не исключает заказ из числа заказов. Клиент разместил и оплатил заказ — этот факт фиксируется в знаменателе независимо от последующего возврата. Корректировка на refund_amount затрагивает только числитель; знаменатель остаётся равным COUNT(DISTINCT order_id) по всем paid-заказам периода, включая те, по которым были возвраты.
Grain метрики «число заказов» и ошибка суммирования по товарным сегментам
Число заказов — это количество уникальных order_id среди paid-заказов за выбранный период. Grain метрики — один order_id, один раз, независимо от того, сколько позиций (строк в order_items) он содержит.
Правильный способ считать:
SELECT COUNT(DISTINCT order_id) AS paid_orders
FROM orders
WHERE status = 'paid'
AND strftime('%Y-%m', order_date) = '2024-01';
Для января 2024 этот запрос даёт 82 заказа. Именно это число стоит в знаменателе AOV за январь.
Проблема возникает, когда аналитик пытается получить общее число заказов через order_items, считая заказы внутри каждой товарной категории и затем складывая результаты. Пример того, почему это неверно:
Предположим, заказ O000017 содержит позиции в категориях «Электроника» и «Аксессуары». В таблице order_items это две отдельные строки с одним и тем же order_id. Если посчитать COUNT(DISTINCT order_id) внутри каждой категории, заказ O000017 войдёт в счётчик обеих категорий — по одному разу в каждой.
При сложении этих категориальных счётчиков:
count_orders(Электроника) = 1
count_orders(Аксессуары) = 1
Сумма = 2, но уникальных заказов = 1
Чем больше многокатегорийных заказов в периоде, тем сильнее завышение. Если в январе значительная часть из 82 заказов содержит позиции более чем одной категории, сумма категориальных счётчиков может быть существенно выше 82.
Отдельный нюанс: даже COUNT(order_id) без DISTINCT по таблице order_items считает строки-позиции, а не заказы. Один заказ с тремя позициями одной категории даст COUNT = 3.
Правило: для общего числа заказов всегда идти к таблице orders, где grain — один заказ, и применять COUNT(DISTINCT order_id) или nunique() в pandas. Категориальные агрегаты из order_items показывают охват категорий, но не суммируются в общий итог.
AOV: формула, асимметрия числителя и знаменателя, защита от деления на ноль
AOV (Average Order Value, средний чек) — это выручка, делённая на число заказов:
AOV = net_revenue / paid_orders
Для января 2024: AOV = 911 612 / 82 ≈ 11 117 руб. Именно это значение содержит поле aov в sales_monthly за период 2024-01.
АOV — составная метрика, и ошибки в числителе и знаменателе действуют по-разному.
Ошибка в числителе: включение cancelled-заказов. Их суммы добавляются к выручке, числитель растёт. Если знаменатель при этом остаётся равным количеству paid-заказов, AOV завышается прямо пропорционально лишней сумме. Если же аналитик ошибочно добавляет в знаменатель и cancelled-заказы тоже, итоговое смещение AOV зависит от соотношения сумм — его направление заранее не гарантировано.
Ошибка в знаменателе: удаление возвращённых заказов. Допустим, аналитик исключает из знаменателя те paid-заказы, по которым есть запись в refunds, — оставляя только «чистые» заказы без возвратов. Логика кажется разумной: «возврат был, значит, заказ не состоялся». Но это неверно: клиент разместил и оплатил заказ, факт события зафиксирован. Возврат корректирует полученную выручку (числитель), а не отменяет сам заказ. Удаление таких order_id из знаменателя его занижает. При положительной net_revenue и положительном оставшемся числе заказов AOV завышается — возникает иллюзия более высокого среднего чека.
Если же net_revenue равна нулю, удаление части заказов оставит AOV нулём; если удалить все возвращённые заказы и знаменатель обнулится, AOV станет неопределённым (NULL / NaN).
Симметричное правило: числитель уменьшается на refund_amount, знаменатель остаётся COUNT(DISTINCT order_id) по всем paid-заказам периода, включая те, по которым были возвраты.
Разберём на учебном примере (данные условные):
order_id | status | gross_revenue 101 | paid | 2 000 руб. 102 | paid | 5 000 руб. ← возврат 1 000 руб. 103 | cancelled | 1 500 руб. 104 | paid | 3 000 руб.
Правильный расчёт:
- gross_revenue paid: 2 000 + 5 000 + 3 000 = 10 000 руб.
- возврат по order 102: 1 000 руб.
- net_revenue = 10 000 − 1 000 = 9 000 руб.
- paid_orders = 3 (orders 101, 102, 104)
- AOV = 9 000 / 3 = 3 000 руб.
Ошибка 1 — включить cancelled (order 103), сохраняя вычет возврата:
- числитель = 9 000 + 1 500 = 10 500 руб., знаменатель = 3 → AOV = 3 500 руб. (завышен)
Ошибка 2 — исключить order 102 из знаменателя как «возвращённый»:
- числитель = 9 000 руб., знаменатель = 2 → AOV = 4 500 руб. (завышен, так как net_revenue положительна и остаётся хотя бы один заказ)
Ошибка 3 — не вычесть возврат из числителя:
- числитель = 10 000 руб., знаменатель = 3 → AOV ≈ 3 333 руб. (завышен)
Защита от деления на ноль. Если за выбранный период нет ни одного paid-заказа, знаменатель равен нулю и деление не выполняется. Корректный результат — NULL в SQL или NaN в pandas. SQL-защита с net-числителем (предполагается, что возвраты предагрегированы в переменную r.refund_amount до JOIN):
CASE WHEN COUNT(DISTINCT o.order_id) = 0
THEN NULL
ELSE (SUM(o.gross_revenue) - COALESCE(SUM(r.refund_amount), 0))
* 1.0 / COUNT(DISTINCT o.order_id)
END AS aov
В pandas защита выглядит так:
aov = net_revenue / paid_orders if paid_orders > 0 else float('nan')
Возвращать ноль вместо NULL/NaN неверно: ноль означал бы, что средний чек равен нулю, хотя продаж просто не было.

Условный пример из теории: зелёный столбец — правильный AOV 9000/3. Оранжевые сверху вниз: включение cancelled в числитель, исключение возвращённого заказа из знаменателя, пропуск вычета возврата. Остальные компоненты в каждом сравнении фиксированы.
Реализация расчёта в SQL и pandas: предагрегация возвратов и итоговые метрики
Главная техническая сложность при соединении orders с refunds — избежать дублирования строк. Если у одного order_id несколько записей в refunds и сделать прямой LEFT JOIN без предагрегации, строка заказа размножится. COUNT(DISTINCT order_id) устоит против этого, но SUM(gross_revenue) посчитает одну и ту же выручку несколько раз.
Решение: сначала свернуть refunds до одной строки на order_id, потом присоединять.
SQL — базовый случай (без возвратов):
SELECT
COUNT(DISTINCT o.order_id) AS paid_orders,
SUM(o.gross_revenue) AS gross_rev,
ROUND(
SUM(o.gross_revenue) * 1.0
/ NULLIF(COUNT(DISTINCT o.order_id), 0),
2) AS aov
FROM orders o
WHERE o.status = 'paid'
AND strftime('%Y-%m', o.order_date) = '2024-01';
Для января 2024 результат: paid_orders = 82, gross_rev = 933 000, aov ≈ 11 378 руб. (до вычета возвратов).
SQL — с учётом возвратов:
SELECT
COUNT(DISTINCT o.order_id) AS paid_orders,
SUM(o.gross_revenue) - COALESCE(SUM(r.refund_amount), 0) AS net_revenue,
ROUND(
(SUM(o.gross_revenue) - COALESCE(SUM(r.refund_amount), 0))
* 1.0 / NULLIF(COUNT(DISTINCT o.order_id), 0),
2) AS aov
FROM orders o
LEFT JOIN (
SELECT order_id, SUM(refund_amount) AS refund_amount
FROM refunds
WHERE refund_date <= '2025-08-31'
GROUP BY order_id
) r ON o.order_id = r.order_id
WHERE o.status = 'paid'
AND strftime('%Y-%m', o.order_date) = '2024-01';
Подзапрос агрегирует refunds по order_id до JOIN. COALESCE(SUM(r.refund_amount), 0) обрабатывает заказы, у которых нет строк в refunds: для них r.refund_amount будет NULL после LEFT JOIN, и COALESCE заменяет его нулём. NULLIF(COUNT(DISTINCT order_id), 0) заставляет деление вернуть NULL, если paid-заказов нет.
Для января 2024 запрос даёт: paid_orders = 82, net_revenue = 911 612, aov ≈ 11 117 руб.
pandas — тот же маршрут:
import pandas as pd
orders = pd.read_csv('orders.csv', parse_dates=['order_date'])
refunds = pd.read_csv('refunds.csv', parse_dates=['refund_date'])
# Шаг 1. Фильтр по статусу и месяцу
paid = orders.loc[
(orders['status'] == 'paid') &
(orders['order_date'].dt.to_period('M') == '2024-01')
].copy()
# Шаг 2. Базовые числа
paid_orders = paid['order_id'].nunique() # 82
gross_rev = paid['gross_revenue'].sum() # 933 000
# Шаг 3. Предагрегация возвратов по order_id
ref_agg = (
refunds.loc[refunds['refund_date'] <= pd.Timestamp('2025-08-31')]
.groupby('order_id', as_index=False)
.agg(refund_amount=('refund_amount', 'sum'))
)
# Шаг 4. LEFT JOIN: присоединить возвраты к paid-заказам
paid_ref = paid.merge(ref_agg, on='order_id', how='left')
paid_ref['refund_amount'] = paid_ref['refund_amount'].fillna(0)
# Шаг 5. Итоговые метрики
net_revenue = paid_ref['gross_revenue'].sum() - paid_ref['refund_amount'].sum()
aov_net = net_revenue / paid_orders if paid_orders > 0 else float('nan')
print(f'paid_orders: {paid_orders}')
print(f'gross_revenue: {gross_rev:.2f}')
print(f'net_revenue: {net_revenue:.2f}')
print(f'AOV (net): {aov_net:.2f}')
Вывод для января 2024:
paid_orders: 82
gross_revenue: 933000.00
net_revenue: 911612.00
AOV (net): 11117.22
Логика шагов та же, что в SQL: предагрегация возвратов делается до merge, fillna(0) выполняет роль COALESCE. Метод nunique() на поле order_id эквивалентен COUNT(DISTINCT order_id) — он считает уникальные значения, не строки. Защита от деления на ноль — явная проверка if paid_orders > 0 перед делением.
Задача 1
Отдельный условный магазин. На заданный cutoff известны все перечисленные возвраты: paid-заказ A на 6000 руб.; paid-заказ B на 3000 руб. с возвратом 1500 руб.; cancelled-заказ C на 4000 руб. Определение: net-AOV — net-выручка paid-заказов, делённая на число уникальных paid-заказов, включая возвращённые. Каков net-AOV?
Задача 2
Отдельные независимые ошибки относительно контроля: net_revenue=3000 руб., три уникальных paid-заказа, исходный AOV=1000 руб. Для каждого случая определите получившийся AOV и сопоставьте метку: X=1500 руб., Y=600 руб., Z=1000 руб. Метки могут повторяться.
- Добавили 1500 руб. cancelled-суммы только в числитель; знаменатель оставили 3.
- Удалили возвращённый paid-заказ только из знаменателя; net_revenue оставили 3000 руб.
- Вместо трёх заказов взяли пять строк товарных позиций; net_revenue оставили 3000 руб.
Введите 1<метка>;2<метка>;3<метка>. Допустимы запятые и пробелы после разделителей.
Задача 3
Выполните один маршрут: SQL с mayak.sqlite либо pandas с orders.csv и refunds.csv из корня учебного архива. Сначала воспроизведите январский пример теории и сохраните полную строку метрик для самопроверки.
Теперь проверьте отчёт коллеги за июль 2024. Методологический контракт: учитываются paid-заказы с order_date >= 2024-07-01 и < 2024-08-01; возвраты по этим заказам известны на конец 2025-08-31 включительно. Возврат уменьшает выручку, но не отменяет paid-заказ. Отсутствие возврата не исключает заказ. Net-AOV — чистая выручка / число уникальных paid-заказов, без заказов отношение не определено.
Коллега использовал следующий запрос:
SELECT COUNT(DISTINCT o.order_id) AS paid_orders,
SUM(o.gross_revenue) AS gross_rev,
SUM(COALESCE(r.refund_amount, 0)) AS total_refunds,
SUM(o.gross_revenue)-SUM(COALESCE(r.refund_amount, 0)) AS net_revenue,
ROUND((SUM(o.gross_revenue)-SUM(COALESCE(r.refund_amount, 0)))
*1.0/NULLIF(COUNT(DISTINCT o.order_id), 0), 2) AS aov
FROM orders o
LEFT JOIN refunds r ON r.order_id=o.order_id
AND r.refund_date<='2025-08-31'
WHERE o.status='paid'
AND o.order_date>='2024-07-01' AND o.order_date<'2024-08-01';
Самостоятельно определите, соответствует ли расчёт контракту. Проследите строки и вклады в суммы хотя бы для одного заказа с несколькими возвратами и одного без возвратов. Запишите объяснение обнаруженного дефекта, исправьте реализацию выбранным маршрутом и выведите полную строку paid_orders, gross_rev, total_refunds, net_revenue, aov. Сохраните обе таблицы результата и пояснение.
Введите два контрольных значения через точку с запятой: корректный июльский net-AOV в рублях с двумя знаками после запятой; разность gross_rev исходного запроса и корректного gross_rev в целых рублях. Разность вычисляется как исходное минус корректное значение. Для AOV допустимы десятичная точка или запятая, пробел после ; допустим; разделители тысяч не используйте. Платформа проверяет эти два числа, реализацию, строки и собственное объяснение сверяете самостоятельно.
Задача 4
Отдельная условная ситуация, не числовой результат предоставленных файлов. Все перечисленные возвраты известны на явно выбранный cutoff.
Аналитик магазина «Маяк» строит отчёт по категориям за февраль 2024. Он берёт таблицу order_items, присоединяет к ней статус из orders, фильтрует paid-позиции и считает по каждой категории COUNT(order_id) AS n_orders. Затем суммирует n_orders по всем категориям и получает 980. Параллельно, запросив COUNT(DISTINCT order_id) напрямую из orders по paid-статусу за тот же месяц, он получает 620.
Объясните:
- Почему два числа различаются.
- Какое из них является корректным числом заказов для знаменателя AOV и почему.
- Как можно использовать числа по категориям, не допуская двойного счёта при вычислении итогового AOV.
Сначала самостоятельно запишите объяснение. Затем выберите знаменатель общего AOV. Платформа проверяет выбранный вариант; собственный текст сверяете с теорией самостоятельно.
Задача 5
Заполните семь пропусков метками из списка. Метки могут повторяться.
В знаменатель net-AOV входят (1) ___ paid order_id; возвращённые paid-заказы (2) ___. Возврат уменьшает (3) ___, но не (4) ___. Сумма COUNT(order_id) по категориям из order_items считает (5) ___ и при многопозиционных заказах (6) ___ число заказов. При нулевом числе paid-заказов net-AOV (7) ___.
A=уникальные; B=все позиционные повторы; C=остаются; D=исключаются; E=числитель; F=знаменатель; G=строки позиций; H=клиенты; I=завышает; J=занижает; K=не определён (NULL/NaN); L=равен нулю. Введите семь меток через ;, например X;Y;Z;W;V;U;T (метки условные). Допустимы запятые и пробелы после разделителей.
Продолжить с сохранением прогресса
Перейдите к уроку в учебном кабинете.
