User Service — API Contract

Этот документ является единственным источником правды для API User Service.

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

Содержание

Public API (Bearer JWT)

Франшизы (BR 1.4.4)

Юридические лица

Сотрудники (BR 1.4)

Роли и permissions (BR 1.4.3)

Юридические детали сотрудника (BR 1.4.1)

Шаблоны смен (BR 1.4.1)

Расписание смен (BR 1.4.1)

Фактические смены (BR 1.4.1)

Корректировки смен (BR 1.4.1)

Дашборд активности (BR 1.4.1)

Отчёт по смене (BR 2.2)

Зарплата — формулы (BR 1.4.1)

Зарплата — ведомости (BR 1.4.1)

Internal API (Service Token)

Фактические смены — POS (BR 1.4.1)


GET /franchises/{id}

(Введено в BR 1.4.4)

Получить данные франшизы (tenant). Используется фронтом для отображения franchise.type (управляет видимостью раздела «Юр. лица») и брендового имени.

ПараметрЗначение
AuthBearer JWT (только своя франшиза — id сравнивается с franchise_id из JWT)

Path Parameters

ParamTypeRequiredDescription
iduuidyesID франшизы

Response 200

{
  "data": {
    "id": "uuid",
    "name": "string",
    "type": "corporate | individual",
    "created_at": "datetime"
  }
}

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FRANCHISE_NOT_FOUND404Запрашиваемая id не принадлежит JWT (чужая франшиза) или не существует

Получить список юридических лиц с пагинацией, фильтрами и поиском.

ПараметрЗначение
AuthBearer JWT (Franchise — все; Franchisee — только свои)

Query Parameters

ParamTypeRequiredDescription
pageintegernoНомер страницы (default: 1)
per_pageintegernoЗаписей на страницу (default: 20, max: 100)
searchstringnoПоиск по наименованию и ИНН
statusstringnoФильтр: active / suspended
typestringnoФильтр: franchise / franchisee
sortstringnoСортировка: name_asc (default) / name_desc / created_at_desc

Response 200

{
  "data": [
    {
      "id": "uuid",
      "type": "franchise | franchisee",
      "name": "string",
      "inn": "string",
      "status": "active | suspended",
      "is_primary": "boolean",
      "store_count": "integer",
      "created_at": "datetime"
    }
  ],
  "meta": {
    "page": "integer",
    "per_page": "integer",
    "total": "integer"
  }
}

store_count получается batch-запросом к Store Service (POST /internal/stores/count-by-legal-entities)

Ролевой доступ

  • Franchise: все ЮЛ франшизы (WHERE franchise_id = :jwt_franchise_id)
  • Franchisee: только свои (WHERE owner_user_id = :jwt_user_id)
  • Manager, Cashier: 403 FORBIDDEN

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Роль не имеет доступа (Manager, Cashier)

Получить детали юридического лица.

ПараметрЗначение
AuthBearer JWT (Franchise — любое; Franchisee — только своё)

Path Parameters

ParamTypeRequiredDescription
iduuidyesID юридического лица

Response 200

{
  "data": {
    "id": "uuid",
    "franchise_id": "uuid",
    "type": "franchise | franchisee",
    "name": "string",
    "inn": "string",
    "kpp": "string | null",
    "ogrn": "string",
    "legal_address": "string",
    "bank_name": "string | null",
    "bank_bik": "string | null",
    "bank_account": "string | null",
    "bank_corr_account": "string | null",
    "contact_phone": "string | null",
    "contact_email": "string | null",
    "is_primary": "boolean",
    "status": "active | suspended",
    "contract_number": "string | null",
    "contract_date": "date | null",
    "owner_user_id": "uuid | null",
    "store_count": "integer",
    "created_at": "datetime",
    "updated_at": "datetime"
  }
}

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Franchisee пытается посмотреть чужое ЮЛ
LEGAL_ENTITY_NOT_FOUND404ЮЛ не найдено или удалено

POST /legal-entities

Создать юридическое лицо.

ПараметрЗначение
AuthBearer JWT (только Franchise)
Content-Typeapplication/json

Request Body

{
  "type": "string, required — franchise | franchisee",
  "name": "string, required — полное наименование",
  "inn": "string, required — 10 или 12 цифр, проверка контрольной суммы",
  "kpp": "string, optional — 9 цифр",
  "ogrn": "string, required — 13 или 15 цифр",
  "legal_address": "string, required",
  "bank_name": "string, optional",
  "bank_bik": "string, optional — 9 цифр",
  "bank_account": "string, optional — 20 цифр",
  "bank_corr_account": "string, optional — 20 цифр",
  "contact_phone": "string, optional — формат +7XXXXXXXXXX",
  "contact_email": "string, optional — email",
  "contract_number": "string, optional — только для type=franchisee",
  "contract_date": "date, optional — только для type=franchisee",
  "owner": {
    "first_name": "string, required",
    "last_name": "string, required",
    "email": "string, required — уникален в рамках franchise_id",
    "phone": "string, optional",
    "password": "string, optional — min 6 chars, генерируется если не задан"
  },
  "owner_permissions": {
    "mode": "string, optional — full | custom (default full)",
    "permissions": "string[], optional — массив permission-ключей; используется только при mode=custom, при mode=full игнорируется"
  }
}

Блок owner для type=franchisee (Введено в BR 1.4.2)

При type=franchisee блок owner обязателен. Backend в одной транзакции:

  1. Проверяет franchises.type = corporate (иначе 403 FRANCHISE_TYPE_INDIVIDUAL)
  2. Создаёт legal_entities
  3. Создаёт employees из owner.* (в BR 1.4.4 поле role enum удалено — больше не передаётся)
  4. В зависимости от owner_permissions.mode:
    • full (default) — назначает системную роль «Администратор» в employee_roles
    • custom — создаёт скрытую роль в rolesowner_legal_entity_id = {новый_id_ЮЛ}, permissions из запроса + автоматически форсятся pos.access, stores.read, employees.read); назначает её владельцу
  5. Обновляет legal_entities.owner_user_id → id созданного employee

При type=franchise блоки owner и owner_permissions игнорируются (если переданы).

Поле owner_user_id в request body больше не принимается (заменено на блок owner).

Response 201

{
  "data": {
    "id": "uuid",
    "franchise_id": "uuid",
    "type": "franchise | franchisee",
    "name": "string",
    "inn": "string",
    "kpp": "string | null",
    "ogrn": "string",
    "legal_address": "string",
    "bank_name": "string | null",
    "bank_bik": "string | null",
    "bank_account": "string | null",
    "bank_corr_account": "string | null",
    "contact_phone": "string | null",
    "contact_email": "string | null",
    "is_primary": "boolean",
    "status": "active",
    "contract_number": "string | null",
    "contract_date": "date | null",
    "owner_user_id": "uuid | null",
    "owner": {
      "id": "uuid — id созданного employee (только для type=franchisee, иначе null)",
      "email": "string",
      "temporary_password": "string | null — возвращается ТОЛЬКО если пароль был сгенерирован автоматически"
    },
    "created_at": "datetime",
    "updated_at": "datetime"
  }
}

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Роль не admin_franchise
VALIDATION_ERROR400Невалидные данные (ИНН, ОГРН, и т.д.)
OWNER_FIELDS_REQUIRED400type=franchisee, но блок owner отсутствует или неполный (см. BR 1.4.2)
OWNER_EMAIL_DUPLICATE409owner.email уже существует среди сотрудников в рамках franchise_id
INN_DUPLICATE409ИНН уже существует в рамках franchise_id
FRANCHISE_LE_ALREADY_EXISTS409ЮЛ типа franchise уже существует (допускается только одно)
INVALID_INN_CHECKSUM422ИНН не проходит проверку контрольной суммы
FRANCHISE_TYPE_INDIVIDUAL403Попытка создать ЮЛ type=franchisee при franchises.type=individual (BR 1.4.4 §3, §7)
UNKNOWN_PERMISSION_KEY400В owner_permissions.permissions[] есть ключ вне каталога (BR 1.4.4 §5.2)

Обновить юридическое лицо.

ПараметрЗначение
AuthBearer JWT (Franchise — любое; Franchisee — только своё, ограниченный набор полей)
Content-Typeapplication/json

Path Parameters

ParamTypeRequiredDescription
iduuidyesID юридического лица

Request Body

Partial update — отправляются только изменяемые поля.

{
  "name": "string, optional — ТОЛЬКО Franchise",
  "kpp": "string, optional — ТОЛЬКО Franchise",
  "ogrn": "string, optional — ТОЛЬКО Franchise",
  "legal_address": "string, optional",
  "bank_name": "string, optional",
  "bank_bik": "string, optional",
  "bank_account": "string, optional",
  "bank_corr_account": "string, optional",
  "contact_phone": "string, optional",
  "contact_email": "string, optional",
  "contract_number": "string, optional — ТОЛЬКО Franchise, только для type=franchisee",
  "contract_date": "date, optional — ТОЛЬКО Franchise, только для type=franchisee",
  "owner_user_id": "uuid, optional — ТОЛЬКО Franchise, только для type=franchisee"
}

ИНН нельзя менять после создания

Поле inn не принимается в PATCH. Если передано — 422 INN_IMMUTABLE.

Franchisee может менять только: legal_address, bank_name, bank_bik, bank_account, bank_corr_account, contact_phone, contact_email

Попытка изменить другие поля → 403 FORBIDDEN.

Response 200

Полный объект ЮЛ (аналогично GET /legal-entities/{id}).

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Нет прав на редактирование или попытка изменить запрещённое поле
LEGAL_ENTITY_NOT_FOUND404ЮЛ не найдено или удалено
VALIDATION_ERROR400Невалидные данные
INN_IMMUTABLE422Попытка изменить ИНН

Удалить юридическое лицо (soft delete).

ПараметрЗначение
AuthBearer JWT (только Franchise)

Path Parameters

ParamTypeRequiredDescription
iduuidyesID юридического лица

Перед удалением проверяется наличие привязанных ТТ

Синхронный вызов Store Service GET /internal/stores?legal_entity_id={id}. Если есть хотя бы одна ТТ (любой статус) — удаление запрещено, в ответе возвращается список ТТ.

Response 204

Нет тела.

Error Response 422 (есть привязанные ТТ)

{
  "error": {
    "code": "HAS_ATTACHED_STORES",
    "message": "Нельзя удалить ЮЛ с привязанными торговыми точками",
    "details": [
      {
        "store_id": "uuid",
        "store_name": "string"
      }
    ]
  }
}

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Роль не Franchise
LEGAL_ENTITY_NOT_FOUND404ЮЛ не найдено или уже удалено
HAS_ATTACHED_STORES422Есть привязанные ТТ
FRANCHISE_LE_CANNOT_BE_DELETED422Нельзя удалить ЮЛ Франшизы (оно единственное и главное)

POST /legal-entities/{id}/suspend

Приостановить ЮЛ Франчайзи. Все ТТ этого ЮЛ снимаются с публикации.

ПараметрЗначение
AuthBearer JWT (только Franchise)

Path Parameters

ParamTypeRequiredDescription
iduuidyesID юридического лица

Синхронный вызов Store Service

