# teeu — YML-импорт (Этап 6)

Импорт товаров из YML (файлом или по ссылке) с безопасным staging-процессом, AI-классификацией
категорий и штатной модерацией. Один продавец может иметь несколько независимых фидов.

Тем же конвейером идёт выгрузка **из кабинета Ozon или Wildberries** по ключу API площадки
(`source_type = ozon|wildberries`): офферы берутся у источника (`Services\Yml\Sources\FeedSource`), а не
из файла. Что у неё своё — склады для наличия, категории по именам, срок ключа, у WB — собранные заранее
цены и размеры отдельными товарами — в [marketplace-import.md](marketplace-import.md).

## Модель данных

| Таблица | Назначение |
|---|---|
| `yml_feeds` | источник (url/file), интервал (**фикс: 720/1440 мин**), статус (active/paused/blocked/disabled), `settings` json (напр. `show_source_url`)/stats, last_attempt/success, next_sync |
| `yml_feed_addresses` | адреса отправки фида + гео/город (ТЗ §9, §21.4) |
| `yml_import_runs` | запуск: статус (queued→…→completed/partial/failed), счётчики (found/created/updated/unchanged/**skipped** — из них `no_price_count` без цены/deactivated/failed), размер, длительность |
| `yml_import_offers` | **staging**: распарсенные офферы (data json + signature) до обработки |
| `yml_import_errors` | item-level ошибки (warning/error) |
| `yml_category_maps` | **реестр категорий фида**: строка на YML-категорию (external_category_id, category_path, category_id, `source` ai/admin/seller, resolved_at) — оверрайд продавца/админа |
| `category_aliases` | **глобальный словарь** «имя категории → категория teeu» (normalized_name unique, status pending/approved, source, confidence, blocked_count) |
| `products.yml_feed_id/external_id` | unique — привязка; `import_signature` — идемпотентность; `source_url` — ссылка на товар из фида |

## Поток (ТЗ §8.5) — `YmlImportService::run`

1. **Lock** на фид (`Cache::lock`) — параллельный импорт одного фида запрещён (§8.10).
2. Создать run, `downloading`. Скачать (URL) или взять файл.
3. `parsing` → **staging**: потоковый разбор в `yml_import_offers`. Дубликаты offer id → item-warning.
4. `processing`: по каждому офферу — классификация (для новых), применение полей, upsert, характеристики
   из `<param>`, история цены только на реальное изменение, ре-модерация изменённого текста, постановка
   image-джобов. Ошибка одного оффера → item-error + `failed_count`, импорт продолжается (§8.8).
5. **Только после успешного authoritative-прохода** — reconcile исчезнувших офферов: их товары →
   `source_missing` (не удаляются). Вернувшиеся → восстановление (§8.7).
6. Статус `completed` (без ошибок) или `partial` (были item-errors). Обновить `next_sync_at`.

**Критично (§8.5):** если скачивание/парсинг упали — run `failed`, товары **не деактивируются**
(reconcile не вызывается). Покрыто тестом.

## Как мы представляемся продавцу

Заголовок `User-Agent` — `config('teeu.user_agent')`, один и тот же для выгрузки и для картинок.
Без него Guzzle называет себя сам, и антибот-фильтры на стороне продавца отвечают 410 или 403:
`sherif-karter.ru` отдавал браузеру 11 МБ, а нам — 143 байта отказа, и фид отключился после пяти
неудач подряд. Поэтому в отчёте о такой ошибке первым делом стоит проверять не доступность сайта,
а то, каким заголовком мы стучимся.

Правило одно на оба места намеренно: картинки почти всегда лежат на том же домене, что и файл, и
починить только скачивание фида означало бы завести товары без единого фото.

## Безопасность

- **SSRF** (`SsrfGuard`, §8.4): только http/https; блок private/loopback/link-local/reserved/metadata
  (`169.254.169.254`, `::1`, `fe80::`, `fc00::/7`); проверка всех A/AAAA; ре-проверка на редиректах;
  лимиты redirect/timeout/size. Применяется и к URL фида, и к URL картинок (§18.4).
- **XXE** (`YmlParser`, §8.3): `XMLReader` + `LIBXML_NONET` + отключённый external entity loader;
  каждый `<offer>` парсится как маленький фрагмент — большие файлы не грузятся целиком. Повреждённый
  XML → `YmlParseException` (item пропускается / весь файл — fail). Внешние сущности не разворачиваются.

## Удаление выгрузки

Удаление фида уносит его товары — мягко (`deleted_at`), чтобы не потерять историю заказов. Делает это
`YmlFeed::booted()` на событии `deleting`, а не контроллер: фид удаляют и из кабинета, и из консоли.

Без этого внешний ключ `products.yml_feed_id` (`nullOnDelete`) оставлял товары «ничьими», а витрина
проверяла одно отсутствие фида и принимала их за ручные. Так на площадке оказалось **1 654 сироты** с
застывшими ценами: у «ВсяСантехники» старую выгрузку удалили, а 1 525 её товаров висели рядом с копиями
из новой — одна из копий плитки «Тоффи» лежала в «Плиточном шоколаде». Поэтому и `Product::scopePublic()`
теперь считает ручным только товар с `source_type = manual`: товар из выгрузки без выгрузки — сирота, и
на витрину он не попадает, даже если его как-то пропустит удаление.

## Товары без цены не загружаются

Предложение без положительной цены — `<price>` нет, он пустой, нечисловой («по запросу») или 0 — на
площадку не попадает. Купить такой товар нельзя, а показывался он «0 ₽» с кнопкой «В корзину». У
«Медтехники 100» так пришло 1 043 позиции из 8 424.

Отсев стоит в `stage()`, **до staging**, и место выбрано не ради экономии:

- новый товар не создаётся, а папку, где цены нет ни у кого, не разбирают ни словарь, ни AI — за
  подбор категории тому, что не покажем, не платим;
- у уже загруженного товара, у которого цена пропала, предложения в staging нет, и его снимает
  обычная сверка — `source_missing`. Путь через штатную обработку был бы хуже: он записал бы «0 ₽» в
  историю цен и разослал подписчикам «цена снизилась»;
- цена вернулась — предложение снова в staging, и товар восстанавливается как вернувшийся в выгрузку
  (§8.7): с фото и уже пройденной модерацией.

Пропуск входит в общий `skipped_count` (так история импортов сходится с «Найдено») и отдельно пишется в
`no_price_count`. Отдельно — потому что чинится он не в сопоставлении категорий, а в самом файле:
продавец видит на странице фида плашку «Товаров без цены в последней выгрузке: N».

`Product::scopePublic()`/`isPublic()` дополнительно требуют `price > 0` — для того, что лежало в базе
до правила, и на случай, если нулевая цена попадёт туда другим путём. Через `isPublic()` это закрывает
и карточку, и корзину с оформлением.

## Идемпотентность и override

- `import_signature` (hash полей оффера): неизменившийся оффер → `unchanged`, без дублей товаров,
  изображений и лишней истории цен (§8.6).
- **Override (§15):** `products.manual_overrides` (`{"description":true,"images":true}`). При импорте
  переопределённые поля не перезаписываются; ре-модерация описания не запускается, если оно под
  override. Действие «вернуть управление полю» — снять флаг.

## Маппинг категорий (ТЗ §11) — масштабируемый на сотни фидов

Категория teeu присваивается **один раз на YML-категорию фида**, а не на каждый offer (иначе зря
тратятся токены, а ИИ цепляется за случайные слова в названии товара). Три слоя:

1. **Глобальный словарь** `category_aliases` (`CategoryMappingService::resolve(name, path)`): ключ —
   нормализованное имя категории (`normalize()`: lower/trim, ё→е, чистка пунктуации). Резолв:
   словарь → **ИИ один раз** → запись алиаса (approved если уверенно, иначе `pending` → в очередь на
   разбор). «Джинс» в любом фиде = попадание в словарь (0 токенов). `CategoryAliasBackfillSeeder`
   бутстрапит словарь из уже готовых карт.
2. **Реестр/оверрайд фида** `yml_category_maps` (строка на категорию фида). Оверрайд `source=seller|admin`
   **выигрывает** над словарём. `blocked_count` пишется для несопоставленных имён (ранжир очереди).
   **ВАЖНО:** `source` обязан быть в `$fillable` (иначе mass-assignment молча роняет оверрайды).
3. **Fallback** для фидов без дерева `<categories>` — пофишный `DeepSeekCategoryClassifier` (по товару).

Резолв в импорте: **оверрайд фида → глобальный словарь → ИИ**. `DeepSeekCategoryClassifier` сужает до
кандидатов **по префиксу 4 символа** (морфология РУ: хлопок↔хлопковые), AI выбирает только из
переданных id, порог `deepseek.classify_threshold` (0.75).

**Правило (жёсткое):** нет категории teeu → offer **пропускается** (`ImportOfferStatus::Skipped`,
`runs.skipped_count`); товар не создаётся/не публикуется. Существующие товары без категории докатываются
на следующем синке: как только категория определилась и текст уже промодерирован — товар публикуется.

**Админ-очередь (Filament):** `CategoryAliasResource` (`/developer/category-aliases`) — pending-имена по
`blocked_count`, экшены «Назначить категорию»/«Нет категории» (глобально, ко всем фидам).
**Self-service продавца:** на странице фида (`merchant.feeds.categories` → `FeedController::updateCategories`)
select-combobox с поиском; выбор пишет оверрайд `source=seller` и перезапускает импорт. No-op правки не
трогают строки (они продолжают следовать словарю). В select'ах — бредкрамб имён (не slug).

**Дерево категорий заменено на Ozon-таксономию (2026-07):** курированное дерево заменено выгрузкой
Ozon — **9105 категорий (27 корней)** — в `database/data/categories.json` (snapshot); пересборка на
проде миграцией, вызывающей `CategorySeeder`. Слуги — `Str::slug` (совпадают с генерацией в
`CategoryService`), `products_allowed` только у листьев. Подробнее — [catalog.md](catalog.md).

**Урок по классификации:** ИИ ненадёжен на большом дереве для узких фидов. Фид «Хорошие ткани» ИИ
разбросал по мусорным категориям (Сатин/Хлопок→«хлопья»/еда — лексическое сужение по 4-символьному
префиксу ловит ложные совпадения). Фикс — детерминированный **per-feed оверрайд** всех категорий фида
в один лист «Ткань для шитья» (`yml_category_maps` / алиасы `source=admin`). Для таких фидов лучше
сразу задавать оверрайд, а не полагаться на авто-ИИ.

## Настройки фида и видимость товаров

- **Интервал** — только фиксированные варианты: **1 раз в день (1440)** или **2 раза в день (720)**
  (`Rule::in`). Свободного ввода нет. Не хочет обновлять — продавец выключает фид.
- **Выключение/пауза фида ДЕАКТИВИРУЕТ товары**: `Product::scopePublic()`/`isPublic()` требуют статус
  фида `Active` (раньше было «не Blocked»). Пауза → товары скрыты и непокупаемы; включение возвращает.
- **Ссылка на товар:** `products.source_url` заполняется из `<offer><url>` (только http/https; НЕ в
  signature — обновляется дёшево в idempotent-ветке через `updateQuietly`). Кнопка «Смотреть на сайте
  продавца» в карточке товара — только если `yml_feeds.settings['show_source_url']`=true
  (target=_blank, rel=nofollow noopener noreferrer).
- Ручной «Импортировать сейчас» показывается только до первой успешной выгрузки (`last_success_at`);
  дальше — только автосинк по расписанию. Ручного запуска у продавца после первой выгрузки нет.
- **Статусы импорта в истории** — по-русски, цветными бейджами (`ImportRunStatus::label()`:
  Завершён/Частично/Ошибка/В очереди/Загрузка/…).

## Зона доставки фида (город показа/доставки)

Продавец может ограничить, **в какие города** он доставляет/показывает товары фида — например, продукты
питания не везти через полстраны, а показывать только в своём городе.

- **Модель:** pivot `feed_delivery_cities` (yml_feed_id ↔ city_id) — итоговый набор городов. **Нет строк =
  доставка/показ во всех городах** (обратная совместимость). Сам полигон хранится в
  `yml_feeds.settings['delivery_area']` (`[[lat,lng],…]`) — только для перерисовки на карте.
- **UI (страница фида):** Leaflet-карта (базовый, без leaflet-draw), Alpine `deliveryArea`. Клик по карте
  = вершина полигона, клик по точке = удалить. `GeoPolygon::citiesInside` (ray-casting point-in-polygon по
  городам с координатами) резолвит города внутри. Список городов правится и вручную (поиск `cities.search`,
  чипы; показ свёрнут до 10 + «Показать все N»). Эндпоинты `merchant.feeds.delivery.resolve` (превью) и
  `merchant.feeds.delivery` (сохранение) в `FeedController`.
- **Фильтрация — мягкая:** товар виден всем, но покупатель из города вне зоны видит пометку «Не
  доставляется в ваш город» (карточка) / «Продавец не доставляет…» (страница товара), и у такого товара
  **скрыты** кнопки «В корзину», «Снижение цены» и счётчик акции. Определяет `App\Services\Geo\DeliveryScope`
  (scoped): `blockedFeedIds()` = фиды с зоной, не покрывающей текущий город (`CityContext`), один раз на
  запрос. В корзине и `CheckoutService` доставка вложена в eligibility (см. [cabinet.md](cabinet.md)).

## Очереди и расписание

- `RunYmlImportJob` (очередь `yml-processing`), `DownloadProductImageJob` (`images`, SSRF+лимит),
  `ModerateProductTextJob` (`ai`).
- Автообновление: `teeu:yml:sync-due` (`YmlFeed::due()`), планировщик `everyTenMinutes` (§49, §50).
- После `failed` run старые товары не сбрасываются; `next_sync_at` переносится.

## Кабинет

`/merchant/feeds` — список, добавление (URL с SSRF-проверкой), интервал (1/2 раза в день) + чекбокс
«Смотреть на сайте продавца», история импортов (со столбцом «Пропущено»). На странице фида:
вкл/выкл (`feeds.toggle`), настройки (`feeds.settings`), **сопоставление категорий** с поиском
(`feeds.categories`), «Импортировать сейчас» — только до первого успеха. Admin block (§35) — статус
`blocked`. Layout кабинета: публичного подвала нет, кнопка «Вернуться на teeu» — вверху сайдбара.

## Тесты (ТЗ §56.4)

`SsrfGuardTest` (схемы/loopback/private/metadata/публичный), `YmlParserTest` (valid/vendor-model/
несколько картинок/params/пропуски/дубликаты/broken→throw/XXE-не-разворачивается),
`YmlImportTest` (публикация, **идемпотентность**, исчезновение→source_missing→возврат,
**failed не деактивирует**, override описания, blocked/paused feed скрывает товары, маппинг раз-на-
категорию, словарь переиспользуется между фидами, скип без категории, source_url + санитизация,
товар без цены не загружается, пропавшая цена снимает товар и возвращает его вместе с ценой),
`SellerFeedCategoryMappingTest` (self-service оверрайд + ре-импорт, настройки фида, поиск-combobox,
скрытие «Импортировать сейчас» после успеха), `CategoryAliasResourceTest` (Filament-очередь + экшены).

## Тематика выгрузки: контекст для классификатора

Название категории само по себе часто не решает ничего. «Прокладки» бывают автомобильные и
гигиенические, «Подшипники» — велосипедные и автомобильные, «Вилка» — деталь подвески, столовый
прибор и разъём. Без контекста классификатор выбирает наугад — и молча.

Поэтому у фида есть **тема** (`yml_feeds.theme`, перечисление `App\Enums\FeedTheme`): один вопрос к
AI на весь фид, а не на товар. Тема подставляется в промпт классификации отдельным абзацем, который
прямо называет спорные слова — именно на них модель и ошибается.

**Источник контекста — категории самого фида, а не описание магазина.** Описание чаще всего пустое
(продавцы его не заполняют), а дерево категорий есть всегда и говорит прямо: «Авто/мото запчасти»,
«Проводка для авто» не оставляют вопросов. Название и описание магазина тоже идут в запрос, но как
дополнение.

**Категории берутся из разбираемого файла, самые наполненные первыми** (`FeedThemeService::detect(…,
categories:)`), а не из сопоставлений. При первом импорте сопоставлений ещё нет — они создаются в том
же прогоне, ниже, — и до сентября 2026 первый прогон каждого фида шёл **без темы**: все его категории
решались общим словарём без темы, а решённое потом не пересматривается. Так «Вибраторы»
строительного магазина уехали в секс-игрушки, электроды — в «Электроды для ЭКГ», детские боди — во
«Впитывающее бельё для взрослых». Вдобавок склейка промпта теряла хвост из-за приоритета `.` над
`?:`, и модель категорий не видела вовсе. Оба случая закрыты тестами (`FeedThemeTest`,
`YmlImportTest::test_the_first_import_resolves_categories_with_the_detected_theme`).

**Разделы 18+ предлагаются только темам, которые ими торгуют** (`FeedTheme::allowsAdult()` — сейчас
«Красота»; фид без темы — без ограничений). Слова у взрослых товаров бытовые — вибратор, насадка,
помпа, кольцо, пробка, — и, не найдя своей полки, модель соглашалась на ближайшую по слову; товар при
этом прячется за возрастным барьером. Для машинных тем действует ещё и `allowedRoots()`.

**Список тем закрытый.** Свободные формулировки («автозапчасти», «запчасти для авто», «авто»)
развели бы один и тот же магазин по трём записям словаря — а тема входит в его ключ.

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

Это главное решение, и без него фича сделала бы хуже, чем было. `category_aliases` уникален по паре
**(тема, нормализованное имя)**, а не по одному имени. Иначе первый магазин научил бы словарь, что
«Прокладки» — автомобильные, и аптека получила бы ту же категорию **молча, ни разу не спросив AI**.

Словарь при этом не теряет смысла: он остаётся общим, просто в пределах темы. Стоимость —
O(имён × тем), а тем полтора десятка. У фидов без темы ключ пустой (`''`, не NULL: в уникальном
индексе NULL не равен NULL), и они работают ровно как раньше.

### Уже определённое не пересматривается

Повторный импорт **не трогает** категории, у которых сопоставление уже есть: товары, которые
продавец видит на витрине, не должны переезжать сами по себе — даже если новый ответ оказался бы
лучше. Тема применяется к тем категориям, что ещё не разрешены: новым в фиде и тем, которые раньше
остались без ответа.

Переопределение возможно, но только по явной команде: **смена тематики фида** сбрасывает
автоматические сопоставления (`source = ai`), и следующий импорт определяет их заново — ради этого
тему и меняют. Ручные (`seller`, `admin`) не трогаются никогда: человек всегда старше автоматики.

### Последнее слово за продавцом

Тема определяется автоматически, но выбирается в настройках фида вручную, и ручную (`theme_source =
manual`) автоопределение больше не трогает. Причина простая: ошибка в теме уводит **весь каталог
целиком**, а продавец знает свой ассортимент лучше, чем модель по списку категорий.

Неуверенный ответ модели (< 0.6) отбрасывается: отсутствие темы хуже точности, но лучше неверной
темы — без неё работает прежняя логика, а с неверной весь фид едет не туда.

### Что модель видит в запросе

Соседние полки отличаются уточнением: «Мясорубка механическая» и «Мясорубка электрическая»,
«Клеенка, подстилка детская» и клеёнка столовая. Если уточнения не видно, модель выбирает по
главному слову — и уверенно.

- **Кандидаты — путём из имён**, «Дом и сад → Аксессуары для приготовления пищи → Мясорубка
  механическая». До сентября 2026 в запрос уходил `categories.path`, транслит из адреса
  («miasorubka-mexaniceskaia»): отбор кандидатов человеческий путь уже готовил, но до промпта он не
  доходил. Проверяет `DeepSeekCategoryClassifierTest`.
- **Начало описания** (400 символов простым текстом) — при разборе отдельного товара. «Мясорубка
  VETTA алюминиевый сплав» не говорит, ручная она или электрическая, а описание говорит. У папки фида
  описания нет, строка в запрос не добавляется.

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

## Когда категории фида бесполезны

Бывает выгрузка, где категория формально есть, но не значит ничего: весь каталог лежит в
единственной папке «Товары», а в ней налокотник, чай, микрофон и игровое кресло. Или имя вроде
«Разное», «Каталог», «Прочее». Разложить такое по имени папки нельзя в принципе.

Для этого в сопоставлении категорий есть третий вариант выбора, наравне с деревом категорий и
«— не показывать —»:

> **— сборная категория, разобрать каждый товар —**

Тогда товары этой папки создаются **без категории** (`category_status = pending`, на витрину не
выходят) и уходят в очередь `ai` заданием `ClassifyProductCategoryJob` — разбираться по собственному
названию, с учётом тематики фида.

### Почему это выбор, а не автоматика

Сначала было сделано наоборот: любая категория, которую не удалось разрешить, отправляла товары на
разбор сама. Механизм работал — но на проде тут же обошёл решение человека. Четыре складские папки
фида klevver были намеренно скрыты через «— не показывать —»; разбор их не различал, и **89 076
позиций получили категории и вышли на витрину**. Снимали вручную.

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

Технически это три разных состояния строки `yml_category_maps`:

| Состояние | Что значит | Что делает импорт |
|---|---|---|
| `category_id` задан | категория выбрана | товары в неё |
| `category_id` пуст, `classify_per_item = false` | не сопоставлено либо скрыто человеком | оффер пропускается, товар не создаётся |
| `category_id` пуст, `classify_per_item = true` | сборная категория | товар создаётся без категории, уходит на разбор |

### «Подобрать не удалось» — не то же самое, что «не показывать»

Пустая строка сопоставления бывает двух видов, и путать их нельзя. Человек выбрал «— не показывать —»
— это решение. Модель ответила неуверенно (ниже `deepseek.classify_threshold`, по умолчанию 0.75)
или сказала «подходящей категории нет» — это незаконченная работа.

Раньше оба вида выглядели одинаково, и продавец видел «скрыто» там, где ждали его выбора. Живой
случай: у фида 83 из 256 папок разобрались 247, а девять — «Светильники», «Электрооборудование»,
«Расходные материалы» и подобные общие имена — остались пустыми. Со стороны это читалось как «ИИ
перестала отвечать», хотя в журнале `ai_requests` за тот импорт 241 успешный вызов, три ответа «нет
подходящей категории» и пять с уверенностью ниже порога.

Теперь такие строки подписаны «— подобрать не удалось, выберите сами —» (признак: `category_id`
пуст, `classify_per_item` false, `source = ai`), а под таблицей появляется кнопка **«Подобрать
заново»** (`RetryFeedCategoriesJob`).

Кнопка нужна потому, что словарь помнит и отказы: ответ «не знаю» лежит в `category_aliases` как
`pending` и отдаётся вместо нового вопроса. Для денег это правильно — одно имя стоит одного вопроса
на всю площадку, — но имя, не поддавшееся однажды, само уже не разберётся, даже когда отбор стал
точнее или в каталоге появилась нужная категория. Задание удаляет прошлый отказ и спрашивает снова,
а затем переносит товары этих папок (`ReapplyFeedCategoriesJob`). Запускает его человек: подбор
платный.

### Отметка «сборная» задним числом

Папку помечают сборной, как правило, **после** того, как её товары уже импортированы и сидят не там.
Живой случай: папка «Подоконники и часы» разрешилась через словарь в «Часы настенные» — категории
«подоконник» в дереве нет вовсе, а слово «часы» в имени есть, — и пять подоконников уехали в часы.
Продавец пометил папку сборной, и **ничего не произошло**: `FeedCategoryReassigner` смотрел только на
строки с выбранной категорией, а у сборной её нет.

Теперь такие папки он тоже разбирает: снимает у их товаров категорию (она получена по имени папки,
которое человек только что объявил бессмысленным) и ставит `ClassifyProductCategoryJob` на каждый
товар. До разбора товар вне витрины — это лучше, чем висеть в чужом разделе.

Разбор запускается **только по строкам, которые человек только что изменил** (`changedMapIds` из
формы). Иначе правка одной строки оплачивала бы повторный разбор всех сборных папок фида — по
запросу к модели на товар. Для разового прогона руками есть флаг:

```bash
php artisan feeds:reapply-categories 65 --per-item
```

### Товары, у которых категории в файле нет вовсе

Такие офферы собираются в отдельную строку сопоставления с ключом `__none__` и подписью «Товары без
категории в файле». Раньше их не было видно нигде: строка карты создаётся только для папок, реально
встречающихся в выгрузке, — и решать за них было негде.

У этой строки разбор по названию стоит **по умолчанию**, и это не противоречит правилу выше: когда
категории у оффера нет вовсе, выбирать не из чего — название товара остаётся единственным
источником. Так работало и раньше. Продавец может переключить строку на «не показывать» или указать
свою категорию.

Словарь синонимов про эту строку не спрашивают: «Товары без категории в файле» — служебное имя, а не
название категории, и ответ был бы наугад.

## Снимки: качаем только новые адреса

При обновлении товара в очередь ставятся только те картинки, чьих адресов ещё нет в
`product_images.source_url`. Дедупликация в `ImageService` тоже есть, но она по содержимому уже
скачанного файла: к моменту, когда мы понимаем «это то же самое», чужой сервер файл уже отдал.

Цена этого выяснилась на живом магазине. Продавец сменил схему имён файлов (убрал дефисы из
идентификаторов), адреса картинок вошли в подпись оффера — и «изменились» 2591 товар из 2661. Мы
запросили около 7250 снимков за полтора часа и сохранили из них 41: остальные оказались теми же
файлами. Сайт продавца этого не пережил.

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

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

## Перенос товаров при смене сопоставления

Импорт назначает категорию только новым товарам и пропускает неизменившиеся offers по сигнатуре,
поэтому правка сопоставления сама по себе уже импортированных товаров не касается. Их переносит
`FeedCategoryReassigner`, а запускает его **`ReapplyFeedCategoriesJob` из очереди**, а не сам
контроллер.

**Почему из очереди.** У крупной выгрузки в последнем прогоне под двадцать тысяч офферов, и связь
«товар → исходная категория фида» читается именно из них. На сохранении формы это упиралось в
таймаут шлюза: продавец видел «Gateway Timeout» и не понимал, сохранилось ли хоть что-то (решения
сохранялись — падал только ответ). Теперь запрос пишет строки сопоставления и ставит задание,
сообщение честно говорит «товары переедут в течение нескольких минут».

**Категория меняется в двух местах.** Кроме `products.category_id` её хранит индекс фильтров
(`product_attribute_values.category_id`, ТЗ §19). Не обновить второе — значит получить товар,
который переехал, но чьи значения характеристик числятся за прежней категорией: старый фильтр
показывает то, чего там уже нет, новый не находит приехавшее. Полная переиндексация для этого не
нужна — сами значения не изменились, сменилась только приписка.
