# Импорт каталога Vizen по API

Гайд для внешнего разработчика или ИИ-агента, который наполняет магазин
товарами по персональному ключу (PAT).

Соседний документ `/docs/webcoding` учит **вёрстке страниц**. Этот — **данным**:
категории, товары, картинки, характеристики, варианты и связанные товары.
Полная схема — `/openapi.json`.

> Документ написан по итогам разбора реального импорта: каждый раздел
> «Подводный камень» — ошибка, которая уже стоила кому-то повторной работы.

---

## 0. Тридцать секунд: что нужно знать до первого запроса

| Факт | Следствие |
|---|---|
| Магазин определяется **ключом**, не параметром | `company_id` в запросе игнорируется; чужой тенант не виден |
| **Цена — целое число в валюте магазина** | `price.currency` в запросе **игнорируется**; сменить валюту ключом нельзя |
| Ключ пишет либо в **живой сайт**, либо в **черновик** | Свойство ключа, заголовком не переключается |
| `category_id` — **основная** категория, `categories[]` — **дополнительные** | Дублировать основную в `categories[]` не нужно |
| **Галерея — отдельный ресурс магазина**, а не поле товара | Заводится в `/galleries`; товар, категория и статья ссылаются на неё `gallery_ids[]` (§7) |
| Списки требуют **обе** части пагинации | `page.limit` без `page.number` → `400` |
| Каталог живёт **без префикса** `/v1` | `/products`, `/categories`; файлы и организация — под `/v1` |

---

## 1. Шаг 0 — паспорт ключа (обязателен)

```http
GET /v1/account/token
Authorization: Bearer vz_pat_…
```

```json
{
  "company_id": 7,
  "contour": "prod",
  "writes_to_live": true,
  "dev_header_allowed": false,
  "publish_allowed": false,
  "expires_at": null,
  "scopes": ["catalog:read", "catalog:write", "storage:read", "storage:write"],
  "store": {
    "name": "promo",
    "slug": "promo",
    "storefront_url": "https://promo.vizen.shop",
    "currency": "USD",
    "language": "ru",
    "custom_domain": ""
  },
  "capabilities": {
    "catalog_read": true, "catalog_write": true,
    "storage_read": true, "storage_write": true,
    "publish_dev": false, "store_settings_write": false
  },
  "warnings": [
    "Ключ пишет в ЖИВОЙ магазин: изменения видны посетителям сразу…",
    "Цена товара — целое число в валюте магазина (USD)…",
    "Сменить валюту, язык или название магазина этим ключом НЕЛЬЗЯ…"
  ]
}
```

**Что с этим делать перед массовой записью:**

1. `writes_to_live: true` → сказать владельцу прямым текстом: *«записи сразу
   увидят посетители»* — и дождаться подтверждения.
2. `store.currency` → сверить с валютой прайса. **Не совпало — остановиться.**
   Валюту меняет владелец в админке; ключом это невозможно
   (`capabilities.store_settings_write: false`).
3. `capabilities` → проверить, что нужные права есть. Наличие тематического
   scope ещё не значит, что метод открыт для ключа: список `capabilities`
   считается по фактической карте доступа, ему и верить.
4. `warnings` → передать владельцу целиком, не пересказывая.

**Подводный камень.** Раньше контур приходилось выяснять опытом: записать товар
и пойти смотреть витрину. Теперь этого делать не нужно — и не надо.

---

## 2. Пять правил, которые чаще всего ломают импорт

### 2.1. Валюта магазина сильнее присланной

```jsonc
// запрос
{ "item": { "price": { "price": 6400, "currency": "RUB" } } }
// ответ: currency = "USD" — валюта магазина
```

Цена — **целое число** в валюте магазина. Копейки/центы не отделяются
отдельным полем: `6400` — это 6400 единиц валюты.

`price.currency` — **read-only по смыслу**: сервер вернёт валюту магазина
независимо от присланного значения. Не полагайтесь на это поле при записи и
сверяйте валюту на шаге 0.

### 2.2. Пагинация — обе части или ни одной

```http
GET /products?page.limit=100              → 400 (Number must be >= 1)
GET /products?page.limit=100&page.number=1 → 200
GET /products                              → 200 (дефолтная страница)
```

Передаёте `page.limit` — обязательно передавайте и `page.number` (нумерация
с **1**).

### 2.3. Основная и дополнительные категории — разные поля

| Поле | Что значит |
|---|---|
| `category_id` | **Основная** категория. Определяет основной URL товара и его листинг |
| `categories[]` | **Дополнительные** категории (id). Товар покажется и в них |
| `categories_replace: true` | Полная замена набора дополнительных. `[]` + флаг = очистить |

