POS Sale Flow — оркестрация продажи

Зачем этот документ

Fiscal Core Integration описывает что умеет ФЯ (контракты, поля, ошибки). Этот документ описывает как касса собирает продажу из этих кубиков: последовательность шагов, два варианта архитектуры эквайринга, failure modes, идемпотентность.

Общая последовательность

sequenceDiagram
    actor C as Кассир
    participant UI as erp-pos UI
    participant BFF as POS BFF
    participant FA as ФЯ (api012)
    participant ACQ as Эквайринг
    participant OFD as ОФД

    C->>UI: добавляет позиции в корзину
    C->>UI: «Оплатить»
    UI->>BFF: подтянуть прайс/налоги/итоги
    BFF-->>UI: cart с amount, vat, taxes
    C->>UI: выбирает способ оплаты (нал/карта)

    alt Безналичная оплата
        UI->>ACQ: запросить авторизацию
        ACQ-->>UI: успех + RRN/authCode
    end

    UI->>FA: POST receipt.json (externalId, items, sums)
    FA->>FA: фискализация в ФН
    FA-->>UI: result=0 + docNumber, fiscalCode, qr
    FA->>OFD: фоновая отправка (асинхронно)
    UI->>BFF: order.completed (с docNumber, fiscalCode)
    UI-->>C: «Чек напечатан»

Шаги цикла продажи

  1. Сбор корзины — кассир выбирает позиции, UI считает cost = price × quantity локально
  2. Расчёт чека — определяется СНО (tax), ставки НДС позиций (vatRate), paymentAttr=1 (приход)
  3. Выбор оплаты — нал / карта / комбинация. Сумма по типам должна точно совпадать с итогом (иначе коды 46/47)
  4. Авторизация эквайринга (только если card > 0) — см. варианты A и B ниже
  5. Генерация externalId — UUID v4 из мобайла, до обращения к ФЯ. Сохраняется локально вместе с состоянием заказа
  6. POST receipt.json — отправка чека в ФЯ, ожидание ответа
  7. Обработка ответаresult=0 ⇒ сохранить docNumber, fiscalCode, qr в локальной БД и отправить в наш бэкенд через POS BFF
  8. Печать / показ QR — ФЯ напечатает чек самостоятельно (если silent=0); UI показывает QR-код для покупателя

Два варианта архитектуры эквайринга

ФЯ официально допускает два сценария: касса оркестрирует эквайринг сама, либо доверяет ФЯ через объект iBank в чеке.

Вариант A — касса оркестрирует внешний эквайринг

Касса вызывает банковское приложение (INPAS Smart Sale, Сбер Pay-Лайт, Тинькофф Касса и т.п.) напрямую через Android Intent, получает результат, и только потом отправляет чек в ФЯ. ФЯ ничего не знает о платёжном терминале — для неё это «факт оплаты картой» в полях card + опционально cardPayments[].

sequenceDiagram
    participant UI as erp-pos
    participant ACQ as Bank APK<br/>(INPAS / Сбер)
    participant FA as ФЯ
    participant OFD as ОФД

    UI->>ACQ: Intent: PAY amount=455.00
    ACQ->>ACQ: NFC / чип / магнитка
    ACQ->>ACQ: связь с банком
    ACQ-->>UI: result OK + RRN, authCode
    UI->>FA: receipt.json {card:"455.00", cardPayments:[{amount, ids:RRN}]}
    FA->>FA: фискализация
    FA-->>UI: result=0 + fiscalCode
    FA->>OFD: async

Плюсы:

  • Работает на любом ФЯ-устройстве (не только «класс с банковским терминалом»)
  • Можно подключать разные банковские приложения через один Intent-протокол
  • Касса видит детальный статус авторизации и сама принимает решения
  • RRN / authCode попадают в чек как тег ФФД 1234 (cardPayments[]) — официально и аудируемо

Минусы:

  • Касса несёт ответственность за синхронизацию между «деньги списаны» и «чек выбит» (см. failure mode A1 ниже)
  • Нужна интеграция с каждым банковским приложением отдельно (SDK/Intent-протоколы у всех разные)
  • Окно «оплачено, но не фискализировано» физически возможно

Вариант B — ФЯ оркестрирует через iBank.usePosFeature

Касса передаёт в receipt.json объект iBank с usePosFeature: true. ФЯ сама запускает банковское приложение, ждёт результат, и только при успехе фискализирует чек. После этого запускает приложение из bankClientApp (нашу кассу) для возврата фокуса.

