Customer Service — API Contract

Единственный источник правды для API Customer Service.

Бэкенд реализует контракт. Фронтенд потребляет контракт. Отклонения запрещены.

(Введён в BR 3.1)

Содержание

Public API (Bearer JWT)

Клиенты

Адреса клиентов

Группы клиентов

Internal API (X-Service-Token)


Общие конвенции

ПараметрЗначение
Base path/api/v1 (public), /api/v1/internal (internal)
Content-Typeapplication/json
Auth (public)Authorization: Bearer <JWT>
Auth (internal)X-Service-Token: <token>
Ответ объект{ "data": { ... } }
Ответ список{ "data": [...], "meta": { "page", "per_page", "total" } }
Ошибка{ "error": { "code", "message", "details": [...] } }
Коды ошибокUPPER_SNAKE_CASE

Scope фильтрация: franchise_id достаётся из JWT. Customer Service не принимает franchise_id в query/body — все запросы автоматически ограничены франшизой вызывающего.


GET /customers

(BR 3.1)

Список клиентов франшизы с фильтрами, поиском, пагинацией.

ПараметрЗначение
AuthBearer JWT + permission customers.read

Query Parameters

ParamTypeRequiredDescription
searchstringnoПодстрока для поиска по ФИО / phone / email
group_iduuidnoФильтр: клиенты этой группы
sourcestringnoФильтр по источнику (pos/web/mobile/admin/import)
registered_fromdatenoФильтр: зарегистрирован не раньше
registered_todatenoФильтр: зарегистрирован не позже
pageintegernoDefault: 1
per_pageintegernoDefault: 20, max: 100

Response 200

{
  "data": [
    {
      "id": "uuid",
      "phone": "+79991234567",
      "email": "user@example.com",
      "first_name": "Иван",
      "last_name": "Петров",
      "birthday": "1990-06-15",
      "gender": "male",
      "registration_source": "pos",
      "registered_at": "2026-04-01T10:30:00Z",
      "groups": [
        { "id": "uuid", "name": "VIP", "type": "dynamic" }
      ],
      "ltv": "34500.00",
      "orders_count": 12,
      "last_visit_at": "2026-04-18T14:20:00Z"
    }
  ],
  "meta": { "page": 1, "per_page": 20, "total": 150 }
}

Примечание: поля ltv, orders_count, last_visit_at агрегируются на стороне Admin BFF (из Order Service) — Customer Service возвращает клиента без них, BFF добавляет. В контракте Customer Service возвращает без агрегаций; BFF-agregation описывается в Admin BFF контракте.

Errors

CodeHTTPКогда
UNAUTHORIZED401Нет или невалидный JWT
FORBIDDEN403Нет permission customers.read

GET /customers/search

(BR 3.1)

Endpoint ещё не реализован

Сейчас поиск по телефону доступен только через internal-endpoint GET /internal/customers/search-by-phone (X-Service-Token). Публичный /customers/search с Bearer JWT — planned. POS BFF ходит через internal.

Поиск клиента по телефону — для POS (кассир ищет перед созданием). Возвращает 0 или 1 результат.

ПараметрЗначение
AuthBearer JWT + permission customers.read ИЛИ customers.create_quick

Query Parameters

ParamTypeRequiredDescription
phonestringyesТелефон в любом формате — нормализуется в E.164

Response 200 (найден)

{
  "data": {
    "id": "uuid",
    "phone": "+79991234567",
    "first_name": "Иван",
    "last_name": "Петров",
    "birthday": "1990-06-15",
    "groups": [{ "id": "uuid", "name": "VIP", "type": "dynamic" }],
    "ltv": null,
    "orders_count": null,
    "last_visit_at": null
  }
}

Response 200 (не найден)

{ "data": null }

Errors

CodeHTTPКогда
VALIDATION_ERROR400Телефон не валидный после нормализации
UNAUTHORIZED401
FORBIDDEN403Нет нужного permission

GET /customers/{id}

(BR 3.1)

Карточка клиента с адресами и группами.

ПараметрЗначение
AuthBearer JWT + permission customers.read

Path Parameters

ParamTypeRequiredDescription
iduuidyesID клиента

Response 200

