# Выгрузка из кабинетов маркетплейсов: Ozon и Wildberries

Продавец, который уже торгует на Ozon или Wildberries, подключает не ссылку на YML, а свой кабинет —
ключом API площадки. Товары, цены и остатки приходят оттуда и обновляются по тому же расписанию, что и
фид.

Это **не отдельная сущность**, а та же выгрузка (`yml_feeds`) с `source_type = ozon` или `wildberries`.
Всё, что после получения офферов, — staging, сопоставление категорий, наценка, «Под заказ», зона доставки,
сверка пропавших, история импортов — общее с YML и описано в [yml-import.md](yml-import.md). Здесь только
то, что у кабинетов маркетплейсов своё.

## Источник офферов

`YmlImportService` не читает файл сам — он берёт офферы у источника (`Services\Yml\Sources\FeedSource`):

| Источник | Офферы | Дерево категорий | Точная категория |
|---|---|---|---|
| `YmlFileSource` | `YmlParser` по скачанному/загруженному файлу | `<categories>`; битый блок импорт не валит | нет |
| `OzonFeedSource` | `OzonCatalog` + `OzonOfferMapper` | `/v1/description-category/tree`; отказ валит импорт | `MarketplaceCategoryMatcher` |
| `WildberriesFeedSource` | `WildberriesCatalog` + `WildberriesPrices` + `WildberriesOfferMapper` | `/content/v2/object/all`; отказ валит импорт | `MarketplaceCategoryMatcher` |

Разница в отказе дерева намеренная. В YML без `<categories>` путь папки просто неизвестен. У маркетплейса
без дерева категории превратились бы в номера, и словарь с моделью разбирали бы «17028922:970895715».

## Подключение: общее и своё

Контроллер один — `MarketplaceFeedController`; чем площадки различаются, знает их `MarketplaceConnector`
(`OzonConnector`, `WildberriesConnector`, выбор — `MarketplaceConnectors`):

- поля доступа и их проверка: у Ozon Client ID и API-ключ, у WB один токен;
- `identify()` — чей кабинет, **без запроса к площадке**: проверка «кабинет уже подключён» не должна
  стоить похода в API, а неверный ввод не должен туда уходить вовсе;
- `inspect()` — проверка по-настоящему: кабинет, срок ключа, склады; результат — `MarketplaceAccount`;
- `inspectReplacement()` — новый ключ для того же кабинета.

Общее в контроллере: выбор складов для наличия, адрес склада, «кабинет уже подключён», замена ключа,
пересчёт наличия при смене складов. Отказ площадки — `MarketplaceApiException` (у Ozon и WB свои
наследники): сообщение на языке продавца и поле формы, к которому оно относится.

| Поле | Что |
|---|---|
| `credentials` | `encrypted:array` — Ozon: `client_id`, `api_key`; WB: `api_key` (токен); в `$hidden` |
| `external_account_id` | Ozon — Client ID, WB — ID продавца (`sid`) из токена; по нему «уже подключён» |
| `credentials_expires_at` | срок ключа: Ozon — из `/v1/roles`, WB — поле `exp` токена |
| `settings.warehouses` | снимок складов кабинета, обновляется каждым импортом |
| `settings.stock_warehouses` | склады, которые продавец считает наличием |
| `settings.company` | название магазина на площадке — для подписи |
| `settings.price_pending` | WB: сколько карточек последний импорт пропустил без собранной цены |

Один кабинет подключается к одному магазину и один раз: у того же магазина второй экземпляр задвоил бы
каталог, у чужого — это чужой магазин или попытка выдать его за приведённого клиента.

`/merchant/feeds/create?source=ozon|wildberries` — вкладки рядом с «YML по ссылке».

1. Ключ → «Проверить» (`merchant.feeds.{ozon|wildberries}.check`, `throttle:20,1`): магазин, срок
   доступа, склады, предупреждения.
