Коли інтернет-магазин розростається до кількох складів і десятків магазинів, вибір точки відвантаження для кожного замовлення (order sourcing) перестає бути тривіальним завданням. Відправиш із найближчого складу — швидко вичерпаєш його потужності й залишки. Відправиш із найдешевшого за тарифом — створиш багатоденну чергу на збиранні. Збереш замовлення з двох місць — заплатиш за доставку двічі й засмутиш клієнта.

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

Усередині симулятора плине віртуальний час із точністю до хвилини. Покупці в різних містах наповнюють кошики, вітрина обіцяє їм дату доставки, антифрод перевіряє картки, система керування замовленнями (OMS) шукає склад, комірники виходять на зміни й збирають коробки хвилями, перевізники забирають палети за розкладом, а відділ закупівель поповнює полиці.

osbench Studio: параметри сценарію ліворуч, мапа в центрі, схема життєвого циклу замовлення праворуч
Редактор сценаріїв 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) не зараховуються. На початку симуляції склади заповнені стартовим запасом, черги на збирання порожні, і системі потрібен час, щоб увійти в робочий ритм.

Вкладка Runs у Studio: графіки замовлень, частки вчасно доставлених, витрат за компонентами та розподілу запізнень
Прогін 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 регіони й склади можна перетягувати мишею, і межі тарифних зон одразу підсвічуються.

Редактор мапи: шість складів шестикутником довкола центрального хабу, міста між ними, столиця в центрі
Редактор мапи в 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, ритейл вихідного дня або експрес-доставка з двома горбами — в обід і ввечері. Добовий профіль перерозподіляє замовлення виключно в межах дня, зберігаючи їхню добову кількість і склад кошиків. Завдяки цьому можна провести чистий парний експеримент: як зміниться частка запізнень, якщо за того самого добового обсягу покупці почнуть оформлювати замовлення ввечері, вже після того, як денна фура поїхала зі складу.

Вибір тижневого й добового профілю попиту в Studio з графіками форми
demo_calendar: тижневий профіль ecom_b2c з піком у понеділок і позначеними вихідними, добовий b2c_evening з вечірнім піком.

Шлях одного замовлення

Простежмо, що відбувається із замовленням від секунди, коли покупець натиснув кнопку «Оформити», до моменту, коли кур'єр передав коробку в руки. У Studio цей маршрут винесено на окрему інтерактивну схему: клік на будь-який етап відкриває його налаштування.

Схема життєвого циклу замовлення в Studio з клікабельними кроками
Схема життєвого циклу замовлення в Studio. Кожен крок відкриває свої параметри.

Вітрина, перевірка наявності та обіцяна дата (Promise)

Покупець у кошику бачить обіцяну дату доставки — проміс (delivery promise). Від того, наскільки сміливу обіцянку дасть вітрина, залежать і конверсія в покупку, і майбутні штрафи за запізнення. Проміс розраховують для складу з найшвидшою доставкою до міста клієнта одним із чотирьох способів:

Якщо ввімкнути 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: у який момент обирати склад і бронювати товар?

