# ShopMCP — Parser API Specification (LLM-friendly)

> Полная спецификация HTTP-API, которое должен реализовать парсер торговой сети, чтобы интегрироваться с shopmcp.ru.
> Документ предназначен для скармливания LLM-ассистенту — его достаточно, чтобы сгенерировать рабочий парсер на любом языке.

Версия документа: 3. Базовый URL парсера произвольный, регистрируется владельцем сети в личном кабинете shopmcp.ru или через self-registration API.

---

## 1. Назначение и поток данных

Парсер — это HTTP-сервис, который умеет отвечать на GET-эндпоинты. Несколько из них опциональные.

```
GET /shops                              — статичный список физических точек (раз в 24 ч)
GET /delivery?lat&lon                   — live-варианты доставки/marketplace (per-запрос)
GET /promotions                         — сетевые акции (опционально, раз в 24 ч)
GET /promotions?lat&lon                 — региональные акции для kind=network (опц., live)
GET /products?outlet=<id>&q=<query>     — листинг товаров (live)
GET /products/{id}?outlet=<id>          — карточка товара (live)
```

**Разделение «статика без параметров» vs «live с lat&lon»:**

| Статика, без параметров, кэш 24 ч | Live, `?lat&lon`, не кэшируется |
|---|---|
| `GET /shops` | `GET /delivery?lat&lon` |
| `GET /promotions` | `GET /promotions?lat&lon` (для kind=network) |

> **Категорий и фильтра по category_id нет.** LLM сама формулирует запрос так, чтобы он отсекал ненужное (`"молоко 3.2%"`, `"хлеб бородинский"`). Хлебные крошки категории можно положить в `Product.categories` в `/products/{id}` — справочно.

**Кто кого вызывает:**

```
LLM (Claude/Cursor) ──MCP──► shopmcp.ru ──HTTPS Bearer──► Ваш парсер ──► источник
```

shopmcp **никогда** не общается с пользователем напрямую — только через MCP. Парсер **никогда** не общается с пользователем напрямую — только через shopmcp.