2. Отметить склады. У склада с координатами (Ozon) есть кнопка «Указать этот адрес как адрес склада» —
   заполняет карту (событие `address-picker:set`).
3. «Подключить» — ключ проверяется ещё раз на сервере, отметки — по живому списку складов.

**Срок доставки считается от адреса склада в настройках выгрузки** — так же, как у фида. Складов в
кабинете бывает много (у ZURAVTO на Ozon — 11 по стране), но точка отгрузки у выгрузки одна, и её
указывает продавец. Это решение владельца, а не временное упрощение.

Ключ не печатается нигде: на странице — последние четыре символа, в форму после ошибки он не
возвращается (`dontFlash` в `bootstrap/app.php`), в журнал — только ответ площадки. Демо-аккаунт кабинеты
не подключает — как и фиды.

Поле ключа — `<x-secret-input>`: обычное текстовое поле со скрытыми символами. С `type="password"`
браузер принимал форму подключения за форму входа и подставлял e-mail продавца в название источника,
а пароль от teeu — в ключ, и после отправки предлагал этот «пароль» сохранить.

## Наличие: какие склады считать

У продавца маркетплейса товар лежит в двух разных местах. Свои склады он отгружает сам — оттуда же соберёт
и заказ с teeu. Склады площадки (FBO у Ozon, FBW у WB) — товар уже сдан маркетплейсу, и покупателю teeu
его оттуда никто не отправит.

Поэтому что считать наличием, **выбирает продавец**, и по умолчанию не отмечено ничего: без выбора
подключить кабинет нельзя. Товар «в наличии», если сумма остатка на отмеченных складах больше нуля
(`MarketplaceStock`); иначе — «Недоступен» или «Под заказ», как решает галочка выгрузки.

Остатки в staging лежат **по всем складам**, а не только по отмеченным (`data.stocks`). Смена отметок
не входит в подпись оффера, импорт пропустил бы неизменившиеся товары — поэтому
`FeedAvailabilityReapplier` пересчитывает наличие из последнего прогона сразу, без похода в API.

`stock_quantity` не заполняется: остаток обновляется раз-два в сутки и делится с продажами на самой
площадке, и устаревший потолок отказывал бы покупателю там, где товар есть.

Список складов обновляется каждым импортом. Записывается только свой ключ настроек поверх свежих из базы
(`YmlFeed::mergeSettings`): продавец мог сохранить наценку, пока импорт идёт, и перезапись всего
`settings` устаревшей копией стёрла бы его правку.

## Категории: сначала по именам, потом как у YML

Дерево teeu выросло из таксономии Ozon ([catalog.md](catalog.md)), поэтому категория площадки часто
находит наш лист без словаря и без модели — `MarketplaceCategoryMatcher`, три шага от строгого к мягкому:

1. путь имён целиком;
2. прежний путь из `category_redirects` — лист слили с дублем или переименовали;
3. тот же корень и лист с тем же именем, **единственный в этом корне**.

Третий шаг появился после живого кабинета Ozon. Снимок у нас не новый, а Ozon свою таксономию переделывает:
«Брызговики» переехали из «Авто/мото запчастей» в «Кузовные запчасти». У кабинета автозапчастей (8 887
товаров, 12 типов) путь целиком совпал у трёх типов, с третьим шагом — у пяти, это 3 894 товара.
Остальные решает обычная цепочка — словарь, затем модель с тематикой фида.

Держится третий шаг на единственности. «Футболка» в «Одежде» есть у женщин, мужчин и детей — совпадения
нет. Одно имя листа без корня не используется вовсе: у площадки появляются типы, которых в нашем снимке
нет, и уникальное у нас имя увело бы товар в чужой раздел.

Найденное по именам строка сопоставления помечает `source = ozon` или `wildberries` (в кабинете — «Кто:
Ozon/Wildberries»). Смена тематики такие строки не сбрасывает — от тематики они не зависели. Ручной выбор
продавца, как и у YML, сильнее.