Основную категорию **не нужно** повторять в `categories[]` — это создаёт
лишнюю связь, а не «усиливает» привязку.

```jsonc
// создание — только основная
POST /products   { "item": { "category_id": 85 } }
// создание — основная + две дополнительные
POST /products   { "item": { "category_id": 85, "categories": [86, 87] } }
// правка — полная замена дополнительных
PUT /products/808 { "item": { "categories": [86, 87], "categories_replace": true } }
// правка — очистить дополнительные, основную оставить
PUT /products/808 { "item": { "categories": [], "categories_replace": true } }
```

`categories_replace` работает **только при правке**. Без него `categories` при
правке **дополняет** набор, а пустой массив означает «не менять», а не «снять
все». При создании флаг не нужен — набор и так задаётся с нуля.

### 2.4. Тройные флаги — это строки, а не boolean

Часть переключателей наследуется от магазина, поэтому у них три состояния, и в
JSON они приходят **строкой**:

| Поле | Значения | Смысл |
|---|---|---|
| `catalog_hidden` | `""` / `"true"` / `"false"` | `""` — как настроено выше; `"true"` — скрыть из сеток витрины |
| `comments_enabled`, `questions_enabled` | `""` / `"true"` / `"false"` | то же |
| `reviews_scope` | `""` / `own` / `category` / `shop` / `off` | |

`is_published` — обычный **boolean**. Не перепутайте: `catalog_hidden: false`
(boolean) — ошибка типа, нужно `"false"` строкой либо `""` для сброса.

**Безопасная последовательность:** создать товар с `is_published: false` →
проверить чтением → опубликовать.

### 2.5. Порядок категорий задаётся перемещением, а не полем

`order` в теле создания **игнорируется**: новая категория всегда встаёт
ПОСЛЕДНЕЙ среди соседей. Прочитанное значение поэтому закономерно отличается от
отправленного — не считайте это ошибкой и не пытайтесь «додавить» нужное число
повторной записью.

Так сделано, чтобы позиции соседей оставались плотным набором `0..n-1` без
повторов: на нём держится арифметика перемещений `before`/`after`. Раньше поле
писалось как прислано, и каталог быстро набирал группы, где у всех соседей
`order` = 0 — тогда порядок фактически определялся внутренним id, а
перетаскивание разделов в админке переставало работать.

Нужен конкретный порядок — расставьте его явно после создания всех разделов:

```http
POST /categories/move
{ "from_id": "85", "to_id": "86", "type": "before" }   // before | after | inside
```

---

## 3. Модель каталога

```
Магазин (ключ)
├── Галерея (именованный набор медиа; своя ручка /galleries)
│      ↑ ссылаются товар, категория, статья и набор варианта
└── Категория (раздел витрины; дерево через parent_id)
    └── Товар  ── основная категория + дополнительные
        ├── Характеристики  (значения атрибутов: материал, страна, …)
        ├── Варианты внутри товара  (одна карточка, переключатели)
        └── Членство в склейке      (несколько карточек, связанных осями)
```

Галерея **не принадлежит** товару: она живёт сама по себе, и одну и ту же
галерею можно поставить на несколько товаров, на категорию и на статью сразу.
Это и есть штатный способ «взять галерею из другого товара» (§7).

**Атрибут** (`/attributes`) — общий для магазина справочник: код, тип, виджет,
список опций. Он используется и как характеристика, и как ось переключателя.

---

## 4. Варианты или связанные товары: как выбрать

Платформа поддерживает **обе** модели. Выбор делается один раз и меняется
дорого — сравните по таблице.

| Что нужно | Варианты внутри товара | Склейка отдельных товаров |
|---|---|---|
| Отдельный URL у каждой комбинации | ✗ один URL + параметр | ✓ у каждого свой |
| Отдельные SEO-заголовки и описание | ✗ | ✓ |
| Отдельная галерея у комбинации | ✓ ссылка набора на галерею (`gallery_id`, §5 шаг 4б) | ✓ свой набор галерей у каждого |
| Свой SKU и цена | ✓ | ✓ |
| Отдельный складской остаток | ✓ | ✓ |
| Отдельная карточка в сетке каталога | ✗ одна | ✓ каждая (лишние прячутся `catalog_hidden`) |
| Стоимость заведения | ниже: 1 товар + набор | выше: N товаров + группа |

**Правило выбора одной строкой:** различаются только цена, SKU и остаток →
**варианты**; различаются ещё и фотографии → **варианты + своя галерея у
набора**; различается текст, URL или место в сетке каталога → **склейка**.

