# Модуль: Массовые загрузки (импорт объявлений)

[← К оглавлению](../README.md)

Импорт объявлений из **YML** (Yandex Market Language) — файлом или по ссылке.
Категория каждого товара определяется автоматически через **DeepSeek**, затем
объявление проходит штатную модерацию. Раздел кабинета — **«Массовые загрузки»**
(`/cabinet/imports`).

## Идея

Категория товара в чужом файле не равна категории Placeo, а характеристики и
структуру разделов у всех поставщиков разные. Поэтому раздел Placeo для каждого
товара **подбирает DeepSeek** по названию и описанию — иначе объявления попадали
бы «куда попало». Это платно по токенам (на товар ~2 запроса классификации + 1
модерация), но даёт корректную категоризацию.

## Поток обработки

```
YML (файл/ссылка)
  → RunImportSource: скачать, разобрать, сопоставить с уже импортированными офферами
      → по каждому офферу с фото → очередь ImportOffer
ImportOffer (новый товар):
  классификация категории (DeepSeek) → создать объявление с настройками источника
  → скачать фото → конвертация в WebP → модерация (active / blocked)
ImportOffer (автообновление существующего):
  только цена + фото (дифф); заблокированные — пропуск; без повторной модерации
```

## Модели и таблицы

| Таблица | Назначение |
|---|---|
| `import_sources` | источник: `user_id`, `name`, `format` (yml), `source_type` (file/url), `url`, `auto_update`, `settings(json)`, `status`, `stats(json)`, `last_run_at` |
| `import_items` | связка «оффер ↔ объявление»: `offer_id`, `ad_id`, `status`, `signature`, `pictures(json)` — карта `url→photo_id` для диффа фото, `price`, `last_seen_at`, `reason` |
| `ads.import_source_id` | пометка импортированного объявления |

`settings` источника (применяются ко **всем** его объявлениям): `phone_id`
(из подтверждённых), `contact_method`, `lat`, `lng`, `address`, `city_name`.

Статусы `import_items`: `pending` (в обработке), `active` (опубликовано),
`blocked` (отклонено модерацией), `skipped` (нет фото / не загрузились),
`failed` (ошибка / категория не определена), `gone` (исчез из выгрузки).

## Файлы

| Файл | Назначение |
|---|---|
| `app/Http/Controllers/Cabinet/ImportController.php` | CRUD источников, запуск (`store`/`run`) |
| `app/Services/Import/YmlParser.php` | разбор YML (штатные поля + путь категории из файла как подсказка) |
| `app/Services/Import/CategoryClassifier.php` | определение категории Placeo через DeepSeek |
| `app/Services/ImageService.php` | `storeFromUrl()` — скачать фото → WebP (большая + превью) |
| `app/Jobs/RunImportSource.php` | получить файл, разобрать, дифф, поставить офферы в очередь |
| `app/Jobs/ImportOffer.php` | обработка одного оффера (создание/автообновление) |
| `app/Models/{ImportSource,ImportItem}.php` | модели |
| `resources/views/cabinet/imports/{index,create,show}.blade.php` | кабинет |
| `routes/console.php` | ежедневное автообновление (cron) |

## Определение категории (DeepSeek)

`CategoryClassifier` — двухшаговая классификация с **несколькими корнями-
кандидатами** (чтобы корректный лист не отсекался неверным выбором одного корня):

1. Выбор до **3** наиболее вероятных корневых разделов (`{"ids": [<числа>]}`).
2. Выбор конечной категории среди листьев **всех** этих корней (путь листа
   включает корень).

Промпт учитывает **тип товара** (ткань/материал ≠ готовое изделие, запчасть ≠
сам предмет) и **исходную категорию из файла** как сильную подсказку — иначе,
например, «муслин» уезжал в «Женскую одежду» вместо «Текстиль и ковры». Списки
разделов/листьев кэшируются на 1 час (`Cache`); при смене дерева категорий
сбросить — `php artisan cache:clear`. Возвращённый id валидируется (активный
лист в пределах кандидатов); если не определилось — оффер получает статус `failed`. Ключ/модель DeepSeek — те же, что у модерации
(`/admin → Настройки` приоритетнее `.env`, см. [integrations.md](integrations.md)).

## Фотографии

- Скачиваются по URL из оффера (`ImageService::storeFromUrl`) и сжимаются в
  **WebP** (≤1200px + превью ≤400px), как при обычной подаче (см. [ads.md](ads.md)).
- В `import_items.pictures` хранится карта `url → photo_id` — для диффа при
  обновлении.
- **Объявление без хотя бы одного фото — пропускается** (`skipped`): фото
  обязательно.

## Источник: файл vs ссылка

- **Файл** (`source_type=file`) — разовая обработка. Файл сохраняется в
  `storage/app/private/imports/{id}.yml` (диск `local`).
- **Ссылка** (`source_type=url`) — можно включить **автообновление раз в сутки**.
  Планировщик (`routes/console.php`, `imports-auto-update`, 04:00) ставит
  `RunImportSource` для всех `url`-источников с `auto_update`.

## Автообновление (правила)

- Обновляются **только цена и фото**; название/описание/категория не трогаются
  (чтобы не гонять повторную модерацию).
- **Фото диффятся**: которых уже нет в выгрузке — удаляются; новые — скачиваются.
- **Новые товары** (которых не было) — добавляются с классификацией и модерацией.
- **Заблокированные** модерацией с первого раза при автообновлении
  **пропускаются** (не обновляются).
- Офферы, **исчезнувшие** из файла → их объявления **архивируются**
  (`gone`); если позже снова появляются — реактивируются.

## Настройки источника

При создании обязательны (форма `create`):
- **Телефон** — из подтверждённых номеров пользователя;
- **Способ связи** — звонки/сообщения (см. [ads.md](ads.md));
- **Расположение** — одна точка на карте (адрес + город) для всех объявлений.

Эти значения применяются ко всем объявлениям источника. **Характеристики пока
не импортируются** (нет простого универсального сопоставления).

## Очередь и масштаб

Обработка идёт в **очереди** (драйвер `database`, на проде — воркер
`placeo-queue`). На 1000 товаров — 1000 определений категории + 1000 модераций,
последовательно одним воркером (~3–5 c на товар). Запустить локально:

```bash
php artisan queue:work        # обработка RunImportSource + ImportOffer
php artisan schedule:work     # ежедневное автообновление
```

## Проверка вручную (dev)

```php
// положить YML на диск local (= storage/app/private):
Storage::disk('local')->put("imports/{$source->id}.yml", $yml);
App\Jobs\RunImportSource::dispatchSync($source->id);   // разбор + постановка офферов
// затем обработать офферы:
// php artisan queue:work --stop-when-empty
```

См. также: [ads.md](ads.md), [integrations.md](integrations.md), [cabinet.md](cabinet.md).