Категория на площадке — выбор самого продавца, и он бывает неточным: у кабинета автозапчастей на Ozon
девять брызговиков лежат в типе «Накладка на элементы салона». Мы переносим его выбор как есть; поправить
можно строкой сопоставления или «сборной категорией».

## Отказы и лимиты

Сообщение отказа попадает в историю импортов и на страницу выгрузки: «Ключ Ozon деактивирован или истёк.
Выпустите новый в „Настройки → Seller API“», а не «HTTP 403». Отказ по существу (неверный ключ, нет прав)
не повторяется; 429 и 5xx — повторяются с паузами.

У ключа есть срок: у Ozon три месяца, у WB полгода. Истёк — синхронизации падают, после
`teeu.yml.max_failed_syncs` неудач подряд выгрузка отключается и прячет товары. Узнать об этом продавец
должен заранее: за `YmlFeed::CREDENTIALS_WARN_DAYS` дней на странице выгрузки плашка, в списке — метка
«ключ/токен скоро истечёт». На странице выгрузки ключ **заменяется** (`merchant.feeds.credentials`).
Кабинет менять нельзя — другой кабинет это другой каталог; у WB токен другого кабинета отклоняется по
`sid`. Отключившаяся сама выгрузка после замены снова включается; поставленная на паузу продавцом остаётся
на паузе — это было его решение.

---

# Ozon

## Какие методы Seller API и зачем

Все методы Ozon — POST, в том числе читающие. «Только чтение» держится на списке вызываемых методов:
методов записи в коде нет, и ничего в кабинете продавца мы не меняем. Ключ достаточно с уровнем
доступа **Admin read only** — на нём всё и проверено.

| Метод | Зачем |
|---|---|
| `/v1/roles` | срок действия ключа (`expires_at`) |
| `/v1/seller/info` | название магазина; отказ не мешает подключению |
| `/v3/product/list` | идентификаторы всех товаров, кроме архивных (`visibility: ALL`), по 1000 |
| `/v3/product/info/list` | название, цена, фото, категория, остаток FBO — пачками по 100 |
| `/v4/product/info/attributes` | описание и характеристики — теми же пачками |
| `/v1/description-category/attribute` | названия характеристик — раз на тип товара за прогон |
| `/v1/description-category/tree` | пути категорий |
| `/v2/warehouse/list` | склады FBS/rFBS с адресом и координатами |
| `/v1/product/info/warehouse/stocks` | свободный остаток (`free_stock`) по складу, по 1000 |

`/v1/warehouse/list` и `/v1/product/info/stocks-by-warehouse/fbs` не использовать: Ozon объявил их
отключение с 7 апреля 2026 года.

**Тело всегда объект.** Пустой массив Laravel кодирует как `[]`, Ozon отвечает 400 «proto: syntax
error» — поэтому `OzonClient` кодирует тело сам (та же грабля, что в `OzonPointCatalog`).

Лимит Ozon — 50 запросов в секунду на весь кабинет, и ключ продавца обычно работает ещё и в его
учётной системе. Поэтому ходим последовательно, а 429 и 5xx повторяем с паузами 1, 4 и 15 секунд.

## Товар → оффер

- **`external_id` — `product_id`, а не артикул.** Артикул продавец может сменить, а у эконом-товаров
  он общий на несколько карточек. Артикул (`offer_id`) — в `vendor_code`: по нему заказ сопоставят в
  учётной системе продавца.
- **Описание** — характеристика 4191 «Аннотация». Ozon хранит её с `<br>`, страница товара печатает
  текст с переносами, поэтому теги превращаются в переводы строк.
