# Импорт из 1С

CommerceML 2. Конвейер из четырёх стадий вместо построчной обработки:

```
разбор → склад → слияние → зачистка
```

Легаси делал один `UPDATE`/`INSERT` на товар строковой склейкой, без
подготовленных запросов и без транзакций, а весь набор товаров держал в памяти
PHP (`memory_limit=1200M`), потому что ключевые колонки не были
проиндексированы. Выгрузки регулярно висели дольше двухчасового watchdog.

**Гейт этапа пройден:** 100 000 позиций, 3324 поз./с, пик памяти 36 МБ —
константный. Прогон, убитый `kill -9` на середине, продолжается с места обрыва
без дублей.

## Стадии

| Стадия | Что делает |
|---|---|
| разбор | `XMLReader` потоком, по одной позиции в памяти |
| склад | пачки по 500 строк в `import_stage` одним multi-row INSERT |
| слияние | **один** `INSERT … SELECT … ON DUPLICATE KEY UPDATE` на весь прогон |
| зачистка | **один** `UPDATE … LEFT JOIN` для снятых с выгрузки |

`simplexml_load_file` не используется: пакет на 100 тысяч позиций весит сотни
мегабайт, и загрузка его в дерево объектов — ровно та ошибка, из-за которой
легаси и поднимал лимит памяти.

⚠️ **Ловушка `XMLReader::next()`**: он сразу ставит курсор на следующий узел
того же уровня, поэтому вызывать после него `read()` нельзя — тот перескочит
через одну позицию. В пакете с переводами строк между тегами это незаметно
(курсор попадает на текстовый узел), а в пакете без пробелов теряется каждая
вторая позиция. Поймал тест, не живой прогон.

## Слияние — по внешнему ключу 1С

`(site_id, external_id)` — уникальный ключ каталога. Повторный прогон
превращается в обновление, а не в дубликаты, и это же свойство делает
безопасным возобновление.

`slug` в список обновляемых колонок **не входит**: переименование товара в 1С
не должно менять адрес карточки и убивать её позиции в поиске. Для новых
позиций слаг приезжает из склада — там его посчитал разбор, потому что
в SQL транслитерировать нечем.

### ⚠️ Связь с разделом — вторым запросом того же слияния

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

```sql
INSERT INTO product_sections (site_id, section_id, product_id, sort_order, is_published, is_cascade)
SELECT p.site_id, p.section_id, p.id, p.sort_order, p.is_published, 0
FROM catalog_products p
JOIN import_stage s ON s.site_id = p.site_id AND s.external_id = p.external_id
WHERE s.run_id = ? AND p.section_id = ?
ON DUPLICATE KEY UPDATE sort_order = VALUES(sort_order), is_published = VALUES(is_published)
```