sequenceDiagram
    participant UI as erp-pos
    participant FA as ФЯ
    participant ACQ as Bank APK
    participant OFD as ОФД

    UI->>FA: receipt.json {card:"455.00", iBank:{usePosFeature:true, bankClientApp:"ru.erp.pos"}}
    FA->>ACQ: запуск платежа
    ACQ-->>FA: OK / Cancel / Error
    alt оплата успешна
        FA->>FA: фискализация
        FA-->>UI: result=0 + fiscalCode (через bankClientApp deeplink)
        FA->>OFD: async
    else оплата отклонена
        FA-->>UI: result≠0, чек не выбит
    end

Плюсы:

  • Атомарность «оплата + фискализация» гарантирована ФЯ
  • Failure mode A1 (списали, но не фискализировали) исключён
  • При закрытии смены cycleclose.json с doBankSettlement: true ФЯ автоматически делает банковскую сверку

Минусы:

  • В PDF явная оговорка: «Объект только для андроидных касс с банковским терминалом» — то есть это класс оборудования, не общая фича. Применимо ли к Unitodi K10-F — пока неизвестно, надо проверить эмпирически
  • В strings APK Unitodi K10-F строки iBank, usePosFeature, bankClientApp не найдены — косвенный признак, что в этом конкретном билде iBank не реализован
  • Нет списка bankClientApp — какие банковские приложения поддерживаются, не задокументировано
  • Контроль UX отдан ФЯ: касса не знает, на каком экране сейчас покупатель (вставляет карту? вводит ПИН?)

Сравнение A vs B

КритерийA — внешняя оркестрацияB — iBank.usePosFeature
Атомарность оплаты+фискализации❌ касса должна сшивать сама✅ гарантирует ФЯ
Применимо к Unitodi K10-F✅ работает всегда❓ требует проверки
UX контроль✅ полностью у кассы❌ передаётся в ФЯ
Поддержка нескольких эквайеров✅ Intent-абстракция❌ только то что зашито в ФЯ
Сложность интеграции🟡 надо знать Intent-API каждого банковского APK🟢 один объект в receipt.json
Failure mode «списали, не выбили чек»❗ возможен — нужны recovery✅ исключён архитектурно

Решение по умолчанию

Идём по варианту A. Причины:

  1. Гарантированно работает на K10
  2. Не зависит от того, реализован ли iBank в конкретном билде ФЯ
  3. Универсально для будущих устройств, где iBank может вообще отсутствовать

Если проверка на K10 покажет, что iBank.usePosFeature работает — переключаемся на B как на более надёжный вариант. Контракт для бэкенда не меняется (поле card и cardPayments[] в чеке те же).

Failure modes

A1. Оплата прошла, фискализация упала (только Variant A)

Сценарий: банковское приложение вернуло OK, деньги списаны, но POST receipt.json упал с тайм-аутом / ошибкой ФН / разрядился аккумулятор.

Что делать:

  1. externalId уже был сгенерирован до запроса — ретраим receipt.json с тем же externalId
  2. Ретрай вернёт result=64 (документ существует) — забираем готовый чек через findfsdoc.json и дальше идём как при успехе
  3. Если ФЯ упала и не зафиксировала запрос — ретрай выбьет чек заново; деньги уже списаны → чек фискализируется → консистентность восстановлена
  4. Если ретрай падает повторно (например, ФН исчерпан, код 1298) — escalation: чек на UI кассира «ОПЛАЧЕНО, ФИСКАЛИЗАЦИЯ НЕ ВЫПОЛНЕНА», RRN/сумму на экран, кассир обязан выбить чек коррекции (correction.json) после восстановления

Как НЕ делать:

  • ❌ Возвращать оплату через банк автоматически без участия кассира — может привести к двойному списанию (банк уже завершил транзакцию, возврат — отдельная операция)
  • ❌ Игнорировать сбой и не сохранять состояние локально

A2. Печать чека упала (оба варианта)

Сценарий: result=0, чек фискализирован, но принтер вернул ошибку (нет бумаги, перегрев, code 65/975/1000-1011).

Что делать:

  1. Чек уже в ФН — транзакция завершена, в ОФД уйдёт сама
  2. Показать на UI: «Чек выбит, печать не выполнена»
  3. Кассир устраняет проблему (вставляет ленту) → жмёт «Допечатать»
  4. UI вызывает GET printnotprinted.json — ФЯ допечатает последний документ

