Плейбук: отгрузка (догрузка) на 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 |
Остатки FBO по складам | snapshot_at, offer_id, warehouse_name, free_to_sell, promised |
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 — только людям для справки.
Актуальные правила учёта (проверено 14.07.2026)
- Основной источник для действующего SKU — Seller API
POST /v1/analytics/stocks: он даётads_clusterза 28 дней, доступный и готовящийся товар, заявки и FBO-транзит. Локальная БД — контрольный источник и fallback. - Ответ
/v1/analytics/stocksможет содержать несколько строк одногоSKU × кластерпо складам. Остатки суммируем, аads_clusterберём один раз (MAX), не суммируем: ADS повторяется в строках складов. - Текущий учитываемый запас:
available_stock_count + valid_stock_count. Входящий FBO-транзит:transit_stock_count;requested_stock_countучитываем только после подтверждения активной заявки через/v3/supply-order/list→/v3/supply-order/get→/v1/supply-order/bundle. - Analytics и supply-order показывают один и тот же inbound с разных сторон: supply-order
используем для проверки статуса, состава и кластера, но не прибавляем его второй раз поверх
уже учтённых
requested_stock_count/transit_stock_count. promised,reservedи клиентские статусыawaiting_*/delivering— не поставка продавца на FBO. Их нельзя прибавлять как входящий товар.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.
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 stock
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
deficit = max(0, target − available − valid − подтверждённый inbound)
Рабочий default при отсутствии указаний: 30 дней покрытия + 10 дней на создание/доставку/
приёмку, safety_factor = 1.0. Параметры фиксируем в SUMMARY.md. Нельзя сначала сложить
весь спрос и весь запас категории, а потом взять один общий max(...): избыток размера S не
должен скрывать дефицит M.
Шаг 5. Выбор кластеров
До ранжирования исключаем направления, которые пользователь не готов обслуживать: закрытия, слишком долгий/дорогой рейс, ограничения перевозчика. Затем ранжируем по сумме положительных SKU-дефицитов, обычно берём 5–7 кластеров. Минимум на кластер уточняем (рабочий default — 20 шт). Склад хранения внутри кластера окончательно предлагает Ozon.
Если пользователь исключил кластер или 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 рядом.
Стартовый слепок кластеров (новый товар / нет своих данных)
Когда у магазина/артикула нет своей истории на 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 × кластер? - ☐
promised/reservedне приняты за inbound; активные supply orders проверены? - ☐ Горизонт, lead time и safety factor зафиксированы?
- ☐ Пользовательские исключения применены до ранжирования?
- ☐ Минимум на кластер выдержан после полного пересчёта?
- ☐ Новые артикулы — по гео-спросу категории, существующие — по своему SKU-дефициту?
- ☐ Excel: 3 колонки lowercase, имя пустое, регистр offer_id как в карточке?
- ☐ Суммы по строкам/столбцам сходятся (validate прошёл)?
- ☐ SUMMARY.md содержит источник, дату, формулу, исключения и входящий транзит?
Вопросы, которые задаём пользователю ДО расчёта
- Что и сколько пришло на ФФ (артикул+размер→кол-во)? Какие из них новые?
- Какой горизонт покрытия и были ли блогеры/акции, искажающие обычный спрос?
- Минимум на кластер и сколько кластеров (5/6/7)?
- Какие направления исключить из-за срока/цены доставки или других ограничений?
- Весь приход обязательно отправить на FBO или избыток можно оставить на ФФ?
- Есть ли уже созданные поставки вне API/другим подрядчиком или особые смещения под акции?