Все материалы
Бизнесгайдсредний

Юнит-экономика в SQL: маржа заказа, CAC и LTV по когортам

Семь выполняемых запросов на данных учебного магазина: доход после возвратов, маржа заказа, когорты, CAC и месяц окупаемости. С результатами и проверками.

КейсПрактика28 сентября 2026 г.13 мин

В отчёте магазина «маржа заказа» выглядит простой разницей. В SQL ошибка возникает раньше вычитания: один заказ содержит несколько товаров и возвратов, а строка рекламного бюджета относится сразу к месяцу и каналу. Прямой JOIN размножает деньги. На учебном магазине Wave соберём расчёт с зерном одной строки на оплаченный заказ, затем перейдём к когортам, CAC и окупаемости. Запросы ниже рассчитаны для DuckDB и дают проверяемые таблицы.

Коротко: порядок SQL-расчёта

Сначала сведите order_items и returns отдельно до order_id. Присоедините эти итоги к оплаченному заказу: так одна оплата остаётся одной строкой. Доход заказа — net_total после скидки минус известные возвраты; вклад — доход минус себестоимость с учётом восстановленного товара и прочие переменные затраты. После этого найдите месяц первой покупки клиента, накопите его вклад по возрасту когорты и сравните с бюджетом того же канала и месяца.

Для июльской paid_ads-когорты 180 новых покупателей сделали 218 заказов за июль–август. Вклад 328 604,93 ₽ даёт маржинальный LTV M1 1 825,58 ₽ на человека; модельный бюджет 250 000 ₽ — CAC 1 388,89 ₽. Отношение LTV/CAC равно 1,31. Это срез двух месяцев, не «пожизненная» ценность. Если нужен общий порядок расчёта для бизнеса, начните с хаба юнит-экономики.

Один клиент одной когорты
LTV M1 = сумма вклада его когорты за M0 и M1 / число новых покупателей; CAC = бюджет канала в M0 / то же число покупателей

Одинаковая когорта в двух знаменателях — обязательное условие сравнения.

  • Агрегируйте товар и возврат до заказа.
  • Назовите дату и окно возвратов.
  • Возьмите первый оплаченный заказ для когорты, а не регистрацию.
  • Присоединяйте месячный бюджет только к агрегату канала и месяца.

Зерно таблиц: где JOIN умножает деньги

В Wave orders хранит один заказ, order_items — товарные позиции, returns — события возврата, users — канал пользователя. Если у заказа три позиции и два возврата, сырое соединение позиций с возвратами даст шесть строк. Сумма net_total будет повторена шесть раз. COUNT(DISTINCT order_id) может исправить число заказов, но не сумму денег; ошибка остаётся в SUM.

Мы берём только status = paid, а возврат привязываем к исходному заказу независимо от даты возврата. CSV заканчиваются 31 октября 2024 года: поздние возвраты не видны. Сравнение сентябрьских и октябрьских заказов без одинакового окна возврата сместит вывод в пользу более молодых заказов. Затраты в примере — синтетические допущения, не данные реального магазина; SQL подставляет их из единого файла модели.

Какая строка означает какой объект
ИсточникЗерноКак присоединять
ordersодин заказоснова; фильтр оплаченных
order_itemsпозиция внутри заказасначала SUM себестоимости по order_id
returnsсобытие возвратасначала SUM денег и восстановленного товара по order_id
usersодин пользовательканал к заказу; проверять стабильность поля
бюджет моделимесяц × каналк агрегату новых покупателей, не к каждому заказу

Шаг 1. Доход после скидок и возвратов

gross_total показывает сумму до скидок, net_total — оплату после них. Возвраты лежат отдельно. Сведите их по заказу и только затем вычтите: LEFT JOIN оставляет заказы без возврата, а COALESCE превращает отсутствующий возврат в ноль. Полуоткрытый интервал дат включает весь сентябрь и не захватывает 1 октября даже при временной метке.

