Кому предложить второй продукт: SQL-выборка для перекрёстных продаж

Команда продаж просит клиентов, которые уже купили основной продукт, но ещё не приобрели дополнение. Обычный JOIN с товарами создаёт несколько строк на активного покупателя, а фильтр sku<>B оставляет даже тех, кто купил B в другом заказе. В SQLite построим аудиторию через EXISTS и NOT EXISTS.

Переводим запрос продаж в правила

Одна строка результата должна соответствовать одному клиенту. Включаем тех, у кого есть оплаченная покупка A за согласованный период, нет ни одной действительной покупки B в доступной истории и разрешены коммуникации. Исключаем клиентов из отдельного списка блокировок. Эти условия относятся к разным источникам, поэтому полезно проверить каждое отдельно.

Правило отсутствия B особенно важно. Строка с товаром A действительно не равна B, но клиент мог приобрести B другим заказом. Проверять нужно наличие покупки на уровне клиента. NOT EXISTS выражает именно это: для данного customer не существует подходящей строки B.

Возвраты и отмены должны быть учтены до отбора. Если человек полностью вернул B, бизнес может считать его снова подходящим для предложения или, наоборот, исключить. В примере таблица purchases уже содержит только действительные покупки. Для реальной системы понадобится согласованный слой, который учитывает итоговое количество после возврата.

Не размножаем клиента товарными строками

Основной SELECT идёт от справочника customers, где customer уникален. EXISTS отвечает только да или нет и не добавляет строку на каждый заказ. Поэтому две покупки A у клиента не создадут две записи результата. NOT EXISTS для исключений работает независимо от того, сколько причин блокировки записано.

В примере покупатель 1 купил A дважды, но не B; покупатель 2 приобрёл оба товара; 3 не разрешил коммуникации; 4 находится в блокировке. Покупатель 5 купил только B. Такая небольшая проверка покрывает разные причины включения и исключения, а не только успешный случай.

WITH customers(customer,consent) AS (
 VALUES (1,1),(2,1),(3,0),(4,1),(5,1)
), purchases(customer,sku,paid_at) AS (
 VALUES (1,'A','2026-10-02'),(1,'A','2026-10-03'),
 (2,'A','2026-10-02'),(2,'B','2026-09-01'),
 (3,'A','2026-10-03'),(4,'A','2026-10-04'),(5,'B','2026-10-05')
), exclusions(customer) AS (VALUES (4))
SELECT c.customer
FROM customers c
WHERE c.consent=1
 AND EXISTS(SELECT 1 FROM purchases p WHERE p.customer=c.customer
            AND p.sku='A' AND p.paid_at>='2026-10-01' AND p.paid_at<'2026-11-01')
 AND NOT EXISTS(SELECT 1 FROM purchases p WHERE p.customer=c.customer AND p.sku='B')
 AND NOT EXISTS(SELECT 1 FROM exclusions e WHERE e.customer=c.customer)
ORDER BY c.customer;

Ожидаемые строки результата в порядке столбцов SELECT:

[[1]]

Проверяем аудиторию перед использованием

Результат содержит только клиента 1, ровно один раз. Это можно подтвердить по его истории: две октябрьские покупки A и отсутствие B. Клиент 2 не проходит, хотя его строка A удовлетворяет условию периода: сентябрьская покупка B исключает его целиком. Именно этот случай часто ломает фильтр по отдельной товарной строке.

В рабочем процессе полезно построить счётчики после каждого условия: сколько клиентов купили A, сколько из них уже имеют B, сколько исключены по согласию и блокировкам. Причины могут пересекаться, поэтому их суммы не обязаны равняться общему числу исключённых. Для последовательной воронки отбора фиксируйте порядок применения.

Как превратить разовую выборку в повторяемую

Сохраните дату расчёта, период покупки A и версию правил. Между подготовкой и использованием аудитории человек способен купить B, поэтому перед фактическим действием нужно повторно проверить актуальные исключения. Запрос статьи только вычисляет список и не отправляет сообщений.

Для большой истории полезен индекс по customer,sku и дате, но его пользу следует проверять планом и временем на реальных объёмах. Не добавляйте DISTINCT поверх большого JOIN как замену корректному правилу: он скрывает размножение строк и усложняет диагностику. Семантика EXISTS сохраняет понятный договор: каждый клиент либо проходит условие, либо нет.

Для исключающих условий полезно понимать влияние пропусков: SQL NOT IN и NULL: почему пустой результат и как исправить через NOT EXISTS.

Следующий уровень SQL-практики

В «Продвинутом SQL» вы отрабатываете EXISTS, сложные соединения и причины дублей, затем переходите к многошаговым аналитическим задачам. Практика идёт на SQLite в браузере с автоматической проверкой запросов на открытых и скрытых данных. В финале вы собираете витрину магазина. Откройте программу «Продвинутого SQL».

Продолжить на практике