Одинаковые слоты в онлайн-записи — это скрытая переработка: считаем на симуляции

от автора

У нас сервисная сеть: тридцать две точки, семь городов, порядка ста десяти тысяч обслуженных клиентов в прошлом году. Клиент попадает к нам через шесть входов — форма на сайте, карточки в картографических сервисах, соцсети и телефон администратора, — и вот что мы увидели, когда впервые посмотрели на распределение по времени суток: около пятнадцати процентов броней ставится ночью, при закрытом салоне, и ещё примерно треть приходит с мобильных. Складывается почти половина записей, которые человек на нашей стороне не создаёт и не проверяет: клиент сам тыкает в свободную клетку сетки.

Это и есть место, где мы наступили на грабли, о которых дальше пойдёт речь. Сетка у нас, как почти у всех сервисов, была равномерная — слоты по два часа. Выглядит логично: удобно рисовать, удобно объяснять, удобно считать. Только в сервисе, где длительность обслуживания отличается в четыре раза от клиента к клиенту, равномерная сетка работает как сглаживание: она не убирает разброс, а прячет его — и он вылезает переработкой мастеров и дырами в расписании.

Ниже — модель этой задачи, симуляция на 2000 дней и числа, которые из неё выпадают. Скрипт целиком в конце, менять там надо ровно шесть констант.

Почему слот не равен слоту

Первое, что удивляет людей со стороны: длительность визита в груминге почти не зависит от размера животного. Она зависит от структуры волоса.

Гладкошёрстную таксу нужно помыть, высушить и сделать гигиену — сорок пять минут. Померанского шпица того же веса нужно вычесать, промыть в два захода, высушить с одновременным вытягиванием волоса и только потом оформить — почти три часа, причём сушка занимает больше времени, чем работа ножницами. Пуделя строят ножницами по всей площади: полтора-два с половиной часа в зависимости от стрижки.

Вот наблюдаемые средние по нашим салонам и доли этих типов в потоке записей:

Длительность визита задаёт структура волоса, а не размер животного: разброс от 45 до 165 минут при одном и том же двухчасовом слоте

Длительность визита задаёт структура волоса, а не размер животного: разброс от 45 до 165 минут при одном и том же двухчасовом слоте

Те же числа лежат в константе ТИПЫ в коде ниже — их удобно скопировать и заменить своими.

Средневзвешенная длительность визита — 116 минут, то есть двухчасовой слот выбран не с потолка: в среднем он подходит. Но средний модуль расхождения — 33 минуты на каждую запись. Половина визитов не добирает до слота, половина из него вылезает, и «в среднем сходится» здесь — ровно та формулировка, из-за которой задачу и не замечают.

Модель

Симулируем рабочий день салона.

  • Три мастера, смена двенадцать часов (10:00–22:00).

  • Поток заявок за день задаём числом: 12, 15 или 18.

  • Тип каждой заявки разыгрывается по долям из таблицы выше.

  • Фактическая длительность визита — нормальная величина вокруг среднего типа, усечённая снизу третью среднего и сверху двумя средними: собака попалась спокойная или, наоборот, всё оказалось хуже, чем на осмотре.

  • Заявка уходит к мастеру с наименьшей забронированной загрузкой; если слот не влезает в смену, запись не принимается.

Сравниваем две сетки:

A. Равные слоты по два часа. Клиент выбирает любой свободный двухчасовой слот независимо от того, кого он привезёт.

B. Слот по типу шерсти. Длительность подставляется системой в момент записи из пары «услуга + порода» и округляется вверх до пятнадцати минут.

Метрики считаем три:

  • загрузка — доля полезной работы в фонде рабочего времени смены;

  • простой — минуты внутри смены, когда мастер свободен, потому что следующий слот ещё не начался;

  • переработка — минуты за пределами смены, когда мастер доделывает начатое.

Последняя метрика и есть то, ради чего всё считалось: в сервисе она оплачивается не деньгами, а текучкой.

Результаты

Один и тот же сид, один и тот же поток заявок, разные сетки. 2000 прогонов на каждую точку.

Заявок в день

Сетка

Загрузка

Простой

Переработка

Принято

Отказ

Дней с переработкой

12

A. равные 2 ч

63,9%

153 мин

0,5 мин

12,0

0

2%

12

B. по типу

63,9%

70 мин

0,2 мин

12,0

0

1%

15

A. равные 2 ч

80,1%

171 мин

17 мин

15,0

0

27%

15

B. по типу

79,8%

78 мин

7 мин

14,96

0,04

16%

18

A. равные 2 ч

96,1%

195 мин

110 мин

18,0

0

78%

18

B. по типу

90,6%

82 мин

29 мин

17,14

0,86

49%

Что здесь важно.

Куда уходит смена при пятнадцати заявках: загрузка в обоих режимах одинаковая, а простой и переработка отличаются вдвое и втрое

Куда уходит смена при пятнадцати заявках: загрузка в обоих режимах одинаковая, а простой и переработка отличаются вдвое и втрое

Загрузка почти не отличается. Именно поэтому задача годами не видна из отчётов: если смотреть на процент занятости мастеров — а это первое, что показывает любая система записи, — две сетки выглядят одинаково. Разница прячется в двух других колонках.

Простой отличается вдвое с лишним. 153 минуты против 70 при двенадцати заявках, 171 против 78 при пятнадцати. Это не абстракция: это два часа в день, за которые мастеру платят и в которые он ничего не делает, потому что следующая запись начнётся в 16:00, а он освободился в 15:05.

Переработка отличается в разы. При пятнадцати заявках — 17 минут против 7 в день, при восемнадцати — 110 против 29. И, что важнее среднего, меняется частота: на плотном дне равная сетка даёт переработку в 78% дней против 49%.