Результат: 1 433 оплаченных сентябрьских заказа, 8 672 855 ₽ после скидок и 605 982 ₽ известных возвратов. Остаётся 8 066 873 ₽. В 15 заказах генератор вернул сумму больше цены после скидки, суммарно на 17 640 ₽. Мы не обрезаем эти события до нуля: в рабочей базе их пришлось бы сверить с платежами, а здесь они остаются видимыми.

Результат запроса
ЗаказовПосле скидкиВозвратыДоход
1 4338 672 855 ₽605 982 ₽8 066 873 ₽
Одна строка на заказ до суммирования дохода
WITH refunds_by_order AS (
  SELECT order_id, SUM(refund_rub) AS refund_rub
  FROM returns GROUP BY order_id
)
SELECT COUNT(*) AS orders, SUM(o.net_total) AS paid_after_discount,
       SUM(COALESCE(r.refund_rub, 0)) AS refunds,
       SUM(o.net_total - COALESCE(r.refund_rub, 0)) AS revenue
FROM orders o LEFT JOIN refunds_by_order r USING (order_id)
WHERE o.status = 'paid'
  AND o.placed_at >= DATE '2024-09-01'
  AND o.placed_at < DATE '2024-10-01'

Шаг 2. Себестоимость и вклад заказа

Себестоимость позиции в модели — unit_price × qty × ставка категории. В файле допущений ставки от 42% для beauty до 68% для electronics. Доставка 220 ₽ и упаковка 55 ₽ относятся к оплаченной покупке. Эквайринг 1,8% начисляем на net_total; при возврате не пересчитываем. Обработка возврата — 140 ₽ за событие. Восстановленная себестоимость товара уменьшает расход, но не возвращает покупателю деньги: это две разные строки.

Запрос ниже делает два отдельных агрегата и соединяет их с заказом. Результат сентября — 2 459 009,05 ₽ вклада до маркетинга, или 1 715,99 ₽ на оплаченный заказ. Вклад не равен чистой прибыли: ещё есть бюджет привлечения, 3 млн ₽ модельных постоянных расходов и не заданная налоговая база. Удобнее хранить результат запроса как представление с одной строкой на заказ и сверять COUNT(*) = COUNT(DISTINCT order_id).

Сентябрьский вклад по модельным затратам
ЗаказовДоходВклад до маркетингаНа заказ
1 4338 066 873 ₽2 459 009,05 ₽1 715,99 ₽
Агрегаты позиции и возврата до JOIN с заказом
WITH item_cost AS (
  SELECT order_id, SUM(unit_price * qty * (CASE category WHEN 'beauty' THEN 0.42 WHEN 'books' THEN 0.62 WHEN 'clothing' THEN 0.48 WHEN 'electronics' THEN 0.68 WHEN 'home' THEN 0.52 WHEN 'sport' THEN 0.55 END)) AS cogs_initial
  FROM order_items GROUP BY order_id
), refund_cost AS (
  SELECT order_id, COUNT(*) AS return_count, SUM(refund_rub) AS refunds,
         SUM(refund_rub * (CASE category WHEN 'beauty' THEN 0.42 WHEN 'books' THEN 0.62 WHEN 'clothing' THEN 0.48 WHEN 'electronics' THEN 0.68 WHEN 'home' THEN 0.52 WHEN 'sport' THEN 0.55 END) * 0.75) AS recovered_cogs
  FROM returns GROUP BY order_id
), order_economics AS (
  SELECT o.order_id, o.user_id, o.placed_at, u.channel,
         o.gross_total, o.net_total, i.cogs_initial,
         COALESCE(r.refunds, 0) AS refunds,
         COALESCE(r.return_count, 0) AS return_count,
         o.net_total - COALESCE(r.refunds, 0) AS revenue,
         o.net_total - COALESCE(r.refunds, 0)
           - i.cogs_initial + COALESCE(r.recovered_cogs, 0)
           - 275
           - o.net_total * 0.018
           - COALESCE(r.return_count, 0) * 140 AS contribution
  FROM orders o JOIN users u USING (user_id)
  JOIN item_cost i USING (order_id)
  LEFT JOIN refund_cost r USING (order_id)
  WHERE o.status = 'paid'
)
SELECT COUNT(*) AS orders, ROUND(SUM(revenue), 2) AS revenue,
       ROUND(SUM(contribution), 2) AS contribution,
       ROUND(AVG(contribution), 2) AS contribution_per_order
