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

Время цикла закупки: медиана и хвост

«Средний срок закупки — 30 дней» — фраза, которая ничего не описывает, если половина заказов закрывается за 20 дней, а каждый десятый тянется три месяца. Среднее складывает быстрые и медленные заказы и показывает число, которого не было ни у одного заказа.

Поэтому время цикла смотрят распределением: медианой (типичный случай) и перцентилями (хвост). Медиана устойчива к выбросам, p90 показывает, сколько ждёт каждый десятый заказ, p99 — крайние случаи, из-за которых срываются сроки.

В датасете две даты: bids.bid_date — дата предложения поставщика и purchases.order_date — дата заказа. Разница между самой ранней датой предложения и датой заказа — это время от появления первого предложения до принятия решения.

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

purchases и bids, соединённые по order_id. Цикл считается на заказ, поэтому предложения сначала сворачиваются: min(bid_date) на заказ. Десять заказов без предложений в расчёт не попадают — у них нет начальной точки. Их судьба разобрана ниже.

WITH c AS (
SELECT p.id, p.order_date - min(b.bid_date) AS days
FROM purchases p
JOIN bids b ON b.order_id = p.id
GROUP BY p.id, p.order_date
)
SELECT count(*) AS "заказов",
round(avg(days), 1) AS "среднее, дней",
round(percentile_cont(0.5) WITHIN GROUP (ORDER BY days), 1) AS "медиана",
round(percentile_cont(0.9) WITHIN GROUP (ORDER BY days), 1) AS "p90",
round(percentile_cont(0.99) WITHIN GROUP (ORDER BY days), 1) AS "p99",
max(days) AS "максимум"
FROM c;
┌─────────┬───────────────┬─────────┬──────┬───────┬──────────┐
│ заказов │ среднее, дней │ медиана │ p90 │ p99 │ максимум │
├─────────┼───────────────┼─────────┼──────┼───────┼──────────┤
│ 390 │ 30.3 │ 20.5 │ 57.0 │ 208.2 │ 239 │
└─────────┴───────────────┴─────────┴──────┴───────┴──────────┘

Медиана 20,5 дня, среднее 30,3 — на 48% больше. Разрыв означает, что быстрых заказов много, но их перевес не компенсирует длинный хвост: он и тянет среднее вверх.

p90 равен 57 дням: каждый десятый заказ идёт почти в три раза дольше типичного. p99 — 208 дней, максимум — 239. Это не единичная ошибка ввода: заказы длиннее 150 дней есть в семи категориях из девяти, и именно они определяют, попадёт ли поставка в срок.

Для управления этого достаточно. Медиана — то, что можно обещать в регламенте. p90 — то, что нужно объяснять. p99 — то, что нужно разбирать по одному заказу.

WITH c AS (
SELECT p.id, p.category, p.order_date - min(b.bid_date) AS days
FROM purchases p JOIN bids b ON b.order_id = p.id
GROUP BY p.id, p.category, p.order_date
)
SELECT category AS "категория", count(*) AS "заказов",
round(percentile_cont(0.5) WITHIN GROUP (ORDER BY days), 0) AS "медиана",
round(percentile_cont(0.9) WITHIN GROUP (ORDER BY days), 0) AS "p90",
max(days) AS "максимум"
FROM c GROUP BY 1 ORDER BY 3 DESC;
┌─────────────────┬─────────┬─────────┬───────┬──────────┐
│ категория │ заказов │ медиана │ p90 │ максимум │
├─────────────────┼─────────┼─────────┼───────┼──────────┤
│ Упаковка │ 47 │ 30.0 │ 56.0 │ 207 │
│ Спецодежда │ 39 │ 26.0 │ 62.0 │ 222 │
│ Канцелярия │ 24 │ 26.0 │ 132.0 │ 239 │
│ Крепёж │ 47 │ 24.0 │ 53.0 │ 233 │
│ Химия и ГСМ │ 22 │ 22.0 │ 56.0 │ 163 │
│ Электрика │ 47 │ 21.0 │ 55.0 │ 155 │
│ Металлопрокат │ 71 │ 20.0 │ 51.0 │ 63 │
│ Логистика │ 48 │ 18.0 │ 51.0 │ 62 │
│ ИТ-оборудование │ 45 │ 14.0 │ 59.0 │ 157 │
└─────────────────┴─────────┴─────────┴───────┴──────────┘

Медианы расходятся в два раза: 14 дней у ИТ-оборудования и 30 у Упаковки. Вероятное объяснение: там, где поставщик один и спецификация описана, решение принимают быстро; там, где рынок шире, дольше собирают предложения.

Но смотреть надо не только на медиану. У Канцелярии медиана 26 дней, а p90 — 132: каждый десятый заказ идёт в пять раз дольше типичного. У Металлопроката при медиане 20 дней максимум 63 — хвоста почти нет. Это два разных процесса, хотя медианы близки: в первом случае есть проблема, которую не видно по среднему, во втором процесс предсказуем.

Отсюда правило: категорию с высоким p90 при нормальной медиане надо разбирать отдельно. Там обычно прячутся согласования, которые запускаются не всегда, а по условию — например, при сумме выше порога.

SELECT p.category AS "категория", count(*) AS "заказов без предложений"
FROM purchases p
WHERE NOT EXISTS (SELECT 1 FROM bids b WHERE b.order_id = p.id)
GROUP BY 1 ORDER BY 2 DESC, 1;
┌─────────────────┬─────────────────────────┐
│ категория │ заказов без предложений │
├─────────────────┼─────────────────────────┤
│ ИТ-оборудование │ 3 │
│ Химия и ГСМ │ 2 │
│ Крепёж │ 1 │
│ Металлопрокат │ 1 │
│ Спецодежда │ 1 │
│ Упаковка │ 1 │
│ Электрика │ 1 │
└─────────────────┴─────────────────────────┘

Десять заказов прошли без предложений: у них нет ни одной строки в bids. В расчёт времени цикла они не попали, и это правильно — считать их «быстрыми» было бы ошибкой: у них не было этапа, а не быстрый этап.

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

Ошибка Что происходит Как правильно
Показывать среднее время цикла Среднее выше медианы на 48% и не описывает типичный заказ Медиана как основной показатель, среднее — справочно
Считать цикл по одной дате предложения, а не по минимальной Если взять, например, максимум, получится время до последнего предложения Начало цикла — самое раннее предложение
Включать заказы без предложений с нулевым циклом Медиана падает, картина улучшается на пустом месте Исключать и показывать отдельно
Считать среднее по категориям, а потом усреднять категории Мелкая категория весит столько же, сколько крупная Считать по заказам или взвешивать по объёму
Сравнивать цикл «от предложения» с циклом «от заявки» Разные отрезки, цифры несопоставимы Фиксировать начало и конец отрезка в описании метрики