# API доставки отзывов

`PUT /shopping/reviews` принимает новые и изменённые отзывы о товарах, включая скрытие и удаление. Используйте его для обновлений между полными выгрузками по [CSV-контракту](./reviews_file.md). Названия полей и допустимые значения совпадают с форматом файла.

Сейчас API сохраняет отзывы. Shopping Assistant пока не использует их в ответах, а автоматический импорт CSV ещё не реализован. Успешная доставка не означает, что саммари по отзывам уже доступно покупателю.

## Подключение и доступ

Отправляйте запросы из серверной системы, в которой хранятся отзывы. Перед подключением получите у специалиста Gravity Field идентификатор раздела (`tenant_id`) и согласуйте исходящие IP-адреса серверов.

В теле запроса передавайте идентификатор раздела в поле `sec`. Доступ проверяется по списку разрешённых IP-адресов и подсетей, если он настроен для раздела. Запрос с другого IP получает `403 IP_NOT_ALLOWED`. Если список не настроен или пуст, ограничение по IP не применяется. Отдельный заголовок авторизации для этой ручки не предусмотрен.

```http
PUT https://shopping-assistant-api.gravityfield.ai/shopping/reviews
Content-Type: application/json
```

## Запрос

| Поле | Обязательное | Описание |
| :--- | :---: | :--- |
| `sec` | Да | Непустая строка: идентификатор раздела Shopping Assistant (`tenant_id`). |
| `data` | Да | Массив от 1 до 1000 отзывов. Каждый элемент содержит полное актуальное состояние отзыва. |

### Пример доставки

Замените `YOUR_TENANT_ID` на идентификатор своего раздела, а `sku` — на точный SKU из товарного фида.

```bash
curl --request PUT \
  --url 'https://shopping-assistant-api.gravityfield.ai/shopping/reviews' \
  --header 'Content-Type: application/json' \
  --data '{
    "sec": "YOUR_TENANT_ID",
    "data": [
      {
        "reviewId": "rv-123",
        "sku": "sku-456",
        "rating": 5,
        "title": "Отличный крем",
        "text": "Быстро впитывается, хорошо увлажняет",
        "pros": "Текстура",
        "cons": "Сильный запах",
        "status": "published",
        "helpfulCount": 12,
        "locale": "ru-RU",
        "createdAt": "2026-08-20T09:00:00Z",
        "updatedAt": "2026-08-31T10:00:00Z"
      }
    ]
  }'
```

### Поля отзыва

| Поле | Обязательное | Формат и ограничения |
| :--- | :---: | :--- |
| `reviewId` | Да | Непустая строка до 256 символов. Стабильный ID отзыва, уникальный в пределах раздела. В одном запросе каждый `reviewId` встречается только один раз. |
| `sku` | Да | Непустая строка до 256 символов. Должна точно совпадать с `sku` в [товарном фиде](/Integration/products_catalogues/general_reqs.md), включая регистр и разделители. |
| `rating` | Да | Целое число от `1` до `5`. |
| `title` | Нет | Заголовок: строка до 50 000 символов или `null`. |
| `text` | Нет | Основной текст: строка до 50 000 символов или `null`. |
| `pros` | Нет | Достоинства: строка до 50 000 символов или `null`. |
| `cons` | Нет | Недостатки: строка до 50 000 символов или `null`. |
| `status` | Да | `published`, `hidden` или `deleted`. |
| `helpfulCount` | Нет | Целое число от `0` до `2147483647` или `null`. Количество отметок о полезности. |
| `locale` | Нет | Строка до 35 символов, например `ru-RU`, `en` или `en-US`, либо `null`. Язык — 2–3 латинские буквы; следующие части через дефис — по 2–8 латинских букв или цифр. |
| `createdAt` | Да | Дата создания в формате RFC 3339 с часовым поясом, например `2026-08-20T09:00:00Z`. |
| `updatedAt` | Да | Дата последнего изменения в том же формате, например `2026-08-31T13:00:00+03:00`. |

Передавайте числа как JSON-числа, без кавычек. У `reviewId`, `sku` и `sec` удаляются пробелы по краям. Необязательные текстовые поля, переданные пустой строкой или строкой из пробелов, сохраняются как `null`.

Необязательное поле, отсутствующее в отзыве, также сохраняется как `null`: прежнее значение не сохраняется. Поэтому при обновлении отправляйте полное состояние отзыва, а не только изменённые поля. Отзывы только с оценкой допустимы.

Неизвестные поля внутри отзыва отклоняются с `400`. Используйте точные имена в camelCase: например, `reviewId`, а не `review_id`. Не добавляйте имя, email, телефон и другие персональные данные автора.