A3. Платёж завис (оба варианта)

Сценарий: банковское приложение запущено, покупатель долго думает или связь с банком тормозит. UI кассира залочен.

Что делать:

  1. Кассирский timeout на стороне кассы — например, 90 секунд после старта эквайринга
  2. По таймауту: НЕ закрывать платёжный экран принудительно, а показать «Идёт оплата, дождитесь результата или нажмите ОТМЕНА»
  3. Кассир может прервать через UI банковского приложения (обычно есть кнопка «Отмена»)
  4. После возврата управления:
    • Если статус = успех → продолжаем как обычно
    • Если статус = отмена → не отправляем receipt.json, заказ остаётся в кассе как «оплата отменена»

A4. ОФД отстаёт (оба варианта)

Сценарий: notSendedCount > 0 или messagesCount > 0 в cashboxstatus.json.

Что делать:

  1. Это не блокирует работу кассы — ФЯ продолжает фискализировать
  2. На UI кассира — баннер «Не отправлено в ОФД: N» (некритично, информативно)
  3. Если notSendedCount > 100 или прошло > 24 часов с firstNotSendedDocDt — баннер становится жёлтым
  4. Если прошло > 30 дней — блокирующая ошибка ФНС, UI должен запретить новые продажи (это уже подкапотный риск штрафа)

A5. Смена закрылась 24h назад (оба варианта)

Сценарий: кассир пытается выбить чек, но смена открыта > 24 часов (коды 26 / 1302).

Что делать:

  1. Заблокировать UI продажи
  2. Показать: «Смена превысила 24 часа, требуется Z-отчёт»
  3. Кассир жмёт «Закрыть смену» → cycleclose.json → новая cycleopen.json → возврат к продаже

Идемпотентность и externalId

externalId = uuid_v4()  // генерится локально на устройстве, ДО первого запроса

Жизненный цикл:

  1. Пользователь жмёт «Оплатить» → касса генерирует externalId, сохраняет в локальное состояние заказа (orders.externalId)
  2. Все последующие ретраи receipt.json для этого заказа используют тот же externalId
  3. Ответ result=64 ⇒ берём документ через findfsdoc.json или из тела ответа (ФЯ может вернуть ранее зафиксированный документ сразу)
  4. После успешной обработки externalId остаётся в локальной БД минимум 7 дней (для расследования жалоб)

Что НЕ делать:

  • ❌ Регенерировать externalId на ретрае — теряется идемпотентность, можно выбить чек дважды
  • ❌ Использовать id заказа из бэкенда как externalId — у бэкенда может быть свой счётчик / коллизии между торговыми точками. UUID v4 проще
  • ❌ Сохранять только в RAM — после убийства процесса Android при low memory externalId теряется и ретрай создаст новый чек

Маппинг кодов ошибок ФЯ в действия UI кассы

resultUI-действиеБлокирует?
0Успех — переход к печати/QR
4Toast «Ошибка авторизации в ФЯ», лог в Sentryда
14Toast «Нет прав» — нужен ремкомплектда
23Полноэкран «ККТ не зарегистрирована, обратитесь к франчайзи»да
24Игнор (cycleopen повтор — смена уже открыта)нет
25Полноэкран «Откройте смену» с кнопкой cycleopenда
26, 1302Полноэкран «Смена > 24h, нужен Z-отчёт»да
28Toast «Сумма картой больше итога» — пересчитатьда
29Toast «Сумма оплаты меньше итога»да
32Жёлтый баннер «Не отправлено в ОФД: N»нет
45Sentry + Toast «Нет позиций» (баг логики кассы)да
46, 47Toast «Не сходятся суммы»да
64Идемпотентный успех — забрать готовый документ
65Auto-call printnotprinted.json → если опять упал → показать «нет бумаги»условно
975, 1000-1011, 1005Полноэкран «Принтер: »условно (зависит от типа)
1297Полноэкран «ФН не отвечает» — техподдержкада
1298Полноэкран «ФН исчерпан» — критично, замена ФНда
33554432-33555432HTTP-сбой, лог в Sentry, ретрайусловно

Кто заполняет какие поля чека

