AI Asist
v2
Д
Д

Плейбук: перераспределение остатков WB с учётом локализации (ИЛ)

← Заметки  ·  Раздел: Wildberries  ·  Файл: data/notes/wb/redistribution-irp-playbook.md

Плейбук: перераспределение остатков 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. Грабли (обязательно к прочтению перед сборкой)

  1. Живые имена складов ≠ имена в mp.db. get_stocks/AvailableLimits отдают «Склад Казань», «Склад Шушары», «Новосемейкино», «Владимир Воршинское», «Краснодар (Тихорецкая)» — а REGION_BY_WAREHOUSE знает «Казань», «Самара (Новосемейкино)», «Владимир», «Краснодар». Прямой region_for(живое_имя) → «Прочие» (мимо). Нормализуй: срезай префикс «Склад », суффиксы «: Питание»/«: Горючее»/« СГТ», содержимое скобок; плюс явные патчи (Новосемейкино→Приволжье, Владимир Воршинское→Центр, Склад Шушары→СЗ). Иначе легитимный склад падает в «Прочие», считается «застрявшим» и генерит бессмысленный внутрикластерный move (реальный баг: «Склад Казань → Новосемейкино» = Приволжье→Приволжье).
  2. Классифицируй склады по office_id → кластер, а не по живому имени напрямую (см. п.1). В dry-run ВСЕГДА печатай таблицу office→кластер и глазами проверяй, что нет неожиданных «Прочие».
  3. Гранулярность per (nm × размер), НЕ per предмет. Если считать дефицит на уровне предмета и набивать перемещения тем, что лежит в источнике «от большего остатка» — получишь размерный перекос (напр. в один склад уедет гора XS, а дефицит M/S там не закрыт). Балансируй каждый размер отдельно.
  4. Порог 3. Не создавать перемещения < 3 шт.
  5. СЗ/Урал часто в недоборе. WB держит приёмку в СЗ/УФО закрытой (dst ∉ dstWarehouseIDs), плюс availability 0.33 занижает их цель. Эти куски остаются на месте — это норма, не ошибка.
  6. Один бот — один main-магазин (§4). Не забыть переключить .env на нужный магазин.
  7. 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. Чек-лист

  1. Определил магазин (shop_id в mp.db) и предметы.
  2. Посчитал предметные доли спроса за 90 дней по 8 кластерам (Москва/МО отдельно).
  3. Целевые доли = спрос × availability, нормировка по отгружаемым.
  4. По каждой паре (nm × размер): base, target, current, delta.
  5. Перемещения излишек→дефицит, порог 3, не внутри кластера.
  6. Классификацию складов проверил (office→кластер таблицей), живые имена нормализованы.
  7. Сверил выполнимость: dst ∈ dstWarehouseIDs (живой get_stocks); закрытые маршруты — в недобор.
  8. Собрал задачи (nm+src+dst+items), supplier нужного магазина, status=pending.
  9. Переключил main бота на нужный магазин, поднял, проверил tasks в running.
  10. Проговорил, что НЕ поехало и почему (СЗ/Урал закрыты, мелочь < порога).
Фильтры