Следом пересчитываются каскадные разделы — «товары из подразделов»
(см. [catalog.md](catalog.md#товары-из-подразделов)).

## Зачистка — одним запросом

```sql
UPDATE catalog_products p
LEFT JOIN import_stage s ON s.run_id = ? AND s.external_id = p.external_id
SET p.is_published = 0
WHERE p.site_id = ? AND p.external_id IS NOT NULL AND s.id IS NULL AND p.is_published = 1
```

Не `NOT IN (…)` на сто тысяч идентификаторов: такой список MySQL разбирает
дольше, чем сам апдейт.

Товары, заведённые руками (без `external_id`), не трогаются: 1С про них
не знает, и её молчание — не повод их прятать.

⚠️ Флаг публикации **продублирован в связях**, и список читает именно их,
а не колонку товара. Поэтому зачистка вторым запросом переносит флаг в
`product_sections`: без него снятый с выгрузки товар остался бы в разделе
ровно так же, как и был.

## Возобновление

`import_runs` хранит стадию, счётчики и `resume_offset` — сколько позиций уже
уехало в склад.

```bash
php artisan catalog:import medtehnika /путь/import.xml
php artisan catalog:import medtehnika --resume=42
```

Файл при возобновлении перечитывается с начала, но только разбором, без
записи: `XMLReader` не умеет искать по смещению, а узкое место импорта всегда
база, а не парсер. Вдобавок склад защищён уникальным ключом
`(run_id, external_id)`, поэтому даже разошедшийся счётчик не создаст дублей.

## Сторож

```bash
php artisan catalog:import-watchdog          # в расписании, ежечасно
```

Чинит он не «зависший прогон» — прогону уже всё равно, — а **сайт, который
из-за него больше не может импортировать**: приёмник обмена не принимает второй
пакет, пока у сайта есть живой прогон. Убитый воркер оставляет прогон
в `parsing` навсегда, и 1С со стороны клиента получает «предыдущий обмен ещё
идёт» до конца времён.

⚠️ **Мёртвый прогон опознаётся молчанием, а не временем старта.** Конвейер
пишет `import_runs` каждые 500 позиций — это готовый пульс. Прогон на миллион
позиций идёт час и при этом жив; прогон, чей `updated_at` не двигался
полчаса, мёртв независимо от того, сколько он идёт. Легаси резал по времени
старта (два часа) и убивал живые выгрузки.

Порог живёт в одном месте (`ImportWatchdog::STALE_MINUTES`) и им же
пользуется приёмник обмена. Раньше там стояло «прогон моложе часа», и это било
с обеих сторон: миллион позиций разбирается дольше часа и получал бы
параллельный обмен, а убитый воркер держал бы сайт весь этот час.

Закрытый сторожем прогон **можно продолжить**: `failed` — не «завершён»,
а «остановился», и склад его позиций цел.

### Склад — черновик, и он убирается

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

Без этой уборки каждый импорт оставлял бы в базе полный пакет навсегда.
На разработке к моменту написания лежало 302 500 строк от пяти прогонов —
на трёх сотнях сайтов таблица склада переросла бы каталог.

## Два файла одного пакета

1С присылает позицию дважды: в `import.xml` — описанием, в `offers.xml` —
ценой и остатком. Оба кладутся в склад под одним `external_id`, второй
дополняет первый: при повторной постановке обновляются только `price`
и `quantity`, иначе `offers.xml` затёр бы названия пустотой.

Составной идентификатор (`товар#характеристика`) берётся целиком:
характеристика — отдельная товарная позиция со своей ценой и остатком.

## Приёмник обмена

`/1c/exchange` на домене сайта — арендатор определяется резолвером хоста,
как с уведомлениями об оплате.

```
mode=checkauth  → success\n{имя куки}\n{значение}
mode=init       → zip=no\nfile_limit=8388608   + чистка каталога прошлого обмена
mode=file       ← тело запроса: кусок файла, дописывается к предыдущим
mode=import     → success, разбор уходит в очередь
```

Протокол старый и текстовый: первая строка ответа — `success` или `failure`,
конфигурация читает построчно. Никакого JSON.

- **Разбор в очереди, а не в запросе.** Пакет на сто тысяч позиций разбирается
  минутами, а 1С ждёт ответа секунды и по таймауту начинает слать заново.
  Легаси разбирал прямо в запросе — отсюда и висящие обмены.
- **Куски дописываются**, потому что 1С режет большой пакет на части.
  Перезапись дала бы обрезанный файл, целым в котором выглядит только хвост.
- ⚠️ **`init` вычищает каталог.** Раз куски дописываются, остаток прерванной
  выгрузки склеится с новой в мусор, на котором разбор падает невнятной
  ошибкой парсера. Нашлось живым прогоном, а не тестом.
- **Один прогон на сайт за раз**: параллельные обмены дерутся за один
  уникальный ключ каталога и мешают друг другу считать, чего 1С больше
  не присылает.
- **Пока доступы не заданы, приёмник закрыт.** Открытый всем обмен —
  это чужой каталог в вашей базе. Логин и пароль — настройки сайта
  (`Настройки → Каталог → Обмен с 1С`), пароль шифруется.
- **Имя файла приходит снаружи**: подкаталоги и всё, что похоже на выход
  наверх, отбрасываются, принимаются только `*.xml`.
- CSRF для этого адреса снят: 1С присылает Basic-авторизацию, а не токен формы.

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

**Проверено живым прогоном:** 43 МБ тремя кусками → очередь → 100 000 позиций
за 1 мин 22 с вместе с фасетами и переиндексацией.

## Тестовый пакет

```bash
php artisan catalog:make-package /tmp/import.xml --count=100000
php artisan catalog:make-package /tmp/offers.xml --count=100000 --offers
```

Генератор тоже потоковый: иначе он упёрся бы в память раньше импорта.

## Чего ещё нет

| Что | Когда |
|---|---|
| Характеристики, картинки, группы 1С → разделы | по мере надобности |
| Цены по типам (сейчас берётся первая) | вместе со справочником цен из 1С |
| Экран прогресса в админке | после приёмника |
