Когда интернет-магазин вырастает до нескольких складов и десятков магазинов, выбор точки отгрузки для каждого заказа (order sourcing) перестаёт быть тривиальной задачей. Отправить с ближайшего склада — значит быстро выбить его мощности и остатки. Отправить с самого дешёвого по тарифу — создать многодневную очередь на сборке. Собрать заказ из двух мест — заплатить дважды за доставку и расстроить клиента.
Проверить новый алгоритм маршрутизации на живом бизнесе сложно и дорого: сегодня пошёл дождь, завтра началась распродажа, послезавтра поставщик задержал фуру — и вот уже непонятно, почему выросли расходы. Чтобы испытывать алгоритмы в контролируемых условиях, я написал osbench — дискретно-событийный симулятор фулфилмент-сети.
Внутри симулятора течёт виртуальное время с точностью до минуты. Покупатели в разных городах собирают корзины, витрина обещает им дату доставки, антифрод проверяет карты, система управления заказами (OMS) ищет склад, кладовщики выходят на смены и собирают коробки по волнам, перевозчики забирают паллеты по расписанию, а отдел закупок пополняет полки.
osbench studio. Слева параметры по разделам, в центре описание сценария и карта, справа схема жизненного цикла заказа. Сверху выбор политик, сида и кнопка запуска.Как устроен эксперимент и почему всё сводится к деньгам
В основе движка лежит очередь событий: «создан заказ», «закончилась проверка оплаты», «сформирована волна сборки», «пришла поставка». Правила этого мира целиком задаются в YAML-конфиге или через визуальный интерфейс osbench studio.
Главная особенность симулятора — детерминированная работа со случайностью. Мастер-сид раскладывается на независимые генераторы случайных чисел: отдельно для географии, каталога, прихода заказов, сроков поставки, скорости сборки, ошибок учёта и поведения клиентов. Каждое случайное событие привязано к ключу конкретной сущности. Если два разных алгоритма выбора склада запускаются на одном сиде, они видят одних и тех же покупателей с теми же корзинами в те же минуты. Даже если один алгоритм отправил заказ в Казань, а другой в Москву, реакция клиента на опоздание или задержка поставщика на 14-й день будут идентичны. Любая разница в итоговых цифрах объясняется решениями алгоритма.
Сравнивать алгоритмы по одной операционной метрике неудобно: один везёт быстро и дорого, другой экономит на доставке ценой отмен и опозданий. Поэтому симулятор переводит все события в деньги и сводит их к единому показателю — средней стоимости заказа total_cost_per_order.
osbench run --scenario small --sourcing nearest
После прогона в рабочей папке появляются журнал событий, ежедневные снимки остатков и файл metrics.json. Итоговая стоимость заказа складывается из операционных затрат и штрафов за потерянный уровень сервиса:
| Компонент | Экономический смысл |
|---|---|
shipping_cost |
тариф перевозчика + сборка и упаковка на складе (за каждую товарную позицию и за коробку) |
holding_cost |
стоимость хранения: физический остаток на полке × дневная ставка |
purchase_cost |
закупка товара у поставщика |
penalty_cost |
штрафы за сорванные продажи: отказ при оформлении, отмена принятого заказа, недостача при сборке |
lateness_penalty_cost |
штраф за каждый день опоздания относительно обещанной клиенту даты |
| Прочие статьи | межскладские перемещения, доплата за разбивку заказа на несколько коробок, купоны-извинения, инвентаризация, обработка возвратов |
Первые две недели прогона (warmup_days: 14) в зачёт не идут. В начале симуляции склады заполнены стартовым запасом, очереди на сборку пусты, и системе нужно время, чтобы войти в рабочий ритм.
demo_calendar на вкладке Runs: созданные и доставленные заказы, доля вовремя и разница «план — факт», затраты по компонентам, посылки на заказ и потери, распределение опозданий. Кнопка «Open inspector» открывает тот же прогон в osbench replay.География, каталог и спрос
Прежде чем пустить заказы по трубам, симулятор строит мир: расставляет города и склады, договаривается с перевозчиками и наполняет каталог товарами.
Города, склады и тарифные зоны
Карта может быть плоской в километрах (plane) для синтетических тестов или сферической с реальными координатами широты и долготы (haversine). Покупатели живут в регионах вокруг центров городов. Параметр population_weight задаёт долю города в общем спросе, а radius_km — радиус, внутри которого расселены клиенты.
geo: { mode: plane, plane_size: 1000.0 }
region_radius_km: 20.0
regions:
- { region_id: 0, name: Moscow, x: 100, y: 100, population_weight: 1.0 }
- { region_id: 1, name: Kazan, x: 900, y: 100, population_weight: 0.5, radius_km: 60 }
warehouses:
- { warehouse_id: 0, name: W0, x: 200, y: 200, capacity_lines_per_day: 500 }
В компактном городе все покупатели попадают в одну тарифную зону относительно склада. Если же задать широкую агломерацию (radius_km: 60), жители разных окраин одного города окажутся в разных тарифных зонах, и оптимальный склад для них будет отличаться — на этом эффекте построен сценарий demo_contested. В визуальном редакторе Studio регионы и склады можно перетаскивать мышью, сразу подсвечивая границы тарифных зон.
Доставка считается по классической зонной сетке курьерских служб: расстояние от склада до клиента определяет номер зоны, а от зоны зависят базовый тариф за коробку, доплата за килограмм веса и нормативный срок в пути.
tariffs:
zone_bounds_km: [50, 300, 1000, 1e9]
cost_by_zone: [3.0, 5.0, 7.0, 10.0] # базовый тариф за посылку
per_kg_by_zone: [0.1, 0.2, 0.3, 0.5] # доплата за кг
transit_days_by_zone: [1, 2, 4, 7]
steepness: 1.0
delivery_noise_days_mean: 0.0
Посылка весом 2 кг на расстояние 250 км попадает во вторую зону: она обойдётся в 5.0+0.2x2=5.4 у.е. и поедет два дня. Множитель steepness масштабирует всю тарифную сетку разом, позволяя проверить, как поведёт себя логистика при удорожании фрахта на 30 %. Параметр delivery_noise_days_mean добавляет случайные задержки в пути (геометрическое распределение), превращая идеального перевозчика в реального, который обещает привезти за два дня, но иногда везёт четыре.
На самом складе тоже есть прейскурант: сборка одной товарной позиции (строки заказа) стоит handling_cost_per_line (по умолчанию 0.5), упаковка коробки — handling_cost_per_parcel, хранение одной штуки товара в сутки — storage_cost_per_unit_day (0.01). Дополнительно складу можно задать приоритет rank, чтобы отличать собственные распределительные центры от дорогих партнёрских 3PL-складов.
Товары и закон Ципфа
В живом интернет-магазине несколько десятков хитов дают половину оборота, а тысячи позиций продаются по штуке в месяц. В симуляторе популярность товаров (SKU) подчиняется распределению Ципфа с параметром кривизны zipf_a.
sku:
n_sku: 500
zipf_a: 1.1
n_categories: 5
weight_kg_range: [0.1, 5.0]
unit_cost_range: [5.0, 200.0]
price_markup_range: [1.2, 2.5]
При 500 товарах и zipf_a: 1.1 главный бестселлер приносит 19 % всех продаж в штуках, сотый по популярности товар — около 0.1 %, а хвост каталога — сотые доли процента. Если нужно смоделировать острую битву за дефицитные хиты, достаточно поднять zipf_a до 1.4 на коротком каталоге. Вес, закупочная цена и розничная наценка генерируются случайно в заданных границах и от популярности товара не зависят.
По умолчанию все города «любят» одни и те же товары. Но если задать матрицу region_category_matrix, у регионов появятся собственные предпочтения: север будет активнее скупать одну категорию, юг — другую, и одинаковый по объёму запас на складах начнёт вымываться с разной скоростью.
Ритм заказов и ловушка калибровки
Заказы приходят неоднородным пуассоновским потоком. Внутри каждого заказа — случайное число товарных строк (геометрическое распределение со средним lines_per_order_mean: 1.6, так что преобладают заказы из одной-двух позиций) и от 1 до qty_max штук в каждой строке.
Базовая частота заказов base_orders_per_day умножается на годовую сезонность, профиль дня недели и профиль часа суток:
demand:
base_orders_per_day: 200
lines_per_order_mean: 1.6
qty_max: 3
seasonality_amplitude: 0.2
weekly_profile: ecom_b2c # пик в понедельник, спад в пятницу и субботу
intraday_profile: b2c_evening # вечерний пик 19:00–22:00, затишье ночью
Для недельного и суточного ритма заготовлены типовые профили: классический B2C с вечерним пиком и понедельничным всплеском, офисные закупки строго по будням с 9 до 18, ритейл выходного дня или экспресс-доставка с двумя горбами в обед и вечером. Суточный профиль перераспределяет заказы исключительно внутри дня, сохраняя их суточное количество и состав корзин. Благодаря этому можно поставить чистый парный эксперимент: как изменится доля опозданий, если при том же суточном объёме покупатели начнут оформлять заказы вечером, уже после отъезда дневной фуры со склада.
demo_calendar: недельный профиль ecom_b2c с пиком в понедельник и отмеченными выходными, суточный b2c_evening с вечерним пиком.Путь одного заказа
Проследим, что происходит с заказом с секунды, когда покупатель нажал кнопку «Оформить», и до момента, когда курьер передал коробку в руки. В Studio этот маршрут вынесен на отдельную интерактивную схему: клик по любому этапу открывает его настройки.
Витрина, проверка наличия и обещанная дата (Promise)
Покупатель в корзине видит обещанную дату доставки — промис (delivery promise). От того, насколько смелое обещание даст витрина, зависит и конверсия в покупку, и будущие штрафы за опоздание. Промис рассчитывается по складу с самой быстрой доставкой до города клиента одним из четырёх способов:
zone_fixed— минимальное время обработки на складе плюс срок доставки по тарифной зоне. Самый агрессивный вариант без права на ошибку.transit_plus_buffer(по умолчанию) — добавляет к базовому сроку страховочный запасbuffer_days.atp_based— если нужного товара сейчас нет на складе, но ожидается поставка через 4 дня, витрина честно прибавит эти 4 дня к сроку доставки.percentile— статистический расчёт: берёт заданный квантиль случайных задержек перевозчика, чтобы укладываться в обещанный срок, например, в 90 % случаев.
Если включить calendar_aware: true, витрина при расчёте промиса будет заглядывать в производственный календарь склада и не пообещает отгрузить в субботу со склада, который отдыхает на выходных.
Клиенты не любят ждать слишком долго. Параметр cap_days задаёт максимальный срок, который вообще разрешено показывать на сайте, а p_accept_promise — готовность покупателя мириться с ожиданием. При значении 0.6 вероятность того, что клиент согласится на доставку, линейно падает от 100 % на базовом сроке зоны до 60 % на границе cap_days. Если срок не устроил, покупатель уходит с кассы, а система фиксирует потерянную продажу (PROMISE_REJECTED) со штрафом penalty_lost_sale (по умолчанию 5 у. е.).
Тут же на кассе может работать проверка доступного остатка (checkout_atp_check: true). Если она включена, витрина проверяет, есть ли хотя бы один склад в сети, способный целиком отгрузить каждую строку корзины. Если какого-то товара не хватает, покупатель с вероятностью p_substitute согласится на замену из той же категории, с вероятностью p_partial купит урезанную корзину без дефицитной позиции, а в остальных случаях вовсе откажется от заказа. Если же проверка на кассе выключена (поведение по умолчанию), сайт оптимистично принимает любые заказы, перекладывая головную боль с дефицитом на плечи OMS и склада.
Приём заказа: проверка адреса, оплаты и антифрод
Оформленный заказ не попадает на склад в ту же секунду. Сначала он проходит конвейер проверок (intake): валидацию адреса, проведение платежа и фрод-мониторинг.
intake:
address: { auto_minutes: [0, 1], p_hold: 0.02, hold_hours_mean: 6,
hold_window: [540, 1260], p_reject_after_hold: 0.3 }
payment: { auto_minutes: [0, 2], p_reject: 0.01, p_hold: 0.03, hold_hours_mean: 0.5,
p_reject_after_hold: 0.4, reject_cost: 5.0 }
fraud: { auto_minutes: [0, 1], p_reject: 0.002, p_hold: 0.02, hold_hours_mean: 3,
hold_window: [540, 1080], p_reject_after_hold: 0.2 }
reserve_during_checks: false
У каждого шага двухконтурная логика. Быстрый автоматический контур занимает пару минут (auto_minutes) и сразу отсеивает явный брак с вероятностью p_reject (несуществующий адрес, отказ банка, сработавшее стоп-правило антифрода). Подозрительные заказы с вероятностью p_hold отправляются на ручной разбор: операторы колл-центра уточняют адрес, клиент повторно проходит 3-DS, а фрод-аналитики изучают профиль покупателя.
Ручная проверка длится в среднем hold_hours_mean часов, но таймер тикает только в рабочие часы соответствующей службы (hold_window, в минутах от полуночи). Если заказ попал на ручной антифрод в 11 вечера, а аналитики работают с 9:00 до 18:00 ([540, 1080]), он пролежит без движения до утра. Отклонённые на этом этапе заказы (ORDER_REJECTED) не портят статистику потерянных продаж и доставки вовремя (OTIF, On-Time In-Full), так как магазин сам отказался от сделки, но каждый отказ стоит компании reject_cost (работа операторов, комиссии, упущенная выгода).
Надо сразу отметить, что в симуляцию не входит колл-центр и не входит доставка, потому что каждая из этих областей заслуживает полноценного отдельного симулятора и значительно увеличивает сложность, не добавляя ничего полезного в симуляционные эксперименты. Другими словами, предполагается, что ёмкость колл-центра бесконечна, и доставка надёжна на 100% (оба этих предположения имеют мало общего с реальностью). Но когда главная цель крутится вокруг алгоритмов выбора склада, то в целом что там будет с доставкой, например, не очень важно.
Так вот, продолжаем. Здесь возникает важная архитектурная развилка, за которую отвечает флаг reserve_during_checks: в какой момент выбирать склад и бронировать товар?
- Если
false, заказ ищет склад только после успешного прохождения всех проверок. Пока ночной заказ ждёт утреннего фрод-аналитика, последние штуки товара на складе могут раскупить другие клиенты. - Если
true, OMS выбирает склад и ставит резерв мгновенно при оформлении, но запрещает передавать задание на сборку в складскую систему (WMS), пока антифрод не даст зелёный свет. Это защищает честного покупателя от потери товара, но замораживает сток под мошенническими и неоплаченными заказами.
hold_window: [540, 1080]), reserve_during_checks: true. Заказ пришёл ночью и ушёл на ручную проверку (оранжевый отрезок в полосе «Order»), которая стоит до открытия окна в 09:00. Склад выбран и товар зарезервирован почти сразу, но посылка всю ночь ждёт передачи в WMS (фиолетовый отрезок) и уходит на сборку только после одобрения.Даже после всех проверок заказ проводит от 5 до 30 минут в буфере самой OMS (sourcing_wait_min..max) — так моделируется пакетная обработка очереди распределения. Параллельно тикает таймер самого покупателя: с вероятностью cancel_rate клиент может передумать и отменить заказ в первые cancel_within_hours часов, пока его не начали собирать на складе.
Внутри склада: расписание, волны, темп сборщиков и cut-off
После того как OMS выбрала склад и зарезервировала товар, задание нужно передать в складскую систему управления (WMS) и собрать руками.
Реальные склады редко начинают бегать по рядам за каждой отдельной покупкой в ту же секунду. Передача заданий из OMS в WMS настраивается параметром release_mode:
immediate— поштучная передача сразу после резерва;periodic— пакетная выгрузка по расписанию каждыеrelease_period_minминут;waves— запуск волнами в фиксированные часы рабочих дней (wave_times_min: [480, 660, 840, 1020]соответствует волнам в 08:00, 11:00, 14:00 и 17:00), подстроенным под приезд машин перевозчика.
Сам склад живёт по производственному расписанию: рабочие дни недели (work_days: без выходных, шестидневка или будни mon-fri) и часы смены (shift_start_min и shift_end_min). Мощность склада ограничена двумя потолками: суточным лимитом capacity_lines_per_day и часовой производительностью бригады сборщиков pick_lines_per_hour.
warehouses:
- { warehouse_id: 0, ..., work_days: mon-fri, shift_start_min: 480, shift_end_min: 1200,
cutoff_min: 960, release_mode: waves, wave_times_min: [480, 660, 840, 1020],
pick_lines_per_hour: 120 }
Когда задан почасовой темп бригады, заказы не сваливаются в сборку одновременно в начале смены, а встают в живую очередь к сборщикам. Когда до посылки доходит очередь, само хождение по складу со сканером (pick) и упаковка в картон (pack) занимают случайное время из логнормального распределения:
process_times: { pick_median_min: 10, pick_per_line_min: 3, pack_median_min: 5, sigma: 0.3 }
В медиане сборщик тратит 10 минут на подход и по 3 минуты на каждую товарную строку, ещё 5 минут уходит на упаковку. Всё, что бригада не успела взять в работу до конца смены или сверх суточного лимита, остаётся лежать до следующего рабочего утра.
Упакованная коробка попадает на пандус отгрузки и ждёт машину перевозчика. У каждого склада есть время отсечки — cut-off (cutoff_min, по умолчанию 16:00). Коробки, упакованные до 16:00, уезжают вечерним рейсом (если в фуре хватило места в рамках суточной квоты carrier_parcels_per_day). Коробка, заклеенная в 16:05, остаётся ночевать на пандусе до следующего рабочего дня. Если это произошло в пятницу на складе с пятидневной рабочей неделей, посылка уедет только в понедельник вечером.
osbench replay. Заказ оформлен в пятницу вечером и назначен на склад West (пн–пт). Зелёный фон — смена сборки, серый — выходные, красный пунктир — cut-off перевозчика, оранжевая вертикаль — промис. Посылка стоит в очереди «shift closed» все выходные, её собирают в понедельник в 08:00, к перевозчику она уходит в 16:00.По умолчанию опоздания никак не штрафуются в деньгах. Но как только мы включаем в конфиге penalty_late_per_day: 4.0 (штраф за каждые сутки просрочки, начисляемый на весь заказ целиком по самой медленной посылке или на каждую опоздавшую строку), перегрузка складов и промах мимо cut-off начинают стремительно разорять компанию.
cheapest_myopic и weighted_score на одном сиде demo_showcase со штрафом за опоздание. weighted_score обходится в 27.0 на заказ при 89 % заказов вовремя, cheapest_myopic в 69.4 при 15 %. Жадная политика перегружает один склад, разрыв «план — факт» у неё растёт весь прогон, и почти вся разница в стоимости приходится на штрафы за опоздание.Учёт запасов, пополнение и теневые потертые штуки
Чтобы склад мог что-то отгрузить, товар должен лежать на полке. Но между тем, что лежит на полке физически, и тем, что видит алгоритм распределения в базе данных, в реальной жизни почти всегда есть зазор.
Четыре среза остатка и формула ATP
Для каждого товара на каждом складе симулятор параллельно ведёт пять счётчиков:
phys— сколько штук физически стоит на стеллаже прямо сейчас;book— учётный остаток по базе данных складской системы (WMS);reserved— сколько штук уже обещано принятым заказам, но ещё не снято с полки сборщиками;in_transit— товар в пути от поставщика или с другого склада;safety— неприкосновенный страховой запас, который запрещено тратить на онлайн-заказы.
Величина, которую можно свободно обещать новому покупателю, называется ATP (Available to Promise, доступно к обещанию). В зависимости от настроек компании ATP считается по-разному:
on_hand_only: book minus reserved (продаём всё до последней учётной штуки);basic(по умолчанию): book minus reserved minus safety (оставляем страховую подушку);cumulative: прибавляет кbasicбудущие поставки (in_transit), которые должны приехать на склад в ближайшиеhorizon_daysдней. При этом будущий приход разрешено показывать только витрине (для проверки наличия и расчёта даты доставки), а сам выбор склада при комплектации оперирует наличным запасомbasicили ставит заказ в лист ожидания поставки.
Ассортиментная матрица, уровни запаса и закупка
Склады редко хранят весь каталог целиком. Параметр assortment_fraction: 0.6 означает, что на конкретном складе представлена случайная выборка из 60 % товаров (при этом генератор следит, чтобы каждый товар продавался хотя бы с одного склада сети). Именно неполное пересечение ассортимента чаще всего вынуждает систему разбивать заказ из нескольких позиций на разные склады.
На старте прогона движок оценивает среднесуточный спрос на каждый товар и рассчитывает для каждой пары «товар — склад» четыре норматива:
- Начальный запас = (спрос в день) x (
initial_cover_days), который ещё и урезается, если склад упёрся в общую вместимость стеллажейstorage_capacity_units; - Страховой запас (
safety) = (спрос в день) x (safety_days); - Точка перезаказа (
s) = (спрос в день) x (средний срок поставки, lead time); - Целевой уровень (
S) = (спрос в день) x (lead time + период пересмотра) x (S_overshoot).
Посмотрим, какие цифры получаются в пресете small (4 склада, запас на 14 дней, пересмотр закупок раз в 7 дней, ожидание поставки ~4.3 дня):
| Позиция в каталоге | Спрос на один склад в день | Начальный запас | Страховой запас (safety) |
Точка заказа (s) |
Верхняя планка (S) |
|---|---|---|---|---|---|
| Главный хит (ранг 1) | 30.7 шт. | 430 шт. | 15 шт. | 133 шт. | 522 шт. |
| Середняк (ранг 100) | 0.19 шт. | 3 шт. | 0 шт. | 1 шт. | 4 шт. |
| Длинный хвост (ранг 500) | 0.03 шт. | 1 шт. | 0 шт. | 1 шт. | 1 шт. |
Последняя строка наглядно объясняет природу дефицита в интернет-торговле: редкий товар лежит на складе в единственном экземпляре. Если на одной неделе придут два заказа на этот товар, второй неизбежно столкнётся с отказом.
Раз в review_days дней модуль закупок проверяет текущую позицию запаса (book + in_transit - reserved) и отправляет заказы поставщику со сроком доставки lead_time_days (с логнормальным разбросом lead_time_std_days) и округлением вверх до минимальной партии moq, из-за чего график остатков приобретает характерную пилообразную форму.
В симуляторе реализовано шесть политик пополнения (--replenishment): классические base_stock (дозаказ до S каждый цикл), s_S (заказ до S при пробитии s) и R_Q (заказ кратными паллетами по Q штук), адаптивная forecast_driven (пересчитывает S по фактическим продажам с коэффициентом сервиса), централизованная network_allocation (закупает на всю сеть и делит поставку пропорционально фактическим отгрузкам складов) и специальный режим scheduled, который отключает обратную связь и строго воспроизводит заранее записанный график приходов.
demo_calendar. Сверху заказано, пришло и отгружено по всей сети за день. Ниже по каждому складу: линия — запас, столбики — приходы и отгрузки. Запас расходуется и пополняется раз в цикл пересмотра, отсюда «пила».Как возникает дефицит и откуда берутся фантомные остатки
Нехватка товара может вскрыться на одном из трёх этапов, и чем позже это произойдёт, тем больнее удар по репутации и кошельку:
- На кассе (
penalty_lost_sale: 5) — честный ответ «нет в наличии» до оплаты. - В OMS после приёма заказа (
penalty_post_order: 8) — деньги списали, заказ приняли, но при распределении ни один склад в рамках допустимых сроков и тарифов (atp.tolerance_*) не смог покрыть позицию, а звонок клиенту с предложением подольше подождать (p_accept_slow) не помог. - На сборке (
penalty_pick: 10) — самый неприятный сбой: OMS была уверена, что товар есть, отправила задание на склад, сборщик подошёл к ячейке, а полка пуста.
Почему в базе данных товар числился (book > 0), а на полке его не оказалось (phys == 0)? В симуляторе у недостачи на сборке есть четыре физические причины, которые раздельно учитываются в метрике shortage_pick_causes:
- Ошибка приёмки и фантомный сток (
accounting_error). При каждой разгрузке фуры с вероятностью1 − inventory_accuracy.accuracy(например, в 3 % поставок) приёмщики ошибаются и ставят на полку в среднем наerror_meanштук меньше, чем проводят по документам в WMS. Этот «фантомный остаток» висит в базе, пока за последней штукой не придёт сборщик и не обнаружит пустую ячейку. Бороться с фантомами до того, как на них наткнётся клиентский заказ, можно регулярной циклической инвентаризацией (cycle_count_days: 14,cycle_count_cost_per_location: 0.2): ревизоры каждый день обходят 1/14 часть стеллажей и выравнивают учётные цифры с полкой. - Отсутствие резервирования (
no_reservation_conflict). Два заказа одновременно претендовали на последнюю единицу товара, потому что OMS не ставила бронь. - Устаревший снимок остатков (
stale_snapshot). Склад уже списал товар, но интеграционная шина ещё не доставила эту новость в OMS. - Перехват в розничном зале (
walkin_conflict). При отгрузке из розничного магазина последний экземпляр с витрины успел забрать и оплатить на кассе обычный прохожий с улицы, пока сборщик интернет-заказа шёл к полке.
Если сборщик столкнулся с пустой полкой, OMS пытается спасти заказ: перекидывает строку на другой склад, где товар физически есть (p_resource), переводит позицию в режим ожидания ближайшей поставки (backorder) или окончательно отменяет строку с максимальным штрафом.
Алгоритмы выбора склада и работа со сплитами
Когда заказ прошёл проверки и дошёл до распределения, OMS собирает список складов-кандидатов, где товар входит в ассортимент и имеется в наличии. Задачу выбора точки отгрузки решают двенадцать встроенных политик (--sourcing):
Алгоритм (--sourcing) |
Принцип работы | Где применяется на практике |
|---|---|---|
random |
случайный склад, где есть товар | нижняя планка для проверки адекватности |
nearest |
ближайший к клиенту склад, способный собрать заказ целиком; иначе — минимум коробок | базовое правило большинства коробочных OMS |
cheapest_myopic |
перебирает комбинации складов и минимизирует сумму доставки, сборки и штрафа за сплит прямо сейчас | жадные логистические оптимизаторы без учёта загрузки и завтрашнего спроса |
weighted_score |
считает взвешенный балл по 7 нормированным факторам | промышленные OMS (SAP Commerce, Adobe Commerce и др.) |
weighted_score_tuned |
та же формула, но веса подобраны алгоритмом оптимизации (osbench tune) на исторических прогонах |
скоринг, откалиброванный под конкретную сеть |
spillover_limit |
выбирает самый дешёвый склад, но запрещает тратить более X % дневной мощности склада на заказы из чужих регионов | защита региональных складов от перегрузки соседями |
primal_dual |
прибавляет к тарифу доставки динамическую теневую цену товара, которая растёт по мере пустения полки | онлайн-защита дефицитного стока без построения прогнозов |
dtlp_bidprice |
периодически решает задачу линейного программирования (LP) на прогнозе спроса; двойственные оценки служат ценой упущенной выгоды при отдаче товара чужому региону | классический академический алгоритм Acimovic & Graves (2015) |
rolling_milp |
регулярно пересчитывает оптимальные квоты потоков «регион → склад» с учётом мощностей складов | скользящее объёмно-календарное планирование |
hindsight_lp |
глобальная оптимизация, которой заранее известны все будущие заказы до конца прогона | недостижимый в жизни идеал (оценка потенциала сети сверху) |
wait_or_split |
взвешивает в деньгах три альтернативы: разбить заказ сейчас, оформить бэкордер или заморозить заказ до прихода поставки | продвинутая консолидация многострочных заказов |
Промышленный стандарт weighted_score оценивает каждого кандидата по семи критериям (нормируя их от 0 до 1 среди доступных для этого заказа складов): расстояние до клиента (вес 0.20), стоимость доставки (0.20), запас времени до обещанной даты промиса (0.25), способность отгрузить всю корзину одной посылкой (0.10), глубина запаса сверх страхового уровня (0.10), сегодняшняя загрузка мощности склада (0.10) и приоритет склада rank (0.05).
Разбивка заказа (Split), бэкордеры и ожидание поставки
Что делать, если покупатель заказал четыре позиции, три из которых лежат на складе А, а четвёртой нет нигде (но фура с ней приедет на склад Б через три дня)?
Если просто разбить заказ на две посылки с разных складов, компания заплатит трижды: за второй базовый тариф перевозчика, за сборку второй коробки (handling_cost_per_parcel) и за раздражение клиента, вынужденного встречать двух курьеров (cx_penalty_per_extra_parcel: 1.5). А если включён модуль возвратов, добавится и четвёртый удар: клиенты, получившие заказ по частям, на returns.split_uplift чаще возвращают покупки обратно.
Вместо немедленного сплита или отмены симулятор позволяет задействовать два механизма работы с временем: бэкордер (backorder) и отложенное размещение (deferral).
При включённом бэкордере (shortage.backorder: true) дефицитная четвёртая строка жёстко бронируется под конкретную грядущую поставку на склад Б. А судьбу первых трёх строк, которые уже лежат на складе А, решает настройка backorder_shipment:
ship_available— три наличные позиции уезжают к клиенту немедленно, четвёртая догоняет их второй посылкой после разгрузки фуры;consolidate— система придерживает весь заказ и отправляет все четыре позиции одной коробкой после прихода товара;cost_based— калькулятор внутри OMS считает стоимость обоих вариантов (лишняя коробка и штраф за сплит против риска опоздать со всем заказом и заплатить клиенту купон-извинениеapology_cost: 3.0) и выбирает то, что дешевле для бизнеса.
demo_split. Строки, которые были в наличии, собраны на одном складе и уехали сразу. Недостающая строка на другом складе ждала поставку около двух суток (фиолетовый отрезок «waiting for receipt») и уехала отдельной посылкой. В таблице посылок у неё причина backorder, а в сводке видна цена второй коробки («CX: extra parcels»).Отложенное размещение (deferral.mode: wait_for_receipt или wait_to_consolidate) устроено иначе: OMS вообще не выбирает склад и не ставит никаких резервов, а паркует заказ в буфере ожидания до расчётного дедлайна (так, чтобы после прихода поставки ещё оставалось время собрать и довезти посылку до промиса). Как только на любой склад приходит поставка с нужным товаром, ждущие заказы просыпаются и выстраиваются в очередь по правилу арбитража (edf — у кого ближе дедлайн промиса, fifo — кто дольше ждёт, value — чей заказ дороже). В отличие от бэкордера, отложенный заказ не связывает руки другим покупателям жёсткой бронью, но рискует остаться ни с чем, если пришедшую партию разберут более приоритетные соседи по очереди.
Резервирование, рассинхронизация баз и омниканал
Самые тонкие настройки симулятора посвящены информационным задержкам и борьбе разных каналов продаж за одну и ту же полку.
Архитектура резервирования и лаг синхронизации OMS ↔ WMS
Пока заказ проходит проверки, ждёт волны и стоит в очереди к сборщику, обещанный ему товар продолжает физически лежать на стеллаже. Счётчик reserved помнит об этом обещании, но в разных ИТ-архитектурах разные подсистемы по-своему смотрят на этот счётчик:
reservation:
enabled: true
storefront: null # учитывает ли резервы витрина при проверке на кассе
sourcing: null # учитывает ли резервы OMS при выборе склада
replenishment: null # вычитает ли резервы модуль закупок при расчёте дозаказа
Комбинируя эти три флага, можно воспроизвести любую корпоративную архитектуру: от монолита с жёстким сквозным резервом (1 / 1 / 1) и витрины с кешированным фидом остатков перед строгой OMS (0 / 1 / 1) до полностью безрезервной схемы «принимаем всё, разбираемся на сборке» (0 / 0 / 0).
Картину усложняет задержка синхронизации между складской системой (WMS) и OMS:
inventory_sync:
mode: periodic # none | fixed_lag | periodic
period_minutes: 15 # WMS выгружает снимок остатков раз в 15 минут
jitter_minutes: 5
per_warehouse: { 0: 0, 3: 60 } # центральный РЦ — онлайн, розничный магазин — раз в час
В реальной жизни OMS не знает точного остатка WMS в каждую секунду. Она видит снимок (snapshot) какой-то свежести (например, выгруженный 40 минут назад) и ведёт поверх него собственный журнал резервов. Чтобы не вычесть одну и ту же штуку дважды, OMS снимает свой внутренний резерв только в тот момент, когда акт о фактической сборке заказа доезжает до неё в очередном снимке из WMS.
Поэтому для собственных онлайн-заказов при включённом резерве задержка синхронизации безопасна. Проблемы начинаются там, где товар уходит со склада в обход OMS: когда на приёмке ошиблись с количеством, или когда в розничном магазине товар с полки забрал покупатель с улицы. До следующей сессии обмена OMS продолжает смотреть на старую фотографию полки и раздавать обещания на уже проданный товар.
demo_omnichannel с выгрузкой остатков раз в час. В полосе «OMS stock view» розовый отрезок — возраст снимка в момент решения, розовый пунктир — сетка обновлений. В таблице снимок показывает больше, чем учёт WMS (unsynced > 0): часть единиц уже собрана под другие заказы, но списание ещё не дошло до OMS. Эти же единицы стоят в reserved, поэтому ATP OMS не завышен.Замечание про бронь товара в корзине. Отдельной сущности «незавершённая корзина» в движке нет, но её поведение воспроизводится через этап приёма заказа с включённым ранним резервом (reserve_during_checks: true, p_hold: 0.3 как доля брошенных корзин и hold_hours_mean: 1.0 как время жизни брони TTL). Для визуальной проверки механики на таймлайне такой приём подходит отлично, но для строгих численных замеров нужно помнить, что в текущей реализации отсеянные на приёме заказы раздувают расчётный стартовый запас сети и попадают в знаменатель поордерных метрик.
Розничные магазины, самовывоз (BOPIS) и возвраты
Склад в симуляторе можно объявить розничным магазином (tier: store). У магазина сразу меняется экономика и режим жизни: малая пропускная способность сборки (capacity_lines_per_day: 40), дорогой ручной труд продавцов (handling_cost_per_line: 1.5), скромный лимит курьерской машины и, главное, поток живых покупателей с улицы (walkin_units_per_day: 60), которые с 9 утра до 9 вечера снимают товар прямо с витрины.
warehouses:
- { warehouse_id: 2, name: Store-W1, tier: store, capacity_lines_per_day: 40,
handling_cost_per_line: 1.5, walkin_units_per_day: 60,
online_protect_units: 1, ship_online: true }
Покупатель в торговом зале не знает, что последняя куртка на вешалке десять минут назад была зарезервирована под интернет-заказ. Он идёт с ней на кассу, пробивает чек, а сборщик онлайн-заказа через час фиксирует недостачу walkin_conflict. Чтобы интернет-витрина не выметала магазины подчистую и не ссорила каналы продаж, задаётся неснижаемый остаток витрины online_protect_units: 1 — последнюю штуку каждого товара магазин прячет от онлайна для своих уличных гостей.
Помимо отгрузки курьером из магазина (Ship-from-Store), магазины обслуживают самовывоз (BOPIS, Buy Online, Pick Up In Store, доля таких заказов задаётся в bopis_share: 0.25). Если весь товар есть в выбранном магазине, клиент получает обещание забрать покупку уже завтра, а компания экономит на доставке. Если каких-то позиций в магазине не хватает, их подвозят со складов консолидированным межскладским рейсом за 40 % обычного тарифа (ship_to_store_cost_factor: 0.4). В среднем через 20 часов после готовности покупатель забирает заказ, но 5 % клиентов так и не приходят (p_no_show: 0.05) — через пять дней ожидания коробку расформировывают обратно на полку со штрафом за холостую работу персонала.
demo_omnichannel в инспекторе: два РЦ и четыре магазина в двух городах. Точки — заказы одного дня, раскрашенные по складу отгрузки. Выбран заказ, разделённый между двумя РЦ: линии ведут от клиента к обоим складам, справа его карточка и таймлайн.Доставленные заказы через 3–14 дней могут частично вернуться обратно (returns.enabled: true). Доля возвратов зависит от товарной категории (одежду возвращают в 5 раз чаще электроники) и подскакивает на split_uplift: 0.18 за каждую лишнюю посылку, на которую был разбит заказ. Половину возвратов клиенты приносят ногами в магазины, вторую половину отправляют почтой за счёт магазина. После проверки и переупаковки (processing_cost_per_unit: 1.0) 80 % возвращённых вещей снова встают на приход и доступны для продажи, а 20 % списываются в брак.
Балансировка сети, чёрная пятница и падение складов
На уровне всей сети склады могут обмениваться запасами по ночам (transfers.enabled: true): либо центральный хаб (rdc) подпитывает региональные склады (fdc), либо склады напрямую делятся излишками, когда у одного запаса осталось меньше чем на low_days: 5 дней, а у соседа лежит больше чем на high_days: 15. Чтобы алгоритмы балансировки не гоняли один и тот же товар по кругу взад-вперёд, встроены предохранители (cooldown_days, max_source_share), удерживающие метрику холостых перевозок ping_pong_units на нуле.
Чтобы проверить устойчивость всей этой конструкции в экстремальных режимах, в конфиге есть блок шоков (shocks):
shocks:
enabled: true
peak_multiplier: 4.0 # четырёхкратный всплеск спроса («чёрная пятница»)
peak_duration_days: 3
peak_start_day: 20
warehouse_outage_id: 0 # авария и полное закрытие склада W0 на неделю
warehouse_outage_days: 7
warehouse_outage_start_day: 14
При аварии склад пропадает с радаров OMS, а весь поток заказов обрушивается на соседей.
demo_showcase, сеть загружена почти до предела. Склад W1 закрыт на дни 14–20. Сосед W2 во время аварии получает 280–420 строк в день при мощности 200, и его очередь вырастает почти до тысячи строк. После возвращения W1 она тает медленно: свободной мощности на разбор почти нет.Когда основное время симуляции sim_days заканчивается, приём новых заказов останавливается, и системе даётся до 60 суток на «слив» — досборку и развоз уже принятых обязательств. Всё, что застряло в очередях и не доехало даже за этот срок, списывается как безнадёжно сорванное с пометкой horizon — верный признак того, что сеть захлебнулась.
Что показали эксперименты на стенде
Когда весь этот конструктор был собран и снабжён пакетным раннером osbench sweep и оптимизатором весов osbench tune, я провёл на нём серию из тринадцати вычислительных исследований. Несколько результатов оказались весьма поучительными и местами разошлись с привычной отраслевой интуицией. О них — отдельно.
Если редкий читатель дочитал до этого места, то я буду рад, если вы поделитесь гипотезами, которые можно проверить на этом стенде. Связаться со мной можно вот так: Rauf Aliev
Discussion
Powered by GitHub Discussions — sign in with a GitHub account to join the conversation.