POST /internal/stores/unpublish-by-legal-entity — снимает все ТТ этого ЮЛ с публикации.

Response 200

{
  "data": {
    "id": "uuid",
    "status": "suspended",
    "unpublished_stores_count": "integer"
  }
}

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Роль не Franchise
LEGAL_ENTITY_NOT_FOUND404ЮЛ не найдено
NOT_FRANCHISEE_TYPE422Нельзя приостановить ЮЛ Франшизы
ALREADY_SUSPENDED422ЮЛ уже приостановлено

POST /legal-entities/{id}/resume

Возобновить ЮЛ Франчайзи. ТТ НЕ публикуются автоматически.

ПараметрЗначение
AuthBearer JWT (только Franchise)

Path Parameters

ParamTypeRequiredDescription
iduuidyesID юридического лица

Response 200

{
  "data": {
    "id": "uuid",
    "status": "active"
  }
}

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Роль не Franchise
LEGAL_ENTITY_NOT_FOUND404ЮЛ не найдено
NOT_SUSPENDED422ЮЛ не приостановлено

(Введено в BR 1.4.4 §5.2)

Получить текущий режим прав владельца партнёра и список permissions. Используется вкладкой «Права» в карточке ЮЛ партнёра.

ПараметрЗначение
AuthBearer JWT (владелец франшизы — для любого ЮЛ-партнёра франшизы; владелец партнёра — для своего ЮЛ, только чтение собственных прав)

Path Parameters

ParamTypeRequiredDescription
iduuidyesID ЮЛ партнёра (type=franchisee)

Response 200

{
  "data": {
    "mode": "full | custom",
    "permissions": ["string — permission keys; пустой при mode=full (значит системная Администратор)"]
  }
}
  • mode=full — владельцу назначена системная роль «Администратор»; permissions возвращает её полный набор для отображения
  • mode=custom — владельцу назначена скрытая роль (roles.owner_legal_entity_id = id); permissions — её набор (включая принудительный минимум pos.access, stores.read, employees.read)

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Доступ к чужому ЮЛ
LEGAL_ENTITY_NOT_FOUND404ЮЛ не найдено или удалено
NOT_FRANCHISEE_TYPE422Запрошен owner-permissions для ЮЛ type=franchise (бессмысленно)

(Введено в BR 1.4.4 §5.2)

Изменить режим/набор прав владельца партнёра. Применяется немедленно — Auth Service инвалидирует кэш user_permissions владельца на следующем запросе.

ПараметрЗначение
AuthBearer JWT (только владелец франшизы)
Content-Typeapplication/json

Path Parameters

ParamTypeRequiredDescription
iduuidyesID ЮЛ партнёра

Request Body

{
  "mode": "string, required — full | custom",
  "permissions": "string[], required if mode=custom — массив permission-ключей"
}

Серверная логика

  • full: если у владельца сейчас скрытая роль — отвязать (employee_roles удаляется), скрытая роль удаляется (или soft-delete); назначить системную «Администратор»
  • custom: если сейчас «Администратор» — отвязать; если скрытая роль уже есть — обновить набор permissions; иначе создать новую скрытую роль (owner_legal_entity_id = id, техническое имя) и назначить владельцу
  • Минимум (pos.access, stores.read, employees.read) форсится сервером всегда при custom — даже если клиент их не передал
  • Edit implies Read — серверная валидация при сохранении

Response 200

{
  "data": {
    "mode": "full | custom",
    "permissions": ["string — итоговый набор после применения принудительного минимума"]
  }
}

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Не владелец франшизы
LEGAL_ENTITY_NOT_FOUND404ЮЛ не найдено
NOT_FRANCHISEE_TYPE422ЮЛ имеет type=franchise
OWNER_NOT_ASSIGNED422У ЮЛ owner_user_id IS NULL — некому давать права
UNKNOWN_PERMISSION_KEY400В permissions[] есть ключ вне каталога
VALIDATION_ERROR400mode=custom без permissions[], или edit без read

POST /legal-entities/import/preview

Загрузить xlsx-файл, валидировать строки, вернуть preview.

ПараметрЗначение
AuthBearer JWT (только Franchise)
Content-Typemultipart/form-data

Request Body

FieldTypeRequiredDescription
filefileyesxlsx-файл (max 10 000 строк)

Response 200

{
  "data": {
    "preview_id": "uuid",
    "total_rows": "integer",
    "valid_rows": "integer",
    "error_rows": "integer",
    "errors": [
      {
        "row": "integer",
        "field": "string",
        "message": "string"
      }
    ],
    "expires_at": "datetime"
  }
}

Preview хранится в PostgreSQL (таблица import_previews), TTL = 30 минут. Импортируются только ЮЛ Франчайзи.

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Роль не Franchise
VALIDATION_ERROR400Неверный формат файла (не xlsx)
TOO_MANY_ROWS422Больше 10 000 строк

POST /legal-entities/import/{preview_id}/apply

Применить импорт — создать ЮЛ из валидных строк preview.

ПараметрЗначение
AuthBearer JWT (только Franchise)

Path Parameters

ParamTypeRequiredDescription
preview_iduuidyesID preview из предыдущего шага

Response 200

{
  "data": {
    "imported": "integer",
    "skipped": "integer"
  }
}

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Роль не Franchise
PREVIEW_NOT_FOUND404Preview не найден или истёк
PREVIEW_EXPIRED422TTL preview истёк (30 минут)

Скачать xlsx-шаблон для импорта.

ПараметрЗначение
AuthBearer JWT (только Franchise)

Response 200

Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet

Файл xlsx с заголовками колонок: Наименование, ИНН, КПП, ОГРН, Юр. адрес, Банк, БИК, Расч. счёт, Корр. счёт, Телефон, Email, Номер договора, Дата договора.


GET /internal/legal-entities/{id}

Internal endpoint — только для service-to-service вызовов

Получить ЮЛ по ID (для других сервисов).

ПараметрЗначение
AuthService token (X-Service-Token header)

Path Parameters

ParamTypeRequiredDescription
iduuidyesID юридического лица

Response 200

{
  "data": {
    "id": "uuid",
    "franchise_id": "uuid",
    "type": "franchise | franchisee",
    "name": "string",
    "inn": "string",
    "status": "active | suspended",
    "is_primary": "boolean",
    "owner_user_id": "uuid | null"
  }
}

Errors

CodeHTTPКогда
LEGAL_ENTITY_NOT_FOUND404ЮЛ не найдено

GET /internal/users/{id}/scope

(Введено в BR 1.4.4 §2)

Internal endpoint — для Auth Service (кэш user_scope:{user_id}) и downstream-сервисов

Возвращает вычисленный scope сотрудника по правилам Ролевая модель.

ПараметрЗначение
AuthService token (X-Service-Token header)

Path Parameters

ParamTypeRequiredDescription
iduuidyesemployees.id

Response 200

{
  "data": {
    "user_id": "uuid",
    "franchise_id": "uuid",
    "scope": {
      "type": "all_franchise | legal_entity_ids | store_ids",
      "legal_entity_ids": ["uuid"],
      "store_ids": ["uuid"]
    }
  }
}

Логика вычисления scope

Сервер проверяет в следующем порядке:

  1. Если существует legal_entities запись где owner_user_id = {id} И type = 'franchise' (главное ЮЛ) → { type: "all_franchise" } (поля legal_entity_ids / store_ids отсутствуют)
  2. Иначе если существуют записи legal_entities где owner_user_id = {id} И type = 'franchisee' (один или несколько ЮЛ) → { type: "legal_entity_ids", legal_entity_ids: [...все_id_этих_ЮЛ...] }
  3. Иначе{ type: "store_ids", store_ids: [...UNIQUE store_id из employee_role_stores для этого employee...] } (может быть пустым массивом, если у сотрудника нет permissions-ролей)

Errors

CodeHTTPКогда
USER_NOT_FOUND404Сотрудник не найден

POST /internal/users/validate-credentials

Internal endpoint — для Auth Service

Реализация ожидает BR по сотрудникам (ADR-004)

Проверить email + пароль сотрудника. Возвращает данные для формирования JWT.

ПараметрЗначение
AuthService token (X-Service-Token header)
Content-Typeapplication/json

Request Body

{
  "email": "string, required",
  "password": "string, required"
}

Response 200

{
  "data": {
    "id": "uuid",
    "email": "string",
    "first_name": "string",
    "last_name": "string",
    "franchise_id": "uuid",
    "status": "active | inactive",
    "role_ids": ["uuid"],
    "permissions": ["string — permission keys, агрегат с granted=true"],
    "scope": {
      "type": "all_franchise | legal_entity_ids | store_ids",
      "legal_entity_ids": ["uuid"],
      "store_ids": ["uuid"]
    }
  }
}

(Расширено в BR 1.4.3: поля role_ids и permissions.)

(Обновлено в BR 1.4.4: удалены role, store_ids, legal_entity_id. Добавлен scope — вычисляется по правилам Ролевая модель.)

Errors

CodeHTTPКогда
INVALID_CREDENTIALS401Неверный email или пароль
ACCOUNT_DISABLED403Сотрудник деактивирован (status=inactive)

GET /internal/users/by-email

Internal endpoint — для Auth Service

Реализация ожидает BR по сотрудникам (ADR-004)

Получить сотрудника по email (без проверки пароля).

ПараметрЗначение
AuthService token (X-Service-Token header)

Query Parameters

ParamTypeRequiredDescription
emailstringyesEmail сотрудника

Response 200

{
  "data": {
    "id": "uuid",
    "email": "string",
    "first_name": "string",
    "last_name": "string",
    "franchise_id": "uuid",
    "status": "active | inactive",
    "role_ids": ["uuid"],
    "permissions": ["string"],
    "scope": {
      "type": "all_franchise | legal_entity_ids | store_ids",
      "legal_entity_ids": ["uuid"],
      "store_ids": ["uuid"]
    }
  }
}

(Расширено в BR 1.4.3: поля role_ids и permissions.)

(Обновлено в BR 1.4.4: удалены role, store_ids, legal_entity_id. Добавлен scope.)

Errors

CodeHTTPКогда
USER_NOT_FOUND404Сотрудник с таким email не найден

GET /employees

Получить список сотрудников с пагинацией, поиском и фильтрами.

ПараметрЗначение
AuthBearer JWT (Franchise — все по franchise_id; Franchisee — только свои ТТ; Manager — только своя ТТ; Cashier — 403)

Query Parameters

ParamTypeRequiredDescription
pageintegernoНомер страницы (default: 1)
per_pageintegernoЗаписей на страницу (default: 20, max: 100)
searchstringnoПоиск по имени, email, телефону
role_iduuidnoФильтр по назначенной permissions-роли (BR 1.4.4 — заменил enum-фильтр role)
store_iduuidnoФильтр по привязке к ТТ (через employee_role_stores)
statusstringnoФильтр: active / inactive
sortstringnoСортировка: name_asc (default) / name_desc / created_at_desc

Response 200

