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: «Чек напечатан»
Шаги цикла продажи
- Сбор корзины — кассир выбирает позиции, UI считает
cost = price × quantityлокально - Расчёт чека — определяется СНО (
tax), ставки НДС позиций (vatRate),paymentAttr=1(приход) - Выбор оплаты — нал / карта / комбинация. Сумма по типам должна точно совпадать с итогом (иначе коды 46/47)
- Авторизация эквайринга (только если
card > 0) — см. варианты A и B ниже - Генерация
externalId— UUID v4 из мобайла, до обращения к ФЯ. Сохраняется локально вместе с состоянием заказа POST receipt.json— отправка чека в ФЯ, ожидание ответа- Обработка ответа —
result=0⇒ сохранитьdocNumber,fiscalCode,qrв локальной БД и отправить в наш бэкенд через POS BFF - Печать / показ 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. Причины:
- Гарантированно работает на K10
- Не зависит от того, реализован ли iBank в конкретном билде ФЯ
- Универсально для будущих устройств, где iBank может вообще отсутствовать
Если проверка на K10 покажет, что iBank.usePosFeature работает — переключаемся на B как на более надёжный вариант. Контракт для бэкенда не меняется (поле card и cardPayments[] в чеке те же).
Failure modes
A1. Оплата прошла, фискализация упала (только Variant A)
Сценарий: банковское приложение вернуло OK, деньги списаны, но POST receipt.json упал с тайм-аутом / ошибкой ФН / разрядился аккумулятор.
Что делать:
externalIdуже был сгенерирован до запроса — ретраимreceipt.jsonс тем жеexternalId- Ретрай вернёт
result=64(документ существует) — забираем готовый чек черезfindfsdoc.jsonи дальше идём как при успехе - Если ФЯ упала и не зафиксировала запрос — ретрай выбьет чек заново; деньги уже списаны → чек фискализируется → консистентность восстановлена
- Если ретрай падает повторно (например, ФН исчерпан, код 1298) — escalation: чек на UI кассира «ОПЛАЧЕНО, ФИСКАЛИЗАЦИЯ НЕ ВЫПОЛНЕНА», RRN/сумму на экран, кассир обязан выбить чек коррекции (
correction.json) после восстановления
Как НЕ делать:
- ❌ Возвращать оплату через банк автоматически без участия кассира — может привести к двойному списанию (банк уже завершил транзакцию, возврат — отдельная операция)
- ❌ Игнорировать сбой и не сохранять состояние локально
A2. Печать чека упала (оба варианта)
Сценарий: result=0, чек фискализирован, но принтер вернул ошибку (нет бумаги, перегрев, code 65/975/1000-1011).
Что делать:
- Чек уже в ФН — транзакция завершена, в ОФД уйдёт сама
- Показать на UI: «Чек выбит, печать не выполнена»
- Кассир устраняет проблему (вставляет ленту) → жмёт «Допечатать»
- UI вызывает
GET printnotprinted.json— ФЯ допечатает последний документ
A3. Платёж завис (оба варианта)
Сценарий: банковское приложение запущено, покупатель долго думает или связь с банком тормозит. UI кассира залочен.
Что делать:
- Кассирский timeout на стороне кассы — например, 90 секунд после старта эквайринга
- По таймауту: НЕ закрывать платёжный экран принудительно, а показать «Идёт оплата, дождитесь результата или нажмите ОТМЕНА»
- Кассир может прервать через UI банковского приложения (обычно есть кнопка «Отмена»)
- После возврата управления:
- Если статус = успех → продолжаем как обычно
- Если статус = отмена → не отправляем receipt.json, заказ остаётся в кассе как «оплата отменена»
A4. ОФД отстаёт (оба варианта)
Сценарий: notSendedCount > 0 или messagesCount > 0 в cashboxstatus.json.
Что делать:
- Это не блокирует работу кассы — ФЯ продолжает фискализировать
- На UI кассира — баннер «Не отправлено в ОФД: N» (некритично, информативно)
- Если
notSendedCount > 100или прошло > 24 часов сfirstNotSendedDocDt— баннер становится жёлтым - Если прошло > 30 дней — блокирующая ошибка ФНС, UI должен запретить новые продажи (это уже подкапотный риск штрафа)
A5. Смена закрылась 24h назад (оба варианта)
Сценарий: кассир пытается выбить чек, но смена открыта > 24 часов (коды 26 / 1302).
Что делать:
- Заблокировать UI продажи
- Показать: «Смена превысила 24 часа, требуется Z-отчёт»
- Кассир жмёт «Закрыть смену» →
cycleclose.json→ новаяcycleopen.json→ возврат к продаже
Идемпотентность и externalId
externalId = uuid_v4() // генерится локально на устройстве, ДО первого запроса
Жизненный цикл:
- Пользователь жмёт «Оплатить» → касса генерирует
externalId, сохраняет в локальное состояние заказа (orders.externalId) - Все последующие ретраи
receipt.jsonдля этого заказа используют тот жеexternalId - Ответ
result=64⇒ берём документ черезfindfsdoc.jsonили из тела ответа (ФЯ может вернуть ранее зафиксированный документ сразу) - После успешной обработки
externalIdостаётся в локальной БД минимум 7 дней (для расследования жалоб)
Что НЕ делать:
- ❌ Регенерировать
externalIdна ретрае — теряется идемпотентность, можно выбить чек дважды - ❌ Использовать
idзаказа из бэкенда какexternalId— у бэкенда может быть свой счётчик / коллизии между торговыми точками. UUID v4 проще - ❌ Сохранять только в RAM — после убийства процесса Android при low memory
externalIdтеряется и ретрай создаст новый чек
Маппинг кодов ошибок ФЯ в действия UI кассы
result | UI-действие | Блокирует? |
|---|---|---|
0 | Успех — переход к печати/QR | — |
4 | Toast «Ошибка авторизации в ФЯ», лог в Sentry | да |
14 | Toast «Нет прав» — нужен ремкомплект | да |
23 | Полноэкран «ККТ не зарегистрирована, обратитесь к франчайзи» | да |
24 | Игнор (cycleopen повтор — смена уже открыта) | нет |
25 | Полноэкран «Откройте смену» с кнопкой cycleopen | да |
26, 1302 | Полноэкран «Смена > 24h, нужен Z-отчёт» | да |
28 | Toast «Сумма картой больше итога» — пересчитать | да |
29 | Toast «Сумма оплаты меньше итога» | да |
32 | Жёлтый баннер «Не отправлено в ОФД: N» | нет |
45 | Sentry + Toast «Нет позиций» (баг логики кассы) | да |
46, 47 | Toast «Не сходятся суммы» | да |
64 | Идемпотентный успех — забрать готовый документ | — |
65 | Auto-call printnotprinted.json → если опять упал → показать «нет бумаги» | условно |
975, 1000-1011, 1005 | Полноэкран «Принтер: | условно (зависит от типа) |
1297 | Полноэкран «ФН не отвечает» — техподдержка | да |
1298 | Полноэкран «ФН исчерпан» — критично, замена ФН | да |
33554432-33555432 | HTTP-сбой, лог в Sentry, ретрай | условно |
Кто заполняет какие поля чека
| Поле | Источник | Когда |
|---|---|---|
cashier | erp-pos (имя из PIN-логина) | до запроса |
cashierInn | erp-pos (если есть) или пусто | до запроса |
address | POS BFF из Store Service | при загрузке смены |
place | POS BFF из Store Service | при загрузке смены |
tax (СНО) | POS BFF из Store Service / Legal Entity | при загрузке смены |
paymentAttr | erp-pos = 1 (приход) или 2 (возврат) | по типу операции |
operations[] / labledOperations[] | erp-pos из корзины | до запроса |
operations[].name | Catalog Service → erp-pos | при добавлении в корзину |
operations[].price | Price List → erp-pos | при добавлении в корзину |
operations[].quantity | erp-pos | пользователь |
operations[].vatRate | Catalog Service → erp-pos | при добавлении в корзину |
operations[].type, paymentType | Catalog Service → erp-pos (по умолчанию type=1, paymentType=4) | при добавлении в корзину |
cash / card / prepay / postpay | erp-pos из платёжной формы | пользователь |
cardPayments[] | erp-pos из ответа эквайринга (Variant A) или ФЯ сама (Variant B) | после авторизации |
iBank (опц.) | erp-pos (только для Variant B) | до запроса |
externalId | erp-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)
- Регистр
externalidvsexternalIdв 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или65→printnotprinted.json→ допечать после установки бумаги - Принудительно открыть смену > 24h →
result=26или1302→ проверить логику UI кассы - Выключить интернет → выбить 5 чеков → проверить рост
notSendedCount→ включить интернет → дождаться обнуления
Закрытие смены:
-
cycleclose.json(POST сdoBankSettlement: falseдля Variant A,trueдля Variant B) - Проверить полные
cycleCountersв ответе - X-отчёт (
xreport.json) перед закрытием - Допечать Z-отчёт
Связанное
- Fiscal Core Integration — справочник API012
- Репозитории —
erp-pos