FROM order_economics
WHERE placed_at >= DATE '2024-09-01'
  AND placed_at < DATE '2024-10-01'

Как менять допущения, не переписывая смысл метрики

Числа себестоимости, доставки, эквайринга и обработки возврата в SQL — параметры модели, а не поля Wave. Для своего магазина лучше вынести их в отдельную таблицу ставок с датой действия и категорией. Тогда товарные позиции соединяются со ставкой своей категории, а отчёт можно пересчитать для прошлого периода без подмены сегодняшними условиями. До такого справочника явный блок допущений в запросе честнее, чем невидимый процент в BI.

На 1 433 заказах сентября изменение средней маржи на 100 ₽ означает изменение месячного вклада на 143 300 ₽ при том же объёме. Но новый тариф доставки может зависеть от веса, региона или склада. Если измерение отсутствует, нельзя обещать такой же результат на другом составе заказов. Подставьте ставку в одном месте, затем проверьте, что итог по заказам сходится с суммой по категориям.

Затратные параметры в показанном SQL
ПараметрЗначениеЧто меняется
Закупка по категориям42–68% цены позицииСебестоимость товара
Доставка + упаковка220 + 55 ₽Вклад каждого оплаченного заказа
Эквайринг1,8% после скидкиВклад, но не доход
Возврат140 ₽; восстановление 75% себестоимостиВклад возвращённого заказа

Шаг 3. Когорта — месяц первой оплаченной покупки

Регистрация не равна покупке. Для экономического LTV когорта начинается с первого оплаченного заказа пользователя. ROW_NUMBER() выбирает его при сортировке по времени и order_id; второй ключ снимает случайную ничью по времени. Канал здесь — постоянное поле users.channel. Если реальные каналы меняются между сессиями, нужна отдельная модель атрибуции и зафиксированное правило назначения канала когорте.

В июле 2024 года было 580 новых покупателей: 180 paid_ads, 176 organic, 109 email, 67 social и 48 referral. Это исходные знаменатели для LTV и CAC. Не делите июльские расходы на всех июльских посетителей или на тех, кто купил уже в августе. У разных знаменателей может получиться красивое, но бессмысленное отношение.

Новые покупатели июля
КаналПокупатели
email109
organic176
paid_ads180
referral48
social67
Когорта по первому оплаченному заказу
WITH item_cost AS (
  SELECT order_id, SUM(unit_price * qty * (CASE category WHEN 'beauty' THEN 0.42 WHEN 'books' THEN 0.62 WHEN 'clothing' THEN 0.48 WHEN 'electronics' THEN 0.68 WHEN 'home' THEN 0.52 WHEN 'sport' THEN 0.55 END)) AS cogs_initial
  FROM order_items GROUP BY order_id
), refund_cost AS (
  SELECT order_id, COUNT(*) AS return_count, SUM(refund_rub) AS refunds,
         SUM(refund_rub * (CASE category WHEN 'beauty' THEN 0.42 WHEN 'books' THEN 0.62 WHEN 'clothing' THEN 0.48 WHEN 'electronics' THEN 0.68 WHEN 'home' THEN 0.52 WHEN 'sport' THEN 0.55 END) * 0.75) AS recovered_cogs
  FROM returns GROUP BY order_id
), order_economics AS (
  SELECT o.order_id, o.user_id, o.placed_at, u.channel,
         o.gross_total, o.net_total, i.cogs_initial,
         COALESCE(r.refunds, 0) AS refunds,
         COALESCE(r.return_count, 0) AS return_count,
         o.net_total - COALESCE(r.refunds, 0) AS revenue,
         o.net_total - COALESCE(r.refunds, 0)
           - i.cogs_initial + COALESCE(r.recovered_cogs, 0)
           - 275
           - o.net_total * 0.018
           - COALESCE(r.return_count, 0) * 140 AS contribution
  FROM orders o JOIN users u USING (user_id)
  JOIN item_cost i USING (order_id)
  LEFT JOIN refund_cost r USING (order_id)
  WHERE o.status = 'paid'
), first_buy AS (
  SELECT * EXCLUDE (rn) FROM (
    SELECT oe.*, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY placed_at, order_id) AS rn
    FROM order_economics oe
  ) WHERE rn = 1
)
SELECT channel, COUNT(*) AS new_buyers
FROM first_buy
WHERE placed_at >= DATE '2024-07-01'
  AND placed_at < DATE '2024-08-01'