{
  "data": [
    {
      "id": "uuid",
      "first_name": "string",
      "last_name": "string",
      "email": "string",
      "phone": "string | null",
      "roles": [
        { "id": "uuid", "name": "string", "is_hidden": "boolean" }
      ],
      "stores": [
        { "id": "uuid", "name": "string" }
      ],
      "status": "active | inactive",
      "is_courier": "boolean"
    }
  ],
  "meta": {
    "page": "integer",
    "per_page": "integer",
    "total": "integer"
  }
}

(Обновлено в BR 1.4.4: удалены role, store_ids (как старое поле). stores — агрегат уникальных ТТ по всем ролям сотрудника. roles[].is_hidden = true для скрытой роли владельца партнёра.)

Доступ (по scope, BR 1.4.4)

  • Владелец франшизы (scope.type = all_franchise): все сотрудники в franchise_id
  • Владелец партнёра (scope.type = legal_entity_ids): сотрудники, у которых хотя бы одна ТТ принадлежит ЮЛ из scope.legal_entity_ids
  • Обычный сотрудник с employees.read permission (scope.type = store_ids): сотрудники, чьи ТТ пересекаются с scope.store_ids
  • Без employees.read permission → 403 FORBIDDEN

Errors

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

GET /employees/{id}

Получить детали сотрудника.

ПараметрЗначение
AuthBearer JWT (Franchise — любой; Franchisee — только свои ТТ; Manager — только своя ТТ; Cashier — 403)

Path Parameters

ParamTypeRequiredDescription
iduuidyesID сотрудника

Response 200

{
  "data": {
    "id": "uuid",
    "franchise_id": "uuid",
    "first_name": "string",
    "last_name": "string",
    "email": "string",
    "phone": "string | null",
    "status": "active | inactive",
    "is_courier": "boolean",
    "roles": [
      {
        "id": "uuid",
        "name": "string",
        "is_system": "boolean",
        "is_hidden": "boolean — true для скрытой роли владельца партнёра (отображается как 'Собственные права')",
        "store_ids": ["uuid"]
      }
    ],
    "created_at": "datetime",
    "updated_at": "datetime"
  }
}

(Обновлено в BR 1.4.4: удалены поля role, store_ids. Добавлен массив roles[] с флагом is_hidden для скрытых ролей владельцев партнёров.)

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Сотрудник вне scope
EMPLOYEE_NOT_FOUND404Сотрудник не найден

POST /employees

Создать сотрудника.

(Переработано в BR 1.4.4)

Поля role (enum) и отдельное store_ids удалены. Магазины — только в roles[].store_ids (per-role). Создание admin-ролей через этот endpoint больше не имеет смысла (нет enum) — владельцы франшизы и партнёров создаются bootstrap-ом и POST /legal-entities соответственно.

ПараметрЗначение
AuthBearer JWT (employees.edit permission + scope: владелец франшизы — любая ТТ; владелец партнёра — только свои ТТ)
Content-Typeapplication/json

Request Body

{
  "first_name": "string, required",
  "last_name": "string, required",
  "email": "string, required — email, уникален в рамках franchise_id",
  "password": "string, required — минимум 6 символов",
  "phone": "string, optional — формат +7XXXXXXXXXX",
  "pin": "string, optional — 4 цифры, уникален в рамках store_id",
  "is_courier": "boolean, optional — default false",
  "roles": [
    {
      "role_id": "uuid, required",
      "store_ids": "uuid[], optional — магазины где эта роль действует; [] = все доступные сотруднику в рамках scope"
    }
  ]
}

Permissions-роли (Добавлено в BR 1.4.3, переработано в BR 1.4.4)

roles — массив permissions-ролей (объектов из GET /roles). Каждая роль может быть привязана к своему набору магазинов. Если не передано или пустой массив — сотрудник создаётся без permissions-ролей (например, ещё не назначен — позже добавят через PATCH). Валидация:

  • role_id принадлежит той же франшизе, не удалён, не скрытый (owner_legal_entity_id IS NULL)
  • store_ids — подмножество ТТ, доступных создателю по его scope (владелец франшизы — любые ТТ; владелец партнёра — только свои)
  • Пустой store_ids ([]) допустим — роль действует во всех ТТ scope сотрудника

Response 201

{
  "data": {
    "id": "uuid",
    "franchise_id": "uuid",
    "first_name": "string",
    "last_name": "string",
    "email": "string",
    "phone": "string | null",
    "status": "active",
    "is_courier": "boolean",
    "roles": [
      {
        "id": "uuid",
        "name": "string",
        "store_ids": ["uuid"]
      }
    ],
    "created_at": "datetime",
    "updated_at": "datetime"
  }
}

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Нет employees.edit permission или указанная ТТ вне scope
VALIDATION_ERROR400Невалидные данные (email, phone, pin формат)
EMAIL_DUPLICATE409Email уже существует в рамках franchise_id
PIN_DUPLICATE409PIN уже занят в данной ТТ
STORE_NOT_FOUND404store_id не существует
ROLE_NOT_FOUND404role_id из roles[] не существует, удалён или скрытый (BR 1.4.3, BR 1.4.4)
ROLE_STORE_OUT_OF_SCOPE400store_id в roles[].store_ids вне scope создателя (BR 1.4.3)

PATCH /employees/{id}

Обновить сотрудника.

ПараметрЗначение
AuthBearer JWT (Franchise — любой; Franchisee — только свои сотрудники)
Content-Typeapplication/json

Path Parameters

ParamTypeRequiredDescription
iduuidyesID сотрудника

Request Body

Partial update — отправляются только изменяемые поля.

{
  "first_name": "string, optional",
  "last_name": "string, optional",
  "email": "string, optional",
  "phone": "string, optional",
  "password": "string, optional",
  "pin": "string, optional — 4 цифры",
  "is_courier": "boolean, optional",
  "roles": [
    {
      "role_id": "uuid",
      "store_ids": "uuid[]"
    }
  ]
}

(Обновлено в BR 1.4.4)

Поля role (enum) и отдельное store_ids удалены. Магазины — только в roles[].store_ids. Если в запросе пришли legacy-поля role или store_ids (на уровне employee), они игнорируются (или возвращается 400 VALIDATION_ERROR — на усмотрение реализации).

Permissions-роли — полная замена (Добавлено в BR 1.4.3)

Поле roles (массив permissions-ролей) — идемпотентная замена: переданный массив полностью заменяет текущий набор ролей сотрудника. Чтобы добавить/убрать роль — отправить обновлённый полный массив. Валидация та же что и в POST: только обычные (не скрытые) роли, store_ids в рамках scope.

Response 200

Полный объект сотрудника (аналогично GET /employees/{id}).

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Нет прав на редактирование
EMPLOYEE_NOT_FOUND404Сотрудник не найден
VALIDATION_ERROR400Невалидные данные
EMAIL_DUPLICATE409Email уже существует в рамках franchise_id
PIN_DUPLICATE409PIN уже занят в данной ТТ
ROLE_NOT_FOUND404role_id из roles[] не существует, удалён или скрытый (BR 1.4.3, BR 1.4.4)
ROLE_STORE_OUT_OF_SCOPE400store_id в roles[].store_ids вне scope (BR 1.4.3)

POST /employees/{id}/deactivate

Деактивировать сотрудника (status → inactive). Все активные сессии сотрудника завершаются (через Auth Service, когда будет реализован).

ПараметрЗначение
AuthBearer JWT (Franchise — любой; Franchisee — только свои сотрудники)

Path Parameters

ParamTypeRequiredDescription
iduuidyesID сотрудника

Response 200

{
  "data": {
    "id": "uuid",
    "status": "inactive"
  }
}

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Нет прав на деактивацию
EMPLOYEE_NOT_FOUND404Сотрудник не найден
ALREADY_INACTIVE422Сотрудник уже деактивирован

POST /employees/{id}/reactivate

Реактивировать сотрудника (status → active).

ПараметрЗначение
AuthBearer JWT (Franchise — любой; Franchisee — только свои сотрудники)

Path Parameters

ParamTypeRequiredDescription
iduuidyesID сотрудника

Response 200

{
  "data": {
    "id": "uuid",
    "status": "active"
  }
}

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Нет прав на реактивацию
EMPLOYEE_NOT_FOUND404Сотрудник не найден
ALREADY_ACTIVE422Сотрудник уже активен

POST /internal/users/validate-pin

Internal endpoint — для Auth Service (POS PIN-авторизация)

Проверить PIN сотрудника по store_id. Используется для авторизации кассира на POS-терминале.

(Переработано в BR 1.4.4 §4)

Логика: PIN совпадает с pin_hash сотрудника ТТ И в агрегате permissions-ролей сотрудника есть pos.access. Если PIN корректен, но pos.access нет → 403 POS_ACCESS_DENIED. Enum cashier больше не используется.

ПараметрЗначение
AuthService token (X-Service-Token header)
Content-Typeapplication/json

Request Body

{
  "pin": "string, required — 4 цифры",
  "store_id": "uuid, required"
}

Response 200

{
  "data": {
    "id": "uuid",
    "email": "string",
    "first_name": "string",
    "last_name": "string",
    "franchise_id": "uuid",
    "status": "active | inactive",
    "role_ids": ["uuid"],
    "permissions": ["string"],
    "scope": {
      "type": "all_franchise | legal_entity_ids | store_ids",
      "legal_entity_ids": ["uuid"],
      "store_ids": ["uuid"]
    }
  }
}

(Расширено в BR 1.4.3: поля role_ids и permissions.)

(Обновлено в BR 1.4.4: удалены role, store_ids, legal_entity_id. Добавлен scope. Добавлена ошибка POS_ACCESS_DENIED.)

Errors

CodeHTTPКогда
INVALID_CREDENTIALS401Неверный PIN для данной ТТ
ACCOUNT_DISABLED403Сотрудник деактивирован (status=inactive)
POS_ACCESS_DENIED403PIN корректен, но в permissions сотрудника нет pos.access (BR 1.4.4 §4)

PATCH /internal/users/{id}/password

Internal endpoint — для Auth Service

Используется для сброса/смены пароля сотрудника из Auth Service.

Обновить пароль сотрудника по ID. Пароль хэшируется через BCrypt.

ПараметрЗначение
AuthService token (X-Service-Token header)
Content-Typeapplication/json

Path Parameters

ParamTypeRequiredDescription
iduuidyesID сотрудника

Request Body

{
  "password": "string, required — новый пароль"
}

Response 200

{
  "data": {
    "status": "updated"
  }
}

Errors

CodeHTTPКогда
EMPLOYEE_NOT_FOUND404Сотрудник не найден

GET /employees/{id}/legal-details

Получить юридические детали сотрудника (ИНН, паспорт, СНИЛС, водительское удостоверение).

ПараметрЗначение
AuthBearer JWT (Franchise — любой; Franchisee — только свои сотрудники; Manager, Cashier — 403)

Path Parameters

ParamTypeRequiredDescription
iduuidyesID сотрудника

Response 200

{
  "data": {
    "employee_id": "uuid",
    "inn": "string | null",
    "passport_series": "string | null",
    "passport_number": "string | null",
    "driver_license_number": "string | null",
    "driver_license_expiry": "date | null",
    "snils": "string | null",
    "updated_at": "datetime | null"
  }
}

