Полный путь авторизации и работы POS-терминала
Актуализировано по коду на 2026-06-20. Источники истины:
- Офис —
office/locali-office-backend-new@main(модульsrc/pos, outbox).- Шлюз кассы —
kassa/locali-kassa-backend-new@main(src/auth,src/inbox).- POS —
kassa/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/:id | Soft-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уникальны в рамках корпорации» в сервисе. Перенос терминала в «занятое» подразделение больше не конфликт. - Пароль опционален.
passwordHash—String?. При создании пароль можно не задавать — тогда терминал существует, но не сможет авторизоваться на шлюзе, пока пароль не зададут отдельной операцией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иsubdivisions—onDelete: 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):
event_typeприходит готовым → берётсяEVENT_SQL[event_type](если тип неизвестен — событие игнорируется).entity_idиз конверта кладётся вpayload.id(:idв SQL);sensitiveFields.*(например,pinHash) расплющиваются на верхний уровень; вложенные объекты (image,baseMeasurementUnit) — нормализуются.- Выполняется в SQLite в транзакции;
version-guard тихо отбрасывает устаревшие события (0 строк затронуто). - Дедуп на стыке 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 с backoff10с → 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-сигнал помечает «по завершении спросить ещё раз». |
LIVE | WS-сигнал → fetch /api/inbox. Плюс редкий страховочный полинг (на случай тихой деградации сокета). |
OFFLINE | WS недоступен — 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) и шлёт по порядку. Политика ответов:2xx→SENT.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 │ ════ ГОТОВ ════ → экран ввода PINWebSocket-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.prisma—PosTerminal(nullablepasswordHash,@@index([subdivisionId])),CashShift(владелец = терминал).src/common/ports/outbox.port.ts,src/outbox/infrastructure/adapters/prisma-outbox.adapter.ts— envelopedata={version,subdivision_ids,payload}, слияниеsensitiveFieldsв payload.
Шлюз (kassa/locali-kassa-backend-new@main, коммит 2d1a45b4):
src/auth/controllers/auth.controller.ts,src/auth/services/auth.service.ts—POST /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.ts—GET /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.ts—GET /api/inbox,catchUp, подтверждённый401×3.electron/modules/sync/socket.ts— socket.io,inboxкак сигнал,control.electron/modules/sync/state.ts— FSMIDLE/CATCHING_UP/LIVE/OFFLINE,restoreFromPersisted.electron/modules/sync/apply.ts— применение событий, version-guard, дедуп поrevision.electron/modules/offline-queue/{worker,repository,types}.ts—pending_requests,Idempotency-Key,enqueueAndAwait.electron/modules/auth/verify-pin.ts— локальная проверка PIN.