- **Производитель** — 85 «Бренд».
- **Характеристики** — все, кроме служебных: хештеги, Rich-контент в JSON, ТН ВЭД, «объединить в
  карточку», «нужен код маркировки», ссылки (тип `URL`), вложенные группы. 7236 «Партномер (артикул
  производителя)» переименован в «Артикул производителя» — иначе `VendorCharacteristics` не узнала бы
  его и добавила бы второй артикул. `true`/`false` → «Да»/«Нет», несколько значений — через «;».
- **Штрихкод «OZN…» отбрасывается**: такой Ozon выдаёт сам, когда продавец своего не указал, и вне
  Ozon он ничего не значит.
- **Фото** — `primary_image` первым, дальше `images`. Это разные поля: главное фото в `images` может не
  входить.
- **Цена** — `price`: цена продавца **без акций Ozon**, зачёркнутая — `old_price`, только если выше. Так
  решил владелец: акции и скидки — механика самого Ozon, на teeu идёт цена продавца. Цена с акциями
  (`marketing_seller_price`) не используется.
- **Категория** — `description_category_id:type_id`. Ключ составной: один тип встречается в разных
  категориях (у Ozon таких 302 из 9150).
- **Ссылка** на товар на Ozon пишется в `source_url`, но кнопки «Смотреть на сайте продавца» у такой
  выгрузки нет: она увела бы покупателя с teeu на Ozon.

## Склады

Свои склады — FBS и rFBS, по строке на склад (ключ `fbs:{id}`), с адресом и координатами. Все
FBO-склады — одна строка «Склады Ozon (FBO)» (ключ `fbo`): их остаток приходит в карточке товара, и
отметить её продавец может, если держит такой же запас у себя.

Живой замер (кабинет на 8 887 товаров, 11 складов FBS): чтение кабинета — около 300 запросов и четыре
минуты, из них 103 запроса и минута с четвертью — остатки по складам. Первый импорт, создавший 3 894
товара, — около восьми минут, повторный без изменений — пять с половиной. Замок импорта
(`Cache::lock` на 15 минут) у кабинета в десятки тысяч товаров стоит пересмотреть.

---

# Wildberries

## Токен: только базовый

Токен WB — JWT, и всё, что нужно до первого запроса, лежит в его открытой части (`WildberriesToken`):
тип `acc`, категории доступа — битовая маска `s`, срок `exp`, ID продавца `sid`. Подпись не проверяем —
её проверяет WB при каждом запросе.

| `acc` | Тип | Берём? |
|---|---|---|
| 1 | базовый | да |
| 2 | тестовый (`t: true`) | нет — видит только песочницу |
| 3 | персональный | **нет** — WB запрещает передавать его сторонним сервисам и требует от сервисов такие токены не принимать |
| 4 | сервисный | нет — привязан к сервису из каталога решений WB |

Нужны категории (биты `s`) **Контент** (1), **Цены и скидки** (3), **Маркетплейс** (4). Бит 30 —
«только чтение»: без него токен принимаем, но предупреждаем. Всё это проверяется до обращения к WB —
неподходящий токен туда не уходит.

## Только чтение — в самом клиенте

Токен продавцы выпускают и с правом записи — по нему можно поменять цены и обнулить остатки. Поэтому
`WildberriesClient` знает ровно те методы, что помечены в спецификации WB как читающие
(`x-readonly-method`), и на любой другой бросает `LogicException`, не отправив запрос:

| Метод | Сервис | Зачем |
|---|---|---|
| `GET /api/v3/warehouses` | Маркетплейс | склады продавца; при подключении — заодно проверка токена |
| `POST /api/v3/stocks/{warehouseId}` | Маркетплейс | остатки по `chrtIds`, до 1000 за запрос |
| `POST /content/v2/get/cards/list` | Контент | карточки по 100, курсор `updatedAt` + `nmID` |
| `GET /content/v2/object/all` | Контент | предметы с родительскими категориями, по 1000 |
| `GET /content/v2/object/charcs/{subjectId}` | Контент | единицы измерения характеристик; кэш на неделю |
| `GET /api/v2/list/goods/filter` | Цены и скидки | первая тысяча цен кабинета |
| `POST /api/v2/list/goods/filter` | Цены и скидки | цены по `nmList`, до 1000 |
| `GET /api/v1/seller-info` | общий | название магазина; кэш на сутки |