GROUP BY channel ORDER BY channel

Шаг 4. Накопленный маржинальный LTV

Для июльской paid_ads-когорты сначала отберите 180 user_id первой покупки. Затем заберите все их оплаченные заказы до 1 сентября: повторные покупки не должны исчезнуть из LTV. Возраст M0 — июль, M1 — август. SUM(...) OVER накопит вклад по возрасту; знаменатель всё время остаётся 180, даже если часть людей больше не покупала. Делить августовский вклад только на вернувшихся — значит завысить ценность всей исходной когорты.

В M0 вклад на покупателя равен 1 760,00 ₽, к концу M1 — 1 825,58 ₽. Дополнительные 65,58 ₽ не означает, что любой будущий месяц принесёт столько же. В сентябре эта когорта дала ещё деньги, но сравнение каналов в этой статье останавливается на одинаковом M1. Возвраты заказов известны только до 31 октября: у старых когорт окно наблюдения шире, чем у новых.

Paid_ads, июльская когорта из 180 человек
ВозрастВклад месяцаLTV на клиента
M0316 800,39 ₽1 760,00 ₽
M111 804,54 ₽1 825,58 ₽
Кумулятивный вклад и CAC июльской paid_ads-когорты

M1 — август; вклад на исходные 180 новых покупателей, затраты на привлечение — модельный бюджет июля.

LTV, ₽CAC, ₽
Кумулятивный вклад на фиксированный размер исходной когорты
WITH item_cost AS (
  SELECT order_id, SUM(unit_price * qty * (CASE category WHEN 'beauty' THEN 0.42 WHEN 'books' THEN 0.62 WHEN 'clothing' THEN 0.48 WHEN 'electronics' THEN 0.68 WHEN 'home' THEN 0.52 WHEN 'sport' THEN 0.55 END)) AS cogs_initial
  FROM order_items GROUP BY order_id
), refund_cost AS (
  SELECT order_id, COUNT(*) AS return_count, SUM(refund_rub) AS refunds,
         SUM(refund_rub * (CASE category WHEN 'beauty' THEN 0.42 WHEN 'books' THEN 0.62 WHEN 'clothing' THEN 0.48 WHEN 'electronics' THEN 0.68 WHEN 'home' THEN 0.52 WHEN 'sport' THEN 0.55 END) * 0.75) AS recovered_cogs
  FROM returns GROUP BY order_id
), order_economics AS (
  SELECT o.order_id, o.user_id, o.placed_at, u.channel,
         o.gross_total, o.net_total, i.cogs_initial,
         COALESCE(r.refunds, 0) AS refunds,
         COALESCE(r.return_count, 0) AS return_count,
         o.net_total - COALESCE(r.refunds, 0) AS revenue,
         o.net_total - COALESCE(r.refunds, 0)
           - i.cogs_initial + COALESCE(r.recovered_cogs, 0)
           - 275
           - o.net_total * 0.018
           - COALESCE(r.return_count, 0) * 140 AS contribution
  FROM orders o JOIN users u USING (user_id)
  JOIN item_cost i USING (order_id)
  LEFT JOIN refund_cost r USING (order_id)
  WHERE o.status = 'paid'
), first_buy AS (
  SELECT * EXCLUDE (rn) FROM (
    SELECT oe.*, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY placed_at, order_id) AS rn
    FROM order_economics oe
  ) WHERE rn = 1
), acquired AS (
  SELECT user_id, placed_at AS first_paid_at
  FROM first_buy WHERE channel = 'paid_ads'
    AND placed_at >= DATE '2024-07-01' AND placed_at < DATE '2024-08-01'
), by_age AS (
  SELECT date_diff('month', date_trunc('month', a.first_paid_at),
                   date_trunc('month', o.placed_at)) AS age_month,
         SUM(o.contribution) AS contribution
  FROM acquired a JOIN order_economics o USING (user_id)
  WHERE o.placed_at < DATE '2024-09-01'
  GROUP BY 1
)
SELECT age_month, ROUND(contribution, 2) AS contribution,
       ROUND(SUM(contribution) OVER (ORDER BY age_month) / 180, 2) AS cumulative_ltv
