Плейбук: перераспределение остатков WB с учётом локализации (ИЛ)
Как из перекошенного FBO-остатка магазина собрать задачи перераспределения между складами, чтобы поднять локализацию заказов и снизить логистику. Конспект собран 2026-07-10 на живом прогоне по Siluetta (shop_id=3). Смежный плейбук допоставки —
replenishment-playbook.md.⚠️ Обновление 2026-07-14: ИРП отменён WB для заказов с 13.07.2026. Действует ИЛ (индекс локализации) — множитель прямой логистики 0,50–2,00, пересчёт раз в неделю по окну 13 недель, ступенчатый КТР по каждому артикулу. Полная теория, шкала КТР, shadow-расчёт Siluetta (ИЛ 1,14) и формулы порогов —
data/knowledge/wb/localization-index.md(актуальный источник; старая теория ИРП — архивdata/knowledge/wb/index-irp.md). Механика сборки задач ниже не изменилась, но см. правки в §0 и §1 про зоны ИЛ и приоритизацию по КТР-ступеням.
Срабатывает, когда просят: «сделай перераспределение», «поставить задачи на распределение», «выровнять остатки по регионам», «перекинуть товар с перекошенного склада», «распределить с учётом ИРП / локализации».
0. Зачем это вообще (связь с ИЛ; ИРП отменён с 13.07.2026)
WB умножает прямую логистику каждого обычного заказа на ИЛ:
базовый тариф по объёму × коэффициент склада × ИЛ, диапазон ИЛ 0,50–2,00
(1,00 — нейтрально; к обратной логистике и КГТ+/СГТ/КБТ не применяется).
Расчёт: доля локальных заказов каждого артикула за 13 недель → ступенчатый КТР
артикула (60–74,99% → 1,00; ниже — дороже вплоть до 2,00; выше — скидка до 0,50) →
ИЛ магазина = средневзвешенный КТР по числу заказов. Пересчёт — в ночь вс→пн МСК.
Рычаг перераспределения: разложить остаток каждого товара по складам так, чтобы в каждом кластере лежало примерно столько, сколько там спроса. Тогда заказы чаще исполняются локально → растёт доля локализации артикулов → падают их КТР → падает ИЛ. Перераспределение бесплатно двигает уже купленный товар (в отличие от новой поставки, которая ещё и морозит деньги).
Приоритизация под ИЛ (новое): шкала КТР ступенчатая (границы каждые 5%), поэтому
эффект = вес_артикула × ΔКТР — начинать не с худшего процента, а с объёмного артикула
у границы ступени (формулы порогов — в localization-index.md §6). Ставить по размерам:
дефицит одного ходового размера в зоне делает нелокальными заказы всего артикула.
Важно: buyout 25-30% → на 1 выкуп ~4 заказа, логистика (и ИЛ) применяется ко всем 4. Поэтому эффект локализации в 3-4× больше, чем кажется «по продажам».
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. Как лягут в бота (задачи перераспределения)
Бот: /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.
Пример живой вставки — scratchpad/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).
- Скрипт-сборщик:
scratchpad/build_siluetta_tasks_v2.py(спрос из mp.db, остаток/office/dst из живого get_stocks, отмена старых Siluetta-задач + вставка новых, гейтCOMMIT=1).
9. Чек-лист
- Определил магазин (shop_id в mp.db) и предметы.
- Посчитал предметные доли спроса за 90 дней по 8 кластерам (Москва/МО отдельно).
- Целевые доли = спрос × availability, нормировка по отгружаемым.
- По каждой паре (nm × размер): base, target, current, delta.
- Перемещения излишек→дефицит, порог 3, не внутри кластера.
- Классификацию складов проверил (office→кластер таблицей), живые имена нормализованы.
- Сверил выполнимость: dst ∈ dstWarehouseIDs (живой get_stocks); закрытые маршруты — в недобор.
- Собрал задачи (nm+src+dst+items), supplier нужного магазина, status=pending.
- Переключил main бота на нужный магазин, поднял, проверил tasks в running.
- Проговорил, что НЕ поехало и почему (СЗ/Урал закрыты, мелочь < порога).