Таймлайн замовлення з нічною ручною фрод-перевіркою: резерв поставлено одразу, передавання до WMS чекає ранку
Фрод-аналітики працюють з 9 до 18 (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:

Сам склад живе за виробничим розкладом: робочі дні тижня (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 хвилин іде на пакування. Усе, що бригада не встигла взяти в роботу до кінця зміни або понад добовий ліміт, лишається лежати до наступного робочого ранку.

Таймлайн замовлення на складі з хвилями: очікування хвилі, потім очікування вільного збирача
Склад Hub передає замовлення хвилями о 08:00, 11:00, 14:00 і 17:00, бригада збирає 40 рядків на годину. Замовлення надійшло рано-вранці. Спочатку посилка чекає на першу хвилю (фіолетовий відрізок), потім стоїть у черзі до збирача (синій відрізок), бо бригада ще розбирає нічний потік.

Запакована коробка потрапляє на рампу відвантаження й чекає на машину перевізника. Кожен склад має час відсічення — 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 починають стрімко розоряти компанію.

Вкладка Comparisons: cheapest_myopic проти weighted_score на одному сиді demo_showcase
Вкладка Comparisons: cheapest_myopic і weighted_score на одному сиді demo_showcase зі штрафом за запізнення. weighted_score коштує 27,0 на замовлення за 89 % замовлень, доставлених вчасно, cheapest_myopic — 69,4 за 15 %. Жадібна політика перевантажує один склад, розрив «план — факт» у неї зростає впродовж усього прогону, і майже вся різниця у вартості припадає на штрафи за запізнення.

Облік запасів, поповнення й тіньові втрачені штуки

Щоб склад міг щось відвантажити, товар має лежати на полиці. Але між тим, що лежить на полиці фізично, і тим, що бачить алгоритм розподілу в базі даних, у реальному житті майже завжди є зазор.

Чотири зрізи залишку й формула ATP

Для кожного товару на кожному складі симулятор паралельно веде п'ять лічильників:

Величину, яку можна вільно обіцяти новому покупцеві, називають ATP (Available to Promise, доступно для обіцянки). Залежно від налаштувань компанії ATP рахують по-різному:

Асортиментна матриця, рівні запасу й закупівлі

Склади рідко зберігають увесь каталог повністю. Параметр assortment_fraction: 0.6 означає, що на конкретному складі представлено випадкову вибірку з 60 % товарів (водночас генератор стежить, щоб кожен товар продавався хоча б з одного складу мережі). Саме неповний перетин асортименту найчастіше змушує систему розбивати замовлення з кількох позицій між різними складами.

На старті прогону рушій оцінює середньодобовий попит на кожен товар і розраховує для кожної пари «товар — склад» чотири нормативи:

Подивімося, які цифри виходять у пресеті 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, який вимикає зворотний зв'язок і суворо відтворює заздалегідь записаний графік надходжень.

Вкладка Products в інспекторі: запас SKU, що продається найкраще, за складами, надходження й відвантаження
SKU, що продається найкраще, у demo_calendar. Угорі — замовлено, надійшло й відвантажено по всій мережі за день. Нижче для кожного складу: лінія — запас, стовпчики — надходження й відвантаження. Запас витрачається й поповнюється раз на цикл перегляду, звідси «пилка».

Як виникає дефіцит і звідки беруться фантомні залишки

Нестача товару може виявитися на одному з трьох етапів, і що пізніше це станеться, то болючіший удар по репутації та гаманцю:

  1. На касі (penalty_lost_sale: 5) — чесна відповідь «немає в наявності» до оплати.
  2. В OMS після приймання замовлення (penalty_post_order: 8) — гроші списали, замовлення прийняли, але під час розподілу жоден склад у межах допустимих термінів і тарифів (atp.tolerance_*) не зміг покрити позицію, а дзвінок клієнтові з пропозицією почекати довше (p_accept_slow) не допоміг.
  3. На збиранні (penalty_pick: 10) — найнеприємніший збій: OMS була певна, що товар є, відправила завдання на склад, збирач підійшов до комірки, а полиця порожня.

Чому в базі даних товар значився (book > 0), а на полиці його не виявилося (phys == 0)? У симуляторі нестача на збиранні має чотири фізичні причини, які окремо враховуються в метриці shortage_pick_causes:

Якщо збирач натрапив на порожню полицю, 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:

Таймлайн замовлення з двох посилок: одна поїхала одразу, друга чекала на поставку близько двох діб
Замовлення з 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 і далі дивиться на стару фотографію полиці й роздає обіцянки на вже проданий товар.

Таймлайн замовлення зі смугою OMS stock view і таблиця залишків у момент рішення за погодинного вивантаження знімка
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, а весь потік замовлень обрушується на сусідів.

Вкладка Warehouses в інспекторі: призначені рядки й черга сусіднього складу під час аварії
demo_showcase, мережу завантажено майже до межі. Склад W1 закрито на дні 14–20. Сусід W2 під час аварії отримує 280–420 рядків на день за потужності 200, і його черга зростає майже до тисячі рядків. Після повернення W1 вона тане повільно: вільної потужності на розбір майже немає.

Коли основний час симуляції sim_days спливає, приймання нових замовлень зупиняється, і системі дають до 60 діб на «злив» — дозбирання й розвезення вже прийнятих зобов'язань. Усе, що застрягло в чергах і не доїхало навіть за цей термін, списується як безнадійно зірване з позначкою horizon — явна ознака того, що мережа захлинулася.

Що показали експерименти на стенді

Коли весь цей конструктор було зібрано й оснащено пакетним раннером osbench sweep та оптимізатором ваг osbench tune, я провів на ньому серію з тринадцяти обчислювальних досліджень. Кілька результатів виявилися вельми повчальними й подекуди розійшлися зі звичною галузевою інтуїцією. Про них — окремо.

Якщо рідкісний читач дочитав до цього місця, то я буду радий, якщо ви поділитеся гіпотезами, які можна перевірити на цьому стенді. Зв'язатися зі мною можна ось так: Rauf Aliev

Вкладка Sales в інспекторі: виручка й витрати за днями, структура витрат, розрізи за складами й регіонами
Вкладка Sales в інспекторі: виручка й витрати за днями, структура витрат, утрати й розрізи за складами, регіонами та категоріями.