FROM by_age ORDER BY age_month

Шаг 5. CAC на те же каналы и месяц

Расходы на рекламу не хранятся в CSV Wave. В запросе ниже budget — синтетическая таблица июля из единого файла допущений: медиа, команда и инструменты. Бюджет paid_ads равен 250 000 ₽. Присоединяем его к пяти уже посчитанным агрегатам новых покупателей, а не к 218 заказам или товарным позициям. Так 250 000 ₽ остаются одной строкой канала.

CAC paid_ads равен 250 000 / 180 = 1 388,89 ₽. Organic здесь не бесплатен: в модели у него 30 000 ₽ на команду, инструменты и небольшое медиа, поэтому CAC 170,45 ₽. Эти расходы не фактическая бухгалтерия магазина. Если в своём бизнесе часть команды обслуживает и текущих клиентов, явно выберите правило распределения; механическое включение всей зарплаты в привлечение исказит CAC.

Модельные расходы и CAC июля
КаналНовыхБюджетCAC
email10940 000 ₽366,97 ₽
organic17630 000 ₽170,45 ₽
paid_ads180250 000 ₽1 388,89 ₽
referral4825 000 ₽520,83 ₽
social6785 000 ₽1 268,66 ₽
Бюджет месяца соединяется с агрегатом новых покупателей
WITH item_cost AS (
  SELECT order_id, SUM(unit_price * qty * (CASE category WHEN 'beauty' THEN 0.42 WHEN 'books' THEN 0.62 WHEN 'clothing' THEN 0.48 WHEN 'electronics' THEN 0.68 WHEN 'home' THEN 0.52 WHEN 'sport' THEN 0.55 END)) AS cogs_initial
  FROM order_items GROUP BY order_id
), refund_cost AS (
  SELECT order_id, COUNT(*) AS return_count, SUM(refund_rub) AS refunds,
         SUM(refund_rub * (CASE category WHEN 'beauty' THEN 0.42 WHEN 'books' THEN 0.62 WHEN 'clothing' THEN 0.48 WHEN 'electronics' THEN 0.68 WHEN 'home' THEN 0.52 WHEN 'sport' THEN 0.55 END) * 0.75) AS recovered_cogs
  FROM returns GROUP BY order_id
), order_economics AS (
  SELECT o.order_id, o.user_id, o.placed_at, u.channel,
         o.gross_total, o.net_total, i.cogs_initial,
         COALESCE(r.refunds, 0) AS refunds,
         COALESCE(r.return_count, 0) AS return_count,
         o.net_total - COALESCE(r.refunds, 0) AS revenue,
         o.net_total - COALESCE(r.refunds, 0)
           - i.cogs_initial + COALESCE(r.recovered_cogs, 0)
           - 275
           - o.net_total * 0.018
           - COALESCE(r.return_count, 0) * 140 AS contribution
  FROM orders o JOIN users u USING (user_id)
  JOIN item_cost i USING (order_id)
  LEFT JOIN refund_cost r USING (order_id)
  WHERE o.status = 'paid'
), first_buy AS (
  SELECT * EXCLUDE (rn) FROM (
    SELECT oe.*, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY placed_at, order_id) AS rn
    FROM order_economics oe
  ) WHERE rn = 1
), acquired AS (
  SELECT channel, COUNT(*) AS new_buyers FROM first_buy
  WHERE placed_at >= DATE '2024-07-01' AND placed_at < DATE '2024-08-01'
  GROUP BY channel
), budget(channel, marketing_rub) AS (
  VALUES ('email', 40000), ('organic', 30000), ('paid_ads', 250000), ('referral', 25000), ('social', 85000)
)
SELECT a.channel, a.new_buyers, b.marketing_rub,
       ROUND(b.marketing_rub / a.new_buyers, 2) AS cac