## Обновление, скрытие и удаление

Отзыв определяется сочетанием раздела `sec` и `reviewId`. Для всех обновлений одного отзыва сохраняйте тот же `reviewId` и передавайте время изменения в `updatedAt`.

- Новый отзыв сохраняется.
- Обновление с `updatedAt` новее сохранённого заменяет состояние отзыва.
- Обновление с равным `updatedAt` тоже применяется. Для разных версий используйте разные даты изменения, чтобы порядок доставки не определял итоговое состояние.
- Обновление со старым `updatedAt` игнорируется и учитывается в `stale`.
- Повторная доставка того же состояния безопасна и может снова учитываться в `applied`.

Для скрытия отправьте полное состояние со статусом `hidden`, для удаления — со статусом `deleted` и новым `updatedAt`. Обязательные поля нужны и для этих статусов. Например, тело запроса на удаление:

```json
{
  "sec": "YOUR_TENANT_ID",
  "data": [
    {
      "reviewId": "rv-123",
      "sku": "sku-456",
      "rating": 5,
      "status": "deleted",
      "createdAt": "2026-08-20T09:00:00Z",
      "updatedAt": "2026-09-01T10:00:00Z"
    }
  ]
}
```

Запись со статусом `deleted` сохраняется, чтобы запоздавшее старое обновление не восстановило отзыв. Отсутствие отзыва в очередном запросе не удаляет его. Для будущей саммаризации предусмотрены только отзывы со статусом `published`; `hidden` и `deleted` исключаются.

Если хотя бы один элемент не проходит валидацию или `reviewId` повторяется внутри массива, весь запрос отклоняется без сохранения отзывов. В корректном запросе устаревшие элементы игнорируются, а остальные сохраняются.

## Успешный ответ

API возвращает `200 OK`:

```json
{
  "received": 1,
  "applied": 1,
  "stale": 0
}
```

| Поле | Описание |
| :--- | :--- |
| `received` | Количество отзывов в запросе. |
| `applied` | Количество сохранённых новых или обновлённых отзывов, включая повтор с равным `updatedAt`. |
| `stale` | Количество проигнорированных отзывов с более старым `updatedAt`. Равно `received - applied`. |

Ответ подтверждает сохранение данных, а не построение саммари.

## Ошибки и повторная доставка

| HTTP | `error` / `code` | Что делать |
| :--- | :--- | :--- |
| `400` | `Invalid JSON body` | Исправьте синтаксис JSON. |
| `400` | `Invalid request body` | Исправьте поля по массиву `details`. Возвращается до 50 ошибок с путём `path` и описанием `message`. Индексы в `data` начинаются с нуля. |
| `400` | `Invalid request body` / `DUPLICATE_REVIEW_ID` | Уберите повторяющиеся `reviewId` из одного запроса. |
| `403` | `Forbidden` / `IP_NOT_ALLOWED` | Проверьте исходящий IP сервера и согласованный список разрешённых адресов. |
| `404` | `Unknown tenant` / `TENANT_NOT_FOUND` | Проверьте `sec` и подключение раздела к Shopping Assistant. |
| `503` | `Reviews storage is unavailable` / `REVIEWS_STORAGE_UNAVAILABLE` | Повторите доставку позже. Если ошибка сохраняется, обратитесь в поддержку Gravity Field. |
| `500` | `Failed to save reviews` | Повторите доставку позже; при повторяющейся ошибке обратитесь в поддержку. |

При `400`, `403` или `404` исправьте причину перед повтором. После сетевого сбоя, `500` или `503` повторяйте тот же пакет с задержкой и постепенным увеличением интервала. Сохраняйте исходный `updatedAt`: время повтора не является временем изменения отзыва.

Для объёма больше 1000 отзывов разделите обновления на несколько запросов. Ограничение на один файл для полной CSV-выгрузки сохраняется.

## Проверка интеграции

- [ ] `sec` содержит идентификатор нужного раздела.
- [ ] Исходящие IP серверов согласованы для раздела.
- [ ] Один запрос содержит от 1 до 1000 отзывов без повторяющихся `reviewId`.
- [ ] `sku` совпадает с товарным фидом.
- [ ] Обновления содержат полное состояние, а `updatedAt` отражает время изменения в исходной системе.
- [ ] Скрытие и удаление передаются явно через `status`.
- [ ] Обрабатываются `applied`, `stale` и ошибки; повтор сохраняет исходные данные и даты.

[!ref](./reviews_file.md)