Показать «другой цвет» можно тремя способами, и они не заменяют друг друга:

| Если нужно… | Механизм | Куда пишется | Цена решения |
|---|---|---|---|
| у варианта другой **чип** и фото строки в корзине, слайдер общий | картинка набора `image_id` | `PUT /products/{id}/variants` | одна картинка на набор |
| при выборе варианта **другие фотографии в слайдере**, товар один, URL один | галерея + `gallery_id` набора | `POST /galleries` → `PUT /products/{id}` → `PUT /products/{id}/variants` | ≤10 галерей на объект × ≤24 элемента; SEO общий |
| у каждого варианта **свой URL, SEO, описание и карточка в сетке** | склейка товаров | N × `POST /products` + `/product-groups` | N товаров вести; лишние карточки прячутся `catalog_hidden` |

`image_id` и `gallery_id` — **независимые** поля набора: первый отвечает за чип
и строку корзины, второй — только за слайдер.

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

---

## 5. Сценарий А — один товар с вариантами

**Когда:** футболка в трёх размерах, отличается только цена и остаток.

```bash
# 1) Справочник: атрибут-ось с опциями
POST /attributes
{ "item": {
    "code": "size", "name": "Размер",
    "data_type": "select",        # ось переключателя — всегда select
    "widget": "select",           # text | select | icon | swatch | photo
    "options": [ { "value": "s", "label": "S" },
                 { "value": "m", "label": "M" },
                 { "value": "l", "label": "L" } ] } }
# → result.id = 12, у опций свои id

# 2) Товар
POST /products
{ "item": { "name": "Футболка TERRA", "category_id": 85,
            "price": { "price": 2400 }, "sku": "TERRA-TSHIRT",
            "is_published": false } }
# → result.id = 900

# 3) Оси товара (replace-set)
PUT /products/900/variant-axes
{ "items": [ { "attribute_id": 12, "display": "text", "sort_order": 0 } ] }

# 4) Наборы (replace-set): values = attribute_id → value опции
PUT /products/900/variants
{ "items": [
    { "values": { "12": "s" }, "price": 2400, "sku": "TERRA-TSHIRT-S" },
    { "values": { "12": "m" }, "price": 2400, "sku": "TERRA-TSHIRT-M" },
    { "values": { "12": "l" }, "price": 2600, "sku": "TERRA-TSHIRT-L" } ] }

# 4б) галереи и привязка набора (опционально)
#     Галерея — самостоятельный ресурс: сначала заводим её, потом ссылаемся.
POST /galleries
{ "item": { "name": "Синие",
            "items": [ { "file": { "id": "<file_id_blue_1>" } },
                       { "file": { "id": "<file_id_blue_2>" } } ] } }
# → result.id = "31"   (id галереи — СТРОКА: в JSON int64 приходит строкой)

POST /galleries
{ "item": { "name": "Чёрные", "items": [ { "file": { "id": "<file_id_black_1>" } } ] } }
# → result.id = "32"

# галереи товара — это ССЫЛКИ, полный набор в нужном порядке
PUT /products/900
{ "item": { "gallery_ids": ["31", "32"], "gallery_ids_replace": true } }

PUT /products/900/variants
{ "items": [
    { "values": { "12": "s" }, "price": 2400, "sku": "TERRA-TSHIRT-S",
      "gallery_id": "31" },
    { "values": { "12": "m" }, "price": 2400, "sku": "TERRA-TSHIRT-M" },
    { "values": { "12": "l" }, "price": 2600, "sku": "TERRA-TSHIRT-L" } ] }
# → variants_info.variants[].gallery_id в ответе GET /products/900 подтверждает привязку

# 5) Проверка и публикация
GET  /products/900/variants
PUT  /products/900   { "item": { "is_published": true } }
```

**Подводные камни:**

- `variant-axes` и `variants` — **replace-set**: присылайте полный набор,
  отсутствующее удаляется.
- `display` у оси уже: `""` / `text` / `icon` / `swatch`. Значение `select`
  допустимо у атрибута, но **не** у оси товара.
- `price` у набора необязателен: пусто → берётся базовая цена товара.
- Дубль комбинации значений отклоняется — сервер считает подпись набора.
- Максимум: 20 осей, 500 наборов на товар.
- `gallery_ids` в `PUT /products/{id}` — **набор ссылок** по паттерну
  `categories_replace`: непустой массив → полная замена в присланном порядке;
  пустой массив или поле не прислано → привязки **не трогаются** (безопасно
  копировать тело `GET` в `PUT`); пустой массив + `"gallery_ids_replace": true`
  → отвязать все. Содержимое галереи правится своей ручкой `PUT /galleries/{id}`,
  а не через товар.