Ролевой доступ

  • Franchise: юридические детали любого сотрудника франшизы
  • Franchisee: только сотрудники своих ТТ
  • Manager, Cashier: 403 FORBIDDEN

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Роль не имеет доступа или попытка доступа к чужому сотруднику
EMPLOYEE_NOT_FOUND404Сотрудник не найден
LEGAL_DETAILS_NOT_FOUND404Юридические детали не заполнены

PUT /employees/{id}/legal-details

Создать или обновить юридические детали сотрудника (upsert).

ПараметрЗначение
AuthBearer JWT (Franchise — любой; Franchisee — только свои сотрудники; Manager, Cashier — 403)
Content-Typeapplication/json

Path Parameters

ParamTypeRequiredDescription
iduuidyesID сотрудника

Request Body

Все поля опциональны. Отправляются только заполняемые.

{
  "inn": "string, optional — 12 цифр (ИНН физлица)",
  "passport_series": "string, optional — 4 цифры",
  "passport_number": "string, optional — 6 цифр",
  "driver_license_number": "string, optional",
  "driver_license_expiry": "date, optional — формат YYYY-MM-DD",
  "snils": "string, optional — 11 цифр"
}

Response 200

{
  "data": {
    "employee_id": "uuid",
    "inn": "string | null",
    "passport_series": "string | null",
    "passport_number": "string | null",
    "driver_license_number": "string | null",
    "driver_license_expiry": "date | null",
    "snils": "string | null",
    "updated_at": "datetime"
  }
}

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Роль не имеет доступа или попытка доступа к чужому сотруднику
EMPLOYEE_NOT_FOUND404Сотрудник не найден
VALIDATION_ERROR400Невалидные данные (формат ИНН, СНИЛС и т.д.)

GET /shift-templates

Получить список шаблонов смен для торговой точки.

ПараметрЗначение
AuthBearer JWT (Franchise — любая ТТ; Franchisee — только свои ТТ; Manager — только своя ТТ; Cashier — 403)

Query Parameters

ParamTypeRequiredDescription
store_iduuidyesID торговой точки

Response 200

{
  "data": [
    {
      "id": "uuid",
      "store_id": "uuid",
      "name": "string",
      "start_time": "string — формат HH:mm",
      "duration_minutes": "integer",
      "created_at": "datetime",
      "updated_at": "datetime"
    }
  ]
}

Ролевой доступ

  • Franchise: шаблоны любой ТТ франшизы
  • Franchisee: только свои ТТ (store_id IN :jwt_store_ids)
  • Manager: только своя ТТ (store_id = :jwt_store_id)
  • Cashier: 403 FORBIDDEN

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Нет доступа к данной ТТ или роль Cashier
STORE_NOT_FOUND404ТТ не найдена

POST /shift-templates

Создать шаблон смены для торговой точки.

ПараметрЗначение
AuthBearer JWT (Franchise — любая ТТ; Franchisee — только свои ТТ; Manager, Cashier — 403)
Content-Typeapplication/json

Request Body

{
  "store_id": "uuid, required",
  "name": "string, required — название шаблона",
  "start_time": "string, required — формат HH:mm",
  "duration_minutes": "integer, required — длительность в минутах"
}

Response 201

{
  "data": {
    "id": "uuid",
    "store_id": "uuid",
    "name": "string",
    "start_time": "string",
    "duration_minutes": "integer",
    "created_at": "datetime",
    "updated_at": "datetime"
  }
}

Максимум 4 шаблона на ТТ

При попытке создать пятый шаблон — 422 MAX_TEMPLATES_REACHED.

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Нет прав на создание шаблонов для данной ТТ
VALIDATION_ERROR400Невалидные данные
STORE_NOT_FOUND404ТТ не найдена
MAX_TEMPLATES_REACHED422Достигнут лимит в 4 шаблона для данной ТТ

PATCH /shift-templates/{id}

Обновить шаблон смены.

ПараметрЗначение
AuthBearer JWT (Franchise — любой; Franchisee — только свои ТТ; Manager, Cashier — 403)
Content-Typeapplication/json

Path Parameters

ParamTypeRequiredDescription
iduuidyesID шаблона смены

Request Body

Partial update — отправляются только изменяемые поля.

{
  "name": "string, optional",
  "start_time": "string, optional — формат HH:mm",
  "duration_minutes": "integer, optional"
}

Response 200

{
  "data": {
    "id": "uuid",
    "store_id": "uuid",
    "name": "string",
    "start_time": "string",
    "duration_minutes": "integer",
    "created_at": "datetime",
    "updated_at": "datetime"
  }
}

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Нет прав на редактирование
SHIFT_TEMPLATE_NOT_FOUND404Шаблон не найден
VALIDATION_ERROR400Невалидные данные

DELETE /shift-templates/{id}

Удалить шаблон смены.

ПараметрЗначение
AuthBearer JWT (Franchise — любой; Franchisee — только свои ТТ; Manager, Cashier — 403)

Path Parameters

ParamTypeRequiredDescription
iduuidyesID шаблона смены

Response 204

Нет тела.

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Нет прав на удаление
SHIFT_TEMPLATE_NOT_FOUND404Шаблон не найден

GET /schedules

Получить расписание смен (план) с фактическими данными и вычисленным статусом.

ПараметрЗначение
AuthBearer JWT (Franchise — любая ТТ; Franchisee — только свои ТТ; Manager — только своя ТТ; Cashier — 403)

Query Parameters

ParamTypeRequiredDescription
store_iduuidyesID торговой точки
date_fromdateyesНачало периода (формат YYYY-MM-DD)
date_todateyesКонец периода (формат YYYY-MM-DD)

Фильтры employee_id / role_id / status — планируются

Сейчас ScheduleController возвращает все записи в заданном диапазоне для ТТ. Дополнительные фильтры пока не реализованы. Клиент (admin/web) может передавать их в query string — параметры игнорируются (backward-compatible).

Response 200

{
  "data": [
    {
      "id": "uuid",
      "employee_id": "uuid",
      "employee_name": "string",
      "employee_role": "string",
      "store_id": "uuid",
      "date": "date",
      "planned": {
        "start_time": "string — HH:mm",
        "end_time": "string — HH:mm",
        "duration_minutes": "integer",
        "template_id": "uuid | null",
        "template_name": "string | null"
      },
      "actual": {
        "shift_record_id": "uuid | null",
        "clock_in": "datetime | null",
        "clock_out": "datetime | null",
        "break_minutes": "integer | null",
        "worked_minutes": "integer | null",
        "corrections_minutes": "integer"
      },
      "status": "scheduled | in_progress | completed | missed",
      "created_at": "datetime"
    }
  ],
  "meta": {
    "page": "integer",
    "per_page": "integer",
    "total": "integer"
  }
}

Статус вычисляется автоматически

  • scheduled — дата в будущем или сегодня, нет clock_in
  • in_progress — есть clock_in, нет clock_out
  • completed — есть clock_in и clock_out
  • missed — дата в прошлом, нет clock_in

Ролевой доступ

  • Franchise: расписание любой ТТ
  • Franchisee: только свои ТТ
  • Manager: только своя ТТ (read-only)
  • Cashier: 403 FORBIDDEN

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Нет доступа к данной ТТ или роль Cashier
VALIDATION_ERROR400Невалидные параметры (date_from > date_to и т.д.)

POST /schedules

Создать записи расписания (batch). Одним запросом можно запланировать смены для нескольких сотрудников.

ПараметрЗначение
AuthBearer JWT (Franchise — любая ТТ; Franchisee — только свои ТТ; Manager, Cashier — 403)
Content-Typeapplication/json

Request Body

{
  "items": [
    {
      "employee_id": "uuid, required",
      "store_id": "uuid, required",
      "date": "date, required — формат YYYY-MM-DD, только будущие даты",
      "template_id": "uuid, optional — если указан, start_time и duration_minutes берутся из шаблона",
      "start_time": "string, optional — формат HH:mm, обязателен если нет template_id",
      "duration_minutes": "integer, optional — обязателен если нет template_id"
    }
  ]
}

Дата должна быть в будущем

Нельзя создать запись расписания на прошедшую дату. Сегодняшняя дата допускается.

Response 201

{
  "data": [
    {
      "id": "uuid",
      "employee_id": "uuid",
      "store_id": "uuid",
      "date": "date",
      "start_time": "string",
      "end_time": "string",
      "duration_minutes": "integer",
      "template_id": "uuid | null",
      "created_at": "datetime"
    }
  ]
}

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Нет прав на создание расписания для данной ТТ
VALIDATION_ERROR400Невалидные данные (формат даты, времени)
DATE_IN_PAST422Дата в прошлом
EMPLOYEE_NOT_FOUND404Сотрудник не найден
STORE_NOT_FOUND404ТТ не найдена
SHIFT_TEMPLATE_NOT_FOUND404Шаблон смены не найден

PATCH /schedules/{id}

Обновить запись расписания. Только для будущих дат.

ПараметрЗначение
AuthBearer JWT (Franchise — любая ТТ; Franchisee — только свои ТТ; Manager, Cashier — 403)
Content-Typeapplication/json

Path Parameters

ParamTypeRequiredDescription
iduuidyesID записи расписания

Request Body

Partial update — отправляются только изменяемые поля.

{
  "start_time": "string, optional — формат HH:mm",
  "end_time": "string, optional — формат HH:mm",
  "template_id": "uuid, optional — если указан, перезаписывает start_time и duration"
}

Response 200

{
  "data": {
    "id": "uuid",
    "employee_id": "uuid",
    "store_id": "uuid",
    "date": "date",
    "start_time": "string",
    "end_time": "string",
    "duration_minutes": "integer",
    "template_id": "uuid | null",
    "created_at": "datetime",
    "updated_at": "datetime"
  }
}

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Нет прав на редактирование
SCHEDULE_NOT_FOUND404Запись расписания не найдена
DATE_IN_PAST422Нельзя редактировать запись на прошедшую дату
VALIDATION_ERROR400Невалидные данные

DELETE /schedules/{id}

Удалить запись расписания. Только для будущих дат.

ПараметрЗначение
AuthBearer JWT (Franchise — любая ТТ; Franchisee — только свои ТТ; Manager, Cashier — 403)

Path Parameters

ParamTypeRequiredDescription
iduuidyesID записи расписания

Response 204

Нет тела.

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Нет прав на удаление
SCHEDULE_NOT_FOUND404Запись расписания не найдена
DATE_IN_PAST422Нельзя удалить запись на прошедшую дату

GET /shift-records

Получить список фактических смен с корректировками.

ПараметрЗначение
AuthBearer JWT (Franchise — любая ТТ; Franchisee — только свои ТТ; Manager — только своя ТТ; Cashier — 403)

Query Parameters

ParamTypeRequiredDescription
store_iduuidnoФильтр по ТТ
employee_iduuidnoФильтр по сотруднику
date_fromdatenoНачало периода (формат YYYY-MM-DD)
date_todatenoКонец периода (формат YYYY-MM-DD)
pageintegernoНомер страницы (default: 1)
per_pageintegernoЗаписей на страницу (default: 20, max: 100)

Response 200