{
  "data": {
    "id": "uuid",
    "phone": "+79991234567",
    "email": "user@example.com",
    "first_name": "Иван",
    "last_name": "Петров",
    "birthday": "1990-06-15",
    "gender": "male",
    "notes": "Постоянный клиент",
    "registration_source": "pos",
    "registered_by_employee_id": "uuid",
    "registered_at": "2026-04-01T10:30:00Z",
    "consent_signed_at": "2026-04-01T10:30:00Z",
    "addresses": [
      {
        "id": "uuid",
        "city": "Москва",
        "street": "Пушкина, 10",
        "apartment": "25",
        "entrance": "2",
        "floor": "5",
        "intercom": "1234",
        "delivery_zone_id": "uuid",
        "notes": null,
        "is_default": true
      }
    ],
    "groups": [
      { "id": "uuid", "name": "VIP", "type": "dynamic" },
      { "id": "uuid", "name": "Клуб ценителей", "type": "static" }
    ]
  }
}

Errors

CodeHTTPКогда
CUSTOMER_NOT_FOUND404Клиент не найден в scope франшизы или soft-deleted
UNAUTHORIZED401
FORBIDDEN403

POST /customers

(BR 3.1)

Создание клиента. Два режима:

  • Полная форма — если у токена permission customers.edit (админка: все поля)
  • Quick-create — если только customers.create_quick (POS: минимум phone + first_name)
ПараметрЗначение
AuthBearer JWT + permission customers.edit ИЛИ customers.create_quick

Request Body

{
  "phone": "89991234567",
  "email": "user@example.com",
  "first_name": "Иван",
  "last_name": "Петров",
  "birthday": "1990-06-15",
  "gender": "male",
  "notes": "...",
  "consent_signed": true,
  "registration_source": "admin"
}
  • phone — обязательно, нормализуется в E.164
  • first_name — обязательно
  • Остальное — опционально
  • registration_source — если не передано, по умолчанию admin (админка) или pos (POS BFF)
  • consent_signed: trueconsent_signed_at = now(), иначе NULL
  • В quick-режиме поля last_name, gender, notes, birthday, email игнорируются если не переданы

Response 201

{
  "data": {
    "id": "uuid",
    "phone": "+79991234567",
    "first_name": "Иван",
    "...": "остальные поля"
  }
}

Errors

CodeHTTPКогда
VALIDATION_ERROR400phone невалидный / first_name пустой / email невалидный
CUSTOMER_PHONE_TAKEN409Клиент с этим телефоном уже существует во франшизе (включая soft-deleted — нельзя пересоздать пока не восстановлен или не обезличен — обезличенный phone другой)
UNAUTHORIZED401
FORBIDDEN403

PATCH /customers/{id}

(BR 3.1)

Редактирование клиента. Можно менять все поля кроме id, franchise_id, registration_source, registered_by_employee_id, registered_at.

ПараметрЗначение
AuthBearer JWT + permission customers.edit

Path Parameters

ParamTypeRequiredDescription
iduuidyes

Request Body

Любое подмножество полей (аналогично POST). При смене phone — проверяется уникальность.

Response 200

Обновлённый клиент (как в GET /customers/{id}).

Errors

CodeHTTPКогда
CUSTOMER_NOT_FOUND404
CUSTOMER_PHONE_TAKEN409Новый phone занят
VALIDATION_ERROR400
UNAUTHORIZED401
FORBIDDEN403

DELETE /customers/{id}

(BR 3.1)

Soft delete + анонимизация PII. Физическое удаление невозможно (есть FK из Order Service).

ПараметрЗначение
AuthBearer JWT + permission customers.delete

Response 204

Пустой ответ.

Errors

CodeHTTPКогда
CUSTOMER_NOT_FOUND404
UNAUTHORIZED401
FORBIDDEN403Нет customers.delete

Поведение: атомарно в одной транзакции — deleted_at = now(), PII обнуляются (см. Data Model.md § Анонимизация), адреса удаляются (CASCADE), членство в группах удаляется. Связь orders.customer_id сохраняется. Публикуется событие customer.deleted.


POST /customers/{id}/merge

(BR 3.1)

Объединение дубликатов. Целевой клиент (id из path) остаётся; источник (source_id из body) soft-deleted с переносом адресов и связей.

ПараметрЗначение
AuthBearer JWT + permission customers.edit + только владелец франшизы (scope all_franchise)

Request Body

{ "source_id": "uuid" }

Response 200

Обновлённый target клиент с уже перенесёнными адресами и группами.

Errors

CodeHTTPКогда
CUSTOMER_NOT_FOUND404Target или source не найден
MERGE_SAME_CUSTOMER400source_id == id
FORBIDDEN403Не владелец франшизы