## Лимиты базового токена

Лимиты WB считаются **на весь кабинет продавца**, а у базового токена они низкие:

| Категория | Лимит | Как ходим |
|---|---|---|
| Контент | 100 в минуту, интервал 600 мс | пауза 600 мс между запросами |
| Маркетплейс | 150 в минуту, интервал 200 мс; **4xx считается за 10** | пауза 400 мс; удаляемые склады не спрашиваем |
| Цены и скидки | **4 в час, интервал 15 минут** | см. «Цены» |
| `seller-info` | **1 в сутки** | ответ в кэше на сутки; отказ подключению не мешает |

На 429 клиент ждёт столько, сколько сказал WB в `X-Ratelimit-Retry`, — если это секунды (до минуты).
Минуты внутри запроса не ждём: отказ уходит наверх.

## Цены

Цены — единственное, что нельзя прочитать за импорт: тысяча товаров раз в 15 минут. Поэтому они
собираются заранее, порциями, в `wildberries_prices` (`WildberriesPrices`), а импорт берёт собранное.

- Порцию забирает каждое свободное окно: `teeu:wildberries:prices` раз в пять минут ставит
  `PullWildberriesPricesJob` (очередь `yml-download`) кабинетам с открытым окном, и сам импорт перед
  чтением карточек. Окно — ключ кэша `wb:prices:window:{feed}`: после запроса 15 минут, после 429 — сколько
  сказал WB. Одновременную порцию из импорта и задания не пускает замок.
- Порядок: сначала карточки, цену которых ещё не спрашивали (`fetched_at` пуст), потом самые давние
  (старше 30 минут). Самый первый запрос, пока номеров карточек ещё нет, — `GET` первой тысячи цен
  кабинета: у небольшого магазина это все цены сразу.
- Карточка **без собранной цены в импорт не идёт** и не считается «без цены»: товар без цены импорт снял
  бы с витрины. Импорт запоминает такие карточки — следующая порция начнётся с них — и пишет их число в
  `settings.price_pending`. На странице выгрузки — «Цены ещё загружаются: N товаров ждут цену» с оценкой
  времени.
- Спрошенная карточка, которой нет в ответе, — у товара правда нет цены на WB (`sizes = null`): такой
  оффер идёт в импорт с пустой ценой и считается «без цены», как у фида.
- Когда карточки впервые получили цену, задание ставит импорт раньше расписания — сразу, если ждущих не
  осталось, иначе не чаще раза в два часа: импорт каждый раз читает весь каталог.
- Импорт, прочитавший каталог целиком, удаляет цены карточек, которых больше нет.
- Сбой запроса цен в начале импорта импорт не валит — берётся собранное раньше; валит только отказ по
  токену (нет категории «Цены и скидки», токен отозван).

**Окно цен общее на кабинет.** Если его занимает учётная система продавца, наши запросы получают 429.
Это не ошибка импорта; но когда отказы идут дольше часа, страница выгрузки говорит продавцу, что лимит,
похоже, расходует другая его интеграция (`wb:prices:limited:{feed}`). Зарегистрированный в WB сервис с
«базовым токеном с секретом» получил бы сервисный лимит — 10 запросов в 6 секунд; teeu такой регистрации
не имеет.

**Какая цена.** У размера WB есть `price` (цена продавца) и `discountedPrice` (со скидкой продавца). На
teeu идёт `discountedPrice`, зачёркнутая — `price`, если выше. Скидки самого WB — СПП, WB Клуб — в API не
видны и не переносятся, как акции Ozon. Цены в рублях, не в копейках (сверено с витриной). На витрине WB
покупатель видит цену ниже: у проверенной карточки в API 7 600 ₽, на wildberries.ru — 5 031 ₽ с СПП.