{
  "data": [
    {
      "id": "uuid",
      "employee_id": "uuid",
      "employee_name": "string",
      "store_id": "uuid",
      "date": "date",
      "clock_in": "datetime | null",
      "clock_out": "datetime | null",
      "break_start": "datetime | null",
      "break_end": "datetime | null",
      "break_minutes": "integer",
      "worked_minutes": "integer",
      "source": "pos | manual",
      "corrections": [
        {
          "id": "uuid",
          "type": "increase | decrease",
          "minutes": "integer",
          "comment": "string",
          "created_at": "datetime"
        }
      ],
      "total_corrections_minutes": "integer",
      "final_worked_minutes": "integer",
      "created_at": "datetime",
      "updated_at": "datetime"
    }
  ],
  "meta": {
    "page": "integer",
    "per_page": "integer",
    "total": "integer"
  }
}

Ролевой доступ

  • Franchise: фактические смены любой ТТ
  • Franchisee: только свои ТТ
  • Manager: только своя ТТ (read-only)
  • Cashier: 403 FORBIDDEN

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Нет доступа к данной ТТ или роль Cashier

POST /shift-records

Создать фактическую смену вручную (source = manual).

ПараметрЗначение
AuthBearer JWT (Franchise — любая ТТ; Franchisee — только свои ТТ; Manager, Cashier — 403)
Content-Typeapplication/json

Request Body

{
  "employee_id": "uuid, required",
  "store_id": "uuid, required",
  "date": "date, required — формат YYYY-MM-DD",
  "clock_in": "datetime, required",
  "clock_out": "datetime, optional",
  "break_start": "datetime, optional",
  "break_end": "datetime, optional"
}

Response 201

{
  "data": {
    "id": "uuid",
    "employee_id": "uuid",
    "store_id": "uuid",
    "date": "date",
    "clock_in": "datetime",
    "clock_out": "datetime | null",
    "break_start": "datetime | null",
    "break_end": "datetime | null",
    "break_minutes": "integer",
    "worked_minutes": "integer",
    "source": "manual",
    "corrections": [],
    "total_corrections_minutes": 0,
    "final_worked_minutes": "integer",
    "created_at": "datetime",
    "updated_at": "datetime"
  }
}

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Нет прав на создание
VALIDATION_ERROR400Невалидные данные (clock_out < clock_in и т.д.)
EMPLOYEE_NOT_FOUND404Сотрудник не найден
STORE_NOT_FOUND404ТТ не найдена

PATCH /shift-records/{id}

Обновить фактическую смену. Только для записей с source = manual.

ПараметрЗначение
AuthBearer JWT (Franchise — любая; Franchisee — только свои ТТ; Manager, Cashier — 403)
Content-Typeapplication/json

Path Parameters

ParamTypeRequiredDescription
iduuidyesID записи фактической смены

Request Body

Partial update — отправляются только изменяемые поля.

{
  "clock_in": "datetime, optional",
  "clock_out": "datetime, optional",
  "break_start": "datetime, optional",
  "break_end": "datetime, optional"
}

Response 200

{
  "data": {
    "id": "uuid",
    "employee_id": "uuid",
    "store_id": "uuid",
    "date": "date",
    "clock_in": "datetime",
    "clock_out": "datetime | null",
    "break_start": "datetime | null",
    "break_end": "datetime | null",
    "break_minutes": "integer",
    "worked_minutes": "integer",
    "source": "manual",
    "corrections": [],
    "total_corrections_minutes": "integer",
    "final_worked_minutes": "integer",
    "created_at": "datetime",
    "updated_at": "datetime"
  }
}

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Нет прав на редактирование
SHIFT_RECORD_NOT_FOUND404Запись не найдена
POS_RECORD_IMMUTABLE422Нельзя редактировать запись с source = pos
VALIDATION_ERROR400Невалидные данные

POST /shift-records/{id}/corrections

Добавить корректировку к фактической смене (увеличить или уменьшить отработанное время).

ПараметрЗначение
AuthBearer JWT (Franchise — любая; Franchisee — только свои ТТ; Manager, Cashier — 403)
Content-Typeapplication/json

Path Parameters

ParamTypeRequiredDescription
iduuidyesID записи фактической смены

Request Body

{
  "type": "string, required — increase | decrease",
  "minutes": "integer, required — количество минут",
  "comment": "string, required — причина корректировки"
}

Response 201

{
  "data": {
    "id": "uuid",
    "shift_record_id": "uuid",
    "type": "increase | decrease",
    "minutes": "integer",
    "comment": "string",
    "created_at": "datetime"
  }
}

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Нет прав на корректировку
SHIFT_RECORD_NOT_FOUND404Запись фактической смены не найдена
VALIDATION_ERROR400Невалидные данные (minutes 0, пустой comment)

DELETE /shift-records/{id}/corrections/{correction_id}

Удалить корректировку фактической смены.

ПараметрЗначение
AuthBearer JWT (Franchise — любая; Franchisee — только свои ТТ; Manager, Cashier — 403)

Path Parameters

ParamTypeRequiredDescription
iduuidyesID записи фактической смены
correction_iduuidyesID корректировки

Response 204

Нет тела.

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Нет прав на удаление
SHIFT_RECORD_NOT_FOUND404Запись фактической смены не найдена
CORRECTION_NOT_FOUND404Корректировка не найдена

GET /dashboard/activity

Получить дашборд активности сотрудников: сводка по ТТ и детализация по каждому сотруднику.

ПараметрЗначение
AuthBearer JWT (Franchise — любая ТТ; Franchisee — только свои ТТ; Manager — только своя ТТ; Cashier — 403)

Query Parameters

ParamTypeRequiredDescription
store_iduuidnoФильтр по ТТ (Franchise может не указывать — сводка по всем)
datedatenoДата (формат YYYY-MM-DD, default: сегодня)

Response 200

{
  "data": {
    "summary": {
      "revenue": "number",
      "total_orders": "integer",
      "total_employees": "integer",
      "top_seller": {
        "employee_id": "uuid | null",
        "name": "string | null",
        "sales_amount": "number | null"
      },
      "total_stores": "integer"
    },
    "employees": [
      {
        "employee_id": "uuid",
        "name": "string",
        "clock_in": "datetime | null",
        "clock_out": "datetime | null",
        "break_duration": "integer — минуты",
        "worked_hours": "number — часы с десятичными",
        "orders_created": "integer | null",
        "orders_completed": "integer | null",
        "orders_delivered": "integer | null",
        "sales_amount": "number | null",
        "last_sale_at": "datetime | null"
      }
    ]
  }
}

Поля заказов — заглушки до Order Service

Поля orders_created, orders_completed, orders_delivered, sales_amount, last_sale_at, revenue, total_orders, top_seller возвращают null / 0 до тех пор, пока не будет реализован Order Service.

Ролевой доступ

  • Franchise: дашборд по любой ТТ или сводка по всем
  • Franchisee: только свои ТТ
  • Manager: только своя ТТ (read-only)
  • Cashier: 403 FORBIDDEN

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Нет доступа к данной ТТ или роль Cashier

GET /salary-formulas

Получить список формул расчёта зарплаты (ролевые и индивидуальные).

ПараметрЗначение
AuthBearer JWT (Franchise — все; Franchisee — только свои ТТ; Manager, Cashier — 403)

Query Parameters

ParamTypeRequiredDescription
store_iduuidnoФильтр по ТТ

Response 200

{
  "data": [
    {
      "id": "uuid",
      "store_id": "uuid | null",
      "role": "string | null — admin_franchise | admin_franchisee | manager | cashier",
      "employee_id": "uuid | null",
      "employee_name": "string | null",
      "formula_type": "hourly | fixed | mixed",
      "hourly_rate": "number | null",
      "monthly_salary": "number | null",
      "overtime_rate": "number | null — множитель (например 1.5)",
      "norm_hours": "integer | null — норма часов в месяц",
      "created_at": "datetime",
      "updated_at": "datetime"
    }
  ]
}

Приоритет формул

Индивидуальная формула (employee_id задан) имеет приоритет над ролевой (role задан). Ролевая формула может быть привязана к конкретной ТТ (store_id задан) или быть общей (store_id = null).

Ролевой доступ

  • Franchise: все формулы франшизы
  • Franchisee: только формулы своих ТТ
  • Manager, Cashier: 403 FORBIDDEN

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Нет доступа или роль Manager/Cashier

POST /salary-formulas

Создать формулу расчёта зарплаты.

ПараметрЗначение
AuthBearer JWT (Franchise — любая; Franchisee — только свои ТТ; Manager, Cashier — 403)
Content-Typeapplication/json

Request Body

{
  "store_id": "uuid, optional — если не указан, формула общая для франшизы",
  "role": "string, optional — admin_franchise | admin_franchisee | manager | cashier",
  "employee_id": "uuid, optional — индивидуальная формула для сотрудника",
  "formula_type": "string, required — hourly | fixed | mixed",
  "hourly_rate": "number, optional — ставка за час (для formula_type=hourly)",
  "monthly_salary": "number, optional — фиксированный оклад (для formula_type=monthly)",
  "overtime_rate": "number, optional — множитель переработки (default: 1.5)",
  "norm_hours": "integer, optional — норма часов в месяц (default: 160)"
}

Одно из role или employee_id обязательно

Формула должна быть привязана либо к роли (ролевая), либо к конкретному сотруднику (индивидуальная).

Response 201

{
  "data": {
    "id": "uuid",
    "store_id": "uuid | null",
    "role": "string | null",
    "employee_id": "uuid | null",
    "formula_type": "hourly | fixed | mixed",
    "hourly_rate": "number | null",
    "monthly_salary": "number | null",
    "overtime_rate": "number",
    "norm_hours": "integer",
    "created_at": "datetime",
    "updated_at": "datetime"
  }
}

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Нет прав на создание
VALIDATION_ERROR400Невалидные данные (не указан role и employee_id, некорректный formula_type)
EMPLOYEE_NOT_FOUND404Сотрудник не найден
STORE_NOT_FOUND404ТТ не найдена

PATCH /salary-formulas/{id}

Обновить формулу расчёта зарплаты.

ПараметрЗначение
AuthBearer JWT (Franchise — любая; Franchisee — только свои ТТ; Manager, Cashier — 403)
Content-Typeapplication/json

Path Parameters

ParamTypeRequiredDescription
iduuidyesID формулы

Request Body

Partial update — отправляются только изменяемые поля.

{
  "formula_type": "string, optional — hourly | fixed | mixed",
  "hourly_rate": "number, optional",
  "monthly_salary": "number, optional",
  "overtime_rate": "number, optional",
  "norm_hours": "integer, optional"
}

Response 200

{
  "data": {
    "id": "uuid",
    "store_id": "uuid | null",
    "role": "string | null",
    "employee_id": "uuid | null",
    "formula_type": "hourly | fixed | mixed",
    "hourly_rate": "number | null",
    "monthly_salary": "number | null",
    "overtime_rate": "number",
    "norm_hours": "integer",
    "created_at": "datetime",
    "updated_at": "datetime"
  }
}

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Нет прав на редактирование
SALARY_FORMULA_NOT_FOUND404Формула не найдена
VALIDATION_ERROR400Невалидные данные