- `gallery_id` набора — ссылка на **любую живую галерею магазина**, не
  обязательно привязанную к этому товару. Это и есть «взять галерею из другого
  товара». Чужой или несуществующий id → `404 GALLERY_NOT_FOUND`. Галерею
  удалили позже — набор не ломается: на чтении `gallery_id` вернётся `"0"`,
  витрина покажет первую галерею товара.
- Одна галерея может стоять на нескольких товарах, на категории и на статье
  одновременно — `GET /galleries/{id}/usage` покажет весь список.
- Картинка набора (`image_id`, шаг 4) и галерея набора (`gallery_id`) —
  **независимые** поля: `image_id` остаётся чипом и превью строки корзины,
  `gallery_id` отвечает только за слайдер витрины.

---

## 6. Сценарий Б — отдельные товары, связанные склейкой

**Когда:** тарелка в 3 цветах × 3 размерах, у каждой свои фото и свой URL.

```bash
# 1) Две оси-атрибута
POST /attributes
{ "item": { "code": "color", "name": "Цвет", "data_type": "select",
            "widget": "swatch",
            "options": [ { "value": "light",  "label": "Светлый",   "color_hex": "#E8DCC8" },
                         { "value": "brown",  "label": "Коричневый","color_hex": "#8B5A3C" },
                         { "value": "black",  "label": "Чёрный",    "color_hex": "#2B2B2B" } ] } }
POST /attributes
{ "item": { "code": "diameter", "name": "Диаметр", "data_type": "select",
            "widget": "select", "unit": "см",
            "options": [ { "value": "20" }, { "value": "26" }, { "value": "32" } ] } }

# 2) Девять товаров — обычные POST /products, у каждого свой slug, фото, цена
#    (или один POST /products/batch, см. §8)

# 3) Каждому товару — значения по обеим осям (replace-set)
PUT /products/808/attributes
{ "items": [ { "attribute_id": 13, "value": "light" },
             { "attribute_id": 14, "value": "20"    } ] }

# 4) Группа
POST /product-groups
{ "item": { "name": "Тарелка TERRA", "group_type": "variant_group" } }
# → result.id = 5

# 5) Оси группы (replace-set) — порядок переключателей на витрине
PUT /product-groups/5/axes
{ "items": [ { "attribute_id": 13, "display": "swatch", "sort_order": 0 },
             { "attribute_id": 14, "display": "text",   "sort_order": 1 } ] }

# 6) Участники (replace-set)
PUT /product-groups/5/members
{ "items": [ { "product_id": 808, "sort_order": 0 },
             { "product_id": 809, "sort_order": 1 } ] }

# 7) Проверка: карточка отдаёт готовую связь
GET /products/808
# → group_info: оси, значения, слаги соседей, текущий участник
```

**Подводные камни:**

- Значения осей ставятся **товару** (`PUT /products/{id}/attributes`), а не
  участнику группы: в `members` поле `values` — только для чтения.
- Все три `PUT` — replace-set. Добавляете десятый товар в группу — присылайте
  всех десятерых.
- Чтобы в сетке каталога была **одна** карточка вместо девяти, остальным
  поставьте `catalog_hidden: "true"`. Из склейки они не выпадут — переключатель
  на карточке продолжит на них вести.
- `group_info` в ответе товара — **сосед** поля `result`, а не его вложение.
  Там же лежат `variants_info`, `attribute_sections`, `categories`.

---

## 7. Картинки товара

Загрузка — три шага (или один, если файл уже в интернете); подробности и
правила нарезки — в `/docs/webcoding`, §3.

```bash
POST /v1/storages/files
{ "section": "product", "file_type": "image", "original_name": "plate.jpg" }
# → result.id = <file_id>, upload.id, upload.url
PUT  <upload.url>                       # байты, без Authorization
POST /v1/storages/files/<upload.id>/done  { }

# файл уже в сети — одним запросом
POST /v1/storages/files/from-url
{ "source_url": "https://…/plate.jpg", "section": "product", "file_type": "image" }

# галерея заводится ОТДЕЛЬНО и один раз
POST /galleries
{ "item": { "name": "Основная",
            "items": [ { "file": { "id": "<file_id_2>" } },
                       { "file": { "id": "<file_id_3>" } } ] } }
# → result.id = "31"

# привязка при СОЗДАНИИ — превью объектом, галереи ссылками
POST /products
{ "item": { "name": "Тарелка", "preview": { "id": "<file_id>" },
            "gallery_ids": ["31"] } }

# привязка при ПРАВКЕ — главная картинка строкой-идентификатором
PUT /products/808
{ "item": { "preview_id": "<file_id>",
            "gallery_ids": ["31"], "gallery_ids_replace": true } }
```

