Перейти к содержимому

Полный путь авторизации и работы POS-терминала

Актуализировано по коду на 2026-06-20. Источники истины:

  • Офисoffice/locali-office-backend-new@main (модуль src/pos, outbox).
  • Шлюз кассыkassa/locali-kassa-backend-new@main (src/auth, src/inbox).
  • POSkassa/locali-kassa@main (Electron-приложение кассы: electron/modules/sync, offline-queue, auth).

Раздел «Соответствие коду» в конце перечисляет конкретные файлы.


Три участника и роли

┌─────────────┐ Kafka ┌──────────────┐ REST + WebSocket ┌─────────────┐
│ ОФИС │ office.events │ ШЛЮЗ │ /api/auth/terminal │ POS │
│ источник │ ───────────────► │ (gateway) │ /api/inbox /ws │ Electron- │
│ истины │ (outbox → │ inbound_events│ ◄──────────────────► │ приложение │
│ │ CloudEvents) │ + auth + WS │ │ кассы │
└─────────────┘ └──────────────┘ └─────────────┘
  • Офис — единственный источник истины по справочникам (сотрудники, номенклатура, POS-терминалы и т.д.). Публикует изменения через outbox → Kafka (топик office.events, CloudEvents с префиксом типа core.company.office.*).
  • Шлюз кассы (locali-kassa-backend-new) — централизованный Kafka-consumer. Складывает входящие события в inbound_events (с монотонной revision и флагом sync_to_pos), авторизует терминалы (POST /api/auth/terminal), отдаёт инкрементальный синк (GET /api/inbox) и держит WebSocket с подключёнными кассами.
  • POS — десктоп-приложение кассы на Electron + React (Capacitor для планшетов). Главный процесс держит локальную SQLite и фоновый sync-движок; renderer ходит к нему через IPC-мост (getElectron()). Касса дополнительно работает локальным hub-ом для планшетов официантов в той же сети (mDNS-discovery, pairing, TLS, проксирование /api/*) — этот слой здесь подробно не разбирается.

Глобальный префикс HTTP и в офисе, и в шлюзе — api (app.setGlobalPrefix("api")). Все пути ниже даны полностью, с префиксом.


ЭТАП 1. Создание терминала в Офисе

Администратор в веб-интерфейсе офиса создаёт POS-терминал.

POST /api/pos/terminals
{
"name": "Касса 1",
"code": "T-001",
"subdivisionId": "uuid-подразделения",
"groupName": "Основной зал", // опционально
"password": "mySecretPass123" // ОПЦИОНАЛЬНО (см. ниже)
}

Эндпоинты управления терминалами (pos/terminals + глобальный префикс):

Метод и путьНазначение
GET /api/pos/terminalsСписок терминалов корпорации
POST /api/pos/terminalsСоздать терминал
PATCH /api/pos/terminals/:idОбновить (name/code/groupName/subdivision)
POST /api/pos/terminals/:id/passwordЗадать/сменить пароль терминала
DELETE /api/pos/terminals/:idSoft-delete терминала

Схема PosTerminal (credentials — собственные, без связи с User):

model PosTerminal {
id String @id @default(uuid()) @db.Uuid
name String @db.VarChar(255)
code String @unique @db.VarChar(100)
groupName String? @map("group_name") @db.VarChar(255)
isDeleted Boolean @default(false) @map("is_deleted")
passwordHash String? @map("password_hash") @db.VarChar(255) // NULLABLE
version Int @default(1)
corporationId String @map("corporation_id") @db.Uuid
subdivisionId String @map("subdivision_id") @db.Uuid
// LOCALIKASSA-421: партиал-уникальный индекс «один активный hub на
// подразделение» УДАЛЁН — у точки может быть несколько активных касс.
// Остался обычный @@index([subdivisionId]) для лукапов.
@@index([subdivisionId])
@@map("pos_terminals")
}

Терминал — самодостаточная сущность, не пользователь системы. Его credentials — его собственные.

Что изменилось по сравнению с прежней моделью

  • Несколько касс на подразделение (LOCALIKASSA-421). Прежний инвариант «один hub на подразделение» снят: партиал-уникальный индекс pos_terminals_subdivision_active_uq удалён. Уникальность теперь ограничена глобальным @unique по code и проверками «name/code уникальны в рамках корпорации» в сервисе. Перенос терминала в «занятое» подразделение больше не конфликт.
  • Пароль опционален. passwordHashString?. При создании пароль можно не задавать — тогда терминал существует, но не сможет авторизоваться на шлюзе, пока пароль не зададут отдельной операцией POST /api/pos/terminals/:id/password. Ограничения пароля: min 6, max 72 (лимит bcrypt — хеширует только первые 72 байта). Хешируется bcrypt-ом вне транзакции (CPU-bound).
  • passwordHash не светится в публичном payload. В outbox он передаётся отдельным каналом sensitiveFields, а в API-ответах/публичном DTO отсутствует (см. ЭТАП 2).

Связь с кассовой сменой (LOCALIKASSA-421)

Владелец потока смен — POS-терминал, не подразделение:

  • @@unique([posTerminalId, shiftNumber]) — нумерация смен per-terminal.
  • partial unique (pos_terminal_id) WHERE status='OPEN' — ровно одна открытая смена на терминал (декларативно в Prisma не выражается, описано SQL-ом в миграции 20260616_LOCALIKASSA_421_*).
  • FK на pos_terminals и subdivisionsonDelete: Restrict (обе сущности только soft-удаляются; hard-delete не должен уничтожать историю смен).
  • subdivisionId на CashShift остаётся денормализованным snapshot-ом (разрез журнала/отчётов по подразделению), но не является владельцем потока смен.

Удалить терминал (DELETE) можно только при отсутствии открытой смены именно этого терминала; открытые смены соседних касс точки удалению не мешают.

Транзакция создания

PosTerminalService.create пишет в outbox напрямую в транзакции (не через EventListener). Отдельного доменного события pos.terminal-created больше нет.

ТРАНЗАКЦИЯ {
ШАГ 1.1 — Cross-tenant guard
┌────────────────────────────────────────────────┐
│ ACL: subdivision принадлежит корпорации текущего │
│ пользователя (subdivision → legalEntity → │
│ corporation), иначе 404. Проверяется ДО любых │
│ проверок существования — иначе 409/201 на чужом │
│ subdivisionId служил бы side-channel'ом. │
└────────────────────────────────────────────────┘
ШАГ 1.2 — Уникальность name/code в корпорации
┌────────────────────────────────────────────────┐
│ findByNameAndCorporationId / ...Code → 400, если │
│ занято. (Инвариант «один hub» НЕ проверяется.) │
└────────────────────────────────────────────────┘
ШАГ 1.3 — INSERT pos_terminals
┌────────────────────────────────────────────────┐
│ password_hash = bcrypt(пароль) | NULL │
│ version = 1 │
└────────────────────────────────────────────────┘
ШАГ 1.4 — outbox.record(...)
┌────────────────────────────────────────────────┐
│ entityType: pos_terminal, action: created │
│ version: 1 │
│ payload: { id, name, code, groupName, │
│ subdivisionId, corporationId, │
│ isDeleted } // БЕЗ passwordHash │
│ sensitiveFields: { passwordHash } | undefined │
│ subdivisionIds: [subdivisionId] │
└────────────────────────────────────────────────┘
} // КОНЕЦ ТРАНЗАКЦИИ

Update и change-password устроены симметрично (action: updated), delete — action: deleted с isDeleted: true.


ЭТАП 2. Доставка в Шлюз через Kafka

OutboxPublisherScheduler сериализует запись в CloudEvent и публикует в топик office.events. Тип события собирается по конвенции core.company.office.<entity>.<action> (OFFICE_EVENT_TYPE_PREFIX = "core.company.office"; каталог типов — EVENT_TYPES из @locali/office-contracts).

Важно про sensitiveFields. Это разделение существует только на уровне API сервиса (чтобы passwordHash не попадал в публичные DTO/ответы). При записи в outbox адаптер сливает их обратно в payload:

const outboxPayload = input.sensitiveFields
? { ...input.payload, ...input.sensitiveFields }
: input.payload;

То есть на проводе passwordHash снова лежит внутри data.payload — шлюзу он нужен для авторизации.

Envelope события (data = { version, subdivision_ids, payload }):

{
"specversion": "1.0",
"type": "core.company.office.pos_terminal.created",
"source": "office-service",
"id": "uuid-события", // = outbox.id = CloudEvent.id (идемпотентный ключ)
"subject": "uuid-терминала", // entityId
"data": {
"version": 1,
"subdivision_ids": ["uuid-подразделения"], // адресация доставки на терминалы
"payload": {
"id": "uuid-терминала",
"code": "T-001",
"name": "Касса 1",
"groupName": "Основной зал",
"subdivisionId": "uuid-подразделения",
"corporationId": "uuid-корпорации",
"isDeleted": false,
"passwordHash": "bcrypt-хеш" // влит из sensitiveFields
}
}
}

subdivision_idsсписок затронутых подразделений для адресной доставки, а не snapshot бизнес-привязок. Корректность fan-out (объединение «было ∪ стало», раскрытие «доступно везде») обеспечивает офис; шлюз пишет массив как есть.


ЭТАП 3. Шлюз обрабатывает событие

Шлюз — Kafka-consumer. Приём двухфазный (InboundEventService):

ШАГ 3.1 — receive(): идемпотентный INSERT в inbound_events
┌────────────────────────────────────────────────────┐
│ kafka_event_id UNIQUE отсекает дубль до вставки. │
│ syncToPos берётся из реестра обработчиков │
│ (для pos_terminal = false: POS не нужны данные │
│ терминала — он сам и есть терминал). │
│ status: RECEIVED, revision: автоинкремент │
└────────────────────────────────────────────────────┘
ШАГ 3.2 — processOne(): валидация контракта + маршрутизация
┌────────────────────────────────────────────────────┐
│ 1. validateContractPayload (zod установленной версии │
│ @locali/office-contracts). Не прошёл → SKIPPED. │
│ 2. Нет обработчика, но тип ЕСТЬ в каталоге → SKIPPED.│
│ Тип новее пакета → deferRetry (ждём деплой кассы).│
│ 3. claimForProcessing (атомарно, против гонки │
│ consumer ↔ retry-scheduler). │
└────────────────────────────────────────────────────┘
ШАГ 3.3 — handler.handle(): version-guarded upsert
┌────────────────────────────────────────────────────┐
│ PosTerminalHandler (eventTypes: created, updated): │
│ if (current.version >= version) → SKIPPED │
│ else upsert pos_terminals (code, name, passwordHash, │
│ isActive, isDeleted, corporation, subdivision) │
│ markAsApplied → перевыдаёт revision │
└────────────────────────────────────────────────────┘

Реакции PosTerminalHandler на изменения:

  • Смена пароля (passwordHash в payload отличается от сохранённого) → отправка control-команды REAUTH_REQUIRED подключённому терминалу (см. ЭТАП 15).

Расхождение (на момент написания). PosTerminalHandler подписан только на created/updated. Событие pos_terminal.deleted есть в каталоге контрактов, но обработчика для него нет → processOne помечает его терминальным SKIPPED. Деактивация терминала через soft-delete в офисе на шлюз/POS сейчас не доезжает — см. ЭТАП 16.


ЭТАП 4. Физический терминал включается первый раз

Устройство кассы (десктоп/планшет) с пустой SQLite. Экран привязки/входа терминала:

┌─────────────────────────────────────┐
│ ┌───────────────────────┐ │
│ │ Код терминала: T-001 │ │
│ └───────────────────────┘ │
│ ┌───────────────────────┐ │
│ │ Пароль: •••••••••• │ │
│ └───────────────────────┘ │
│ ┌───────────────────────┐ │
│ │ ВОЙТИ │ │
│ └───────────────────────┘ │
└─────────────────────────────────────┘

Оператор вводит code и password терминала + адрес сервера (baseUrl шлюза).


ЭТАП 5. Авторизация: POS → Шлюз

POS Шлюз
│ POST /api/auth/terminal │
│ { "code": "T-001", "password": "mySecret..." } │
│ ─────────────────────────────────────────────► │
│ │
│ ШАГ 5.1 — findByCode │
│ SELECT * FROM pos_terminals│
│ WHERE code = 'T-001' │
│ AND is_active = true │
│ AND is_deleted = false │
│ │
│ ШАГ 5.2 — проверка пароля │
│ • терминал не найден → │
│ bcrypt.compare с dummy- │
│ хешем (анти-timing) → 401│
│ • passwordHash == null → │
│ 401 (пароль не задан) │
│ • bcrypt.compare → false → │
│ 401 │
│ │
│ ШАГ 5.3 — выпуск JWT │
│ payload = { │
│ terminalId, │
│ subdivisionIds: [...], │ ◄── МАССИВ
│ corporationId │
│ } │
│ ◄──────────────────────────────────────────── │
│ { │
│ "accessToken": "eyJhbG...", │
│ "terminalId": "uuid", │
│ "subdivisionIds": ["uuid-подразделения"], │
│ "corporationId": "uuid" │
│ } │

Отличия от прежней схемы:

  • JWT-payload — { terminalId, subdivisionIds: string[], corporationId }. Поля sub/type нет; subdivisionId стал массивом subdivisionIds (фильтрация синка идёт по нему через hasSome).
  • Терминал без пароля (passwordHash == null) получает 401 — авторизоваться нельзя, пока пароль не задан.
  • Хеширование на шлюзе — bcryptjs; анти-timing через bcrypt.compare с фиктивным хешем при отсутствии терминала.

POS сохраняет accessToken (в безопасном хранилище — safeStorage), terminalId, subdivisionIds, corporationId, а также baseUrl шлюза.


ЭТАП 6. Локальная схема SQLite — строится на POS из реестра

Важно: эндпоинта GET /api/schema у шлюза нет. Прежняя модель «шлюз отдаёт DDL + маппинг event→SQL, POS их исполняет» больше не действует.

Схема SQLite и маппинг event_type → SQL собираются на самом POS из декларативного реестра сущностей (electron/modules/sync/registry.ts + schema-builder.ts). Источник истины по именам/типам колонок — payload-интерфейсы из @locali/office-contracts.

defineEntity({ table, columns, upsert, indexes, items, ... })
├── ENTITY_DDL — CREATE TABLE IF NOT EXISTS ... + индексы (идемпотентно)
├── EVENT_SQL — event_type → SQL-шаблон (db.prepare(sql).run(payload))
└── ITEMS_SQL — вложенные массивы (например, ингредиенты техкарты)

Стратегии upsert на POS:

  • VERSION_GUARDED... WHERE version < :version (защита от out-of-order; version берётся из конверта CloudEvent). Большинство справочников.
  • INSERT_OR_REPLACE — для снапшот-сущностей без полезной транспортной версии (corporation, tech_card).
  • soft-delete / cascade / customEvents (например, employee_position.unassigned — точечный version-guarded UPDATE).

Дрейф-чекер при загрузке модуля сверяет каждый сгенерированный event_type с каталогом EVENT_TYPES контракта — если офис переименовал событие, касса падает на старте, а не молча теряет события.

Реплицируемые на POS таблицы (на момент написания): corporations, employees, positions, employee_positions, subdivisions, sales_points, payment_methods, nomenclature_groups, pos_terminals, nomenclatures, modifiers, modifier_groups, tech_cards (+ tech_card_ingredients), warehouse_stocks, deposit_withdrawal_types, preparation_places, counterparties, stop_list_items.

Обновление схемы происходит вместе с релизом POS-приложения (DDL идемпотентен через IF NOT EXISTS), а не запросом к шлюзу — см. ЭТАП 14.


ЭТАП 7. Первичная синхронизация: POS → Шлюз

Синк-эндпоинт — GET /api/inbox (прежний /api/sync переименован).

POS Шлюз
│ GET /api/inbox │
│ ?last_synced_revision=0 │
│ Authorization: Bearer <jwt> │
│ X-POS-Version: <версия> │
│ ─────────────────────────────►│
│ │ InboxQueryService.getForPos:
│ │ SELECT FROM inbound_events
│ │ WHERE revision > :cursor
│ │ AND status = 'APPLIED'
│ │ AND sync_to_pos = true
│ │ AND subdivision_ids ∩ JWT.subdivisionIds
│ │ ORDER BY revision ASC LIMIT 100
│ │ (+ watermark gap-guard, см. ниже)
│ ◄───────────────────────────── │
│ { │
│ "events": [ │
│ { │
│ "event_type": "core.company.office.employee.created",
│ "entity_id": "uuid-сотр",
│ "entity_type":"employee",
│ "action": "created", // = последний сегмент event_type
│ "payload": { ..., "version": 1 }, // version подмешан в payload
│ "revision": 1
│ }
│ // ...
│ ],
│ "last_revision": 100,
│ "has_more": true
│ }

POS применяет батч (applyEvents) и двигает курсор: last_synced_revision = max(maxFromEvents, last_revision). Цикл catchUp повторяет запрос, пока has_more = true.

Применение каждого события (applyEvent):

  1. event_type приходит готовым → берётся EVENT_SQL[event_type] (если тип неизвестен — событие игнорируется).
  2. entity_id из конверта кладётся в payload.id (:id в SQL); sensitiveFields.* (например, pinHash) расплющиваются на верхний уровень; вложенные объекты (image, baseMeasurementUnit) — нормализуются.
  3. Выполняется в SQLite в транзакции; version-guard тихо отбрасывает устаревшие события (0 строк затронуто).
  4. Дедуп на стыке pull/push: события с revision <= cursor отбрасываются (revision строго монотонна для клиента).

Watermark / gap-guard (LOCALIKASSA-405, К4)

/api/inbox не отдаёт события с revision >= самого раннего молодого неприменённого «висяка» (RECEIVED/PROCESSING/FAILED моложе INBOX_WATERMARK_STALE_MS, по умолчанию 30 с). Это не даёт курсору терминала «перешагнуть» событие, которое получит revision при INSERT, но станет видимым (APPLIED) позже и иначе потерялось бы. На удержании выдаётся пустой батч, курсор не двигается — терминал переспросит с того же места.


ЭТАП 8. WebSocket — сигнальный канал

Сразу после конфигурации синка POS поднимает постоянный WebSocket (socket.io). Это сигнальный канал, а не канал доставки данных.

POS Шлюз
│ WS connect (socket.io, путь /ws) │
│ auth: { token: "Bearer <jwt>", posVersion } │
│ ─────────────────────────────────────────────►│
│ TerminalGateway: │
│ • валидирует JWT │
│ • извлекает terminalId, │
│ subdivisionIds, corp. │
│ • кладёт сокет в пул │
│ ◄──── emit "connected" { socketId, ts } ─────── │

События сокета на стороне POS (sync/socket.ts):

  • inboxтолько сигнал «у бэка есть новые события». Payload не применяется и даже не валидируется — данные и курсор берутся исключительно из ответов /api/inbox.
  • control{ command }; допускается только reauth_required (см. ЭТАП 15).
  • На io server disconnect (например, деплой бэка) встроенный reconnection socket.io молчит — POS делает ручной reconnect с backoff 10с → 120с.

Шлюз технически шлёт в inbox-пуше и полезную нагрузку ({ events, last_revision }), но POS её сознательно игнорирует (контракт К1, LOCALIKASSA-406): это устраняет потерю молча пропавшего пуша N, который иначе перескочил бы курсор навсегда.


ЭТАП 9. Терминал готов — вход сотрудника по PIN

┌─────────────────────────────────────┐
│ Касса 1 (T-001) │
│ Ресторан на Пушкина │
│ Введите PIN: ____ │
│ [ 1 2 3 / 4 5 6 / 7 8 9 / 0 ]│
└─────────────────────────────────────┘

PIN проверяется локально в SQLite (electron/modules/auth/verify-pin.ts), без сети:

SELECT * FROM employees WHERE pin = :pinHash AND is_deleted = 0

В SQLite лежат только сотрудники подразделений терминала (отфильтрованы при синке по subdivisionIds), поэтому дополнительный фильтр по подразделению не нужен.


ЭТАП 10. Рабочий режим — обновления в реальном времени

Админ провёл приказ: цена «Цезарь» 550 → 600.

ОФИС ШЛЮЗ POS
│ outbox → Kafka ────────►│ │
│ nomenclature.updated │ INSERT inbound_events │
│ data.version: 2 │ (revision=543, sync_to_pos=true)│
│ payload.price: 600 │ version-guarded upsert │
│ │ markAsApplied │
│ │ │
│ │ WS emit "inbox" ───────────────►│ (сигнал)
│ │ (адресно терминалам │
│ │ подразделения) │
│ │ │ POS дёргает
│ │◄──── GET /api/inbox?rev=542 ─────│ /api/inbox
│ │────── events:[rev 543] ────────►│ applyEvents →
│ │ │ price=600,
│ │ │ cursor=543

Курсор last_synced_revision двигается только по ответам /api/inbox. WS-сигнал лишь запускает коалесцирующий fetch-цикл.

Конечный автомат синхронизации POS (LOCALIKASSA-406, К1)

electron/modules/sync/state.ts — фазы:

ФазаЧто происходит
IDLEМашина не запущена.
CATCHING_UPДогон через /api/inbox; WS-сигнал помечает «по завершении спросить ещё раз».
LIVEWS-сигнал → fetch /api/inbox. Плюс редкий страховочный полинг (на случай тихой деградации сокета).
OFFLINEWS недоступен — REST-полинг с фиксированным интервалом.

Гарантия: событие применяется ровно один раз, параллельные циклы применения исключены (коалесцирование + дедуп по revision).


ЭТАП 11. Отправка операций: POS → Шлюз (offline-queue)

POS-операции (чеки, смены, эквайринг) уходят на шлюз через offline-queue (electron/modules/offline-queue) — устойчивую очередь в SQLite. Это касается и онлайна: запись сначала кладётся в очередь, воркер тут же её отправляет.

Строка очереди (pending_requests):

{ id, method, url, body, headers,
idempotency_key, // генерируется при enqueue
status: PENDING|SENDING|SENT|FAILED,
attempts, last_error, created_at }

Отправка (worker.ts):

  • Idempotency-Key — из строки очереди (повтор = тот же ключ = шлюз вернёт сохранённый результат).
  • Authorizationвсегда текущим терминальным токеном (а не снапшотом из строки: заявка могла пережить истечение TTL токена и обновление приложения). X-POS-Version — тоже текущая.
  • Воркер берёт батч (claimBatch) и шлёт по порядку. Политика ответов:
    • 2xxSENT.
    • 401 → не вердикт по заявке: остаётся в очереди, уйдёт после переавторизации (cap по offlineMaxAttempts).
    • 409 → часто транзиентный конфликт порядка доставки (например, фискализация обогнала эквайринг-операцию того же чека) → повтор (cap).
    • прочие 4xx → терминальный FAILED (сообщение бэка сохраняется в last_error и показывается кассиру).
    • сеть/5xx → ретрай до offlineMaxAttempts.
  • Критичные операции (закрытие смены) ставятся через enqueueAndAwait — ждут вердикт первой попытки и не показывают «оптимистичный успех». В офлайне заявка остаётся PENDING и уйдёт фоном.
ОНЛАЙН: enqueue → воркер сразу flush → POST /api/... (Idempotency-Key) → 2xx → SENT
ОФЛАЙН: enqueue → PENDING в SQLite → при появлении сети воркер отправляет
по порядку created_at с тем же Idempotency-Key

ЭТАП 12. Обрыв WebSocket → восстановление

POS ШЛЮЗ
│ ══ WS оборвался ══ │
│ FSM: LIVE → OFFLINE │
│ REST-полинг /api/inbox │
│ с фиксированным интервалом │
│ │ ... в офисе копятся изменения
│ │ (rev 544, 546, 548)
│ ── GET /api/inbox?rev=543 ─────►│
│ ◄── events[544,546,548] ─────── │
│ applyEvents, cursor=548 │
│ │
│ offline-queue фоном досылает │
│ накопленные POST (Idempotency) │
│ │
│ ══ WS восстановился ══ │
│ FSM: OFFLINE → CATCHING_UP → │
│ (догон) → LIVE │

При входе в LIVE также выполняется полная сверка общего стоп-листа (GET /api/stop-list-items, LOCALIKASSA-432) — закрывает окно, когда снятие позиции на другом терминале пришлось на разрыв связи.

Подтверждённый отзыв токена. Если фоновый синк получает 401 три раза подряд (UNAUTHORIZED_CONFIRM_THRESHOLD) без единого успешного ответа между ними — токен считается отозванным: синк останавливается, в renderer уходит sync.unauthorized → экран привязки. Один транзиентный 401 (блип при деплое) терминал не разлогинивает.


ЭТАП 13. Повторный запуск терминала

SQLite уже содержит данные. Главный процесс восстанавливает фоновый синк из сохранённого токена и baseUrl (restoreFromPersisted, токен — из safeStorage):

POS старт
│ loadTerminalToken() + getSetting(apiBaseUrl)
│ configureRest(token, baseUrl) + configureWs(token, wsUrl)
│ FSM: IDLE → CATCHING_UP
│ GET /api/inbox?last_synced_revision=<сохранённый> (догон пропущенного)
│ WS connect /ws → LIVE
│ ════ ГОТОВ ════ → экран ввода PIN

WebSocket-URL выводится из baseUrl: http(s) → ws(s), путь /ws. Токен жив до истечения TTL; при 401×3 — переавторизация (ЭТАП 12).


ЭТАП 14. Обновление схемы

Схема SQLite и маппинг событий — в коде POS (registry.ts), а не присылаются шлюзом. Обновление едет с релизом приложения (Electron-auto-updater, electron/modules/updater):

Новый релиз POS:
│ при старте выполняется ENTITY_DDL (CREATE TABLE IF NOT EXISTS ...,
│ а также при необходимости — миграционные ALTER в коде)
│ обновлённый EVENT_SQL начинает обрабатывать новые/изменённые события
│ дрейф-чекер сверяет event_type с @locali/office-contracts на старте

Согласованность контракта обеспечивается версией пакета @locali/office-contracts, на которую собран POS, и проверкой дрейфа на шлюзе (kassa_contract_schema_drift_total) и на POS (падение на старте при незнакомом event_type).


ЭТАП 15. Изменение пароля терминала

ОФИС ШЛЮЗ POS
│ POST /api/pos/terminals/:id/password │
│ ├─ UPDATE password_hash = bcrypt(новый), version++ │
│ └─ outbox.record(updated, sensitiveFields:{passwordHash}) │
│ outbox → Kafka ────────►│ │
│ │ PosTerminalHandler.upsert: │
│ │ passwordHash изменился → │
│ │ control "reauth_required" ──────►│ (если терминал
│ │ (sync_to_pos = false) │ подключён по WS)
│ │ │ POS: экран
│ │ │ «нужен повторный
│ │ │ вход»
│ │ │
│ │ при следующем POST /api/auth/terminal
│ │ старый пароль → 401

Команда REAUTH_REQUIREDединственная существующая control-команда (ControlCommand на шлюзе содержит только её; POS принимает только reauth_required).


ЭТАП 16. Деактивация терминала

ОФИС ШЛЮЗ
│ DELETE /api/pos/terminals/:id │
│ ├─ UPDATE is_deleted = true, version++ │
│ └─ outbox.record(action: deleted) │
│ outbox → Kafka ────────►│
│ │ event_type = core.company.office.pos_terminal.deleted
│ │ обработчика на 'deleted' НЕТ →
│ │ тип есть в каталоге контрактов →
│ │ markAsSkipped (терминальный SKIPPED)

Текущее поведение (расхождение с прежним дизайном). PosTerminalHandler подписан только на created/updated. Событие pos_terminal.deleted шлюз помечает SKIPPED и не применяет: запись pos_terminals на шлюзе не получает is_deleted = true, control-команды terminal_deactivated не существует, WS-соединение не закрывается. Практическое следствие: soft-удалённый в офисе терминал может продолжать авторизоваться на шлюзе по старому паролю.

Если требуется немедленная блокировка терминала — на текущей реализации это делается сменой/сбросом пароля (ЭТАП 15: REAUTH_REQUIRED + старый пароль → 401), а не удалением. Поддержку распространения деактивации (обработчик pos_terminal.deleted на шлюзе + закрытие WS) следует завести отдельной задачей.


Единый источник истины: /api/inbox

┌──────────────────────────────────────────────────────────┐
│ POS — единый потребитель синка │
│ │
│ Курсор last_synced_revision двигается ТОЛЬКО по ответам │
│ /api/inbox. Два «источника пробуждения»: │
│ │
│ • WebSocket "inbox" — СИГНАЛ «есть новые события» │
│ → requestCatchUp() → fetch /api/inbox │
│ │
│ • REST-полинг — в OFFLINE (WS недоступен) и как редкий │
│ страховочный опрос в LIVE │
│ │
│ applyEvents(): version-guard + дедуп по revision │
│ → один и тот же event применяется ровно один раз, │
│ даже если пришёл и сигналом, и опросом. │
└──────────────────────────────────────────────────────────┘

Это отличается от прежней модели «два транспорта (WS и /sync) несут один и тот же формат событий, POS применяет оба»: теперь данные несёт только /api/inbox, а WS — управляющий сигнал.


Сводная схема

ОФИС ШЛЮЗ POS
═════ ═════ ═════
1. POST /api/pos/terminals
(code + опц. password,
несколько касс на точку)
2. outbox.record(...)
payload без passwordHash,
sensitiveFields:{passwordHash}
→ на проводе влит в payload
3. Outbox → Kafka ──────► 4. InboundEventService.receive
office.events INSERT inbound_events
core.company.office. (revision, sync_to_pos=false)
pos_terminal.created PosTerminalHandler.upsert
(version-guarded)
│ 5. Включение POS
│ код + пароль + baseUrl
│◄─────────────────── 6. POST /api/auth/terminal
│ bcrypt → JWT { terminalId,
│ {terminalId, subdivisionIds[],
│ subdivisionIds[], corporationId }
│ corporationId} 7. локальная схема SQLite
│ строится из registry
│◄─────────────────── 8. GET /api/inbox?rev=0
│ inbound_events (sync_to_pos=true,
│ WHERE subdiv ∩ JWT ∩ subdivisionIds)
│───────────────────► applyEvents → SQLite
│◄─────────────────── 9. WS connect /ws
│───── "connected" ──► LIVE
10. PIN локально → касса
11. operations → offline-queue
POST + Idempotency-Key
Изменение ──► Kafka ──► upsert + revision++
данных WS "inbox" (сигнал) ──────────► requestCatchUp →
│◄──── GET /api/inbox ──── /api/inbox → SQLite
══ WS обрыв ══ → FSM OFFLINE → REST-полинг
│◄──── GET /api/inbox ──── догон
│◄──── offline-queue ───── досыл операций
│ (Idempotency-Key)
══ WS reconnect ══ → CATCHING_UP → LIVE

Соответствие коду

Офис (office/locali-office-backend-new@main):

  • src/pos/presentation/controllers/pos-terminal.controller.ts — роуты pos/terminals.
  • src/pos/application/services/pos-terminal.service.ts — create/update/changePassword/delete, outbox, cross-tenant guard, sensitiveFields.
  • src/pos/application/dto/{create-pos-terminal,change-pos-terminal-password}.dto.ts — опциональный пароль, min 6 / max 72.
  • prisma/schema/pos.prismaPosTerminal (nullable passwordHash, @@index([subdivisionId])), CashShift (владелец = терминал).
  • src/common/ports/outbox.port.ts, src/outbox/infrastructure/adapters/prisma-outbox.adapter.ts — envelope data={version,subdivision_ids,payload}, слияние sensitiveFields в payload.

Шлюз (kassa/locali-kassa-backend-new@main, коммит 2d1a45b4):

  • src/auth/controllers/auth.controller.ts, src/auth/services/auth.service.tsPOST /api/auth/terminal, JWT {terminalId, subdivisionIds[], corporationId}.
  • src/auth/infrastructure/repositories/prisma-terminal.repository.ts — фильтр is_active=true AND is_deleted=false.
  • src/inbox/presentation/inbox.controller.ts, src/inbox/application/services/inbox-query.service.tsGET /api/inbox, watermark gap-guard.
  • src/inbox/application/services/inbound-event.service.ts — приём CloudEvents, маршрутизация, SKIPPED для типов без обработчика.
  • src/inbox/application/handlers/pos-terminal.handler.ts — version-guarded upsert, REAUTH_REQUIRED, подписка только на created/updated.
  • src/inbox/infrastructure/transports/websocket/terminal.gateway.ts, src/common/ports/pos-control-command.ts — WS /ws, единственная команда REAUTH_REQUIRED.

POS (kassa/locali-kassa@main, коммит 4e8b07f7):

  • electron/modules/sync/registry.ts + schema-builder.ts — локальная схема SQLite + маппинг event_type → SQL.
  • electron/modules/sync/poll.tsGET /api/inbox, catchUp, подтверждённый 401×3.
  • electron/modules/sync/socket.ts — socket.io, inbox как сигнал, control.
  • electron/modules/sync/state.ts — FSM IDLE/CATCHING_UP/LIVE/OFFLINE, restoreFromPersisted.
  • electron/modules/sync/apply.ts — применение событий, version-guard, дедуп по revision.
  • electron/modules/offline-queue/{worker,repository,types}.tspending_requests, Idempotency-Key, enqueueAndAwait.
  • electron/modules/auth/verify-pin.ts — локальная проверка PIN.