Перейти к содержимому

ABC-анализ закупок по сумме

Вопрос, с которого начинается почти любой spend-анализ: на что уходят деньги. Отчёт по всем девяти категориям сразу бесполезен — в нём нет приоритета. ABC расставляет категории по вкладу в затраты и показывает, где заканчиваются деньги, которые стоит контролировать индивидуально.

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

Нужна одна таблица — purchases. Одна строка — один заказ, поле amount — его сумма. Позиции заказов здесь не нужны: сумма заказа уже собрана из них, и соединение с purchase_lines умножит каждую строку на число позиций.

Все 400 заказов идут в расчёт. Статус не фильтруем: заказ, который ещё не закрыт, всё равно связывает деньги. Чем это грозит при фильтрации — в разделе про ошибки.

Сумма по категории, доля от итога и накопленная доля. Накопленную считает оконная функция: она складывает суммы от самой крупной категории вниз.

WITH cat AS (
SELECT category, count(*) AS orders, sum(amount) AS spend
FROM purchases
GROUP BY category
),
tot AS (SELECT sum(spend) AS total FROM cat)
SELECT category AS "категория",
orders AS "заказов",
round(spend / 1000000, 1) AS "млн ₽",
round(100 * spend / (SELECT total FROM tot), 1) AS "доля, %",
round(100 * sum(spend) OVER (ORDER BY spend DESC, category)
/ (SELECT total FROM tot), 1) AS "накопл., %"
FROM cat
ORDER BY spend DESC, category;
┌─────────────────┬─────────┬───────┬─────────┬────────────┐
│ категория │ заказов │ млн ₽ │ доля, % │ накопл., % │
├─────────────────┼─────────┼───────┼─────────┼────────────┤
│ Металлопрокат │ 72 │ 130.0 │ 46.8 │ 46.8 │
│ ИТ-оборудование │ 48 │ 52.5 │ 18.9 │ 65.7 │
│ Электрика │ 48 │ 30.9 │ 11.1 │ 76.8 │
│ Логистика │ 48 │ 20.2 │ 7.3 │ 84.0 │
│ Спецодежда │ 40 │ 12.8 │ 4.6 │ 88.6 │
│ Крепёж │ 48 │ 11.5 │ 4.2 │ 92.8 │
│ Химия и ГСМ │ 24 │ 9.1 │ 3.3 │ 96.1 │
│ Упаковка │ 48 │ 8.7 │ 3.1 │ 99.2 │
│ Канцелярия │ 24 │ 2.3 │ 0.8 │ 100.0 │
└─────────────────┴─────────┴───────┴─────────┴────────────┘
  • Одна категория — почти половина бюджета. Металлопрокат даёт 130,0 млн ₽ из 277,9 млн ₽, или 46,8%. Один процент цены на прокат — это 1,3 млн ₽, и он важнее любой экономии в хвосте.
  • Первые три категории — 76,8%. Металлопрокат, ИТ-оборудование и Электрика забирают три четверти затрат. Это и есть граница, за которой начинается индивидуальная работа с поставщиком и ценой.
  • Число заказов не показывает деньги. У Упаковки 48 заказов — столько же, сколько у ИТ-оборудования, но 8,7 млн ₽ против 52,5 млн ₽. Если сортировать по количеству заказов, приоритеты получатся другими и неверными.
  • Хвост короткий, но не пустой. Три последние категории — 7,2% затрат, накопленная доля идёт от 96,1 к 100,0. Управлять ими индивидуально дороже, чем они приносят: это работа правилами и каталогом.
  • Накопленный процент не складывается из округлённых долей. 76,8 + 7,3 даёт 84,1, а в таблице 84,0: накопленная сумма считается по неокруглённым значениям, а доли показаны с одним знаком. Расхождение в 0,1 — это округление, а не ошибка расчёта, но именно из-за него отчёты не сходятся с калькулятором.

Границы групп задают заранее. Распространённая схема: A — до 80% накопленной доли, B — до 95%, C — остаток. Проверим, сколько категорий попадает в каждый класс:

WITH cat AS (
SELECT category, sum(amount) AS spend FROM purchases GROUP BY category
),
calc AS (
SELECT category,
sum(spend) OVER (ORDER BY spend DESC, category) AS running,
sum(spend) OVER () AS total
FROM cat
),
cls AS (
SELECT category,
CASE WHEN 100 * running / total <= 80 THEN 'A'
WHEN 100 * running / total <= 95 THEN 'B'
ELSE 'C' END AS abc
FROM calc
)
SELECT abc AS "класс", count(*) AS "категорий",
string_agg(category, ', ' ORDER BY category) AS "категории"
FROM cls GROUP BY abc ORDER BY abc;
┌───────┬───────────┬───────────────────────────────────────────┐
│ класс │ категорий │ категории │
├───────┼───────────┼───────────────────────────────────────────┤
│ A │ 3 │ ИТ-оборудование, Металлопрокат, Электрика │
│ B │ 3 │ Крепёж, Логистика, Спецодежда │
│ C │ 3 │ Канцелярия, Упаковка, Химия и ГСМ │
└───────┴───────────┴───────────────────────────────────────────┘

Треть категорий даёт 76,8% денег, треть — 16,0%, треть — 7,2%. Ровное деление 3/3/3 здесь случайность конкретного датасета, а не правило: в живых данных в группу C обычно попадает большинство строк.

Первая и самая дорогая — фильтр по статусу. Заказы за ноябрь и декабрь 2025 года ещё не закрыты, и условие status = 'исполнен' вырезает их целиком:

SELECT round(sum(amount)/1000000, 1) AS "все заказы, млн ₽",
round(sum(amount) FILTER (WHERE status = 'исполнен')/1000000, 1) AS "только исполненные, млн ₽",
count(*) FILTER (WHERE status = 'в работе') AS "заказов в работе"
FROM purchases;
┌───────────────────┬───────────────────────────┬──────────────────┐
│ все заказы, млн ₽ │ только исполненные, млн ₽ │ заказов в работе │
├───────────────────┼───────────────────────────┼──────────────────┤
│ 277.9 │ 252.5 │ 33 │
└───────────────────┴───────────────────────────┴──────────────────┘

9,1% бюджета исчезает, и вместе с ним — два последних месяца: на графике динамики линия просто обрывается. Если статус нужен, задавайте условие через исключение, а не через единственное значение: иначе новые статусы будут молча выпадать из отчёта.

Ошибка Что происходит Как правильно
Сортировать по числу заказов, а называть отчёт ABC по деньгам У Металлопроката 72 заказа и 130,0 млн ₽, у Упаковки 48 заказов и 8,7 млн ₽: заказов в 1,5 раза меньше, денег — в 15 раз Метрика одна и названа в заголовке отчёта
Считать накопленную долю от максимальной суммы Последняя строка даёт не 100%, а другое число Знаменатель — общая сумма всех категорий
Соединять purchases с purchase_lines и суммировать amount Каждый заказ считается столько раз, сколько у него позиций: 595,0 млн ₽ вместо 277,9 млн ₽ Суммировать заказы до соединения
Брать границы 80/15/5 как норму Группы получаются несопоставимыми между отчётами: два аналитика с разными границами дадут разные A Границы фиксируют в методике до расчёта
Сравнивать ABC за разные периоды без пересчёта Сезонная категория переезжает из C в A и обратно Сравнивать на одном окне и одной метрике