DELETE /salary-formulas/{id}

Удалить формулу расчёта зарплаты. Можно удалить только индивидуальные формулы (с employee_id).

ПараметрЗначение
AuthBearer JWT (Franchise — любая; Franchisee — только свои ТТ; Manager, Cashier — 403)

Path Parameters

ParamTypeRequiredDescription
iduuidyesID формулы

Response 204

Нет тела.

Только индивидуальные формулы

Ролевые формулы (role задан, employee_id = null) нельзя удалить — 422 ROLE_FORMULA_CANNOT_BE_DELETED. Их можно только обновить через PATCH.

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Нет прав на удаление
SALARY_FORMULA_NOT_FOUND404Формула не найдена
ROLE_FORMULA_CANNOT_BE_DELETED422Нельзя удалить ролевую формулу

GET /payroll

Получить ведомости по зарплате за период.

ПараметрЗначение
AuthBearer JWT (Franchise — все; Franchisee — только свои ТТ; Manager, Cashier — 403)

Query Parameters

ParamTypeRequiredDescription
store_iduuidnoФильтр по ТТ
periodstringyesПериод в формате YYYY-MM
pageintegernoНомер страницы (default: 1)
per_pageintegernoЗаписей на страницу (default: 20, max: 100)

Response 200

{
  "data": [
    {
      "id": "uuid",
      "employee_id": "uuid",
      "employee_name": "string",
      "employee_role": "string",
      "store_id": "uuid",
      "store_name": "string",
      "period_start": "date",
      "period_end": "date",
      "planned_hours": "number",
      "actual_hours": "number",
      "break_hours": "number",
      "net_hours": "number",
      "formula_type": "hourly | fixed | mixed",
      "formula_snapshot": "object — снимок формулы на момент расчёта",
      "amount": "number — рассчитанная сумма",
      "status": "calculated | confirmed | paid",
      "confirmed_by": "uuid | null",
      "confirmed_at": "datetime | null",
      "created_at": "datetime"
    }
  ],
  "meta": {
    "page": "integer",
    "per_page": "integer",
    "total": "integer"
  }
}

Ролевой доступ

  • Franchise: все ведомости
  • Franchisee: только свои ТТ
  • Manager, Cashier: 403 FORBIDDEN

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Нет доступа или роль Manager/Cashier
VALIDATION_ERROR400Невалидный формат периода

POST /payroll/calculate

Рассчитать зарплату за период для всех сотрудников ТТ. Создаёт или пересчитывает записи ведомости.

ПараметрЗначение
AuthBearer JWT (Franchise — любая ТТ; Franchisee — только свои ТТ; Manager, Cashier — 403)
Content-Typeapplication/json

Request Body

{
  "store_id": "uuid, required",
  "period": "string, required — формат YYYY-MM"
}

Response 200

{
  "data": [
    {
      "id": "uuid",
      "employee_id": "uuid",
      "employee_name": "string",
      "employee_role": "string",
      "store_id": "uuid",
      "period_start": "date",
      "period_end": "date",
      "planned_hours": "number",
      "actual_hours": "number",
      "break_hours": "number",
      "net_hours": "number",
      "formula_type": "hourly | fixed | mixed",
      "formula_snapshot": "object",
      "amount": "number",
      "status": "calculated",
      "created_at": "datetime"
    }
  ]
}

Пересчёт допускается

Если ведомость за период уже существует со статусом calculated — она будет пересчитана. Ведомости со статусом confirmed или paid не пересчитываются.

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Нет прав на расчёт
STORE_NOT_FOUND404ТТ не найдена
VALIDATION_ERROR400Невалидный формат периода

POST /payroll/{id}/confirm

Подтвердить ведомость. Переводит статус из calculated в confirmed.

ПараметрЗначение
AuthBearer JWT (Franchise — любая; Franchisee — только свои ТТ; Manager, Cashier — 403)

Path Parameters

ParamTypeRequiredDescription
iduuidyesID записи ведомости

Response 200

{
  "data": {
    "id": "uuid",
    "status": "confirmed",
    "confirmed_at": "datetime"
  }
}

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Нет прав на подтверждение
PAYROLL_NOT_FOUND404Запись ведомости не найдена
INVALID_STATUS_TRANSITION422Статус не calculated (уже подтверждена или оплачена)

POST /payroll/{id}/mark-paid

Отметить ведомость как оплаченную. Переводит статус из confirmed в paid.

ПараметрЗначение
AuthBearer JWT (Franchise — любая; Franchisee — только свои ТТ; Manager, Cashier — 403)

Path Parameters

ParamTypeRequiredDescription
iduuidyesID записи ведомости

Response 200

{
  "data": {
    "id": "uuid",
    "status": "paid",
    "paid_at": "datetime"
  }
}

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Нет прав на отметку оплаты
PAYROLL_NOT_FOUND404Запись ведомости не найдена
INVALID_STATUS_TRANSITION422Статус не confirmed (не подтверждена или уже оплачена)

GET /payroll/{id}/export

Экспортировать ведомость в CSV-файл.

ПараметрЗначение
AuthBearer JWT (Franchise — любая; Franchisee — только свои ТТ; Manager, Cashier — 403)

Path Parameters

ParamTypeRequiredDescription
iduuidyesID записи ведомости

Response 200

Content-Type: text/csv; charset=utf-8

Формат CSV

Файл в кодировке UTF-8 с BOM для корректного открытия в Excel. Колонки: Сотрудник, Роль, Отработано часов, Норма часов, Переработка часов, Базовая сумма, Переработка сумма, Корректировки сумма, Итого, Статус.

Errors

CodeHTTPКогда
UNAUTHORIZED401Невалидный JWT
FORBIDDEN403Нет прав на экспорт
PAYROLL_NOT_FOUND404Запись ведомости не найдена

POST /internal/shift-records/clock-in

Internal endpoint — для POS BFF (через service token)

Зафиксировать начало смены сотрудника на POS-терминале. Создаёт запись фактической смены с source = pos.

ПараметрЗначение
AuthService token (X-Service-Token header)
Content-Typeapplication/json

Request Body

{
  "employee_id": "uuid, required",
  "store_id": "uuid, required"
}

Response 201

{
  "data": {
    "id": "uuid",
    "employee_id": "uuid",
    "store_id": "uuid",
    "date": "date",
    "clock_in": "datetime",
    "source": "pos",
    "created_at": "datetime"
  }
}

Errors

CodeHTTPКогда
EMPLOYEE_NOT_FOUND404Сотрудник не найден
STORE_NOT_FOUND404ТТ не найдена
SHIFT_ALREADY_OPEN422У сотрудника уже есть открытая смена в данной ТТ

POST /internal/shift-records/clock-out

Internal endpoint — для POS BFF (через service token)

Зафиксировать окончание смены сотрудника на POS-терминале.

ПараметрЗначение
AuthService token (X-Service-Token header)
Content-Typeapplication/json

Request Body

{
  "employee_id": "uuid, required",
  "store_id": "uuid, required"
}

Response 200

{
  "data": {
    "id": "uuid",
    "employee_id": "uuid",
    "store_id": "uuid",
    "date": "date",
    "clock_in": "datetime",
    "clock_out": "datetime",
    "break_minutes": "integer",
    "worked_minutes": "integer",
    "source": "pos",
    "updated_at": "datetime"
  }
}

Errors

CodeHTTPКогда
EMPLOYEE_NOT_FOUND404Сотрудник не найден
NO_OPEN_SHIFT422Нет открытой смены для данного сотрудника в данной ТТ

POST /internal/shift-records/break-start

Internal endpoint — для POS BFF (через service token)

Зафиксировать начало перерыва сотрудника.

ПараметрЗначение
AuthService token (X-Service-Token header)
Content-Typeapplication/json

Request Body

{
  "employee_id": "uuid, required",
  "store_id": "uuid, required"
}

Response 200

{
  "data": {
    "id": "uuid",
    "employee_id": "uuid",
    "store_id": "uuid",
    "break_start": "datetime",
    "updated_at": "datetime"
  }
}

Errors

CodeHTTPКогда
EMPLOYEE_NOT_FOUND404Сотрудник не найден
NO_OPEN_SHIFT422Нет открытой смены
BREAK_ALREADY_STARTED422Перерыв уже начат

POST /internal/shift-records/break-end

Internal endpoint — для POS BFF (через service token)

Зафиксировать окончание перерыва сотрудника.

ПараметрЗначение
AuthService token (X-Service-Token header)
Content-Typeapplication/json

Request Body

{
  "employee_id": "uuid, required",
  "store_id": "uuid, required"
}

Response 200

{
  "data": {
    "id": "uuid",
    "employee_id": "uuid",
    "store_id": "uuid",
    "break_start": "datetime",
    "break_end": "datetime",
    "break_minutes": "integer",
    "updated_at": "datetime"
  }
}

Errors

CodeHTTPКогда
EMPLOYEE_NOT_FOUND404Сотрудник не найден
NO_OPEN_SHIFT422Нет открытой смены
BREAK_NOT_STARTED422Перерыв не был начат

GET /shift-records/{id}/report

Полный X/Z отчёт по конкретной смене. Собирает данные смены из shift_records + вызывает Order Service за финансовой агрегацией.

(Добавлено в BR 2.2)

ПараметрЗначение
AuthBearer JWT (Franchise — все; Franchisee — свои ТТ; Manager — своя ТТ; Cashier — 403)

Path Parameters

ParamTypeRequiredDescription
iduuidyesID записи shift_record

Response 200

{
  "data": {
    "shift": {
      "id": "uuid",
      "employee_id": "uuid",
      "employee_name": "Иванов Иван Иванович",
      "store_id": "uuid",
      "store_name": "Кофейня на Арбате",
      "date": "2026-04-14",
      "clock_in": "2026-04-14T08:00:00Z",
      "clock_out": "2026-04-14T16:30:00Z",
      "break_duration_minutes": 30,
      "status": "completed"
    },
    "report_type": "z_report",
    "financials": {
      "total_revenue": 15420.00,
      "cash_amount": 5200.00,
      "card_amount": 8720.00,
      "qr_amount": 1500.00,
      "refund_amount": 0.00,
      "discount_amount": 0.00,
      "total_orders": 23,
      "completed_orders": 21,
      "cancelled_orders": 2,
      "average_check": 734.29
    },
    "top_items": [
      {
        "product_id": "uuid",
        "name": "Латте 0.4",
        "quantity": 15,
        "amount": 4500.00
      }
    ]
  }
}
FieldTypeDescription
shift.iduuidID записи смены
shift.employee_iduuidID сотрудника
shift.employee_namestringФИО сотрудника
shift.store_iduuidID торговой точки
shift.store_namestringНазвание ТТ
shift.datedateДата смены
shift.clock_indatetimeНачало смены
shift.clock_outdatetime | nullКонец смены (null = смена открыта)
shift.break_duration_minutesintegerДлительность перерыва в минутах
shift.statusstringСтатус смены
report_typestringx_report (смена открыта) | z_report (смена закрыта)
financials.total_revenuedecimalВыручка
financials.cash_amountdecimalНаличные
financials.card_amountdecimalКарта
financials.qr_amountdecimalQR
financials.refund_amountdecimalВозвраты (0 в Phase 1)
financials.discount_amountdecimalСкидки (0 в Phase 1)
financials.total_ordersintegerВсего заказов
financials.completed_ordersintegerЗавершённых
financials.cancelled_ordersintegerОтменённых
financials.average_checkdecimalСредний чек
top_itemsarrayТоп-10 товаров по сумме
top_items[].product_iduuidID товара
top_items[].namestringНазвание товара
top_items[].quantityintegerСуммарное количество
top_items[].amountdecimalСуммарная стоимость