Поведение: атомарно в одной транзакции — адреса source → target (с сохранением default логики, если default конфликтует — source.default обнуляется), customer_group_members source переносятся target (без дублей), source получает deleted_at + anonymize. На стороне Order Service orders.customer_id = source_id не мигрируются (в MVP) — история source остаётся под обезличенным именем. В будущих итерациях — добавить internal event customer.merged и Order Service мигрирует FK.


GET /customers/{id}/addresses

(BR 3.1)

Список адресов клиента.

ПараметрЗначение
AuthBearer JWT + permission customers.read

Response 200

{
  "data": [
    {
      "id": "uuid",
      "city": "Москва",
      "street": "Пушкина, 10",
      "apartment": "25",
      "entrance": "2",
      "floor": "5",
      "intercom": "1234",
      "delivery_zone_id": "uuid",
      "notes": null,
      "is_default": true
    }
  ]
}

POST /customers/{id}/addresses

(BR 3.1)

Добавить адрес клиенту.

ПараметрЗначение
AuthBearer JWT + permission customers.edit

Request Body

{
  "city": "Москва",
  "street": "Пушкина, 10",
  "apartment": "25",
  "entrance": "2",
  "floor": "5",
  "intercom": "1234",
  "delivery_zone_id": "uuid",
  "notes": null,
  "is_default": true
}

Поля city, street обязательны. Остальные — nullable. is_default по умолчанию false. Если is_default=true — предыдущий default клиента снимается.

Response 201

Созданный адрес.

Errors

CodeHTTPКогда
CUSTOMER_NOT_FOUND404
VALIDATION_ERROR400

PATCH /customers/{id}/addresses/{address_id}

(BR 3.1)

Редактирование адреса. Любое подмножество полей. Переключение is_default=true → старый default снимается.

ПараметрЗначение
AuthBearer JWT + permission customers.edit

Response 200

Обновлённый адрес.

Errors

CodeHTTPКогда
CUSTOMER_NOT_FOUND404
ADDRESS_NOT_FOUND404

DELETE /customers/{id}/addresses/{address_id}

(BR 3.1)

Удаление адреса (физическое, не soft).

ПараметрЗначение
AuthBearer JWT + permission customers.edit

Response 204


GET /customer-groups

(BR 3.1)

Список групп клиентов франшизы.

ПараметрЗначение
AuthBearer JWT + permission customer_groups.read

Query Parameters

ParamTypeRequiredDescription
typestringnostatic / dynamic — фильтр
searchstringnoПодстрока названия
pageintegernoDefault: 1
per_pageintegernoDefault: 20

Response 200

{
  "data": [
    {
      "id": "uuid",
      "name": "VIP",
      "description": "...",
      "type": "dynamic",
      "members_count": 42,
      "last_recomputed_at": "2026-04-20T03:15:00Z",
      "created_at": "2026-04-01T10:00:00Z"
    }
  ],
  "meta": { "page": 1, "per_page": 20, "total": 8 }
}

GET /customer-groups/{id}

(BR 3.1)

Карточка группы с правилами (для dynamic) и кол-вом участников.

ПараметрЗначение
AuthBearer JWT + permission customer_groups.read

Response 200

{
  "data": {
    "id": "uuid",
    "name": "VIP",
    "description": "...",
    "type": "dynamic",
    "rules_json": {
      "version": 1,
      "rules": [
        { "type": "total_spent_all_time", "operator": "gte", "amount": 50000 }
      ]
    },
    "members_count": 42,
    "last_recomputed_at": "2026-04-20T03:15:00Z",
    "created_at": "2026-04-01T10:00:00Z",
    "created_by": "uuid"
  }
}

Для type=staticrules_json: null.

Errors

CodeHTTPКогда
GROUP_NOT_FOUND404

POST /customer-groups

(BR 3.1)

Создание группы.

ПараметрЗначение
AuthBearer JWT + permission customer_groups.edit

Request Body

{
  "name": "VIP",
  "description": "...",
  "type": "dynamic",
  "rules_json": {
    "version": 1,
    "rules": [
      { "type": "total_spent_all_time", "operator": "gte", "amount": 50000 }
    ]
  }
}
  • Для type=static: rules_json не передаётся (или null)
  • Для type=dynamic: rules_json обязателен, массив 1-5 правил

Response 201

Созданная группа.

Errors