**Подводные камни:**

- **Создание и правка принимают главную картинку по-разному:** при создании —
  `preview: { "id": … }`, при правке — `preview_id: "…"` строкой. Галереи в
  обоих случаях — `gallery_ids`, массив id-строк. Прислать при правке `preview` вместо
  `preview_id` — тихо ничего не изменить: запрос ответит `200`, картинка
  останется прежней. Всегда перечитывайте товар после записи.
- Снять главную картинку при правке — `preview_id: ""` (пустая строка).
- В `/done` идёт **`upload.id`**, а к товару цепляется **`result.id`**. Это
  разные значения.
- `file_type` — категория (`image | video | audio | model | file`), не MIME.
- Один `file_id` можно использовать в нескольких галереях и товарах —
  **повторно тот же файл не загружайте**.
- `public_path` в ответе хранилища — **полный адрес объектного хранилища**.
  На витрине ставьте относительный путь витрины с нарезкой
  (`/w/{ширина}/webp{путь}`), а не этот URL и не оригинал.
- `gallery_ids` — replace-set ссылок: присылайте полный список в нужном
  порядке.

### 7.1. Галерея — самостоятельный ресурс

Галерея не лежит внутри товара. Это отдельная сущность магазина со своим CRUD;
товар, категория (любого типа) и статья только **ссылаются** на неё.

| Ручка | Что делает |
|---|---|
| `GET /galleries` | живые галереи магазина вместе с элементами, свежие сверху; **страницами** — `?page.number=&page.limit=` (1..100), без параметров первые 50; в ответе рядом с `result[]` есть `total` и `currentPage` |
| `GET /galleries/{id}` | одна галерея с элементами |
| `POST /galleries` | создать галерею вместе с элементами |
| `PUT /galleries/{id}` | правка: имя и/или элементы |
| `DELETE /galleries/{id}` | мягкое удаление; занятую удалить нельзя |
| `GET /galleries/{id}/usage` | где используется: товары, категории, статьи и наборы вариантов |

Форма галереи:

```json
{ "id": "31", "name": "Синие", "source": "images", "canvas_id": "0",
  "items": [
    { "item_key": "8231acd9-…", "kind": "image",
      "file": { "id": "<file_id>", "url": "…" }, "poster": null,
      "html": "", "caption": "вид спереди" },
    { "item_key": "4fba917d-…", "kind": "html", "file": null,
      "html": "<b>Схема сборки</b>", "caption": "" } ] }
```

Что важно знать про элементы:

- **роль (`kind`) ставит сервер**, а не вы. Она выводится из типа файла:
  `image | video | audio`. Вручную задаётся только `"html"` — у него нет файла,
  есть разметка. Прислали `kind`, не совпавший с типом файла (например
  `"video"` на картинке) → `400 GALLERY_ITEM_KIND_MISMATCH`. Тихой починки
  нет: молчаливая подмена роли даёт витрину, не похожую на то, что вы
  отправляли;
- у не-html элемента файл **обязателен** (`400 GALLERY_ITEM_FILE_REQUIRED`),
  у html обязателен непустой `html` (`400 GALLERY_ITEM_HTML_REQUIRED`) не
  длиннее **64 КБ** (`400 GALLERY_ITEM_HTML_TOO_LONG`);
- `poster` — постер видео или обложка звука, тот же `{ "id": … }`;
- `item_key` — стабильный ключ элемента. Пусто на входе → сервер выдаст uuid;
  прислали свой — сохранится. По нему элемент остаётся «тем же» при
  перестановке, поэтому при повторе запроса после таймаута присылайте
  собственные ключи;
- `items` в `PUT /galleries/{id}` — **replace-set**: непустой список заменяет
  все элементы целиком; пустой список (или отсутствие поля) означает «не
  трогать». Чтобы **очистить** галерею — пустой список плюс
  `"items_replace": true`: без флага удалить последний элемент нельзя вовсе.
  Имя правится отдельно и независимо: `{ "item": { "name": "…" } }` элементы не
  тронет.

Привязка к объектам — одинаковая у товара, категории и статьи:

```bash
PUT /products/808    { "item": { "gallery_ids": ["31","32"], "gallery_ids_replace": true } }
PUT /categories/85   { "item": { "gallery_ids": ["31"],      "gallery_ids_replace": true } }
PUT /articles/12     { "item": { "gallery_ids": ["31"],      "gallery_ids_replace": true } }
```

На чтении карточки приходят оба поля: `gallery_ids` — набор ссылок по порядку,
`galleries` — их содержимое. **Одна галерея законно стоит на нескольких
объектах** — правка её содержимого меняет вид всех сразу, и это ожидаемо.

⚠️ `galleries` может быть **длиннее** `gallery_ids`. Если набор варианта
ссылается на галерею, которая к самому товару не привязана, её содержимое всё
равно приезжает в карточку — в конце `galleries`, но **не** в `gallery_ids`.
Так сделано нарочно: вернув тело `GET` обратно в `PUT`, вы не привяжете к
товару чужую галерею. Из этого следует правило: набор привязок читайте из
`gallery_ids`, а содержимое для показа ищите в `galleries` **по id**, а не по
позиции. Фолбэк превью считается только по ПРИВЯЗАННЫМ галереям — товар,
у которого галерея есть только по ссылке набора, отдаёт `preview: null` и
`preview_auto: false`. Проверено вживую.

Удалить занятую галерею нельзя: `400 GALLERY_IN_USE: used in N places`.
Сначала снимите ссылки (у объектов — `gallery_ids: []` + `gallery_ids_replace`,
у наборов — `gallery_id: "0"`), сверьтесь по `GET /galleries/{id}/usage`, и
только потом удаляйте.

Лимиты: **≤24 элемента** в галерее, имя **1..64 символа**, **≤10 галерей** на
один объект.

### 7.2. Что игнорируется молча — и как проверить, что запись состоялась

Сервер отвечает `200` и в этих случаях, но делает не то, что вы ожидали:

| Что прислали | Что произошло на самом деле |
|---|---|
| `preview` вместо `preview_id` при правке | картинка не изменилась |
| `gallery_ids: []` без `gallery_ids_replace` | прочитано как «не трогать», а не «отвязать все» |
| `categories: []` без `categories_replace` | то же самое: дополняем, не заменяем |
| `price.currency` | отброшено, валюта берётся из магазина |
| `company_id` в теле запроса | отброшено, магазин определяет ключ |
| `order` при создании категории | отброшено, раздел встаёт последним |
| `items` у галереи, которой управляет канвас-документ (`canvas_id ≠ 0`) | отброшены; хранимые элементы остаются резервной копией до отвязки — включая запрос с `canvas_id: 0` и элементами вместе: отвязка сработает, элементы нет |
| `items: []` у галереи без `items_replace` | прочитано как «не трогать»; очистить — только с флагом |
| чтение `gallery_ids` / `galleries` из **списков** | у `GET /products` полей нет вовсе, у `GET /categories` они приходят пустыми — списки галереи не отдают, там есть только `preview` |

**Проверка после каждой записи — одна и та же: перечитать объект и сверить
поля, которые отправляли.** Ответ `200` доказывает только то, что запрос
разобран.

```bash
# записали
PUT /products/808 { "item": { "gallery_ids": ["31"], "gallery_ids_replace": true } }
# перечитали и сверили
GET /products/808        # result.gallery_ids == ["31"], result.galleries[0].id == "31"
GET /galleries/31        # items — те, что отправляли, в том же порядке
GET /galleries/31/usage  # в списке есть product 808
```

Три поля, которые особенно легко прочитать неверно:

- `preview_auto: true` — превью показано **фолбэком**: своего у товара нет, и
  сервер взял первый элемент-изображение первой галереи. Не записывайте это
  превью обратно в `preview_id` — получите дубль;
- `gallery_id` набора равен `"0"` — ссылки нет либо галерею удалили; витрина
  покажет первую галерею товара;
- `source: "canvas"` у галереи — её элементы ведёт канвас-документ
  (`canvas_id`), и присланные вами элементы туда не попадут;
- `galleries` длиннее `gallery_ids` — в конце стоят галереи, на которые
  ссылаются наборы вариантов, а к товару они не привязаны.

---

## 8. Массовый импорт

```bash
POST /products/batch
{ "items": [ { … }, { … } ] }
```

Ответ — **частичный успех**: по каждой позиции отдельно приходит результат или
ошибка. Разбирайте ответ поштучно; общий `200` не значит, что прошли все.

**Порядок безопасного импорта:**