FROM acquired a JOIN budget b USING (channel) ORDER BY a.channel

Шаг 6. Найти первый месяц окупаемости

Окупаемость — первый возраст когорты, в котором накопленный вклад на одного исходного клиента не меньше CAC той же когорты. У paid_ads июля уже M0 даёт 1 760,00 ₽ против 1 388,89 ₽ CAC, поэтому первый наблюдённый месяц окупаемости — M0. Это не обещание о новых покупателях: их цена привлечения и состав заказов могут отличаться.

В запросе лимит до 1 сентября ограничивает горизонт M0–M1. Если порог за этот срок не достигнут, MIN(CASE...) возвращает NULL. В отчёте напишите «не окупился за наблюдаемый горизонт», а не «никогда не окупится». При нескольких каналах повторите запрос с разделением по каналу и одним CAC на каждую когорту; поздние когорты сравнивайте только на том возрасте, который доступен всем.

Июльский paid_ads, наблюдение до конца августа
Первый месяцLTV M1CAC
M01 825,58 ₽1 388,89 ₽
Первый возраст с накопленным вкладом не ниже CAC
WITH item_cost AS (
  SELECT order_id, SUM(unit_price * qty * (CASE category WHEN 'beauty' THEN 0.42 WHEN 'books' THEN 0.62 WHEN 'clothing' THEN 0.48 WHEN 'electronics' THEN 0.68 WHEN 'home' THEN 0.52 WHEN 'sport' THEN 0.55 END)) AS cogs_initial
  FROM order_items GROUP BY order_id
), refund_cost AS (
  SELECT order_id, COUNT(*) AS return_count, SUM(refund_rub) AS refunds,
         SUM(refund_rub * (CASE category WHEN 'beauty' THEN 0.42 WHEN 'books' THEN 0.62 WHEN 'clothing' THEN 0.48 WHEN 'electronics' THEN 0.68 WHEN 'home' THEN 0.52 WHEN 'sport' THEN 0.55 END) * 0.75) AS recovered_cogs
  FROM returns GROUP BY order_id
), order_economics AS (
  SELECT o.order_id, o.user_id, o.placed_at, u.channel,
         o.gross_total, o.net_total, i.cogs_initial,
         COALESCE(r.refunds, 0) AS refunds,
         COALESCE(r.return_count, 0) AS return_count,
         o.net_total - COALESCE(r.refunds, 0) AS revenue,
         o.net_total - COALESCE(r.refunds, 0)
           - i.cogs_initial + COALESCE(r.recovered_cogs, 0)
           - 275
           - o.net_total * 0.018
           - COALESCE(r.return_count, 0) * 140 AS contribution
  FROM orders o JOIN users u USING (user_id)
  JOIN item_cost i USING (order_id)
  LEFT JOIN refund_cost r USING (order_id)
  WHERE o.status = 'paid'
), first_buy AS (
  SELECT * EXCLUDE (rn) FROM (
    SELECT oe.*, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY placed_at, order_id) AS rn
    FROM order_economics oe
  ) WHERE rn = 1
), acquired AS (
  SELECT user_id, placed_at AS first_paid_at FROM first_buy
  WHERE channel = 'paid_ads' AND placed_at >= DATE '2024-07-01'
    AND placed_at < DATE '2024-08-01'
), by_age AS (
  SELECT date_diff('month', date_trunc('month', a.first_paid_at),
                   date_trunc('month', o.placed_at)) AS age_month,
         SUM(o.contribution) AS contribution
  FROM acquired a JOIN order_economics o USING (user_id)
  WHERE o.placed_at < DATE '2024-09-01' GROUP BY 1
), running AS (
  SELECT age_month, SUM(contribution) OVER (ORDER BY age_month) / 180 AS ltv
  FROM by_age
)
SELECT MIN(CASE WHEN ltv >= 250000.0 / 180 THEN age_month END) AS payback_month,
       ROUND(MAX(ltv), 2) AS ltv_m1,
       ROUND(250000.0 / 180, 2) AS cac