И самое неприятное. При восемнадцати заявках сетка B отказывает в среднем 0,9 заявки в день, а сетка A — ни одной. Со стороны бизнеса это выглядит как проигрыш: одна потерянная запись. На деле это честность: заявка, которую сетка B не приняла, в сетке A принимается и превращается в два часа переработки. Пропускную способность равномерная сетка не увеличивает: она лишь перекладывает превышение на людей.

Причём тут ночные записи

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

Отсюда практическое требование к системе, которое звучит скучно, а стоит дорого: длительность должна выводиться из услуги и породы в момент выбора слота, а не подставляться администратором постфактум. Иначе ночной клиент выбирает двухчасовое окно под самоеда, и утром администратор либо звонит и переносит, либо оставляет как есть и закладывает переработку.

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

Чего модель не умеет

Раздел, ради которого стоит читать любую симуляцию.

Нет очереди с ожиданием. У нас запись, а не живая очередь: заявка либо помещается в сетку, либо не принимается. Классическая теория массового обслуживания сюда прикладывается плохо — там клиент ждёт, здесь он уходит.

Нет перерывов, уборки и дезинфекции между животными. В реальности это 10–15 минут, которые съедают часть «простоя» из таблицы. То есть разрыв между сетками в жизни меньше, чем в модели, — но он не исчезает, а сдвигается.

Нет распределения заявок по времени дня. У нас поток равномерный, а в жизни он двугорбый: утро и вечер плотнее середины дня. Это скорее усиливает эффект, потому что переработка накапливается именно на вечернем горбу.

Нет привязки мастера к типу. В модели любой мастер делает любую собаку с одинаковым средним. В салоне это не так: у кого-то быстрее идут пудели, у кого-то кошки.

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

Код целиком

import randomimport statisticsfrom dataclasses import dataclassСИД = 20260917МАСТЕРОВ = 3СМЕНА = 12 * 60        # минутДНЕЙ = 2000РАВНЫЙ_СЛОТ = 120      # «универсальная» сетка в режиме AОКРУГЛЕНИЕ = 15        # шаг сетки в режиме B# тип → (среднее время визита, стандартное отклонение, доля в потоке)ТИПЫ = {    "ниспадающая": (120, 25, 0.30),    "объёмная":    (150, 30, 0.14),    "двойная":     (165, 45, 0.18),    "жёсткая":     (150, 30, 0.06),    "гладкая":      (45, 10, 0.14),    "кошки":        (75, 20, 0.18),}@dataclassclass День:    работа: float    простой: float    переработка: float    принято: int    отказано: intdef длительность(тип, rnd):    среднее, разброс, _ = ТИПЫ[тип]    v = rnd.gauss(среднее, разброс)    return max(среднее / 3, min(среднее * 2, v))def поток(rnd, заявок):    типы = list(ТИПЫ)    веса = [ТИПЫ[т][2] for т in типы]    return rnd.choices(типы, weights=веса, k=заявок)def слот_равный(_тип):    return РАВНЫЙ_СЛОТdef слот_по_типу(тип):    среднее = ТИПЫ[тип][0]    return -(-среднее // ОКРУГЛЕНИЕ) * ОКРУГЛЕНИЕdef прожить_день(rnd, заявки, слот):    план = [0] * МАСТЕРОВ    факт = [0.0] * МАСТЕРОВ    принято = отказано = 0    for тип in заявки:        нужно = слот(тип)        i = min(range(МАСТЕРОВ), key=lambda k: план[k])        if план[i] + нужно > СМЕНА:            отказано += 1            continue        план[i] += нужно        факт[i] += длительность(тип, rnd)        принято += 1    конец = [max(п, ф) for п, ф in zip(план, факт)]    return День(        работа=sum(факт),        простой=sum(max(0.0, min(к, СМЕНА) - ф) for к, ф in zip(конец, факт)),        переработка=sum(max(0.0, к - СМЕНА) for к in конец),        принято=принято,        отказано=отказано,    )def прогон(слот, заявок_в_день):    rnd = random.Random(СИД)    дни = [прожить_день(rnd, поток(rnd, заявок_в_день), слот) for _ in range(ДНЕЙ)]    ёмкость = МАСТЕРОВ * СМЕНА    return {        "загрузка": statistics.fmean(д.работа for д in дни) / ёмкость,        "простой": statistics.fmean(д.простой for д in дни),        "переработка": statistics.fmean(д.переработка for д in дни),        "принято": statistics.fmean(д.принято for д in дни),        "отказано": statistics.fmean(д.отказано for д in дни),        "дни_с_переработкой": statistics.fmean(д.переработка > 0 for д in дни),    }if __name__ == "__main__":    for заявок in (12, 15, 18):        for имя, слот in (("A", слот_равный), ("B", слот_по_типу)):            р = прогон(слот, заявок)            print(заявок, имя, {к: round(v, 2) for к, v in р.items()})

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

Что мы поменяли у себя

Три вещи, в порядке отдачи.

Длительность выводится из пары «услуга + порода» в момент записи. Клиент видит не «свободно с 14:00 до 16:00», а конкретное окно нужной длины. Это единственное изменение, которое вообще что-то дало на потоке ночных записей.

Сетка стала пятнадцатиминутной. Не потому, что кто-то записывается на 14:15, а потому что на такой сетке округление вверх стоит в среднем семь минут вместо получаса.

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

Вывод

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

Мы ожидали, что выигрыш будет в пропускной способности. Его там нет — принять больше записей за смену не получилось. Выигрыш оказался в другом месте: люди перестали задерживаться после смены, а свободные часы внутри дня перестали быть случайными. Для бизнеса, где себестоимость услуги — это час мастера, второе важнее первого.

ссылка на оригинал статьи https://habr.com/ru/articles/1084476/