## Карточка → офферы

Карточка WB — товар одного цвета **со всеми размерами**: у размера свой `chrtID`, штрихкоды, цена и
остаток. Вариантов товара у teeu нет, поэтому:

- карточка с одним размером — один товар, `external_id = nmID`;
- с несколькими — **каждый размер свой товар**: `external_id = nmID-chrtID`, название «…, размер 48»,
  ссылка `…/detail.aspx?size={chrtID}`. Покупатель заказывает ровно то, что видит, — со своей ценой и
  наличием. Так же на Ozon: там размер — отдельный товар изначально.

Размер «0» или пустой WB ставит товарам без размера — это не размер.

- **Артикул продавца** (`vendorCode`) — в `vendor_code`, **бренд** — в производителя, **штрихкод** —
  первый `skus` размера.
- **Описание** — простой текст с переносами; лишние пустые строки схлопываются.
- **Фото** — `photos[].big` (webp) в порядке карточки.
- **Характеристики** — значения массивов через «;», единицы — из справочника предмета («Ширина упаковки:
  12 см»; если единица уже в названии — «Вес товара с упаковкой (г)» — не повторяется). Служебные не
  идут: бренд, наименование, описание, SKU, ТНВЭД, ставка НДС, ИКПУ, коды упаковки и ТРУ, артикул Ozon,
  номера и даты разрешительных документов. Размер и российский размер добавляются из размера карточки.
- **Категория** — предмет (`subjectID`), путь — «Родительская категория / Предмет» («Автотовары / AKF
  системы»). Ключ родителя в дереве — `parent:{id}`: номера предметов и родителей пересекаются.
- **Ссылка** — `https://www.wildberries.ru/catalog/{nmID}/detail.aspx` в `source_url`, кнопки «Смотреть на
  сайте продавца» нет.

Карточку, изменённую во время чтения, WB отдаёт ещё раз в конце списка — источник пропускает повтор.

## Склады

Только склады продавца — FBS, DBS, DBW, самовывоз, EDBS (ключ `wh:{id}`). Адреса склада продавца WB API
не отдаёт (только ID склада WB, куда сдаются заказы, — это не точка отгрузки), поэтому кнопки «указать
адрес склада» у WB нет.

**Складов WB (FBW) в списке нет.** Их остатки отдаёт только асинхронный отчёт категории «Аналитика»
(создать задание → дождаться → скачать, по базовому токену — раз в 15 минут на каждый шаг), а заказ
покупателя teeu оттуда всё равно не отправить. Если своих складов в кабинете нет, при проверке токена
продавец видит об этом предупреждение, а подключить кабинет нельзя — отметить нечего.

## Живой замер

Кабинет автозапчастей: 985 карточек, у каждой один размер, один склад FBS (в наличии 663).

- Первый прогон попал в занятое окно цен (429): импорт штатно завершился без товаров, запомнив 985
  карточек как ждущие цену, ничего не сняв с витрины.
- Со свободным окном: одна страница цен покрыла весь кабинет. Запросов — 10 страниц карточек, 8 страниц
  предметов, 2 справочника характеристик, 1 склад, 1 остатки, 1 цены; около минуты вместе с созданием
  985 товаров. Повторный прогон без изменений — 30 секунд, все 985 «без изменений», цены не спрашивались.
- **WB отдаёт характеристики карточки каждый раз в другом порядке.** Пока маппер их не сортировал,
  повторный импорт считал изменившимися 475 товаров из 985. Теперь характеристики и значения внутри
  сортируются — подпись оффера от порядка не зависит.

## Что дальше

- **Напоминание об истечении ключа** — пока только плашкой в кабинете, без письма.
- **Остатки FBW** — отчётом «Аналитики», если владелец решит, что они нужны.
