Отто · операционный директор доставки «Гонец»
Курьеры пишут в чат, что весь день в мыле. А я смотрю на карту и вижу, что полсмены они стоят у кафе. Сколько времени курьер реально везёт заказ? И сколько из смены он вообще с заказом в руках?
В прошлых уроках мы разобрались с часовыми поясами, научились резать время на часы и дни и находить дыры в календаре. Всё это были вопросы про один момент: когда случился заказ. Отто спрашивает про другое: сколько прошло между двумя моментами. Сегодня считаем длительности.
Секундомер
Представь, что у каждого курьера на руле секундомер. Забрал пакет в кафе, нажал «старт». Отдал клиенту, нажал «стоп». Длительность это разница двух показаний.
В базе «Гонца» обе кнопки уже нажаты: picked_up_at это момент забора, delivered_at это момент доставки. Оба поля пишет приложение курьера, оба timestamptz. Раз пояс у них общий, их можно вычитать напрямую:
SELECT id, picked_up_at, delivered_at,
delivered_at - picked_up_at AS ride
FROM orders
WHERE status = 'delivered'
ORDER BY id
LIMIT 3;
id | picked_up_at | delivered_at | ride
----+------------------------+------------------------+----------
1 | 2026-03-01 04:18:38+00 | 2026-03-01 04:34:21+00 | 00:15:43
2 | 2026-03-01 04:14:10+00 | 2026-03-01 04:36:56+00 | 00:22:46
3 | 2026-03-01 04:22:59+00 | 2026-03-01 04:47:22+00 | 00:24:23
Разность двух моментов это интервал: 15 минут 43 секунды. Первый заказ курьер вёз чуть больше четверти часа.
Вычитать можно только моменты из одного мира. created_at у заказа записан сайтом в местном времени Екатеринбурга, без пояса. Если вычесть его из delivered_at напрямую, PostgreSQL примет местное время за UTC, и каждый заказ «доставят» на пять часов раньше. Как это чинить через AT TIME ZONE, мы разбирали в уроке про пояса.
Из интервала в минуты
Интервалы можно складывать и усреднять: avg(delivered_at - picked_up_at) вернёт что-то вроде 00:17:12.4. Человеку так читать легко, а делить, округлять и сравнивать с порогом трудно. Поэтому интервал обычно переводят в число. extract(epoch FROM интервал) даёт длительность в секундах, делим на 60 и получаем минуты:
SELECT count(*) AS deliveries,
round(avg(extract(epoch FROM delivered_at - picked_up_at) / 60), 1) AS avg_ride_min
FROM orders
WHERE status = 'delivered'
AND delivered_at IS NOT NULL;
deliveries | avg_ride_min
------------+--------------
39343 | 17.2
В среднем курьер везёт заказ 17 минут. Но среднее, как мы помним по чекам в Пограничье, легко тянут вверх редкие длинные поездки. Самая долгая в базе длится 3 часа 43 минуты: пешком почти 18 км. Таким значениям нужна мера, которой они не страшны.
Медиана
Выстрой все поездки по длительности и возьми ту, что ровно посередине. Половина поездок короче неё, половина длиннее. Это медиана. Одна поездка на 3 часа сдвинет её на одну позицию, а не на минуты.
В PostgreSQL медиану считает агрегат percentile_cont(0.5). Синтаксис у него непривычный: что сортировать, пишут отдельно, в WITHIN GROUP (ORDER BY …).
SELECT c.transport,
round(avg(extract(epoch FROM o.delivered_at - o.picked_up_at) / 60), 1) AS avg_min,
round((percentile_cont(0.5) WITHIN GROUP (
ORDER BY extract(epoch FROM o.delivered_at - o.picked_up_at) / 60))::numeric, 1) AS median_min
FROM orders o
JOIN couriers c ON c.id = o.courier_id
WHERE o.status = 'delivered'
AND o.delivered_at IS NOT NULL
GROUP BY c.transport
ORDER BY c.transport;
transport | avg_min | median_min
-----------+---------+------------
bike | 17.3 | 15.4
car | 11.8 | 10.5
foot | 27.0 | 22.4
percentile_cont возвращает double precision, а round с числом знаков работает только с numeric. Отсюда ::numeric перед округлением. Медиана везде ниже среднего: длинные поездки есть у всех, но их мало. Пеший курьер везёт заказ вдвое дольше машины, хотя диспетчер и так отдаёт ему короткие заказы.
урок из курса
Урок из курса «Аналитика данных с нуля». Курс покупается один раз и остаётся навсегда: 500 ₽.
Оплата пока не подключена: премиум и курсы открываются промокодом.