1. Паспорт ключа (§1) → сверка валюты и контура, подтверждение владельца.
2. Категории → зафиксировать порядок через `/categories/move`.
3. Атрибуты и опции → запомнить их `id`, они нужны везде дальше.
4. Файлы → собрать карту `локальное имя → file_id`.
5. Галереи (`POST /galleries`) → собрать карту `набор фото → gallery_id`.
   Галерея заводится **до** товара: товар ссылается на неё, а не наоборот.
6. **Один товар-канарейка**, `is_published: false` → прочитать обратно →
   сверить каждое поле, которое отправляли → показать владельцу.
7. Остальной каталог батчами.
8. Связи (варианты или склейки).
9. Публикация: `is_published: true`.
10. Проверка витрины: страница категории и карточка товара отвечают `200` и
    содержат ожидаемое.

**Идемпотентность.** Сервер не хранит ваш внешний ключ товара. Ведите локальный
манифест `внешний артикул → id товара → gallery_id → file_id → slug` и перед созданием
проверяйте наличие: `GET /products/by-slug/{slug}`. Иначе повторный запуск
создаст дубли.

**Откат.** Не удаляйте — скрывайте: `is_published: false` +
`catalog_hidden: "true"`. Это обратимо и не рвёт ссылки.

---

## 9. Ошибки

Основная форма — RPC-конверт:

```json
{ "error": "rpc error: code = InvalidArgument desc = INVALID_WIDGET" }
```

Машинный код — **первое слово** после `desc = `. Частые:

| Код | Что значит |
|---|---|
| `PAT_METHOD_NOT_ALLOWED` | Метод вообще недоступен ключам этого типа |
| `PAT_SCOPE_MISSING` | Не хватает права; требуемое указано в тексте |
| `AUTH_COMPANY_PROBLEM` | Магазин не определён — проверьте ключ |
| `INVALID_WIDGET` | Виджет вне списка `text · select · icon · swatch · photo` |
| `INVALID_DATA_TYPE` | Тип вне списка `string · number · bool · select · multiselect · date · color · url · group` |
| `INVALID_CODE`, `INVALID_OPTION_VALUE` | Код атрибута и `value` опции — латиница-слаг, не текст для людей |
| `FILE_IN_USE` | Файл нельзя удалить: он привязан |
| `CATEGORY_FILE_NOT_FOUND` (404) | `preview_id` или `seo.og_image_id` категории — не живой файл вашего магазина. Проверка появилась в волне «Галереи»: раньше такая запись проходила молча |
| `URL_NOT_ALLOWED`, `URL_FETCH_FAILED`, `FILE_TOO_LARGE` | Загрузка по ссылке |

Галереи (§7) отвечают своими кодами:

| Код | HTTP | Что значит |
|---|---|---|
| `GALLERY_NOT_FOUND` | `404` | Нет такой галереи — либо она чужая, либо удалена. Один код на оба случая: существование чужого ресурса сервер не подтверждает |
| `GALLERY_IN_USE: used in N places` | `400` | Галерея занята; снимите ссылки, `GET /galleries/{id}/usage` покажет где |
| `GALLERY_NAME_REQUIRED` / `GALLERY_NAME_TOO_LONG` | `400` | Имя обязательно, максимум 64 символа |
| `GALLERY_TOO_MANY_ITEMS` | `400` | Больше 24 элементов в галерее |
| `GALLERY_DUPLICATE_ITEM_KEY` | `400` | Один `item_key` встретился дважды |
| `GALLERY_ITEM_KIND_MISMATCH` | `400` | Присланная роль не совпала с типом файла (или файл такой категории в галерее не показывается) |
| `GALLERY_ITEM_FILE_REQUIRED` | `400` | У элемента без `kind: "html"` нет файла |
| `GALLERY_ITEM_HTML_REQUIRED` | `400` | У `kind: "html"` пустая разметка |
| `GALLERY_ITEM_HTML_TOO_LONG` | `400` | Разметка элемента больше 64 КБ |
| `GALLERY_FILE_NOT_FOUND` | `404` | `file_id` или `poster.id` — не живой файл вашего магазина |
| `GALLERIES_TOO_MANY` | `400` | Больше 10 галерей на один объект |
| `GALLERY_DUPLICATE_LINK` | `400` | Одна галерея указана в `gallery_ids` дважды |
| `GALLERY_MANAGED_BY_CANVAS` | `400` | Нельзя одним запросом включить канвас-режим и прислать элементы: сначала привязка, потом элементы |
| `GALLERY_DUPLICATE_CANVAS` | `400` | Этот канвас-документ уже ведёт другую галерею магазина |
| `CANVAS_DOCUMENT_NOT_FOUND` | `404` | `canvas_id` — не живой документ вашего магазина |
| `CANVAS_TYPE_MISMATCH` | `400` | Документ есть, но он не того типа |