X vs Z

Если clock_out IS NULL (смена открыта) → report_type = "x_report", период = clock_in..now. Если clock_out IS NOT NULL (смена закрыта) → report_type = "z_report", период = clock_in..clock_out.

Errors

CodeHTTPКогда
SHIFT_RECORD_NOT_FOUND404Запись смены не найдена
FORBIDDEN403Нет доступа к этой ТТ или роль Cashier
UNAUTHORIZED401Нет токена или невалидный

Роли и permissions (BR 1.4.3)

Источник требований

BR 1.4.3, спека Роли


GET /roles

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

(Обновлено в BR 1.4.4 §5.2)

По умолчанию возвращаются только обычные роли (owner_legal_entity_id IS NULL). Скрытые роли владельцев партнёров исключены — они доступны только через GET /legal-entities/{id}/owner-permissions.

ПараметрЗначение
AuthBearer JWT (roles.read; владелец франшизы — все; владелец партнёра — те что назначены сотрудникам в своих ТТ)

Query Parameters

ParamTypeRequiredDescription
statusstringnoactive (default) / deleted / all
is_systembooleannoФильтр по флагу системной
include_hiddenbooleannodefault false. Если true — также возвращает скрытые роли владельцев партнёров (owner_legal_entity_id IS NOT NULL). Используется только для админских целей; в обычном UI всегда false (BR 1.4.4)
searchstringnoПоиск по name (LIKE, case-insensitive)
pageintegernodefault 1
per_pageintegernodefault 20, max 100

Response 200

{
  "data": [
    {
      "id": "uuid",
      "name": "string",
      "description": "string | null",
      "is_system": "boolean",
      "employee_count": "integer — число сотрудников с этой ролью",
      "status": "active | deleted",
      "created_at": "datetime"
    }
  ],
  "meta": { "page": 1, "per_page": 20, "total": 15 }
}

Errors

CodeHTTPКогда
UNAUTHORIZED401
FORBIDDEN403Нет roles.read

GET /roles/{id}

Детали роли с полным набором permissions и формулой зарплаты.

(Обновлено в BR 1.4.4 §5.2)

Если запрашиваемая роль — скрытая (owner_legal_entity_id IS NOT NULL), endpoint возвращает 404 ROLE_NOT_FOUND (даже если роль существует). Для управления скрытыми ролями владельцев партнёров используется GET /legal-entities/{id}/owner-permissions.

ПараметрЗначение
AuthBearer JWT (roles.read)

Path Parameters

ParamTypeRequiredDescription
iduuidyesID роли

Response 200

{
  "data": {
    "id": "uuid",
    "franchise_id": "uuid",
    "name": "string",
    "description": "string | null",
    "is_system": "boolean",
    "owner_legal_entity_id": "uuid | null — если задан, это скрытая роль владельца партнёра (BR 1.4.4 §5.2); при include_hidden=false сюда не попадают",
    "status": "active | deleted",
    "permissions": [
      "menu.read",
      "menu.edit",
      "pos.access",
      "pos.shift.open"
    ],
    "salary_formula": {
      "formula_type": "hourly | fixed | mixed",
      "hourly_rate": "number | null",
      "monthly_salary": "number | null",
      "overtime_rate": "number | null",
      "norm_hours": "integer | null"
    },
    "employee_count": "integer",
    "created_at": "datetime",
    "updated_at": "datetime"
  }
}

Errors

CodeHTTPКогда
ROLE_NOT_FOUND404
FORBIDDEN403

POST /roles

Создать новую роль.

ПараметрЗначение
AuthBearer JWT (roles.edit; фактически только admin_franchise)
Content-Typeapplication/json

Request Body

{
  "name": "string, required — уникально в рамках франшизы",
  "description": "string, optional",
  "permissions": ["string, required — массив permission-ключей из каталога"],
  "salary_formula": {
    "formula_type": "hourly | fixed | mixed, optional",
    "hourly_rate": "number, required if hourly/mixed",
    "monthly_salary": "number, required if fixed/mixed",
    "overtime_rate": "number, required if mixed",
    "norm_hours": "integer, default 160"
  }
}

Response 201

Полный объект роли (см. GET /roles/{id}).

Errors

CodeHTTPКогда
VALIDATION_ERROR400Невалидные поля (в т.ч. edit без read)
NAME_DUPLICATE409Роль с таким name уже есть (case-insensitive)
UNKNOWN_PERMISSION_KEY400В permissions[] есть ключ вне каталога
FORBIDDEN403Нет roles.edit

PATCH /roles/{id}

Частичное редактирование роли.

ПараметрЗначение
AuthBearer JWT (roles.edit)
Content-Typeapplication/json

Path Parameters

ParamTypeRequiredDescription
iduuidyesID роли

Request Body

{
  "name": "string, optional",
  "description": "string, optional",
  "permissions": "string[], optional — полная замена набора",
  "salary_formula": "object | null, optional — null удаляет формулу"
}

Системная роль

Для is_system=true разрешается менять только name и description. Попытка передать permissions или salary_formula422 SYSTEM_ROLE_PROTECTED.

Response 200

Полный объект роли.

Errors

CodeHTTPКогда
ROLE_NOT_FOUND404
SYSTEM_ROLE_PROTECTED422Попытка изменить запрещённые поля системной роли
NAME_DUPLICATE409
UNKNOWN_PERMISSION_KEY400
VALIDATION_ERROR400(в т.ч. edit без read)

DELETE /roles/{id}

Soft delete роли.

ПараметрЗначение
AuthBearer JWT (roles.edit)

Path Parameters

ParamTypeRequiredDescription
iduuidyesID роли

Response 204

Нет тела ответа.

Errors

CodeHTTPКогда
ROLE_NOT_FOUND404
ROLE_IN_USE409Роль назначена хоть одному активному сотруднику — возвращается в details список с employee_id
SYSTEM_ROLE_PROTECTED422Попытка удалить системную роль

POST /roles/{id}/restore

Восстановить удалённую роль.

ПараметрЗначение
AuthBearer JWT (roles.edit)

Response 200

Полный объект роли (со статусом active).

Errors

CodeHTTPКогда
ROLE_NOT_FOUND404Или status != deleted
NAME_DUPLICATE409За время отсутствия создана активная роль с таким же именем

GET /roles/permission-catalog

Справочник доступных permission-ключей с человекочитаемыми лейблами для фронта.

ПараметрЗначение
AuthBearer JWT (roles.read)

Response 200

{
  "data": {
    "backoffice": [
      {
        "section": "menu",
        "label": "Меню",
        "permissions": [
          { "key": "menu.read", "label": "Чтение" },
          { "key": "menu.edit", "label": "Редактирование" }
        ]
      }
    ],
    "pos": [
      { "key": "pos.access", "label": "Доступ к POS" },
      { "key": "pos.shift.open", "label": "Открытие смены" }
    ]
  }
}

Permission catalog (полный)

Back-office (19 разделов, 36 ключей — 17 с read+edit, 2 только read)

(Обновлено в BR 3.1 — добавлены разделы «Клиенты» и «Группы клиентов» с .read / .edit, плюс у «Клиенты» есть .delete)

РазделReadEditДоп.
Менюmenu.readmenu.edit
Прейскурантыprice_list.readprice_list.edit
Техкартыrecipes.readrecipes.edit
Ингредиентыingredients.readingredients.edit
Стоп-листыstoplists.readstoplists.edit
Складwarehouse.readwarehouse.edit
Торговые точкиstores.readstores.edit
Юридические лицаlegal_entities.readlegal_entities.edit
Сотрудникиemployees.reademployees.edit
Ролиroles.readroles.edit
Расписание сменschedule.readschedule.edit
Учёт рабочего времениtime_tracking.readtime_tracking.edit
Зарплатаpayroll.readpayroll.edit
Дашбордdashboard.read— (нет редактирования)
Отчётыreports.read
Заказыorders.readorders.edit
Настройкиsettings.readsettings.edit
Клиентыcustomers.readcustomers.editcustomers.delete — только для Owner франшизы (BR 3.1)
Группы клиентовcustomer_groups.readcustomer_groups.edit(BR 3.1)

POS (15 ключей, без min/max — deferred)

(Обновлено в BR 3.1 — добавлен customers.create_quick)

КлючОперация
pos.accessБазовый доступ к POS-приложению
pos.shift.openОткрытие смены
pos.shift.closeЗакрытие смены (Z-отчёт)
pos.orders.createСоздание заказов
pos.orders.editРедактирование незакрытого заказа
pos.discount.applyПрименение скидок
pos.cash.depositВнесение наличных
pos.cash.withdrawИзъятие наличных
pos.cash.collectionИнкассация
pos.refundВозврат средств
pos.order.cancelАннулирование заказа
pos.item.cancelАннулирование позиции
pos.price.changeИзменение цены для товаров с открытой ценой
pos.settings.editИзменение настроек кассы
customers.create_quickПоиск клиента по телефону + quick-create + прикрепление к заказу (BR 3.1)

Deferred: лимиты min/max

Yuma позволяет задать минимум/максимум на некоторые POS-операции (например, «не более 5000₽ за изъятие»). В MVP — только флажки. См. Роли § Deferred.


GET /internal/users/{id}/permissions

Internal endpoint — для Auth Service

Используется для рефреша кэша permissions в Auth Service при TTL-истечении или явной инвалидации.

Возвращает актуальный агрегированный список permissions сотрудника.

ПараметрЗначение
AuthService token (X-Service-Token header)

Path Parameters

ParamTypeRequiredDescription
iduuidyesemployees.id

Response 200

{
  "data": {
    "user_id": "uuid",
    "role_ids": ["uuid"],
    "permissions": ["string — agg granted=true ключей со всех ролей"],
    "scope": {
      "type": "all_franchise | legal_entity_ids | store_ids",
      "legal_entity_ids": ["uuid"],
      "store_ids": ["uuid"]
    }
  }
}

(Обновлено в BR 1.4.4: дополнительно возвращается scope для удобства консьюмеров — не нужно делать второй запрос в /internal/users/{id}/scope.)

Errors

CodeHTTPКогда
USER_NOT_FOUND404

POST /admin/kds/devices/register

(BR 5.1 — KDS)

Регистрация KDS-устройства (Android-планшет на кухне). Вызывается при первом запуске KDS-приложения: владелец/менеджер логинится по email+password (получает обычный JWT), вводит UUID устройства и выбирает ТТ из доступных в его scope.

ПараметрЗначение
AuthBearer JWT с permission kds.settings.edit
Content-Typeapplication/json

Request Body