ПолеИсточникКогда
cashiererp-pos (имя из PIN-логина)до запроса
cashierInnerp-pos (если есть) или пустодо запроса
addressPOS BFF из Store Serviceпри загрузке смены
placePOS BFF из Store Serviceпри загрузке смены
tax (СНО)POS BFF из Store Service / Legal Entityпри загрузке смены
paymentAttrerp-pos = 1 (приход) или 2 (возврат)по типу операции
operations[] / labledOperations[]erp-pos из корзиныдо запроса
operations[].nameCatalog Service → erp-posпри добавлении в корзину
operations[].pricePrice List → erp-posпри добавлении в корзину
operations[].quantityerp-posпользователь
operations[].vatRateCatalog Service → erp-posпри добавлении в корзину
operations[].type, paymentTypeCatalog Service → erp-pos (по умолчанию type=1, paymentType=4)при добавлении в корзину
cash / card / prepay / postpayerp-pos из платёжной формыпользователь
cardPayments[]erp-pos из ответа эквайринга (Variant A) или ФЯ сама (Variant B)после авторизации
iBank (опц.)erp-pos (только для Variant B)до запроса
externalIderp-pos UUID v4при «Оплатить»
docNumber, fsNumber, fiscalCode, qr, dt, cycle, receiptФЯв ответе
vat20, vat10 (расчётный НДС)ФЯв ответе
ftsSiteФЯв ответе

После получения ответа касса отправляет в POS BFF: externalId, docNumber, fsNumber, fiscalCode, qr, dt + полную корзину для агрегации в Order Service (в скоупе MVP сервиса нет, временно складываем в Store Service).

Чек-лист эмпирической проверки на K10

Подготовка:

  • Установлен pbfk10_012_0.0.100.apk на K10
  • Касса зарегистрирована в ФНС (через UI ФЯ)
  • Смена открыта (cycleopen через UI ФЯ или curl)
  • В ФЯ создан сервисный кассир pos-service через POST savecashier.json

Транспорт:

  • curl http://<k10-ip>:8088/api012/v1/cashboxstatus.json -u 'pos-service:<pwd>' → 200 + JSON с cycleIsOpen
  • Тот же запрос без Basic Auth → 401
  • Порт 8088 работает (если нет — попробовать 8080, 80, обнаружить через UDP discovery)
  • Регистр externalid vs externalId в query-параметре — какое имя реально работает

Базовый чек (Variant A):

  • POST receipt.json минимальный (1 позиция, оплата налом, без iBank) → result=0
  • Принтер напечатал бумажный чек
  • В ответе пришёл qr, fiscalCode, docNumber
  • findfsdoc.json?fd=<docNumber> возвращает тот же документ
  • Через 30 сек документ улетел в ОФД (messagesCount = 0)

Идемпотентность:

  • Отправить тот же externalId повторно → result=64 + готовый документ
  • Отправить новый externalId с теми же позициями → выбивается новый чек

Эквайринг — Variant B:

  • Установлено хотя бы одно банковское приложение, которое умеет работать с iBank API (INPAS / Сбер)
  • POST receipt.json с card="100.00", iBank: {usePosFeature: true, bankClientApp: "host.exp.exponent"}
  • Если запустился платёжный экран — Variant B доступен, дальше понимаем какие bankClientApp поддерживаются
  • Если ничего не произошло или вернулся result≠0 — Variant B недоступен, идём только Variant A

Эквайринг — Variant A (если выбрали этот путь):

  • Из erp-pos через Android Intent запустить INPAS Smart Sale (или другой) → провести оплату → получить RRN
  • POST receipt.json с card="100.00", cardPayments: [{amount, wayType:1, ids:"<RRN>"}], без iBank
  • Чек выбит, в ОФД ушёл, на чеке указан безналичный расчёт

Failure modes:

  • Симулировать обрыв питания между «эквайринг OK» и receipt.json → ретрай по externalId → чек выбит без двойного списания
  • Закрыть лоток для бумаги → выбить чек → result=0 или 65printnotprinted.json → допечать после установки бумаги
  • Принудительно открыть смену > 24h → result=26 или 1302 → проверить логику UI кассы
  • Выключить интернет → выбить 5 чеков → проверить рост notSendedCount → включить интернет → дождаться обнуления

Закрытие смены:

  • cycleclose.json (POST с doBankSettlement: false для Variant A, true для Variant B)
  • Проверить полные cycleCounters в ответе
  • X-отчёт (xreport.json) перед закрытием
  • Допечать Z-отчёт

Связанное