⚠️ Больше 10 значений в `gallery_ids` одиночная ручка отклоняет **раньше**
машинного кода — сообщением валидатора (`value must contain no more than 10
item(s)`). Код `GALLERIES_TOO_MANY` в чистом виде приходит из
`POST /products/batch`, где ошибка возвращается по каждой позиции отдельно.

Есть **вторая** форма — для неизвестного пути. Она приходит с `404` и содержит
подсказку:

```json
{ "error": "not_found", "path": "/v1/products/808",
  "hint": "catalog is unprefixed (/products); storage/account/org under /v1",
  "docs": "/openapi.json" }
```

Клиент должен уметь разобрать обе. Признак: наличие поля `path`.

Ограничитель нагрузки отвечает `429` и сообщает время ожидания — уважайте его и
повторяйте запрос после паузы.

---

## 10. Стоп-условия

Остановиться и спросить владельца, если:

- валюта магазина не совпала с валютой прайса;
- ключ пишет в живой магазин, а подтверждения на массовую запись не было;
- товар после создания не находится в фильтре своей категории;
- прочитанное значение отличается от отправленного, и это не описано здесь;
- публичная страница отвечает не `200`;
- пришёл `429`, а повтор с паузой не помогает.

И правило, которое не обсуждается: **ключ не попадает ни в один файл, лог,
HTML, отчёт или сообщение**. Он живёт в отдельном хранилище с правами `600`.

---

## 11. Справочник полей с сюрпризами

| Поле | Тип | Что важно знать |
|---|---|---|
| `price.price` | целое | В валюте магазина. Без дробной части |
| `price.currency` | строка | Игнорируется при записи; ответ — валюта магазина |
| `category_id` | целое | Основная категория |
| `categories[]` | массив целых | Дополнительные. При правке дополняет набор; полная замена — только с `categories_replace: true` |
| `preview` / `preview_id` | объект / строка | Создание — `preview: {id}`; правка — `preview_id: "…"`. Перепутать = тихий `200` без изменения |
| `catalog_hidden` | строка | `""` / `"true"` / `"false"` — не boolean |
| `is_published` | boolean | Обычный флаг |
| `order` (категория) | целое | **При создании игнорируется** — раздел встаёт последним. Порядок задавать через `/categories/move` |
| `widget` (атрибут) | строка | `text · select · icon · swatch · photo` |
| `display` (ось) | строка | `"" · text · icon · swatch` — уже, чем `widget` |
| `data_type` (атрибут) | строка | Ось переключателя — всегда `select` |
| `values` (набор) | объект | `attribute_id` (строкой) → `value` опции |
| `gallery_ids` | массив **строк** | Ссылки на галереи (`int64` приходит строкой). Непустой → замена; пустой без `gallery_ids_replace` → «не трогать» |
| `galleries` | массив объектов | Только на чтении карточки. В списках пуст. Может быть длиннее `gallery_ids`: в конце — галереи по ссылкам наборов |
| `items_replace` (галерея) | boolean | Нужен, чтобы очистить элементы: пустой `items` без флага = «не трогать» |
| `gallery_id` (набор) | строка | Любая живая галерея магазина, не только «своя». `"0"` = ссылки нет или галерею удалили |
| `kind` (элемент галереи) | строка | Ставит **сервер** из типа файла. Вручную — только `"html"`. Несовпадение = отказ |
| `item_key` (элемент галереи) | строка | Стабильный ключ. Пусто → выдаст сервер; свой — сохранится при повторе запроса |
| `source`, `canvas_id` (галерея) | строка | `"canvas"` = элементы ведёт канвас-документ, присланные игнорируются |
| `preview_auto` | boolean | `true` = превью подставлено фолбэком из галереи. Не записывать обратно в `preview_id` |
| `public_path` (файл) | строка | Полный адрес хранилища. На витрине — относительный путь с нарезкой |
| `page.number` | целое | С 1. Обязателен, если передан `page.limit` |
| `group_info`, `variants_info`, `attribute_sections`, `categories` | объекты | Соседи `result` в ответе товара, а не его поля |

---

## 12. Куда дальше

| Документ | О чём |
|---|---|
| `/docs/webcoding` | Вёрстка страниц, HTML-блоки, картинки и нарезка, лимиты |
| `/openapi.json` | Полная схема API |
| `/v1/account/token` | Паспорт ключа: контур, магазин, валюта, права |
| `/llms.txt` | Краткая карта API для ИИ-агента |