{
  "device_id": "uuid",
  "store_id": "uuid",
  "kitchen_station_id": "uuid",
  "name": "Кухня — bedc86",
  "app_version": "0.1.0"
}
FieldTypeRequiredDescription
device_iduuidyesГенерируется на устройстве при первом запуске, сохраняется в SQLite. Уникален в рамках франшизы.
store_iduuidyesТТ из scope.store_ids юзера-регистратора
kitchen_station_iduuidno (yes для wizard v2)Цех (kitchen_stations.id), который обслуживает устройство. BR 5.1 v2 — wizard всегда передаёт это поле; legacy-клиенты без поля → запись создаётся с kitchen_station_id=NULL (старая логика multi-station через PIN).
namestringnoПонятное имя. Default = "KDS-{первые 6 hex device_id}"
app_versionstringnoВерсия установленного APK

Response 201

{
  "data": {
    "id": "uuid",
    "device_id": "uuid",
    "franchise_id": "uuid",
    "store_id": "uuid",
    "kitchen_station_id": "uuid | null",
    "name": "Кухня — bedc86",
    "app_version": "0.1.0",
    "last_user_id": null,
    "current_user_id": null,
    "last_seen_at": null,
    "revoked_at": null,
    "created_at": "datetime",
    "updated_at": "datetime"
  }
}

Errors

CodeHTTPDescription
UNAUTHORIZED401
FORBIDDEN403Нет permission kds.settings.edit
STORE_NOT_FOUND404store_id не существует
STORE_NOT_IN_USER_SCOPE403Юзер не имеет доступа к этой ТТ
VALIDATION_ERROR400

Idempotent register (BR 5.1 v2)

Эндпоинт идемпотентен в рамках (franchise_id, device_id):

  • Если active запись (revoked_at IS NULL) с такой парой уже есть → обновить store_id, kitchen_station_id, name, app_version и вернуть 200 OK с обновлённой записью.
  • Если active записи нет (новый device_id или старая revoked) → создать новую запись и вернуть 201 Created.

409 DEVICE_ALREADY_REGISTERED больше не возвращается — это покрывает кейсы переустановки exe, переноса терминала на другую ТТ/цех, повторного wizard’а после clearRegistration().

Side-effects

  • Создаётся или обновляется запись в kds_devices. Если запись была ранее с revoked_at != NULL (после force-logout) — создаётся новая запись с тем же device_id, старая остаётся в БД для аудита.

POST /internal/kds-devices/{deviceId}/heartbeat

(BR 5.1 — KDS)

Heartbeat от KDS-устройства. Вызывается pos-bff на каждом запросе KDS, обновляет last_seen_at, current_user_id, app_version в kds_devices.

ПараметрЗначение
AuthX-Service-Token

Path Parameters

ParamTypeDescription
deviceIduuiddevice_id (не суррогатный id)

Request Body

{
  "user_id": "uuid",
  "app_version": "0.1.0"
}

user_id — текущий залогиненный сотрудник или null если на экране PIN.

Response 204 — успех

Errors

CodeHTTPDescription
DEVICE_NOT_FOUND404
DEVICE_REVOKED401revoked_at != NULL — устройство force-logout, должно ре-регистрироваться

GET /internal/kds-devices/validate

(BR 5.1 — KDS device validation на PIN-логине)

Проверка что KDS-устройство зарегистрировано и привязано к указанной ТТ. Используется pos-bff перед PIN-логином: если valid=false, возвращает клиенту 403 DEVICE_NOT_REGISTERED → клиент чистит localStorage и показывает wizard заново.

ПараметрЗначение
AuthX-Service-Token

Query Parameters

ParamTypeRequiredDescription
device_iduuidyesdevice_id (не суррогатный id)
store_iduuidyesТТ к которой привязано устройство

Response 200

{ "data": { "valid": true, "kitchen_station_id": "uuid | null" } }

valid=true — устройство существует, привязано именно к этой ТТ, не отозвано (revoked_at IS NULL). valid=false — нет соответствия (удалено / другая ТТ / revoked). Поле kitchen_station_id отсутствует.

kitchen_station_id (BR 5.1 v2) — цех привязки этого устройства. Используется pos-bff для аугментации PIN-response: клиент сразу узнаёт к какому цеху привязан, skip экран выбора станций. null для legacy-устройств (зарегистрированных до v2).

Всегда 200, не 404

Для удобства вызывающего возвращаем 200 с {valid: bool}. pos-bff проще читать body.data.valid чем различать 200/404.

Errors

CodeHTTPDescription
UNAUTHORIZED401Невалидный/отсутствующий X-Service-Token

GET /admin/kds/devices

(BR 5.1 — KDS)

Список зарегистрированных KDS-устройств франшизы.

ПараметрЗначение
AuthBearer JWT с permission kds.settings.edit

Query Parameters

ParamTypeRequiredDescription
store_iduuidnoФильтр по ТТ
onlineboolnoТолько online (last_seen_at < 2 min ago)
include_revokedboolnoDefault false. True — включить отозванные

franchise_id берётся из JWT.

Response 200

{
  "data": [
    {
      "id": "uuid",
      "device_id": "uuid",
      "store_id": "uuid",
      "store_name": "Кофейня на Ленина",
      "name": "Планшет горячего цеха",
      "last_user": {
        "id": "uuid",
        "first_name": "Иван",
        "last_name": "Петров"
      },
      "current_user": null,
      "app_version": "0.1.0",
      "last_seen_at": "datetime | null",
      "is_online": true,
      "revoked_at": null,
      "created_at": "datetime"
    }
  ],
  "meta": { "total": 5 }
}

is_online рассчитывается на сервере: last_seen_at != NULL AND last_seen_at > NOW() - 2 min.

store_name денормализуется через JOIN/lookup в Store Service (или денормализован в kds_devices.store_name для упрощения).

Errors

CodeHTTPDescription
UNAUTHORIZED401
FORBIDDEN403

PATCH /admin/kds/devices/{id}

(BR 5.1 — KDS)

Переименовать KDS-устройство.

ПараметрЗначение
AuthBearer JWT с kds.settings.edit
Content-Typeapplication/json

Request Body

{ "name": "Бар-1" }

Response 200

Обновлённая запись (формат как в GET).

Errors

CodeHTTPDescription
DEVICE_NOT_FOUND404
VALIDATION_ERROR400

DELETE /admin/kds/devices/{id}

(BR 5.1 — KDS)

Force-logout: soft-delete KDS-устройства. Активные сессии закрываются, JWT отзывается. Устройство при следующей попытке работы получает 401 DEVICE_REVOKED и переходит в режим «Регистрация требуется».

ПараметрЗначение
AuthBearer JWT с kds.settings.edit (по бизнес-правилу — только владелец франшизы/партнёра)

Response 204 — успех

Side-effects

  • Проставляется revoked_at = NOW()
  • Публикуется событие user.kds_device.revoked (см. Events)
  • pos-bff (consumer) разрывает WebSocket-сессии этого device_id

Errors

CodeHTTPDescription
UNAUTHORIZED401
FORBIDDEN403Не владелец франшизы
DEVICE_NOT_FOUND404
ALREADY_REVOKED409Устройство уже отозвано

POST /admin/pos/devices/register

(POS Desktop onboarding — структура зеркало POST /admin/kds/devices/register)

Регистрация Windows-кассы. Вызывается с устройства из RegistrationScreen после admin-логина.

ПараметрЗначение
AuthBearer JWT с pos.settings.edit
Content-Typeapplication/json

Request Body

{
  "device_id": "uuid",
  "store_id": "uuid",
  "name": "Касса бар Тверская",
  "app_version": "0.1.1"
}

Response 201

Полная запись pos_desktop_devices в формате PosDeviceResponse (см. ниже GET /admin/pos/devices).

Errors

CodeHTTPDescription
STORE_NOT_IN_USER_SCOPE403ТТ вне scope админа-регистрирующего
DEVICE_ALREADY_REGISTERED409(franchise_id, device_id) уже зарегистрирована
VALIDATION_ERROR400

POST /internal/pos-devices/{deviceId}/heartbeat

(POS Desktop onboarding)

Heartbeat от Windows-кассы. Вызывается pos-bff/middleware/auth.ts на каждом аутентифицированном запросе. Обновляет last_seen_at, current_user_id, app_version в pos_desktop_devices.

ПараметрЗначение
AuthX-Service-Token

Path Parameters

ParamTypeDescription
deviceIduuiddevice_id из X-Device-Id header клиента

Request Body

{
  "user_id": "uuid",
  "app_version": "0.1.1"
}

Response 204 — успех

Errors

CodeHTTPDescription
DEVICE_NOT_FOUND404Устройство не зарегистрировано или revoked. Pos-bff перехватывает 404 и отдаёт клиенту 401 DEVICE_REVOKED.

GET /admin/pos/devices

(POS Desktop onboarding)

Список зарегистрированных POS-касс франшизы.

ПараметрЗначение
AuthBearer JWT с pos.settings.edit

Query Parameters

ParamTypeRequiredDescription
store_iduuidnoФильтр по ТТ
onlineboolnoТолько online (last_seen_at < 2 min ago)
include_revokedboolnoDefault false

Response 200

{
  "data": [
    {
      "id": "uuid",
      "device_id": "uuid",
      "store_id": "uuid",
      "store_name": null,
      "name": "Касса бар Тверская",
      "last_user": { "id": "uuid", "first_name": "Иван", "last_name": "Петров" },
      "current_user": null,
      "app_version": "0.1.1",
      "last_seen_at": "datetime | null",
      "is_online": true,
      "revoked_at": null,
      "created_at": "datetime"
    }
  ]
}

is_online = (last_seen_at != NULL AND last_seen_at > NOW() - 2 min). store_name в P0 отдаётся null (cross-service lookup отложен).


PATCH /admin/pos/devices/{id}

(POS Desktop onboarding)

Переименовать POS-кассу.

ПараметрЗначение
AuthBearer JWT с pos.settings.edit
Content-Typeapplication/json

Request Body

{ "name": "Касса бар-2" }

Response 200

Обновлённая запись.

Errors

CodeHTTPDescription
DEVICE_NOT_FOUND404
VALIDATION_ERROR400name пустой или > 100 символов

DELETE /admin/pos/devices/{id}

(POS Desktop onboarding)

Force-logout: soft-delete POS-кассы. На следующем запросе через pos-bff клиент получает 401 DEVICE_REVOKED, чистит локальную регистрацию и показывает RegistrationScreen.

ПараметрЗначение
AuthBearer JWT с pos.settings.edit

Response 204 — успех

Side-effects

  • revoked_at = NOW(), current_user_id = NULL
  • В P0 — log-only (см. PosDeviceEventPublisher). В P1 — Kafka событие user.pos_device.revoked.

Errors

CodeHTTPDescription
DEVICE_NOT_FOUND404
ALREADY_REVOKED409

Общий формат ошибок

{
  "error": {
    "code": "string — машиночитаемый код (UPPER_SNAKE_CASE)",
    "message": "string — человекочитаемое описание",
    "details": [
      {
        "field": "string — путь к полю",
        "message": "string — описание ошибки"
      }
    ]
  }
}