Плейбук: перераспределение остатков WB с учётом локализации (ИЛ)
Как из перекошенного FBO-остатка магазина собрать задачи перераспределения между складами, чтобы поднять локализацию заказов и снизить логистику. Основа конспекта собрана 2026-07-10 на живом прогоне по Siluetta (shop_id=3). Смежный плейбук допоставки —
replenishment-playbook.md.ИЛ (индекс локализации) — множитель прямой логистики 0,50–2,00, пересчёт раз в неделю по окну 13 недель, ступенчатый КТР по каждому артикулу. Полная теория, шкала КТР, shadow-расчёт Siluetta (ИЛ 1,14) и формулы порогов —
data/knowledge/wb/localization-index.md. Механика сборки задач ниже учитывает зоны ИЛ и приоритизацию по КТР-ступеням.⚠️ Обновление 2026-07-21: зафиксирована тарифная механика перераспределения. Стоимость самого перемещения и последующей доставки покупателю считаются по разным коэффициентам; см. §0.1. Старое утверждение «перераспределение бесплатно» без оговорок использовать нельзя.
🚫 Статус на 16.08.2026: фактическое перемещение отключено WB. В официальной инструкции, обновлённой 29.07.2026, указано, что опция временно недоступна, возможность перемещения отключена и поштучное перераспределение тоже не работает. Пока этот статус не снят на официальной странице и в кабинете, разрешены только расчёт и dry-run: не создавать
pending/runningзадачи и не запускатьtransfer/order.
Срабатывает, когда просят: «сделай перераспределение», «поставить задачи на распределение»,
«выровнять остатки по регионам», «перекинуть товар с перекошенного склада», «распределить
с учётом локализации». Пока функция WB отключена, результатом запроса является план с
пометкой DRY RUN, а не запись задач в боевую БД.
0. Зачем это вообще: связь с ИЛ
WB умножает прямую логистику каждого обычного заказа на ИЛ:
базовый тариф по объёму × коэффициент склада × ИЛ, диапазон ИЛ 0,50–2,00
(1,00 — нейтрально; к обратной логистике и КГТ+/СГТ/КБТ не применяется).
Расчёт: доля локальных заказов каждого артикула за 13 недель → ступенчатый КТР
артикула (60–74,99% → 1,00; ниже — дороже вплоть до 2,00; выше — скидка до 0,50) →
ИЛ магазина = средневзвешенный КТР по числу заказов. Пересчёт — в ночь вс→пн МСК.
Рычаг перераспределения: разложить остаток каждого товара по складам так, чтобы в каждом кластере лежало примерно столько, сколько там спроса. Тогда заказы чаще исполняются локально → растёт доля локализации артикулов → падают их КТР → падает ИЛ. Само перемещение оплачивается либо поштучно, либо через дополнительную комиссию подключённой опции конструктора тарифов; подробности и формулы — в §0.1.
Приоритизация под ИЛ (новое): шкала КТР ступенчатая (границы каждые 5%), поэтому
эффект = вес_артикула × ΔКТР — начинать не с худшего процента, а с объёмного артикула
у границы ступени (формулы порогов — в localization-index.md §6). Ставить по размерам:
дефицит одного ходового размера в зоне делает нелокальными заказы всего артикула.
Важно: buyout 25-30% → на 1 выкуп ~4 заказа, логистика (и ИЛ) применяется ко всем 4. Поэтому эффект локализации в 3-4× больше, чем кажется «по продажам».
0.1. Как считаются тарифы после перераспределения
Тарифная механика проверена на 21.07.2026; доступность операции проверена на 16.08.2026 и сейчас отключена. Не смешивать три разных начисления:
| Что считаем | Какой тариф / коэффициент применяется |
|---|---|
| Поштучное перемещение остатка со склада A на склад B | базовая логистика × коэффициент логистики склада назначения B; ИЛ в формуле перемещения не указан |
| Прямая логистика заказа покупателю до конца фиксации исходной поставки | зафиксированные базовый тариф и коэффициент склада первой приёмки A × текущий ИЛ магазина |
| Прямая логистика после окончания фиксации | актуальный тариф на остаток склада, где товар фактически находится на момент заказа × текущий ИЛ |
| Хранение перераспределённого товара | фиксированный тариф склада первой приёмки; перенос на B сам по себе его не заменяет |
То есть при обычном перераспределении уже принятых остатков склад назначения B не меняет коэффициент последующих заказов немедленно. До конца периода фиксации действует склад, где WB впервые принял товар, причём это не обязательно непосредственный склад-источник текущего перемещения. После окончания фиксации логистика переключается на актуальный тариф фактического склада хранения.
Срок фиксации отсчитывается от первоначальной передачи товара продавцом WB, а не от даты перераспределения, и заново не запускается:
- 90 дней — одежда, спортивная одежда, головные уборы, обувь, одежда и аксессуары для малышей, бельё для новорождённых;
- 60 дней — остальные категории.
Пример для одежды: товар впервые приняли на A с коэффициентом 100%, на 30-й день переместили на B с коэффициентом 200%. Поштучное перемещение считается с коэффициентом B = 200%; заказы до конца исходных 90 дней — с зафиксированным коэффициентом A = 100% и текущим ИЛ; с 91-го дня, если товар всё ещё лежит на B, — по актуальному тарифу B и текущему ИЛ. Если переместить товар на 80-й день, старый коэффициент останется примерно на 10 дней, а не ещё на 90.
Как оплачивается само перемещение:
- без опции конструктора тарифов — отдельное поштучное списание по коэффициенту склада назначения; в финансовом отчёте комментарий «Поштучное перераспределение остатков между складами»;
- с опцией «Перераспределение остатков между складами» — отдельного поштучного списания нет, но стоимость заложена в дополнительную комиссию со всех продаж в период действия опции, а не только с перераспределённых товаров.
Связь с ИЛ: тарифный коэффициент до конца фиксации остаётся от первой приёмки, но локальность нового заказа WB определяет по фактическому складу, с которого товар отправили покупателю. Поэтому региональное перемещение может улучшать долю локальных заказов и будущий ИЛ, не меняя сразу зафиксированный коэффициент логистики. Сам ИЛ пересчитывается еженедельно по окну 13 недель.
Не путать с новой поставкой «На нескольких складах»
Для новой FBW-поставки с вариантом «На нескольких складах» действует отдельная механика:
- WB сам выбирает склады и количества; продавец привозит всю поставку на один склад отгрузки;
- перевозка от склада отгрузки к складам назначения — без отдельного списания;
- коэффициент округа склада отгрузки применяется ко всем распределённым единицам и фиксируется на 60/90 дней; после срока применяется тариф фактического склада;
- заказы таких товаров считаются заказами-исключениями с КТР 1,00 в течение года с приёмки;
- функция пока доступна не всем продавцам, а предложенные склады и количества менять нельзя.
Для новой партии с КТР артикула выше 1,00 этот вариант нужно проверять раньше ручного перераспределения: он может нейтрализовать вклад партии в ИЛ и не требует оплаты межскладской перевозки. Если КТР артикула уже ниже 1,00, фиксация КТР на 1,00 может быть невыгодна.
Официальные источники WB:
- Перераспределение остатков между складами, обновлено 29.07.2026; на 16.08.2026 содержит явное уведомление об отключении обоих способов перемещения.
- Фиксация тарифов: тарифы на поставку и на остаток, обновлено 13.07.2026.
- Как распределить товары из поставки на несколько складов, обновлено 03.06.2026.
- Оферта WB, раздел 6 — пп. 6.6.3–6.6.6.
- Оферта WB, раздел 13 — пп. 13.1.16 и 13.15.3.
1. Кластеры (регионы) и какой склад к какому относится
Мы группируем НЕ по официальным ФО WB, а по своим бизнес-зонам (8 бакетов). Программный
источник истины — app/reference/warehouses.py (REGION_BY_WAREHOUSE, region_for,
region_for_delivery). Правило локализации WB объединяет ЮФО+СКФО и СФО+ДФО — у нас
это отражено.
| Кластер | Что входит | Доступность | Склад-получатель (перераспределение) |
|---|---|---|---|
| Москва и МО | Москва + Московская обл. (вынесена ИЗ Центрального!) | 1.0 | Коледино (507) |
| Южный и СК | ЮФО + Северо-Кавказский (объединены) | 1.0 | Краснодар (130744) → Волгоград (301983) → Невинномысск (208277) |
| Приволжский | ПФО | 1.0 | Самара / Новосемейкино (301805) |
| Центральный | ЦФО без Москвы/МО | 1.0 | Тула (206348) |
| Северо-Западный | СЗФО | 0.33 | Склад Шушары (50045246; НЕ 300987 — стейл) |
| Уральский | УФО | 0.33 | Екатеринбург — Перспективная 14 (300571) |
| Сибирь | СФО + ДФО (объединены) | 0.0 | — (нет приёмки) |
| Прочие | СНГ (Армения/Беларусь/Грузия/Казахстан/Узбекистан) + неопознанные | 0.0 | — |
Москва и МО — ВСЕГДА отдельный кластер, хотя WB относит её к Центральному: оттуда ~20-32%
спроса, это самостоятельная зона. region_for_delivery выделяет её по regionName ∈ {Москва,
Московская область} при oblastOkrugName = Центральный ФО.
⚠️ Но для расчёта ИЛ зоны — официальные ФО WB: ЦФО (Москва и МО ВНУТРИ него!), СЗФО, ПФО, УФО, ЮФО+СКФО, СФО+ДФО. Следствия: (1) перемещения Москва↔Центр (напр. Коледино→Тула) ИЛ не меняют; (2) в shadow-расчёте ИЛ Москву+Центр обязательно объединять в ЦФО — иначе ложная локализация (у Siluetta даёт мнимые 46,79% и завышенный ИЛ ~1,24 вместо 1,13).
Доктрина планирования (подтверждена юзером 2026-07-14): Москва и МО — приоритетная зона наполнения НЕЗАВИСИМО от ИЛ. Товар там влияет на срок доставки и выдачу (ранжирование/конверсию), поэтому Москва всегда получает свою процентовку по заказам/ продажам из Москвы и МО, как отдельный кластер спроса. ИЛ — вторая линза: он добавляет ценности межзональным маршрутам (Юг, ПФО, СЗ, Урал), но НЕ отменяет и не урезает московскую долю. Расчёт остаётся на наших 8 бизнес-кластерах; зоны ИЛ используются только для shadow-расчёта индекса и оценки ИЛ-эффекта конкретного маршрута.
Доступность (REGION_AVAILABILITY) — это ОПЕРАТОРНЫЙ коэффициент планирования
(«насколько свободно мы можем туда завозить»), НЕ измеренная частота открытия квот:
1.0 — везём свободно, 0.33 — можем с трудом, 0.0 — практически недоступно. Он занижает
целевую долю остатка для СЗ/Урал (у WB там часто ограничена приёмка) и обнуляет Сибирь/Прочие
(туда не кладём вовсе). Реально открыта ли квота на приём — это отдельная живая величина
(dstQuota, см. §5), коэффициент её не определяет.
Полный список склад→кластер — в warehouses.py (Москва/МО: ~40 складов, Юг: 12, Приволжье: 14,
Центр: 18, СЗ: 11, Урал: 10, Сибирь: 17, Прочие: 15).
2. Данные
| Что | Источник |
|---|---|
| Спрос по кластерам | orders.raw_json в mp.db: subject (предмет), oblastOkrugName+regionName (адрес доставки) → region_for_delivery() |
| Текущий FBO-остаток | stocks_snapshot (последний snapshot_at): warehouse, tech_size, quantity |
| Предмет-родитель | из orders.subject / products.category: «Комбинезоны спортивные»→Комбинезоны, «Майки бельевые»→Майки, «Леггинсы»→Легинсы |
| shop_id | shops: Siluetta WB = 3 (amityaev25 = 1, Tote WB = 5) |
| Живой остаток + office_id + допустимые dst | WB API бота: wb_client.get_stocks(nm) (office_id, chrtID, count, dstWarehouseIDs), get_all_quotas() (srcQuota/dstQuota) |
mp.db — 1 ГБ, с WAL. Читать sqlite3.connect("file:mp.db?mode=ro", uri=True) БЕЗ immutable=1
(иначе не видит свежий WAL — та же ловушка, что с bot.db).
Названия складов в mp.db НОРМАЛИЗОВАНЫ (совпадают с REGION_BY_WAREHOUSE), а живой WB-API
отдаёт ДРУГИЕ имена — см. §6 (грабли).
3. Как считать — пошагово
Гранулярность расчёта: по (артикул × размер), т.е. на каждую пару (nm_id, tech_size)
отдельно. Проценты спроса — предметные (см. ниже почему).
Шаг 1. Доли спроса по кластерам (предметные, 90 дней)
Для каждого предмета (Майки/Легинсы/Комбинезоны целиком) за последние 90 дней:
raw_share[кластер] = заказов_в_кластер / всего_заказов_предмета
Кластер заказа = region_for_delivery(oblastOkrugName, regionName).
Почему спрос предметный, а не по-артикульный: по отдельному (nm × размер) заказов мало,
процент шумный. По предмету статистика плотная. Поэтому доля берётся предметная, но
ПРИМЕНЯЕТСЯ к количеству каждой пары (nm × размер) в шаге 3.
Шаг 2. Целевые доли (спрос × доступность, нормировка)
Только отгружаемые кластеры (доступность > 0): Москва, Юг, Приволжье, Центр, СЗ, Урал.
weighted[к] = raw_share[к] × availability[к]
target_share[к] = weighted[к] / Σ weighted # нормировка до 100% по 6 кластерам
Сибирь/Прочие получают 0 (availability 0) — туда не целимся, их спрос перераспределяется на доступные кластеры.
Шаг 3. Цель / текущее / дельта — по каждой паре (nm × размер)
base = Σ quantity(nm, size) по ВСЕМ складам # 100% этой пары
target_units[к] = round(target_share[к] × base)
current[к] = Σ quantity(nm, size) на складах кластера к
delta[к] = target_units[к] − current[к]
delta > 0 → дефицит (везём сюда), delta < 0 → излишек (везём отсюда).
Суть: каждый размер выравнивается САМ ПО СЕБЕ. Нельзя, чтобы «S весь в Невинномысске, M в Самаре, XL в Москве, а суммарно по предмету проценты сходятся» — по каждому размеру своя регионалка. Именно это отличает правильный расчёт от наивного (агрегировать по предмету и набивать перемещения чем попало из источника).
Шаг 3б. Если дефицит кластера закрываем внешней поставкой (не перемещением)
Когда юзер готов довезти товар поставкой («докинем руками на Волгоград»), простая
delta = target − current ЗАНИЖАЕТ объём: поставка увеличивает общий base, и цель
кластера растёт вместе с ним. Решаем (current + X) / (base + X) = target_share:
X = (target_share × base − current) / (1 − target_share) # округлить вверх
Пример (Tote ELS04 grafit M, 2026-07-13): base 15, Юг цель 37.8%, факт 0 → простая дельта 6, а корректная докидка X = (0.378×15 − 0) / 0.622 ≈ 10. Порог 3 применяется и здесь (X < 3 — не возить).
Шаг 4. Перемещения (жадно, порог 3)
Для каждой пары (nm × размер): из складов излишковых кластеров → в склад-получатель
дефицитного кластера (REDISTRIBUTION_TARGETS, §1). Жадно: сначала самые крупные источники,
самые большие дефициты.
Порог отсечки: не двигать < 3 шт. Куски меньше 3 не создаём (мелочь не окупает движение и
плодит задачи). Пары с base < 3 пропускаем целиком.
Не двигать внутри одного кластера (src_кластер == dst_кластер — бессмысленно; следи за корректной классификацией склада, см. §6).
Шаг 5. Проверка выполнимости (WB-ограничения)
После восстановления функции заявка transfer/order пройдёт только если ОДНОВРЕМЕННО
(проверяет бот в момент выстрела, но dst-структуру лучше проверить на этапе сборки через
живой get_stocks):
stock@src > 0 (есть остаток на складе-источнике; из get_stocks)
srcQuota@src > 0 (открыта квота на вывоз; AvailableLimits)
dstQuota@dst > 0 (открыта квота на приём; AvailableLimits)
dst ∈ dstWarehouseIDs (WB структурно разрешает этот src→dst для этого размера; из get_stocks)
Если dst ∉ dstWarehouseIDs — маршрут невозможен, товар остаётся на месте (частый случай для
СЗ: WB держит приёмку закрытой). Такие куски уходят в «недобор», задачи по ним НЕ создаём.
4. Как задачи лягут в бота после восстановления функции
До снятия уведомления WB от 29.07.2026 этот раздел использовать только как описание
формата: не вставлять строки pending, не переводить задачи в running и не перезапускать
бота ради выстрела.
Бот: /home/mi/Bot_TG/Pererasp/wb-bot-active/, БД bot.db, таблица tasks.
Одна задача = один nm_id + src_office_id + dst_office_id + items[] (в items
можно несколько размеров: [{"chrtID": …, "count": …}, …]). Поэтому перемещения одной пары
группируем по (nm, src, dst) → один таск на маршрут с массивом размеров.
Поля вставки (status = pending): nm_id, src_office_id, src_name, dst_office_id, dst_name,
items(JSON), status, created_at, chat_id, card_title(=предмет), supplier_id, shop_name, bot_id.
Исторический одноразовый сборщик build_siluetta_tasks_v2.py не сохранён ни локально,
ни на сервере; воспроизводимым контрактом считать схему и алгоритм этого плейбука.
Критично — один инстанс бота обрабатывает задачи ТОЛЬКО своего main-магазина
(monitor.py: task.supplier_id == self._client.account_id). Значит, чтобы бот отрабатывал
Siluetta-задачи, его main должен быть Siluetta: в .env WB_COOKIE_SUPPLIER_ID = supplier
Siluetta + SHOP_NAME=Siluetta, затем systemctl restart wb-bot. (Multi-shop shop_clients
существуют, но фильтр задач всё равно по main — не полагаться.)
Выстрелы идут в окно капчи (CAPTCHA_AUTO_TIME_MSK, ~03:55 МСК) — transfer/order требует
антибот-токен, который минтит captcha_provider.py. Задачи в running видны и отменяемы в
Telegram-меню бота до выстрела.
Маппинг supplier↔магазин (WB user 16601326 владеет всеми тремя):
476b5a0e…=Siluetta, 5cbdd021…=Shakti, 21b0201f…=Tote. (В .env метка SHOP_NAME
исторически могла врать — сверяйся по supplier_id, не по имени.)
5. Справочник office_id (снято с живого Siluetta, 2026-07-10)
Живой WB-API оперирует числовыми office_id, не именами. Известные:
| office_id | Склад (живое имя) | Кластер | Роль |
|---|---|---|---|
| 507 | Коледино | Москва и МО | получатель Москва |
| 120762 | Электросталь | Москва и МО | |
| 130744 | Краснодар (Тихорецкая) | Южный и СК | получатель Юг / источник |
| 301983 | Волгоград | Южный и СК | источник (перекос) |
| 208277 | Невинномысск | Южный и СК | источник (перекос) |
| 301805 | Новосемейкино (Самара) | Приволжский | получатель Приволжье |
| 117986 | Склад Казань | Приволжский | |
| 50045809 | Пенза | Приволжский | |
| 301987 | Сарапул | Приволжский | |
| 206348 | Тула | Центральный | получатель Центр / источник |
| 301809 | Котовск | Центральный | |
| 301808 | Воронеж | Центральный | |
| 301981 | Владимир Воршинское | Центральный | |
| 301760 | Рязань (Тюшевское) | Центральный | |
| 50045246 | Склад Шушары | Северо-Западный | получатель СЗ (часто закрыт) |
| 300571 | Екатеринбург — Перспективная 14 | Уральский | получатель Урал |
office_id могут меняться; сверяться живым
get_stocks/AvailableLimits.300987(«Шушары») — устаревший, в текущем AvailableLimits его нет.
6. Грабли (обязательно к прочтению перед сборкой)
- Живые имена складов ≠ имена в mp.db.
get_stocks/AvailableLimitsотдают «Склад Казань», «Склад Шушары», «Новосемейкино», «Владимир Воршинское», «Краснодар (Тихорецкая)» — аREGION_BY_WAREHOUSEзнает «Казань», «Самара (Новосемейкино)», «Владимир», «Краснодар». Прямойregion_for(живое_имя)→ «Прочие» (мимо). Нормализуй: срезай префикс «Склад », суффиксы «: Питание»/«: Горючее»/« СГТ», содержимое скобок; плюс явные патчи (Новосемейкино→Приволжье, Владимир Воршинское→Центр, Склад Шушары→СЗ). Иначе легитимный склад падает в «Прочие», считается «застрявшим» и генерит бессмысленный внутрикластерный move (реальный баг: «Склад Казань → Новосемейкино» = Приволжье→Приволжье). - Классифицируй склады по office_id → кластер, а не по живому имени напрямую (см. п.1). В dry-run ВСЕГДА печатай таблицу office→кластер и глазами проверяй, что нет неожиданных «Прочие».
- Гранулярность per (nm × размер), НЕ per предмет. Если считать дефицит на уровне предмета и набивать перемещения тем, что лежит в источнике «от большего остатка» — получишь размерный перекос (напр. в один склад уедет гора XS, а дефицит M/S там не закрыт). Балансируй каждый размер отдельно.
- Порог 3. Не создавать перемещения < 3 шт.
- СЗ/Урал часто в недоборе. WB держит приёмку в СЗ/УФО закрытой (
dst ∉ dstWarehouseIDs), плюс availability 0.33 занижает их цель. Эти куски остаются на месте — это норма, не ошибка. - Один бот — один main-магазин (§4). Не забыть переключить
.envна нужный магазин. - read_only=False при разборе ответа
transfer/order(баг парсера xlsx: WB-файл объявляет ложный<dimension ref="A1">, read_only читает только заголовок → успех выглядит провалом). Уже исправлено вwb_client._parse_order_xlsx, но помнить.
7. SQL / рецепты
Диапазон и объём заказов (shop 3, 90 дней):
SELECT MIN(date), MAX(date) FROM orders WHERE shop_id=3;
-- окно: date >= date(<MAX>, '-90 day')
Доли спроса по кластерам (в Python, т.к. кластер считается функцией):
from app.reference.warehouses import region_for_delivery
# по каждому orders.raw_json: parent(subject) + region_for_delivery(oblastOkrugName, regionName)
Текущий FBO по (nm, размер, склад):
SELECT nm_id, tech_size, warehouse, quantity
FROM stocks_snapshot
WHERE shop_id=:shop AND snapshot_at=(SELECT MAX(snapshot_at) FROM stocks_snapshot WHERE shop_id=:shop)
AND quantity>0;
Живой остаток + допустимые dst (бот, под нужным supplier):
await client.get_stocks(nm) # StockItem: office_id, warehouse_name, chrt_id, size, quantity, dst_office_ids
await client.get_all_quotas() # {office_id: {"src","dst","name"}}
8. Рабочий пример (Siluetta, 2026-07-10)
- Спрос 90 дн по предметам: Майки 1574 зак., Легинсы 558, Комбинезоны 122. Москва/МО ~17-23%.
- Перекос: Майки/Легинсы в Юг+СК ~40% остатка при ~17% спроса; дефицит в Приволжье/Москве.
- Расчёт per (nm × размер), порог 3 → 18 задач, 189 шт (Майки XS: Невинномысск→Самара; M/S/XL из Волгограда → Коледино/Самара/Тула; Легинсы S: Волгоград→Коледино/Самара).
- СЗ (Склад Шушары) — весь в недоборе (dst закрыт), ~30 шт остались. Комбинезоны выпали (по nm×размеру дельты < 3).
- Исторический сборщик брал спрос из
mp.db, остаток/office/dst из живогоget_stocks, отменял старые Siluetta-задачи и вставлял новые только с гейтомCOMMIT=1; сам одноразовый файл не сохранился и не должен считаться зависимостью.
9. Чек-лист
- Открыл официальную инструкцию WB и кабинет. Если перемещение всё ещё отключено — только dry-run, без записи и запуска задач.
- Определил магазин (shop_id в mp.db) и предметы.
- Посчитал предметные доли спроса за 90 дней по 8 кластерам (Москва/МО отдельно).
- Целевые доли = спрос × availability, нормировка по отгружаемым.
- По каждой паре (nm × размер): base, target, current, delta.
- Перемещения излишек→дефицит, порог 3, не внутри кластера.
- Классификацию складов проверил (office→кластер таблицей), живые имена нормализованы.
- Сверил выполнимость: dst ∈ dstWarehouseIDs (живой get_stocks); закрытые маршруты — в недобор.
- Только после восстановления WB: собрал задачи (nm+src+dst+items), supplier нужного магазина, status=pending.
- Только после восстановления WB: переключил main бота на нужный магазин, поднял, проверил tasks в running.
- Проговорил, что НЕ поехало и почему (СЗ/Урал закрыты, мелочь < порога).