FROM running

Сценарии: что изменит вывод о канале

Проверенный скрипт меняет один параметр, оставляя 180 покупателей и 218 заказов. Дополнительная скидка 10% от уже сниженной суммы заказа уменьшает LTV M1 до 1 146,77 ₽ и отношение к тому же CAC до 0,83. Возвраты +5 п. п. снижают LTV до 1 644,48 ₽ и отношение до 1,18. При CAC +20% вклад не меняется, но отношение становится 1,10. Это сценарии, не прогноз спроса: скидка может изменить число покупок, а расширение рекламы — состав покупателей.

В SQL такие сценарии лучше оформить отдельными параметрами рядом с моделью затрат. Не меняйте фактические net_total или returns задним числом. Записывайте номер сценария и сравнивайте одинаковый возраст когорт. При ином сроке возвратов пересчитайте доход и восстановленную себестоимость вместе, иначе «улучшение LTV» может означать только пропавший возврат.

Чувствительность июльской paid_ads-когорты на M1
ИзменениеLTV / клиентLTV/CACЧто проверить
База1 825,58 ₽1,31Окно M0–M1
Ещё 10% скидки1 146,77 ₽0,83Спрос может измениться
Возвраты +5 п. п.1 644,48 ₽1,18Время возврата и восстановление товара
CAC +20%1 825,58 ₽1,10Цена следующих покупателей

Контроль качества перед отправкой в BI

На всём учебном наборе 6 328 оплаченных заказов. Для каждого order_id в модельной таблице должна остаться ровно одна строка. Если COUNT(*) и COUNT(DISTINCT order_id) расходятся, следующие суммы недостоверны. Второй контроль — после группировки по каналу сумма дохода должна сходиться с общей суммой без разреза. Третий — возврат должен относиться к существующему товару исходного заказа.

Запрос ниже проверяет зерно и отсутствие пропущенной себестоимости. Он не доказывает, что платежи и возвраты верны: 15 сентябрьских переплат по возвратам остались в синтетической выгрузке. В реальном потоке добавьте карантин для таких случаев и не подменяйте проверку автоматическим GREATEST(0, ...) — он создаст красивые, но необъяснимые суммы.