**Что shopmcp делает с ответами:**
- `/shops` — кэшируется на 24 часа per-retailer (включая `kind=store` И `kind=network` outlet'ы). shopmcp строит R\*-tree гео-индекс из координат каждой `OutletLocation` (или геокодит адрес через Yandex, если координат нет). Парсер не должен держать свой гео-индекс.
- `/delivery` — **не кэшируется**. Каждый `find_outlets` для координат пользователя зовёт live, чтобы у парсера была возможность учесть зону, время суток и т.п.
- `/promotions` (без параметров) — кэшируется per-retailer на 24 часа; в MCP-ответах встраивается в объект ретейлера, не дублируется.
- `/promotions?lat&lon` (для `kind=network`) — **не кэшируется**. Дёргается live при каждом `find_outlets` с координатами, кладётся в `outlet.promotions` network-outlet'а.
- `/products`, `/products/{id}` — **проксируются live**. Цены и наличие никогда не кэшируются.

**Жёсткие лимиты shopmcp на ответы:**

| Что | Лимит | Где обрезается |
|---|---|---|
| Outlet'ы в `find_outlets` | **30** | при формировании ответа |
| Товары на один query в `search_products` | **30** | при формировании ответа |
| Акции сети (`/promotions`) | **5** | при кэшировании |
| Региональные акции (`/promotions?lat&lon`, kind=network) | **5** | live, при формировании ответа |
| Акции pricing-контекста (`outlet.promotions` для kind=delivery/marketplace) | **5** | live |
| Локальные акции точки (`OutletLocation.promotions`) | **3** | при кэшировании со `/shops` |
| Акции товара (`product.promotions`) | **3** | live, при формировании ответа |

---

## 1.1. Outlet — единая модель «точки получения товара»

shopmcp унифицирует все способы получения товара в одну сущность — **outlet**, с четырьмя `kind`:

| `kind` | Что это | Где отдаётся | `locations[]` | Target payload |
|---|---|---|---|---|
| `store` | Физическая точка, цены **per-store** (Лента, Магнит). | `/shops` (статика) | одна | `outlet.id` парсера |
| `network` | Сеть с **общим каталогом**, цена единая на регион (Эльдорадо, Леруа). Несколько магазинов отдаются как **один** outlet с массивом locations. | `/shops` (статика) | несколько | **координаты пользователя** (центр area). shopmcp подменяет payload |
| `delivery` | Курьерская доставка в фиксированной зоне. | `/delivery?lat&lon` (live) | обычно 0 | `outlet.id` парсера |
| `marketplace` | Маркетплейс (Ozon/WB). Варианты получения per-товар в `Product.delivery`. | `/delivery?lat&lon` (live) | обычно 0 | `outlet.id` парсера (часто координаты внутри) |

**Один SKU может быть в нескольких outlet'ах одной сети с разной ценой и наличием.** Пример «Магнит»:
- `kind=store` — магазин на Тверской.
- `kind=delivery, name:"Магнит у дома", delivery_fee:"99", min_days:0` — курьер быстрый.
- `kind=delivery, name:"Магнит Экспресс", fee_note:"Бесплатно от 1500 ₽", min_days:1` — медленнее, дешевле.

shopmcp шлёт их все в `find_outlets`, LLM выбирает.

### Когда `kind=network`?

Когда у сети **на сайте/в каталоге общая цена по региону**, а магазин нужен только для самовывоза или проверки наличия (DIY, бытовая техника, аптеки, мебель). LLM зовёт `search_products` на такой outlet **один раз** — цена та же во всех locations.

При этом **наличие может отличаться per-магазин**: `Product.stock_by_location[]` ссылается на `OutletLocation.id`, и LLM видит «холодильник: 2 шт на Маросейке, нет на Никитской, 5 шт в Питере».

**Marketplace** работает похоже на network (один outlet, координаты в payload), но добавляет per-SKU варианты доставки в `Product.delivery[]` (свой ПВЗ / партнёрский ПВЗ / курьер).

### Какой `kind` выбрать для своей сети?

`kind` указывает парсер в ответе `/shops` — shopmcp не угадывает. Решайте по критериям:

**Цена/наличие конкретного товара зависит от магазина?**
- Да, у каждого магазина свой каталог/цены/остатки → `kind=store`. Один outlet = один магазин. Примеры: Магнит, Пятёрочка, Лента, ВкусВилл, Дикси — продуктовая розница.
- Нет, цена на сайте/в каталоге единая (хотя бы в пределах города/региона) → `kind=network`. Один outlet = вся сеть в регионе; магазины для самовывоза/проверки наличия. Примеры: Эльдорадо, М.Видео, DNS, Леруа Мерлен, OBI, Hoff, аптеки (Ригла, 36.6).

**Есть ли вообще физические магазины?**
- Нет, только курьерская доставка с фиксированной зоной → `kind=delivery`. Координат для outlet'а нет, есть зона покрытия. Примеры: Самокат, Яндекс.Лавка, Купер, отдельная курьерская служба «Магнит у дома».
- Да, есть и магазины, и курьер → отдавайте **два разных outlet'а**: `kind=store` или `kind=network` для магазинов, плюс `kind=delivery` для курьера.

**Это агрегатор сторонних продавцов с гибкими вариантами получения?**
- Да, у каждого товара свой набор: ПВЗ / постамат / партнёрский ПВЗ / курьер. Один SKU может ехать только курьером (холодильник), другой — пятью способами (книга) → `kind=marketplace`. Один outlet на регион, варианты в `Product.delivery[]`. Примеры: Ozon, Wildberries, Я.Маркет, Lamoda, AliExpress.

**Смешанные сети.** Один retailer может одновременно иметь несколько kind'ов в одном `/shops`-ответе. Например IKEA: основные магазины как `store` (можно прийти и купить с полки, цена per-магазин) + сеть городских пунктов выдачи онлайн-заказов как `network` (единая онлайн-цена). Просто отдайте все outlet'ы в одном массиве.

**Сомневаетесь?** Откройте сайт сети из двух разных регионов или попросите цену у одного и того же товара в разных магазинах. Если цена/наличие отличаются — `store`. Если одинаковые по городу — `network`. Если сайт показывает разные варианты получения для разных товаров на одной странице с ценой — `marketplace`.

---

## 1.2. Обязательное правило: четыре уровня акций — четыре разных места

shopmcp хранит **четыре отдельных** справочника акций. Если положить акцию не туда — она либо не дойдёт до LLM, либо размножится в ответе.

| Уровень | Эндпоинт парсера | Куда попадает в MCP-ответе |
|---|---|---|
| **Сеть** — действует везде | `GET /promotions` (без параметров) | `retailers[id].promotions` |
| **Регион** — только в одном городе/регионе (kind=network) | `GET /promotions?lat&lon` | `outlet.promotions` на kind=network |
| **Локация** — в одной физической точке | `OutletLocation.promotions` в `/shops` | `locations[i].promotions` |
| **Товар** — на конкретный SKU | `product.promotions` в `/products` | в каждом результате |

**⚠ Региональные акции не должны пересекаться с сетевыми.** Если акция действует **на всю сеть** — она должна быть в `/promotions` (без параметров). Если **только в одном регионе** — в `/promotions?lat&lon`. Не дублируйте: иначе LLM покажет одну и ту же акцию дважды.

**Правила:**
1. **Сетевую акцию** класть только в `/promotions`. Не дублировать в outlet'ы — попадёт N раз.
2. **Региональную акцию** (на kind=network) класть только в `/promotions?lat&lon`. shopmcp дёргает live для каждого `find_outlets` с координатами и кладёт в `outlet.promotions`.
3. **Акцию одной точки** класть в `OutletLocation.promotions` нужной location в `/shops`.
4. **Товарную акцию** класть в `product.promotions` ответа `/products`. На SKU.

**Формат всех четырёх одинаковый.** Каждая акция — строка (целиком текст) или объект `{title, description?, image?, id?}`. Можно смешивать в массиве.

```json
[
  "купи 2 получи 3 в подарок",
  { "title": "Двойные баллы по средам", "description": "x2 на карту" }
]
```

Все детали (даты, проценты, ограничения) — словами в `description`. shopmcp не нормализует, передаёт как пришло.

**Лимиты:** сеть и регион — по 5 акций, точка (`OutletLocation.promotions`) — 3, товар — 3. shopmcp обрежет лишнее, поэтому парсер обязан **сам** отбирать только действующие акции и сортировать их по важности (самые ценные первыми).

---

## 2. Базовые правила

### 2.1. Транспорт

- HTTP/1.1 или HTTP/2.
- HTTPS обязателен для production.
- `Content-Type: application/json; charset=utf-8` во всех ответах.
- UTF-8.

### 2.2. Аутентификация

Если в личном кабинете shopmcp прописан `parser_token`, shopmcp шлёт его в каждом запросе:

```
Authorization: Bearer <parser_token>
```

Парсер обязан проверить и вернуть **401** при несовпадении. Если токен в ЛК не прописан — заголовка не будет; парсер должен это допускать (открытый режим).

### 2.3. Формат ошибок

Не-2xx статус + тело:

```json
{ "error": { "code": "snake_case_id", "message": "Произвольное описание для логов" } }
```

| Код | HTTP | Когда |
|---|---|---|
| `outlet_unavailable` | 404 | Запрошен `outlet=...`, которого нет/он временно недоступен. |
| `unauthorized` | 401 | Невалидный `Authorization`. |
| `not_found` | 404 | Запрошенный `product_id` или путь не существует. |

### 2.4. Идентификаторы

`id`-поля могут быть строкой или числом — shopmcp нормализует в строку.

### 2.5. Денежные значения

`price`, `delivery_fee`, `service_fee`, `pickup_fee`, `min_order`, `max_weight_kg` — **строки** (Decimal-сериализация): `"89.90"`. `currency` — ISO-4217 трёхбуквенный код, в `Product` опционально (дефолт `RUB`).

---

## 3. Эндпоинты

### 3.1. `GET /shops`

Полный статичный список физических точек сети (`kind=store` или `kind=network`). Без параметров — парсер просто отдаёт всё что у него есть. shopmcp кэширует ответ на 24 часа, индексирует каждую location в R\*-tree, потом фильтрует под area-запрос.

**Capabilities точки:** для каждой `OutletLocation` укажите `walkin` (есть прилавки) и/или `pickup` (можно забрать готовый онлайн-заказ).

**Пример: per-store сеть (`kind=store`)** — у каждого магазина свой outlet, своя цена/наличие:

```json
[
  {
    "id": "msk-tverskaya-1",
    "kind": "store",
    "name": "Магнит — Тверская",
    "locations": [
      {
        "address": "Москва, Тверская 1",
        "lat": 55.7589, "lon": 37.6109,
        "hours": "08:00–22:00",
        "phone": "+7 495 ...",
        "walkin": true,
        "pickup": true,
        "promotions": [ "При чеке от 2000 ₽ — скидка 10% (только в этой точке)" ]
      }
    ]
  }
]
```

**Пример: network-сеть (`kind=network`)** — один outlet, единая цена в регионе, несколько физических точек самовывоза:

```json
[
  {
    "id": "eldorado",
    "kind": "network",
    "name": "Эльдорадо",
    "description": "Единая цена в каталоге. Магазины для самовывоза и проверки наличия.",
    "locations": [
      {
        "id": "msk-marosey",
        "address": "Москва, Маросейка 9",
        "lat": 55.7575, "lon": 37.6364,
        "hours": "10-22",
        "walkin": true, "pickup": true
      },
      {
        "id": "msk-nikitsk",
        "address": "Москва, Большая Никитская 14",
        "lat": 55.7561, "lon": 37.6043,
        "walkin": false, "pickup": true,
        "promotions": [ "−20% сегодня только в этой точке" ]
      },
      {
        "id": "spb-nevsky",
        "address": "СПб, Невский 30",
        "lat": 59.9343, "lon": 30.3351,
        "walkin": true, "pickup": true
      }
    ]
  }
]
```

Парсер отдаёт **все** locations сети по всем регионам. shopmcp при `find_outlets` фильтрует под area запроса.

`OutletLocation.id` обязателен для `kind=network`, если планируете отдавать `Product.stock_by_location` (привязка остатков к конкретному магазину). Для `kind=store` — необязательно.

### 3.2. `GET /delivery?lat=...&lon=...`

**Live**, не кэшируется. Возвращает массив доступных fulfillment-вариантов для конкретной точки доставки. Каждый вариант — `kind=delivery` (курьерская доставка с фиксированной зоной) или `kind=marketplace` (агрегатор-маркетплейс). Если у сети нет доставки или координаты вне зоны — отдайте `[]` или `404`.

```json
[
  {
    "id": "fast",
    "kind": "delivery",
    "name": "Магнит у дома (курьер)",
    "description": "Курьер из ближайшего магазина, 1-2 часа.",
    "delivery_fee": "99.00",
    "min_order": "300",
    "fee_note": "99 ₽ при заказе от 300 ₽",
    "min_days": 0, "max_days": 1
  },
  {
    "id": "slow",
    "kind": "delivery",
    "name": "Магнит Экспресс (распред-центр)",
    "description": "Шире ассортимент. Доставка следующего дня.",
    "delivery_fee": "199.00",
    "min_order": "500",
    "fee_note": "Бесплатно от 1500 ₽, иначе 199 ₽",
    "min_days": 1, "max_days": 2
  }
]
```

**Поведение для маркетплейсов** (Ozon, WB, Я.Маркет): отдайте **один** outlet с `kind=marketplace`. Координаты пользователя сохраните внутри `id`-payload (любая ваша кодировка) — они понадобятся в `/products?outlet=...`, чтобы фильтровать ассортимент и считать варианты доставки per-SKU. Конкретные варианты доставки/получения отдаются на уровне товара в `Product.delivery[]` (см. §4).

```json
[
  {
    "id": "ozon-msk@55.755,37.617",
    "kind": "marketplace",
    "name": "Доставка Ozon в Москве",
    "description": "Варианты доставки зависят от товара"
  }
]
```

**Поля `Outlet`:**

| Поле | Тип | Обяз. | Описание |
|---|---|---|---|
| `id` | string \| number | да | Opaque payload — что угодно нужное парсеру. Для `store`/`delivery`/`marketplace` shopmcp передаст обратно в `?outlet=<id>`. Для `network` — **подменяется** shopmcp на координаты пользователя. |
| `kind` | `"store"` \| `"network"` \| `"delivery"` \| `"marketplace"` | да | Тип outlet'а. |
| `name` | string | да | Человекочитаемое имя. LLM покажет пользователю. |
| `description` | string | нет | Поясняющий текст. |
| `locations` | OutletLocation[] | нет | Физические точки. Для `store` — одна; для `network` — несколько; для `delivery`/`marketplace` — обычно пусто. |
| `delivery_fee` | string (Decimal) | нет | Фиксированная стоимость доставки (одна цена независимо от корзины). Для ступенчатых тарифов — `delivery_tiers` ниже. |
| `delivery_tiers` | DeliveryTier[] | нет | Ступенчатые тарифы доставки. См. §3.2.1. |
| `service_fee` | string (Decimal) | нет | Сервисный сбор платформы (отдельно от доставки/сборки). |
| `pickup_fee` | string (Decimal) | нет | Фиксированная стоимость сборки готового онлайн-заказа для самовывоза (когда у какой-то из `locations` `pickup=true`). |
| `min_order` | string (Decimal) | нет | Минимальная сумма заказа. |
| `max_weight_kg` | string (Decimal) | нет | Максимальный вес заказа в кг (ограничение по упаковке / грузоподъёмности курьера). |
| `fee_note` | string | нет | Человекочитаемое описание цены: «Бесплатно от 1500 ₽, иначе 199 ₽». |
| `min_days` / `max_days` | integer | нет | Срок в **целых днях** (0 = в день заказа). Подходит для классической доставки. |
| `delivery_time` | string | нет | Человекочитаемое время доставки в свободной форме: «от 40 минут», «2-3 часа», «~ 2 часа», «следующий день», «1-2 рабочих дня». Используй когда `min/max_days` теряет точность (экспресс-доставка часами) или формулировка нестандартная. LLM покажет как есть. |

> **Семантика трёх состояний fee-полей (`service_fee`/`delivery_fee`/`pickup_fee`/`min_order`/`max_weight_kg`):**
> - **поле отсутствует** — парсер не знает / не отдаёт эту информацию;
> - **`"0"` (или `"0.00"`)** — явно бесплатно / нет ограничения;
> - **`"99.00"`** — конкретное значение.
>
> Различие важно: на отсутствие поля LLM не должна утверждать «бесплатно» — это «неизвестно».

#### Динамические цены (зависят от корзины, времени и т.п.)

Бывает что цена сборки / доставки / минимальный заказ **не фиксированы**: они зависят от состава корзины, времени дня, конкретного магазина и определяются в момент оформления. У сетей типа Lenta это типичный кейс — `pickup_fee` для одного и того же магазина может быть `89 ₽` для маленькой корзины и `149 ₽` для большой. Дёргать API сети при каждом `find_outlets` для актуализации `/shops` — слишком дорого (магазинов сотни, кэш 24h).

**Паттерн:** для динамических цен **оставляйте структурное поле `None`** (`null`/опускаете), а в `fee_note` укажите **ориентировочный диапазон** в свободной форме:

```json
{
  "id": "103",
  "kind": "store",
  "locations": [...],
  "pickup_fee": null,
  "min_order": null,
  "fee_note": "Платная сборка для самовывоза, обычно 69-150 ₽ в зависимости от корзины. Минимальный заказ ~500 ₽. Точная сумма при оформлении."
}
```

LLM прочитает текст и сообщит пользователю «учитывай примерно 100 ₽ за сборку». На этапе **оформления заказа** (будущая фаза MVP — `create_order`) парсер вернёт **итоговую** цену с учётом всех динамических факторов — она и будет финальной.

**Когда отдавать структурно vs текстом:**
- Структурно (`"99"`) — если парсер **уверен** в цене (фикс по сети, есть API-эндпоинт, прошит в коде).
- Текстом (`fee_note`) — если цена зависит от корзины и точное число можно узнать только при оформлении.
- Совмещайте: `pickup_fee: "99"` + `fee_note: "Бесплатно от 1500 ₽"` — для базового тарифа со скидкой.

| Поле | Тип | Обяз. | Описание |
|---|---|---|---|
| `promotions` | Promotion[] | нет | Акции pricing-контекста (для `delivery`/`marketplace` — на услугу; для `network` shopmcp **дополняет** региональными из `/promotions?lat&lon`). |

**Поля `OutletLocation`:**

| Поле | Тип | Обяз. | Описание |
|---|---|---|---|
| `id` | string | нет (обяз. для `network` если есть `Product.stock_by_location`) | Стабильный идентификатор точки. Связь с `Product.stock_by_location[].location`. |
| `address` | string | нет (обяз. если нет `lat`/`lon`) | Адрес. По нему shopmcp геокодит при отсутствии координат. |
| `lat` / `lon` | number | нет | Координаты. Без координат и адреса — location в индекс не попадает. |
| `hours` | string | нет | График работы. |
| `phone` | string | нет | Телефон. |
| `walkin` | boolean | нет | Есть прилавки, можно купить с полки. |
| `pickup` | boolean | нет | Можно забрать готовый онлайн-заказ. |
| `promotions` | Promotion[] | нет | Локальные акции **только этой** точки (см. §1.2 про непересечение уровней). |

> **`id` outlet'а — opaque-payload** для парсера. shopmcp передаёт обратно через `?outlet=<id>` без модификаций. Старайтесь держать **< 80 символов**. Для `kind=network` shopmcp подменит payload на координаты пользователя — ваш id в этом случае только для логирования.

### 3.2.1. Тип `DeliveryTier` (ступенчатые тарифы доставки)

Типичный российский паттерн: «199 ₽ при заказе от 1000 ₽, 109 ₽ от 2599 ₽, бесплатно от 4099 ₽». Используется на `Outlet.delivery_tiers` (для `kind=delivery`/`kind=marketplace` обычно, реже у других).

```json
"delivery_tiers": [
  { "price": "199", "min_order": "1000" },
  { "price": "109", "min_order": "2599" },
  { "price": "0",   "min_order": "4099" }
]
```

| Поле | Тип | Обяз. | Описание |
|---|---|---|---|
| `price` | string (Decimal) | да | Стоимость доставки в этом тире. `"0"` — явно бесплатно. |
| `min_order` | string (Decimal) | да | Минимальная сумма заказа для применения этого тира. |

**Правила:**
- Парсер отдаёт массив **отсортированный по `min_order` возрастающе** (shopmcp всё равно пересортирует, но удобнее читать в правильном порядке).
- LLM для конкретной корзины ищет последний tier, у которого `cart_sum >= tier.min_order` — он и применяется.
- **Когда отдавать `delivery_tiers` vs `delivery_fee` vs `fee_note`:**
  - Одна цена для любой корзины → `delivery_fee: "199"`, без tiers.
  - Ступенчатые пороги — `delivery_tiers: [...]`. `delivery_fee` тогда обычно не нужен.
  - Зависит от других параметров (района, времени, типа товара) → ни одно из структурных не подходит, пиши в `fee_note`.
  - Совмещать `delivery_fee` и `delivery_tiers` не запрещено, но обычно избыточно (tiers покрывают все случаи).

### 3.3. `GET /promotions` _(опционально, сетевые)_

Без параметров — акции, действующие **на всю сеть целиком**. Если не реализовано — `404`, shopmcp трактует это как «у сети нет сетевых акций» и не повторяет как ошибку.

**Важно:** эти акции не должны пересекаться с региональными из `/promotions?lat&lon` — см. §1.2.

### 3.3.1. `GET /promotions?lat=...&lon=...` _(опционально, региональные)_

**Только для `kind=network`-ретейлеров.** Live, не кэшируется. Парсер по координатам определяет регион пользователя и возвращает акции, действующие **только в этом регионе**. Если у сети нет региональных акций или координаты вне зоны покрытия — `[]` или `404`.

```json
[
  { "id": "msk-spring-2026", "title": "Весенняя распродажа техники в Москве", "description": "−15% на крупную бытовую технику. До 31 мая 2026." },
  "Скидка 10% на товары для дома — только в Москве"
]
```

⚠ **Региональные акции не должны пересекаться с сетевыми.** Если акция действует везде в сети — она в `/promotions`. Если только в одном регионе — в `/promotions?lat&lon`. Иначе LLM покажет дубль.

### 3.4. `GET /products?outlet=<id>&q=<query>`

Листинг товаров в контексте конкретного outlet'а. **Live, не кэшируется.**

**Параметры:**

| Параметр | Тип | Обяз. | Описание |
|---|---|---|---|
| `outlet` | string | да | Тот же `id`, что парсер вернул в `/outlets`. Может быть закодированной строкой с любой структурой внутри — парсер сам декодирует. |
| `q` | string | нет | Поисковый запрос пользователя (`"молоко 3.2%"`). |

**Семантика:**
- Парсер декодирует `outlet` в свой внутренний контекст (магазин / даркстор / pickup-point) и отдаёт товары, релевантные этому контексту. Цена, наличие, акции — в этом контексте.
- Для одного и того же SKU цена и наличие могут отличаться между разными outlet'ами — это нормально, LLM зовёт `search_products` с разными `outlet_id` для сравнения.

**Если outlet недоступен** (устарел / координаты вне зоны / парсер изменил формат) — `404 outlet_unavailable`. shopmcp превратит в structured error с подсказкой «вызови find_outlets».

**Ответ:** массив `Product`. См. §4.

### 3.5. `GET /products/{id}?outlet=<id>`

Полная карточка одного товара в контексте outlet'а. Те же параметры что у `/products`, без `q`.

Возвращает один `Product`. Обычно с заполненными расширенными полями (`description`, `images`, `categories`, `attributes`), которые в листинге парсер может не отдавать ради экономии трафика.

**Поведение при долгоживущих ссылках:** LLM может сохранить `(outlet, product_id)` и через месяц вернуться. Парсер должен **стабильно** декодировать `outlet`-id (если он указал координаты внутри — координаты должны работать спустя время). Если формат изменился — `404 not_found`.

---

## 4. Тип `Product`

**Один тип для листинга и расширенной карточки.** Парсер заполняет то, что есть.

```json
{
  "id": "sku-12345",
  "title": "Молоко Простоквашино 3.2% 930 мл",
  "price": "89.90",
  "currency": "RUB",
  "in_stock": true,
  "stock_qty": "12",
  "old_price": "99.90",
  "url": "https://example.com/catalog/sku-12345",
  "image": "https://cdn.example.com/sku-12345.jpg",
  "weight": "930", "unit": "ml",
  "nutrition": { "calories_kcal": "60", "protein_g": "2.9", "fat_g": "3.2", "carbs_g": "4.7" },
  "brand": "Простоквашино",
  "manufacturer": "Простоквашино",
  "country_of_origin": "Россия",
  "vendor_sku": "PRSTKV-MILK-32-930",
  "barcode": "4607004210020",
  "age_restriction": 0,
  "rating": "4.7",
  "votes": 5314,
  "badges": ["Цены недели", "Хит продаж"],
  "promotions": [
    { "id": "milk-2plus1", "title": "2+1 на молоко", "description": "Третий бесплатно" }
  ],
  "description": "Цельное пастеризованное молоко, без консервантов.",
  "images": ["https://cdn.example.com/sku-12345-back.jpg"],
  "categories": ["Молочные продукты", "Молоко", "Питьевое 3.2%"],
  "attributes": [
    { "name": "Жирность", "value": "3.2%" },
    { "name": "Срок годности", "value": "7 суток" }
  ]
}
```

**Поля:**

| Поле | Тип | Обяз. | Описание |
|---|---|---|---|
| `id` | string \| number | да | Артикул в каталоге сети. Stable, используется в `get_product`. |
| `title` | string | да | Полное наименование. |
| `price` | string (Decimal) | да | Текущая цена с учётом постоянных скидок. |
| `currency` | string | нет | ISO-4217. По умолчанию `RUB`. |
| `in_stock` | boolean | **нет** | true/false. Если опущено — наличие неизвестно (парсер не отдаёт эту инфу). LLM не утверждает «есть»/«нет», говорит «нужно уточнить в магазине». |
| `stock_qty` | string (Decimal) | нет | Остаток. Поддерживает штуки (`"3"`) и вес (`"0.45"`). Если опущено — точное число неизвестно. |
| `old_price` | string (Decimal) | нет | Старая цена — для скидок. |
| `url` | string | нет | Ссылка на страницу товара. |
| `image` | string | нет | URL главной картинки. |
| `images` | string[] | нет | Дополнительные изображения. Обычно только в `/products/{id}`. |
| `description` | string | нет | Описание. Обычно только в `/products/{id}`. |
| `weight` | string (Decimal) | нет | Вес/объём. Единица — в `unit`. |
| `unit` | string | нет | `g`/`kg`/`ml`/`l`/`pcs`. |
| `nutrition` | NutritionFacts | нет | БЖУ на 100 г/мл. |
| `brand` | string | нет | Бренд (YML `vendor`). |
| `manufacturer` | string | нет | Производитель. |
| `country_of_origin` | string | нет | Страна. |
| `vendor_sku` | string | нет | Артикул (YML `vendorCode`). |
| `barcode` | string | нет | EAN/UPC. |
| `age_restriction` | integer | нет | 18/16. |
| `rating` | string (Decimal) | нет | Оценка 0–5. |
| `votes` | integer | нет | Кол-во оценок. |
| `categories` | string[] | нет | Хлебные крошки. Обычно только в `/products/{id}`. |
| `attributes` | `[{name, value}]` | нет | Произвольные характеристики. Обычно только в `/products/{id}`. |
| `badges` | string[] | нет | Маркеры карточки («Хит продаж»). |
| `promotions` | Promotion[] | нет | Акции на этот SKU. Лимит 3. |
| `delivery` | DeliveryVariant[] | только для marketplace | Варианты получения для marketplace-товара (см. §4.4). На обычных сетях — пусто. |
| `stock_by_location` | LocationStock[] | только для network | Остатки/сроки поставки per-магазин для `kind=network` outlet'ов. См. §4.5. |

> **Минимум в listing'е MCP**: shopmcp по умолчанию отдаёт LLM только `id`, `title`, `price`, `currency`, `in_stock`, `promotions`, `delivery` и `stock_by_location` — все остальные опциональные поля скрыты ради экономии токенов. LLM явно запрашивает через `extra_fields: ["image", "rating", ...]` если нужно. Для полной карточки — `get_product`. Парсер может смело отдавать максимум в `/products`: shopmcp обрежет лишнее.

### 4.1. Тип `Promotion`

Минимальный формат. Можно строкой или объектом:

```json
{ "title": "...", "description": "...", "image": "...", "id": "..." }
```

`title` обязателен. `id` опционален — passthrough из 1С/CRM.

### 4.2. `NutritionFacts` (на 100 г / 100 мл)

```json
{ "calories_kcal": "60", "protein_g": "2.9", "fat_g": "3.2", "carbs_g": "4.7" }
```

Все Decimal как строки.

### 4.3. `Attribute`

```json
{ "name": "Срок годности", "value": "7 суток" }
```

### 4.4. `DeliveryVariant` _(только для marketplace)_

Конкретный вариант получения товара на маркетплейсе. Tagged union с явным `type`:

| `type` | Что это |
|---|---|
| `pickup` | Собственный ПВЗ маркетплейса (ПВЗ Ozon, постамат WB). |
| `partner_pickup` | Партнёрский ПВЗ (СДЭК, Boxberry, PickPoint). |
| `courier` | Курьерская доставка до адреса. |

```json
{
  "type": "courier",
  "name": "Курьер Ozon",
  "delivery_fee": "299",
  "service_fee": "49",
  "min_days": 1, "max_days": 2,
  "description": "С подъёмом на этаж +500 ₽"
}
```

| Поле | Тип | Обяз. | Описание |
|---|---|---|---|
| `type` | `"pickup"` \| `"partner_pickup"` \| `"courier"` | да | Тип варианта. |
| `name` | string | да | Имя сервиса («ПВЗ Ozon», «Boxberry», «Курьер»). |
| `description` | string | нет | Поясняющий текст в свободной форме. |
| `delivery_fee` | string (Decimal) | нет | Стоимость доставки. |
| `service_fee` | string (Decimal) | нет | Сервисный сбор платформы. |
| `fee_note` | string | нет | Человекочитаемое описание цены/условий. |
| `min_days` / `max_days` | integer | нет | Срок. |

**Правила:**
- Для `kind=marketplace` outlet'а парсер обязан отдать **все** доступные для товара варианты получения (если у Ozon-холодильника только курьер — отдайте один `courier`; если у книги пять способов — все пять).
- Для обычных сетей (`kind=store`/`delivery`) поле пустое или не отдавайте: fulfillment описывает outlet, не товар.

### 4.5. `LocationStock` _(только для kind=network)_

Остатки и сроки поставки одного SKU в конкретной физической точке сети. Используется, когда у сети `kind=network` (общий каталог, единая цена в регионе) — наличие при этом часто различается между магазинами.

```json
{
  "id": "fridge-bosch-kgn",
  "title": "Холодильник Bosch KGN39",
  "price": "89990",
  "in_stock": true,
  "stock_by_location": [
    { "location": "msk-marosey", "in_stock": true,  "stock_qty": "2" },
    { "location": "msk-nikitsk", "in_stock": false, "min_days": 3, "max_days": 5 },
    { "location": "spb-nevsky",  "in_stock": true,  "stock_qty": "5" }
  ]
}
```

| Поле | Тип | Обяз. | Описание |
|---|---|---|---|
| `location` | string | да | Ссылка на `OutletLocation.id` из `/shops`. |
| `in_stock` | boolean | да | Есть ли товар сейчас в этой точке. |
| `stock_qty` | string (Decimal) | нет | Конкретный остаток в этой точке. |
| `min_days` / `max_days` | integer | нет | Срок поставки в эту точку под заказ, если `in_stock=false`. Часто отличается между магазинами (склад дальше — срок дольше). |

**Когда отдавать `stock_by_location`?** Только для `kind=network`. Для `kind=store` (один магазин — один outlet) используется `Product.in_stock`/`stock_qty` на корне.

**Корневое `Product.in_stock` для network** — это «есть хоть где-то в регионе». Если хотите указать, что товара нигде нет, но есть под заказ — корневое `in_stock=false`, и в `stock_by_location[]` для каждой точки `in_stock=false` с `min_days`/`max_days`.

---

## 5. Self-registration

Парсер может сам зарегистрировать `base_url` в shopmcp вместо ручного ввода в ЛК.

```
POST https://shopmcp.ru/api/v1/retailers/<retailer_id>/endpoint
Authorization: Bearer shp_...    # API-ключ владельца сети из ЛК

{
  "base_url": "https://parser.example.com",
  "parser_token": "опциональный bearer для исходящих запросов"
}
```

Идемпотентный upsert. Чтобы удалить токен — `"parser_token": null`.

---

## 6. Чек-лист перед запуском

- [ ] `/shops` отдаёт массив outlet'ов с `kind=store` или `kind=network`. Без параметров.
- [ ] У каждой `OutletLocation` заполнены `lat`/`lon` или хотя бы `address` (shopmcp геокодит).
- [ ] У каждой `OutletLocation` проставлены capability-флаги `walkin` и/или `pickup`.
- [ ] Для `kind=network` outlet'ов: единая цена в регионе. `OutletLocation.id` заполнен у каждой точки (для связки с `Product.stock_by_location`).
- [ ] `/delivery?lat&lon` отдаёт массив (или `[]`/`404` если у сети нет доставки/координаты вне зоны). У вариантов заполнены либо `delivery_fee` (одна цена), либо `delivery_tiers` (ступенчатые пороги), либо `fee_note` (если динамика). И `min_days`/`max_days` или `delivery_time`.
- [ ] Для маркетплейсов: `/delivery` отдаёт один outlet `kind=marketplace` с координатами внутри `id`.
- [ ] `/promotions` (без параметров) либо отдаёт массив сетевых акций, либо `404`.
- [ ] `/promotions?lat&lon` (только если есть `kind=network`): региональные акции для координат. **Не пересекаются** с сетевыми (см. §1.2). `[]` или `404` если нет.
- [ ] Все отдаваемые акции — **актуальные** на момент запроса. Просроченные не отдаются. Отсортированы по важности.
- [ ] Лимиты: сеть и регион — 5 акций, точка — 3, товар — 3.
- [ ] `/products?outlet=<id>` отвечает массивом `Product`. Цена/наличие специфичны для этого outlet'а.
- [ ] Для `kind=network` outlet'ов: каждый `Product` содержит `stock_by_location[]` с привязкой к `OutletLocation.id`. Если товар под заказ — заполнены `min_days`/`max_days`.
- [ ] Для marketplace-outlet'ов: каждый `Product` содержит `delivery[]` со всеми доступными для товара вариантами (`pickup`/`partner_pickup`/`courier`).
- [ ] `/products?outlet=<unknown>` отвечает `404 outlet_unavailable`.
- [ ] `/products/{id}?outlet=<id>` возвращает один `Product`, опционально с `description`/`images`/`attributes`/`categories`.
- [ ] `Authorization: Bearer <parser_token>` проверяется.
- [ ] Все денежные значения и `max_weight_kg` (`price`, `delivery_fee`, `service_fee`, `pickup_fee`, `min_order`, `max_weight_kg`) — строки Decimal.
- [ ] `id`-поля могут быть числами; не нужно приводить к строке руками.
- [ ] Ответы укладываются в ~10 секунд.

---

## 7. Что вне MVP

- Корзина (отдельный `cart_id` с расчётом скидок) — следующая фаза.
- Тексты отзывов — будет отдельный MCP-метод.
- YML-фиды как альтернатива API.
- Резерв товара / оформление заказа / платежи.
