В этой статье разберу 4 задачи, которые решала на данных, приближенных к реальному ритейлу. Каждая задача сложнее предыдущей, и каждая дает конкретный бизнес-результат.
Данные для всех задач
Для всех примеров используем структуру аптечной сети, вот данные, которые можно скопировать и вставить для проверки:
-- Таблица аптек CREATE TABLE pharmacies ( pharmacy_id INT PRIMARY KEY, pharmacy_name VARCHAR(100), city VARCHAR(50), open_date DATE ); -- Таблица товаров CREATE TABLE products ( product_id INT PRIMARY KEY, product_name VARCHAR(200), category VARCHAR(100), price DECIMAL(10,2) ); -- Таблица продаж CREATE TABLE sales ( sale_id INT PRIMARY KEY, pharmacy_id INT, product_id INT, sale_date DATE, quantity INT, amount DECIMAL(10,2), customer_id INT ); -- Заполняем данными INSERT INTO pharmacies VALUES (1, 'Столичная', 'Москва', '2020-01-15'), (2, 'Доктор Айболит', 'Москва', '2020-03-20'), (3, 'Здравсити', 'СПб', '2020-02-10'), (4, 'Горздрав', 'СПб', '2020-04-05'), (5, 'Аптека 24', 'Казань', '2020-05-12'); INSERT INTO products VALUES (101, 'Ибупрофен 200мг', 'Обезболивающие', 150.00), (102, 'Цитрамон П', 'Обезболивающие', 80.00), (103, 'Аквафорте', 'Витамины', 450.00), (104, 'Мезим форте', 'Пищеварение', 220.00), (105, 'Но-шпа', 'Спазмолитики', 180.00), (106, 'Парацетамол', 'Жаропонижающие', 50.00), (107, 'Супрастин', 'Антигистаминные', 200.00), (108, 'Кагоцел', 'Противовирусные', 300.00); -- Генерируем продажи (результат выполнения этого запроса будет ниже) -- Для полноты данных я сразу покажу результат
Данные по продажам (упрощенно):
sale_id |
pharmacy_id |
product_id |
sale_date |
quantity |
amount |
customer_id |
|---|---|---|---|---|---|---|
1 |
1 |
101 |
2026-02-01 |
2 |
300.00 |
1001 |
2 |
1 |
102 |
2026-02-01 |
3 |
240.00 |
1002 |
3 |
1 |
103 |
2026-02-02 |
1 |
450.00 |
1003 |
4 |
2 |
101 |
2026-02-01 |
1 |
150.00 |
1004 |
5 |
2 |
104 |
2026-02-02 |
2 |
440.00 |
1005 |
6 |
3 |
102 |
2026-02-01 |
5 |
400.00 |
1006 |
7 |
3 |
105 |
2026-02-02 |
2 |
360.00 |
1007 |
8 |
3 |
101 |
2026-02-03 |
3 |
450.00 |
1008 |
9 |
4 |
103 |
2026-02-01 |
1 |
450.00 |
1009 |
10 |
4 |
106 |
2026-02-02 |
10 |
500.00 |
1010 |
11 |
5 |
107 |
2026-02-01 |
2 |
400.00 |
1011 |
12 |
5 |
108 |
2026-02-02 |
1 |
300.00 |
1012 |
13 |
1 |
104 |
2026-02-03 |
1 |
220.00 |
1013 |
14 |
2 |
105 |
2026-02-03 |
3 |
540.00 |
1014 |
15 |
3 |
106 |
2026-02-04 |
8 |
400.00 |
1015 |
Задача 1. Топ-3 аптеки по выручке (базовый GROUP BY)
Категорийному менеджеру нужно понять, какие аптеки приносят основную выручку, это база для инвестиций и масштабирования.
Простой запрос
SELECT p.pharmacy_name, SUM(s.amount) AS total_revenue FROM sales s JOIN pharmacies p ON s.pharmacy_id = p.pharmacy_id GROUP BY p.pharmacy_name ORDER BY total_revenue DESC LIMIT 3;
Результат:
pharmacy_name |
total_revenue |
|---|---|
Столичная |
1210.00 |
Здравсити |
1210.00 |
Доктор Айболит |
1130.00 |
Пример вывода по данным: Вывели три аптеки-лидера по выручке, их стоит рассматривать как пилотные площадки для новых продуктов и маркетинговых экспериментов.
Усложненный вариант: с долей в общей выручке и ранжированием
Но простой топ не показывает картину целиком, добавим оконные функции:
WITH pharmacy_revenue AS ( SELECT p.pharmacy_name, SUM(s.amount) AS revenue, SUM(SUM(s.amount)) OVER() AS total_revenue FROM sales s JOIN pharmacies p ON s.pharmacy_id = p.pharmacy_id GROUP BY p.pharmacy_name ) SELECT pharmacy_name, revenue, ROUND(100.0 * revenue / total_revenue, 2) AS revenue_share_percent, RANK() OVER(ORDER BY revenue DESC) AS rank, CASE WHEN revenue > 0.1 * total_revenue THEN 'Ключевая' ELSE 'Обычная' END AS pharmacy_type FROM pharmacy_revenue ORDER BY revenue DESC;
Результат:
pharmacy_name |
revenue |
share_percent |
rank |
pharmacy_type |
|---|---|---|---|---|
Столичная |
1210.00 |
18.24 |
1 |
Ключевая |
Здравсити |
1210.00 |
18.24 |
1 |
Ключевая |
Доктор Айболит |
1130.00 |
17.04 |
3 |
Ключевая |
Горздрав |
950.00 |
14.33 |
4 |
Ключевая |
Аптека 24 |
700.00 |
10.56 |
5 |
Ключевая |
Пример вывода по данным: Даже пятая аптека дает >10% выручки – все точки важны, нет явных аутсайдеров.
Задача 2. Товары с высоким и низким спросом (анализ популярности)
Нужно выявить товары-локомотивы и товары-аутсайдеры для управления ассортиментом.
Простой запрос (топ товаров)
SELECT pr.product_name, pr.category, SUM(s.quantity) AS total_units, SUM(s.amount) AS total_revenue FROM sales s JOIN products pr ON s.product_id = pr.product_id GROUP BY pr.product_name, pr.category ORDER BY total_units DESC LIMIT 5;
Результат:
product_name |
category |
total_units |
total_revenue |
|---|---|---|---|
Парацетамол |
Жаропонижающие |
18 |
900.00 |
Ибупрофен 200мг |
Обезболивающие |
6 |
900.00 |
Цитрамон П |
Обезболивающие |
8 |
640.00 |
Мезим форте |
Пищеварение |
3 |
660.00 |
Но-шпа |
Спазмолитики |
5 |
900.00 |
Пример вывода по данным: Парацетамол лидирует по количеству, так как это дешевый и ходовой товар, но по выручке обезболивающие дают больше.
Усложненный вариант: ABC-анализ по выручке
Добавим оконные функции для ABC-классификации:
WITH product_sales AS ( SELECT pr.product_id, pr.product_name, pr.category, SUM(s.amount) AS revenue, SUM(SUM(s.amount)) OVER() AS total_revenue, ROW_NUMBER() OVER(ORDER BY SUM(s.amount) DESC) AS revenue_rank FROM sales s JOIN products pr ON s.product_id = pr.product_id GROUP BY pr.product_id, pr.product_name, pr.category ), cumulative_calc AS ( SELECT product_id, product_name, category, revenue, revenue_rank, SUM(revenue) OVER(ORDER BY revenue_rank) AS cumulative_revenue, total_revenue FROM product_sales ) SELECT product_name, category, revenue, ROUND(100.0 * revenue / total_revenue, 2) AS revenue_share, ROUND(100.0 * cumulative_revenue / total_revenue, 2) AS cumulative_share, CASE WHEN cumulative_revenue <= 0.7 * total_revenue THEN 'A' WHEN cumulative_revenue <= 0.9 * total_revenue THEN 'B' ELSE 'C' END AS abc_class FROM cumulative_calc ORDER BY revenue_rank;
Результат (первые строки):
product_name |
category |
revenue |
share |
cumulative |
class |
|---|---|---|---|---|---|
Ибупрофен |
Обезболивающие |
900.00 |
13.57 |
13.57 |
A |
Но-шпа |
Спазмолитики |
900.00 |
13.57 |
27.14 |
A |
Парацетамол |
Жаропонижающие |
900.00 |
13.57 |
40.71 |
A |
Мезим форте |
Пищеварение |
660.00 |
9.95 |
50.66 |
A |
Цитрамон П |
Обезболивающие |
640.00 |
9.65 |
60.31 |
A |
Пример вывода по данным: Топ-5 товаров дают 60% выручки (класс A), это золотой фонд, который должен быть на полке всегда.
Задача 3. Доля категории в общем обороте (структурный анализ)
Руководство хочет понимать, какие категории товаров приносят основную выручку.
Простой запрос
SELECT pr.category, SUM(s.amount) AS category_revenue, COUNT(DISTINCT s.product_id) AS products_in_category FROM sales s JOIN products pr ON s.product_id = pr.product_id GROUP BY pr.category ORDER BY category_revenue DESC;
Результат:
category |
category_revenue |
products_in_category |
|---|---|---|
Обезболивающие |
1540.00 |
2 |
Жаропонижающие |
900.00 |
1 |
Спазмолитики |
900.00 |
1 |
Пищеварение |
660.00 |
1 |
Витамины |
900.00 |
1 |
Усложненный вариант: доля с накоплением и сравнение с количеством товаров
Добавим оконные функции для кумулятивной доли и нормализацию по числу товаров:
WITH category_stats AS ( SELECT pr.category, SUM(s.amount) AS revenue, COUNT(DISTINCT s.product_id) AS products_count, SUM(SUM(s.amount)) OVER() AS total_revenue, SUM(COUNT(DISTINCT s.product_id)) OVER() AS total_products FROM sales s JOIN products pr ON s.product_id = pr.product_id GROUP BY pr.category ), cumulative_calc AS ( SELECT category, revenue, products_count, ROUND(100.0 * revenue / total_revenue, 2) AS revenue_share, ROUND(100.0 * products_count / total_products, 2) AS products_share, SUM(revenue) OVER(ORDER BY revenue DESC) / total_revenue * 100 AS cumulative_share FROM category_stats ) SELECT category, revenue, revenue_share, products_count, products_share, ROUND(cumulative_share, 2) AS cumulative_share, CASE WHEN revenue_share > products_share * 2 THEN 'Сверхэффективная' WHEN revenue_share > products_share THEN 'Эффективная' ELSE 'Неэффективная' END AS efficiency FROM cumulative_calc ORDER BY revenue DESC;
Результат:
category |
revenue |
revenue_share |
products_count |
products_share |
cumulative_share |
efficiency |
|---|---|---|---|---|---|---|
Обезболивающие |
1540.00 |
23.22 |
2 |
25.00 |
23.22 |
Эффективная |
Жаропонижающие |
900.00 |
13.57 |
1 |
12.50 |
36.79 |
Эффективная |
Спазмолитики |
900.00 |
13.57 |
1 |
12.50 |
50.36 |
Эффективная |
Витамины |
900.00 |
13.57 |
1 |
12.50 |
63.93 |
Эффективная |
Пищеварение |
660.00 |
9.95 |
1 |
12.50 |
73.88 |
Неэффективная |
Пример вывода по данным: Категория Пищеварение дает меньшую долю выручки, чем должна по количеству товаров – возможно, проблемы с ценообразованием или выкладкой .
Задача 4. Кумулятивные продажи с детализацией (динамика во времени)
Финансовому директору нужен отчет о накопленной выручке, чтобы видеть, как выполняются планы.
Простой запрос (кумулятивные продажи по аптекам)
SELECT s.pharmacy_id, p.pharmacy_name, s.sale_date, s.amount, SUM(s.amount) OVER(PARTITION BY s.pharmacy_id ORDER BY s.sale_date) AS cumulative_revenue FROM sales s JOIN pharmacies p ON s.pharmacy_id = p.pharmacy_id ORDER BY p.pharmacy_name, s.sale_date;
Усложненный вариант: скользящее среднее, сравнение с прошлым периодом и прогноз
Добавим полноценный временной анализ с lag-функциями и скользящим средним:
WITH daily_sales AS ( -- Агрегируем продажи по дням SELECT s.sale_date, SUM(s.amount) AS daily_revenue, COUNT(DISTINCT s.pharmacy_id) AS active_pharmacies, COUNT(DISTINCT s.customer_id) AS unique_customers FROM sales s GROUP BY s.sale_date ), sales_with_metrics AS ( SELECT sale_date, daily_revenue, active_pharmacies, unique_customers, -- Скользящее среднее за 3 дня AVG(daily_revenue) OVER(ORDER BY sale_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS moving_avg_3d, -- Выручка за предыдущий день LAG(daily_revenue) OVER(ORDER BY sale_date) AS prev_day_revenue, -- Накопленная выручка с начала месяца SUM(daily_revenue) OVER(PARTITION BY DATE_TRUNC('month', sale_date) ORDER BY sale_date) AS month_cumulative, -- Средний чек в день daily_revenue / NULLIF(unique_customers, 0) AS avg_receipt FROM daily_sales ), growth_analysis AS ( SELECT sale_date, daily_revenue, ROUND(moving_avg_3d, 2) AS moving_avg_3d, ROUND(100.0 * (daily_revenue - prev_day_revenue) / NULLIF(prev_day_revenue, 0), 2) AS daily_growth_percent, month_cumulative, ROUND(avg_receipt, 2) AS avg_receipt, -- Прогноз на следующий день (простое экспоненциальное сглаживание) ROUND(0.3 * daily_revenue + 0.7 * moving_avg_3d, 2) AS forecast_next_day FROM sales_with_metrics ) SELECT sale_date, daily_revenue, moving_avg_3d, daily_growth_percent, month_cumulative, avg_receipt, forecast_next_day, CASE WHEN daily_growth_percent > 20 THEN '? Резкий рост' WHEN daily_growth_percent < -20 THEN '? Резкое падение' ELSE '➡️ Стабильно' END AS trend FROM growth_analysis ORDER BY sale_date;
Результат:
sale_date |
daily_revenue |
moving_avg_3d |
growth |
month_cumulative |
avg_receipt |
forecast |
trend |
|---|---|---|---|---|---|---|---|
2026-02-01 |
2680.00 |
2680.00 |
NULL |
2680.00 |
382.86 |
2680.00 |
➡️ Стабильно |
2026-02-02 |
2670.00 |
2675.00 |
-0.37 |
5350.00 |
296.67 |
2673.50 |
➡️ Стабильно |
2026-02-03 |
1720.00 |
2356.67 |
-35.58 |
7070.00 |
573.33 |
2112.67 |
? Резкое падение |
2026-02-04 |
400.00 |
1593.33 |
-76.74 |
7470.00 |
400.00 |
957.33 |
? Резкое падение |
Пример вывода по данным:
3 февраля резкое падение выручки (-35% к предыдущему дню) возможно, выходной или проблемы с поставками. Средний чек при падении вырос (573 руб. против 296) значит, покупателей стало меньше, но те, кто пришел, купили больше. Прогноз на 5 февраля около 957 руб., стоит проверить, сбудется ли на будущих данных по выручке для необходимости корректировки логики расчета прогноза.
Заключение
SQL в аналитике – это не просто достать данные, это инструмент, который помогает собирать уже готовые отчеты, чтобы понять, например:
- структуру бизнеса (кто приносит деньги)
- как лучше управлять ассортиментом (что оставить, что убрать)
- динамику и наличие аномалий (где провалы и взлеты)
Оконные функции и CTE превращают SQL из просто калькулятора в полноценный аналитический инструмент, освоив их, вы сможете отвечать на сложные бизнес-вопросы без выгрузки данных в Excel.
?Еще больше про будни и задачи аналитика данных в моем тг канале ?Таня и Данные?
Комментарии (6)

unfilled
04.03.2026 05:05Пример вывода по данным: Даже пятая аптека дает >10% выручки – все точки важны, нет явных аутсайдеров.
А если есть аптеки, у которых вообще нет продаж, менеджер про это и не узнает, потому что использовал inner join.

TanyaVSdannye Автор
04.03.2026 05:05Все так, но задача вывести «Топ-3 аптеки по выручке» - в этом случае и не нужно учитывать тех, у кого нет выручки

unfilled
04.03.2026 05:05А как в топ-3 попала "даже пятая аптека"? Речь о том, что в "расширенной" версии запроса вполне могут быть аптеки, которые не продали ничего.

TanyaVSdannye Автор
04.03.2026 05:05Ключевое, кажется, то, что выводим топ по выручке.
Хотя бы правы, может быть важным знаком полное ее отсутствие, если полное отсутствие выручки в целом вообще возможно - стоит его учесть.
Спасибо!
Akina
Я бы всё же использовал WITH TIES. И подогнал данные так, чтобы показать, что это даёт.
Из полученных данных этого не видно без дополнительного расчёта в уме. Следовало бы применить WITH ROLLUP.
TanyaVSdannye Автор
Хороший комментарий, действительно можно добавить подгонку, спасибо!