Плейбук: отгрузка (догрузка) на Ozon FBO — Siluetta Ozon (shop 4)
Срабатывает, когда пользователь пишет «Хочу сделать отгрузку на Озон», «догрузить товары на озон», «создать поставку озон», «фулфилмент подготовил товары» и т.п.
Это рабочая инструкция под этот стек: данные в корневом mp.db, магазин
shop_id = 4 (Siluetta Ozon), интерпретатор .venv/bin/python (Linux) или
.venv\Scripts\python.exe (Windows),
генератор scripts/ozon_supply_<DATE>.py. Теория Ozon (локальность, наценки, состав
кластеров) — в data/knowledge/ozon/ и в Stock-Ozon-Orders-Sales/docs/supply-playbook.md.
Принцип (теория локальности): возим туда, где спрос был, а локального товара нет/мало.
Нелокальная продажа всё равно числится в кластере покупателя → cluster_to_name = истинный
гео-спрос, даже если товара там не было. Догрузка дефицита поднимает долю локальных продаж
и режет переплату за логистику.
Модель данных (mp.db, всё с shop_id = 4)
| Таблица | Что | Ключевые поля |
|---|---|---|
ozon_postings |
Заказы | posting_number, status, created_at, cluster_to_name (кластер покупателя = спрос) |
ozon_posting_items |
SKU в заказах | posting_number, offer_id, name, quantity |
ozon_stocks_snapshot |
Legacy-fallback FBO по складам | snapshot_at, offer_id, warehouse_name, free_to_sell, promised, reserved |
ozon_warehouses |
Склад → кластер | name (склад), cluster_name |
ozon_clusters |
Справочник кластеров | name |
- Join заказов:
ozon_postings p JOIN ozon_posting_items pi ON pi.posting_number = p.posting_number AND pi.shop_id = p.shop_id. offer_id=АРТИКУЛ-цвет-Размер, напр.ELS04-black-S,MS-07-black-M. Цвет lowercase, размер UPPERCASE. Регистр обязан совпадать с карточкой Ozon, иначе Excel не подцепится.- ⚠ Склад→кластер берём из
ozon_warehouses, НЕ из xlsx-справочникаdata/knowledge/ozon/ klastery-dostavki_v2.xlsx— тот неполный (нет Ростова, Новосибирска и др.; на прошлой задаче из-за него Ростову/Новосибу ложно показался сток=0). xlsx — только людям для справки.
Актуальные правила учёта (перепроверено 15.08.2026)
Базовые направления и полный имущественный итог — FBO / к клиенту / от клиента /
поставка на FBO / особые статусы / всего — описаны в
data/notes/inventory-value-playbook.md, раздел «Ozon: подробный алгоритм и endpoints».
В этом плейбуке transit и requested всегда относятся к поставкам продавца на склад Ozon;
клиентское движение считается только по FBO-postings и не уменьшает дефицит поставки
напрямую.
- Основной источник для действующего SKU — Seller API
POST /v1/analytics/stocks: он даётads_clusterза 28 дней, доступный и готовящийся товар, заявки и FBO-транзит. Локальная БД — контрольный источник и fallback. - Ответ
/v1/analytics/stocksможет содержать несколько строк одногоSKU × кластерпо складам. Остатки суммируем, аads_clusterберём один раз (MAX), не суммируем: ADS повторяется в строках складов. - Числовые SKU для
/v1/analytics/stocksполучать по всему каталогу через/v3/product/list→/v3/product/info/list, а не только из ненулевых строк/v4/product/info/stocks: иначе новые/нулевые карточки могут молча выпасть из запроса. - Текущий учитываемый запас для дефицита:
available_stock_count + valid_stock_count. Подтверждённый inbound:transit_stock_count+ подтверждённая активная частьrequested_stock_count. Заявку проверяем через/v3/supply-order/list→/v3/supply-order/get→/v1/supply-order/bundle. - Если analytics запаздывает и
transit = 0, а supply-order подтверждает физическое движение, количество активного bundle можно временно использовать вместо нулевого transit. - Analytics и supply-order показывают один и тот же inbound с разных сторон: supply-order
используем для проверки статуса, состава и кластера, но не прибавляем его второй раз поверх
уже учтённых
requested_stock_count/transit_stock_count. - В legacy
/v2/analytics/stock_on_warehouses/ozon_stocks_snapshotполеpromisedсоответствует входящему FBO-транзиту. На shop 4 15.08.2026 оно совпало сtransit_stock_countпо SKU и итогу: 60 = 60. Использовать только как fallback и никогда не прибавлять одновременно сtransit_stock_count. reserved— клиентский резерв, а не inbound. Он совпадает с postingsawaiting_packaging; эти единицы уже проданы/зарезервированы и не являются доступным запасом для нового спроса.awaiting_deliverиdelivering— исходящий поток к клиенту. Ни один из этих клиентских статусов не прибавлять к входящей поставке.REPORTS_CONFIRMATION_AWAITINGозначает согласование актов после физической приёмки. Если снимок остатков сделан позже приёмки, такую поставку второй раз не прибавляем.- Не использовать для нового кода устаревающий
/v2/analytics/stock_on_warehousesи старые/v1/draft/create/info,/v1/draft/timeslot/info,/v1/draft/supply/create(отключение было назначено на 16.03.2026). Актуальный flow: отдельные create-методы черновика +/v2/draft/*.
Шаги
Шаг 1. Вход — что подготовил фулфилмент
Получить от пользователя таблицу артикул + размер → количество (это объём к распределению, обычно весь отгружаем). Зафиксировать суммы по каждому SKU — это контроль в конце. Отметить новые артикулы (нет истории на Ozon) — для них шаг 6 особый.
Шаг 2. Спрос — строго offer_id × кластер
Для существующих SKU основной сигнал — ads_cluster из /v1/analytics/stocks. Агрегат всей
категории нужен только для нового SKU без своей истории. Нельзя распределять размер S по
общему спросу всех размеров: один размер может быть затоварен, другой — отсутствовать.
Если API недоступен, строим fallback по неотменённым заказам. Период уточняем у пользователя (типично 28–30 дней), окна блогеров/разовых акций исключаем или считаем отдельным сценарием.
SELECT pi.offer_id, p.cluster_to_name, SUM(pi.quantity) AS qty
FROM ozon_postings p
JOIN ozon_posting_items pi
ON pi.posting_number = p.posting_number AND pi.shop_id = p.shop_id
WHERE p.shop_id = 4
AND p.created_at >= '2026-05-25' -- период/отсечка блогеров
AND p.status NOT IN ('cancelled','canceled')
AND pi.offer_id IN (...) -- только SKU текущей поставки
GROUP BY pi.offer_id, p.cluster_to_name
ORDER BY pi.offer_id, qty DESC;
Комбинезоны Siluetta =
ELS04-*иMS-07-*(name «Комбинезон спортивный…»).skirt-*— юбка.
Шаг 3. Остатки и уже едущие поставки — offer_id × кластер
В API суммируем складские строки до уровня offer_id × cluster_name по правилам выше. Если
используем локальный fallback, берём последний снимок и маппим склад через ozon_warehouses.
Fallback не видит valid_stock_count и requested_stock_count, поэтому без отдельной проверки
заявок/приёмки может завысить дефицит.
WITH snap AS (SELECT MAX(snapshot_at) m FROM ozon_stocks_snapshot WHERE shop_id=4)
SELECT
s.offer_id,
w.cluster_name,
SUM(s.free_to_sell) AS available_stock,
SUM(s.promised) AS inbound_transit,
SUM(s.reserved) AS reserved_for_customer
FROM ozon_stocks_snapshot s
JOIN ozon_warehouses w ON w.name = s.warehouse_name AND w.shop_id = s.shop_id
WHERE s.shop_id=4 AND s.snapshot_at=(SELECT m FROM snap)
AND s.offer_id IN (...)
GROUP BY s.offer_id, w.cluster_name
ORDER BY s.offer_id, w.cluster_name;
Перед расчётом проверить время обновления. Для локального fallback сначала освежить снимок:
.venv/bin/python scripts/ingest_ozon.py --shop-id 4 stocks.
Шаг 4. Целевой запас и дефицит
Считаем отдельно для каждого offer_id × кластер:
target = ads_cluster × (дни покрытия + lead time) × safety_factor
confirmed_inbound = transit_stock_count + подтверждённая активная часть requested_stock_count
deficit = max(0, target − available_stock_count − valid_stock_count − confirmed_inbound)
reserved/awaiting_packaging и товар в статусах awaiting_deliver/delivering сюда не
подставлять: это уже клиентский спрос, а не запас, доступный для покрытия будущих продаж.
Рабочий default при отсутствии указаний: 30 дней покрытия + 10 дней на создание/доставку/
приёмку, safety_factor = 1.0. Параметры фиксируем в SUMMARY.md. Нельзя сначала сложить
весь спрос и весь запас категории, а потом взять один общий max(...): избыток размера S не
должен скрывать дефицит M.
Шаг 5. Выбор кластеров
До ранжирования обязательно проверить ЧС: открыть .claude/skills/emergency/SKILL.md,
общий data/notes/чс/ПЛЕЙБУК — чрезвычайная ситуация.md и актуальный журнал Ozon в
data/notes/чс/. Поражённые, остановленные или временно не принимающие объекты исключать
до расчёта, даже если исторический спрос и дефицит у них высокие. Если Ozon не позволяет
надёжно исключить конкретный поражённый склад внутри кластера, исключать весь кластер до
подтверждения безопасного маршрута в свежем черновике.
Затем исключаем остальные направления, которые пользователь не готов обслуживать: слишком долгий/дорогой рейс, ограничения перевозчика. Оставшиеся кластеры ранжируем по сумме положительных SKU-дефицитов, обычно берём 5–7 кластеров. Минимум на кластер — 25 шт (бизнес-правило Siluetta; меньшие короба не формируем). Склад хранения внутри кластера окончательно предлагает Ozon.
При открытой серии ЧС проверяем концентрацию новой поставки. По указанию пользователя можно
снизить объём рискованного, но работающего направления на заданный процент и перенести его
в другой безопасный кластер того же макрорегиона. Это не ручной перенос готового файла:
матрицу offer_id × кластер пересчитываем заново, сохраняем суммы по SKU и минимум коробки.
В SUMMARY.md фиксируем дату проверки ЧС, исключённые объекты/кластеры, процент снижения и
кластер диверсификации.
Если пользователь исключил кластер или Ozon не дал слот, запускаем расчёт заново без него. Нельзя просто перенести весь готовый файл в соседний кластер: размерная потребность отличается.
Шаг 6. Раскладка фактического прихода
- Для каждого SKU распределяем только реально подготовленное количество. Если его меньше суммарного дефицита — делим по положительным дефицитам (Hamilton). Если больше — сначала закрываем дефицит, затем либо оставляем избыток на ФФ, либо, если весь объём надо отгрузить, выравниваем итоговые дни покрытия между выбранными кластерами (water-filling).
- После округления сумма по SKU обязана точно совпасть с подготовленным количеством.
- Новый артикул (истории нет): дефицит не посчитать → раскладываем по гео-паттерну спроса категории со сдвигом в крупнейший рынок (Москва — присутствие нового товара там, где спроса больше всего). Дальние/мелкие кластеры (Красноярск, ДВ) — по минимуму.
- После округления проверяем минимальную партию по кластеру. Если она не выдержана, удаляем слабый кластер и пересчитываем всю матрицу.
Шаг 7. Проверка возможности поставки
- Справочно проверить capacity через
GET /v1/supplier/available_warehouses. - Точный принимающий склад и bookable-слот подтверждаются только свежим черновиком. Capacity не гарантирует слот.
- Для чтения существующих заявок безопасны
/v3/supply-order/list,/v3/supply-order/get,/v1/supply-order/bundle. Создание/изменение черновика или заявки — внешняя мутация; без явного указания пользователя её не выполнять. - Локальное зеркало
Dev-API-Ozonпроверять поLAST_UPDATE.txt: версии методов могли измениться позже даты зеркала.
Шаг 8. Генерация Excel — один файл = один кластер
Формат Ozon (строго): заголовки lowercase артикул | имя (необязательно) | количество,
колонка «имя» пустая (Ozon подтянет из карточки), количество — целое > 0.
Скопировать прошлый генератор scripts/ozon_supply_2026-06-16.py, заменить ALLOC /
SUPPLY_TOTALS / DATE. Встроенный validate() упадёт, если суммы по колонкам ≠ заявке.
.venv/bin/python scripts/ozon_supply_<DATE>.py
Выход: data/ozon_supply/Siluetta_ozon_supply_<DATE>/*.xlsx + ZIP + консольная сводка.
Шаг 9. Контроль и выдача
- Σ по строкам = заявке (по каждому SKU). ✔ автоматически в
validate(). - Σ по столбцам = объём кластера; общий ИТОГО = весь приход.
- Регистр offer_id новых артикулов сверен с карточкой ЛК.
- В ответе пользователю: таблица раскладки + кликабельные ссылки на файлы, SUMMARY.md рядом.
Новый SKU в магазине, у которого своя история УЖЕ есть
Если у самого артикула истории нет, а у магазина есть — гео-паттерн берём у своего
магазина, а не по слепку shop 2 ниже (слепок нужен только когда пуст и магазин).
У shop 4 на 07.08.2026 накоплено 30 дн × 699 ед по 26 кластерам — достаточно.
Размерную раскладку в этом случае берём из выкупов WB по той же модели
(sales, sale_id LIKE 'S%'), это единственная реальная история размеров.
⚠ История WB по «старым» моделям может лежать под shop 1 (legacy WB), а не под 3/5 —
искать без фильтра по shop_id, иначе покажется «продаж нет».
Пример: поставка 07.08.2026 (scripts/ozon_supply_2026-08-07.py) — 4 новых на Ozon SKU,
кластеры по спросу shop 4, размеры по выкупам WB за ноя 2025 – май 2026.
Стартовый слепок кластеров (новый товар / нет своих данных)
Когда у магазина/артикула нет своей истории на Ozon (новый товар, новый магазин), гео-спрос берём по родственному магазину-референсу. Референс — shop 2 «Мой Ozon» (Shakti), у него есть история заказов.
Слепок ниже = доли кластеров в спросе Shakti за 2026-03-19 … 2026-05-23 (1034 ед, 24 кластера; в БД пока только этот период — не с декабря). Обновлять при накоплении данных или при ингесте более длинной истории.
| # | Кластер | доля | накопл. | наценка* | Замена, если не даёт слот |
|---|---|---|---|---|---|
| 1 | Москва, МО и Дальние регионы | 20.7% | 20.7% | 8% | Тверь / Ярославль (сам почти не закрывается) |
| 2 | Санкт-Петербург и СЗО | 11.7% | 32.4% | 8% | Ярославль / Калининград |
| 3 | Ростов | 9.5% | 41.9% | 0% | Краснодар / Саратов |
| 4 | Дальний Восток | 9.1% | 51.0% | 0% | Новосибирск / Красноярск |
| 5 | Новосибирск | 5.7% | 56.7% | 0% | Екатеринбург / Красноярск |
| 6 | Красноярск | 4.6% | 61.3% | 0% | Новосибирск / Екатеринбург |
| 7 | Казань | 4.4% | 65.7% | 8% | Самара / Уфа |
* ориентировочная наценка за нелокальность (см. Stock-Ozon-Orders-Sales/docs/supply-playbook.md).
Замены — географически близкий, НЕ закрытый кластер с похожей нишей покупателей. Берём,
если основной кластер в исключениях или Ozon не даёт слот. Способ: либо отдельный Excel на
замену, либо файл с несколькими вкладками (как Замена_Екатеринбурга_Уфа_Самара_Тюмень.xlsx).
Прим.: Иркутск/Бурятия/Хакасия отдельным кластером не идут — они внутри «Красноярска».
Рекомендация для нового товара: брать топ-5 (Москва, СПб, Ростов, ДВ, Новосибирск = 57% спроса) или топ-7 (+Красноярск, Казань = 66%). Москва+СПб — всегда; Ростов/ДВ/ Новосибирск/Красноярск — 0%-наценка, хороши для присутствия. Распределять объём по долям (Hamilton). Когда у самого товара появится история — переходить на его дефицитную раскладку (шаги 2–4), слепок нужен только для холодного старта.
Пересчёт слепка:
SELECT p.cluster_to_name, SUM(pi.quantity) qty
FROM ozon_postings p JOIN ozon_posting_items pi
ON pi.posting_number=p.posting_number AND pi.shop_id=p.shop_id
WHERE p.shop_id=2 AND p.status NOT IN ('cancelled','canceled')
GROUP BY p.cluster_to_name ORDER BY qty DESC;
Чек-лист
- ☐
/v1/analytics/stocksили локальный fallback свежий? - ☐ ADS взят один раз, а остатки сложены по складам на уровне
SKU × кластер? - ☐ Inbound взят один раз:
transit/legacypromisedне задвоены, активная частьrequestedподтверждена? - ☐ Горизонт, lead time и safety factor зафиксированы?
- ☐ Перед ранжированием прочитаны скилл и актуальный журнал ЧС; поражённые/не принимающие направления исключены?
- ☐ Пользовательские исключения применены до ранжирования?
- ☐ Концентрация проверена; снижение рискованного направления и кластер диверсификации зафиксированы?
- ☐ Минимум на кластер выдержан после полного пересчёта?
- ☐ Новые артикулы — по гео-спросу категории, существующие — по своему SKU-дефициту?
- ☐ Excel: 3 колонки lowercase, имя пустое, регистр offer_id как в карточке?
- ☐ Суммы по строкам/столбцам сходятся (validate прошёл)?
- ☐ SUMMARY.md содержит источник, дату, формулу, ЧС, исключения и входящий транзит?
Вопросы, которые задаём пользователю ДО расчёта
- Что и сколько пришло на ФФ (артикул+размер→кол-во)? Какие из них новые?
- Какой горизонт покрытия и были ли блогеры/акции, искажающие обычный спрос?
- Минимум на кластер и сколько кластеров (5/6/7)?
- Какие направления исключить из-за срока/цены доставки или других ограничений?
- Весь приход обязательно отправить на FBO или избыток можно оставить на ФФ?
- Есть ли уже созданные поставки вне API/другим подрядчиком или особые смещения под акции?
- Есть ли новые ЧС/остановки складов, которых ещё нет в актуальном журнале ЧС?