CodeHTTPКогда
VALIDATION_ERROR400Невалидная структура rules_json / пустой name
GROUP_NAME_TAKEN409Группа с таким именем уже есть

PATCH /customer-groups/{id}

(BR 3.1)

Редактирование группы. Меняются name, description, rules_json (только для dynamic).

type неизменяем — нельзя конвертировать static ↔ dynamic после создания (создайте новую группу).

Изменение rules_json триггерит пересчёт dynamic группы.

Errors

CodeHTTPКогда
GROUP_NOT_FOUND404
GROUP_NAME_TAKEN409
VALIDATION_ERROR400Попытка изменить type или невалидные правила

DELETE /customer-groups/{id}

(BR 3.1)

Soft delete группы. Членство в истории остаётся, но группа не отображается.

ПараметрЗначение
AuthBearer JWT + permission customer_groups.edit

Response 204


GET /customer-groups/{id}/members

(BR 3.1)

Список клиентов в группе с пагинацией.

Response 200

{
  "data": [
    {
      "customer_id": "uuid",
      "added_at": "2026-04-15T10:00:00Z",
      "customer": {
        "id": "uuid",
        "phone": "+79991234567",
        "first_name": "Иван",
        "last_name": "Петров"
      }
    }
  ],
  "meta": { "page": 1, "per_page": 50, "total": 42 }
}

POST /customer-groups/{id}/members

(BR 3.1)

Добавить клиентов в статическую группу. Только для type=static.

Request Body

{ "customer_ids": ["uuid", "uuid"] }

Response 200

{ "data": { "added": 2, "already_in": 0 } }

Errors

CodeHTTPКогда
GROUP_WRONG_TYPE400Группа type=dynamic — членством управляют правила
GROUP_NOT_FOUND404

DELETE /customer-groups/{id}/members/{customer_id}

(BR 3.1)

Удалить клиента из статической группы.

Errors

CodeHTTPКогда
GROUP_WRONG_TYPE400Для dynamic нельзя вручную удалять
GROUP_NOT_FOUND404

POST /customer-groups/{id}/recompute

(BR 3.1)

Триггер ручного пересчёта динамической группы. Только для type=dynamic.

ПараметрЗначение
AuthBearer JWT + permission customer_groups.edit

Response 202

Без тела. Фактический пересчёт — асинхронный. Пользователь увидит обновлённый last_recomputed_at и members_count при последующем GET группы.

Текущая реализация

CustomerGroupController.recompute() возвращает 202 Accepted с пустым body. Подробный { group_id, status, started_at } payload — planned.

Errors

CodeHTTPКогда
GROUP_WRONG_TYPE400Для static нельзя
GROUP_NOT_FOUND404

Internal API

Service Token

Все endpoints в этой секции требуют X-Service-Token (shared secret). JWT не обрабатывается.

GET /internal/customers/{id}

(BR 3.1)

Endpoint ещё не реализован

InternalCustomerController имеет только search-by-phone, quick-create и recompute-команды. Cross-service lookup по ID planned — до его реализации потребители дёргают public GET /customers/{id} с service-role JWT.

Получить клиента для cross-service сценариев (Order Service, Loyalty Service в будущем).

Response 200

{
  "data": {
    "id": "uuid",
    "franchise_id": "uuid",
    "phone": "+79991234567",
    "first_name": "Иван",
    "last_name": "Петров",
    "deleted_at": null
  }
}

Возвращает также soft-deleted (с обезличенными PII) — downstream-сервис решает что делать.

Errors

CodeHTTPКогда
CUSTOMER_NOT_FOUND404
UNAUTHORIZED401Нет или невалидный X-Service-Token

POST /internal/customer-groups/recompute-for-customer

(BR 3.1)

Точечный пересчёт всех dynamic групп одной франшизы для одного клиента.

Используется:

  1. Kafka consumer’ом Customer Service при order.completed и customer.updated
  2. Админкой через POST /customer-groups/{id}/recompute (для одной группы) и через будущий handle (для одного клиента при смене данных)

Request Body

{ "customer_id": "uuid" }

Response 200

{
  "data": {
    "customer_id": "uuid",
    "added_to": ["group_id_1"],
    "removed_from": ["group_id_2"],
    "recomputed_at": "2026-04-20T10:30:00Z"
  }
}

Errors

CodeHTTPКогда
CUSTOMER_NOT_FOUND404
UNAUTHORIZED401

Ссылки