Результат контроля
СтрокЗаказовБез себестоимости
6 3286 3280
Контроль единственной строки заказа и заполнения закупки
WITH item_cost AS (
  SELECT order_id, SUM(unit_price * qty * (CASE category WHEN 'beauty' THEN 0.42 WHEN 'books' THEN 0.62 WHEN 'clothing' THEN 0.48 WHEN 'electronics' THEN 0.68 WHEN 'home' THEN 0.52 WHEN 'sport' THEN 0.55 END)) AS cogs_initial
  FROM order_items GROUP BY order_id
), refund_cost AS (
  SELECT order_id, COUNT(*) AS return_count, SUM(refund_rub) AS refunds,
         SUM(refund_rub * (CASE category WHEN 'beauty' THEN 0.42 WHEN 'books' THEN 0.62 WHEN 'clothing' THEN 0.48 WHEN 'electronics' THEN 0.68 WHEN 'home' THEN 0.52 WHEN 'sport' THEN 0.55 END) * 0.75) AS recovered_cogs
  FROM returns GROUP BY order_id
), order_economics AS (
  SELECT o.order_id, o.user_id, o.placed_at, u.channel,
         o.gross_total, o.net_total, i.cogs_initial,
         COALESCE(r.refunds, 0) AS refunds,
         COALESCE(r.return_count, 0) AS return_count,
         o.net_total - COALESCE(r.refunds, 0) AS revenue,
         o.net_total - COALESCE(r.refunds, 0)
           - i.cogs_initial + COALESCE(r.recovered_cogs, 0)
           - 275
           - o.net_total * 0.018
           - COALESCE(r.return_count, 0) * 140 AS contribution
  FROM orders o JOIN users u USING (user_id)
  JOIN item_cost i USING (order_id)
  LEFT JOIN refund_cost r USING (order_id)
  WHERE o.status = 'paid'
)
SELECT COUNT(*) AS rows, COUNT(DISTINCT order_id) AS orders,
       SUM(CASE WHEN cogs_initial IS NULL THEN 1 ELSE 0 END) AS missing_cost
FROM order_economics

Ошибки, которые меняют решение

Первая — соединить позиции и возвраты до агрегации: сумма заказа размножится. Вторая — использовать gross_total вместо net_total: скидки исчезнут, маржа вырастет на бумаге. Третья — отнести возврат только к месяцу возврата при сравнении когорт заказов: старая когорта получит чужой минус, молодая покажется прибыльной. Четвёртая — вычитать всю себестоимость возвращённого товара, не учитывая, пригоден ли он к повторной продаже.

Пятая — строить когорту по регистрации и делить бюджет на покупателей: знаменатель CAC и знаменатель LTV окажутся разными. Шестая — соединить бюджет месяца с каждым заказом: расходы умножатся на число строк. Седьмая — считать LTV по выручке и сравнивать с CAC: товар, доставка и возвраты выпадут. Восьмая — назвать M1 «пожизненным» и экстраполировать его на будущих клиентов без одинакового окна наблюдения.

Проверяйте эти ошибки в указанном порядке. Если зерно неверно, спорить о коэффициенте эквайринга бессмысленно: до нужной формулы запрос ещё не дошёл. Сохраните контрольные суммы на каждом переходе: от исходных оплат к заказам после возврата, от заказов к когортам, от когорт к бюджетам. Когда цифра внезапно меняется, такая цепочка покажет конкретный JOIN или фильтр, а не заставит искать ошибку во всём отчёте.

Частые вопросы и следующий шаг

«Можно ли считать LTV прямо из таблицы заказов?» Да, после того как вы определили первый оплаченный заказ и фиксированный размер исходной когорты. «Почему COUNT(DISTINCT) не чинит JOIN?» Он исправляет только счётчик заказов, но повторённая выручка остаётся в SUM. «Нужен ли SQL, если есть калькулятор?» Для одного планового товара — нет; для тысяч заказов, возвратов и каналов SQL показывает, из каких строк вырос ввод калькулятора.

«Почему paid_ads окупился в M0, а LTV/CAC всего 1,31?» Окупаемость означает пересечение CAC накопленным вкладом; 1,31 показывает небольшой запас на M1, а не противоречит пересечению. «Можно ли перенести SQL в другую СУБД?» Логика зерна переносится, но функции дат и чтение CSV меняются. Этот текст использует DuckDB; синтаксис date_trunc и date_diff описан в официальной документации.

Хотите потренировать JOIN и оконные функции на выполняемых задачах — откройте SQL-тренажёр. Для своего запроса без задания есть песочница симулятора. Плановую экономику одного заказа можно сравнить в калькуляторе.

Продолжить чтение
Вся библиотека