Тема
ТЗ — ОФД/фискализация (cms/ofd)
Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке
Назначение и возможности
Коннекторы операторов фискальных данных (АТОЛ Онлайн, Эвотор, ОФД.ру) поверх cms/integrations-bus: принимает запросы на фискализацию чека от cms/commerce-receipts и доводит их до отправки в ККТ с контролем статуса.
- Коннекторы АТОЛ Онлайн, Эвотор, ОФД.ру:
provides: ofd-provider - Очередь фискализации — чек не отправляется синхронно из HTTP-запроса
- Статусы чека (в очереди/отправлен/принят/ошибка) с журналом переходов
- Повторная отправка при временной ошибке оператора (ретраи через
cms/integrations-bus) - Мониторинг состояния подключённой ККТ (онлайн/офлайн/ошибка смены)
- Fallback-провайдер: переключение на резервного оператора ОФД при недоступности основного
Зависимости и выключение
requires: cms/integrations-bus · suggests: cms/commerce-invoices · provides: ofd-provider (потребитель — cms/commerce-receipts)
Поведение при выключении: чеки ставятся в очередь «ожидает фискализации» и не отправляются, заказы оформляются штатно — фискализация требует ручного донабора после включения модуля.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_ofd_receipts_queue | id, order_id, provider_code, status, payload (json), attempts | очередь фискализации с журналом попыток |
cms_ofd_kkt_status | id, kkt_id, provider_code, status, last_checked_at | мониторинг состояния подключённых ККТ |
Индексы: FK order_id — constrained() + index(); status — PHP Enum; payload — json()->nullable() + cast array; cms_ofd_receipts_queue — журнал, append-only по попыткам, BRIN по created_at; составной индекс status, created_at (выборка «в очереди дольше N минут» для алерта — см. «Производительность и кеш»).
ПДн-паспорт: cms_ofd_receipts_queue.payload может содержать состав корзины и ФИО/e-mail покупателя (при электронном чеке) — минимально необходимый набор для 54-ФЗ. Хранение — по регламенту оператора ОФД (фискальные документы хранятся дольше типового ретеншна контента); модуль не хранит ПДн дольше договора с оператором. Участие в хуках ядра «выгрузить всё по субъекту» / «забыть по запросу» (152-ФЗ) — ограниченное: фискальный номер и сумма сохраняются для бухгалтерского учёта (обязательное требование 54-ФЗ), обезличиваются только контактные поля (payload.customer_contact) — исключение из общего правила ядра фиксируется здесь явно, а не молчаливым игнором хука.
Входные и выходные данные
Принцип: всё, что не перечислено во входах ниже, модуль обязан отвергать — неизвестные поля payload, вебхуки без валидной подписи, provider_code, отсутствующий в реестре подключённых коннекторов.
| Вход | Источник | Канал | Поля | Проверка |
|---|---|---|---|---|
| Запрос фискализации чека | cms/commerce-receipts | канал 4 (requires), OfdService::enqueue() | order_id, items[], amount, customer_contact? | сумма сверяется с заказом (см. «Крайние случаи»), whitelist полей payload |
| Ручной ретрай чека | admin UI | API POST /queue/{id}/retry | id | ofd.manage, Idempotency-Key (мутация с внешним эффектом) |
| Смена настроек (provider/fallback/kill-switch) | admin | API настроек | ключи группы ofd | settings.manage + ofd.manage |
| Вебхук статуса чека от оператора ОФД | внешний оператор (АТОЛ/Эвотор/ОФД.ру) | cms/webhooks-in → cms/integrations-bus | order_id, provider_code, fiscal_document_number, status, timestamp | подпись (HMAC, секрет из .env per-провайдер) обязательна; протухший timestamp (replay) отклоняется; неизвестный provider_code отклоняется |
| Выход | Получатель | Формат |
|---|---|---|
| Ответ API очереди/статуса ККТ | admin UI | {data, meta}, keyset-пагинация |
OfdReceiptSent / OfdReceiptFailed / OfdKktOffline | подписчики (канал 1) | события ядра |
| Фискальный номер/ссылка на чек | cms/commerce-receipts (через событие, не напрямую покупателю) | поле fiscal_document_number в payload события |
Публичных чтений, зависящих от статуса внешнего провайдера ОФД, у модуля нет — все admin-эндпоинты читают локальную БД (cms_ofd_receipts_queue/cms_ofd_kkt_status) и не деградируют от доступности оператора; конвенция ядра 200 + meta.degraded (ревизия 14.07.2026) здесь не применима впрямую именно поэтому, а не потому что модуль исключение из правила.
Настройки (группа ofd)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
ofd.primary_provider | string | null | нет | Код основного провайдера ОФД |
ofd.fallback_provider | string | null | нет | Код резервного провайдера при недоступности основного |
ofd.retry_attempts | int | 5 | нет | Число ретраев отправки чека |
ofd.kkt_offline_alert_minutes | int | 15 | нет | Порог простоя ККТ для алерта |
ofd.queue_size_alert | int | 500 | нет | Лимит размера очереди фискализации — превышение эскалирует алерт (риск законодательного порога 54-ФЗ, см. «Крайние случаи») |
ofd.disabled_providers | array | [] | нет | Kill-switch: коды провайдеров, временно исключённых из отправки без выключения модуля — чеки остаются в очереди |
ofd.kkt_test_mode | bool (per-коннектор) | false | нет | Тестовый режим коннектора/ККТ — блокирует боевую отправку |
ofd.price_per_receipt | decimal | null | нет | Справочная стоимость чека у оператора (видимость расхода в админке, не тарификация) |
Секреты доступа к API ОФД (токены, сертификаты) — только .env.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/admin/ofd/queue | admin (ofd.view) | Очередь фискализации с фильтрами по статусу |
| POST | /api/v1/admin/ofd/queue/{id}/retry | admin (ofd.manage) | Повторная отправка чека вручную |
| GET | /api/v1/admin/ofd/kkt-status | admin (ofd.view) | Статус подключённых ККТ |
Очередь фискализации — keyset-пагинация, не OFFSET. {id} в путях — публичный идентификатор строки очереди (ULID/UUID первичного ключа), не автоинкремент — см. «Крайние случаи» (⚠️ Противоречие со стандартом §4, если реализация выберет обычный id). POST /queue/{id}/retry — мутация с внешним эффектом (повторная отправка оператору): заголовок Idempotency-Key обязателен (стандарт §7).
Компоненты
Filament: очередь фискализации со статусами и ручным ретраем, виджет состояния ККТ. Команды: cms:ofd:retry-failed --json, cms:ofd:doctor --json (проверка доступности провайдера).
События и обмен
| Событие | Когда | Payload |
|---|---|---|
OfdReceiptSent | чек успешно передан оператору | order_id, provider_code, fiscal_document_number |
OfdReceiptFailed | отправка чека завершилась ошибкой после всех ретраев | order_id, provider_code, error |
OfdKktOffline | ККТ не отвечает дольше порога | kkt_id, provider_code |
Реализует provides: ofd-provider — потребитель cms/commerce-receipts. Весь обмен с оператором ОФД — через cms/integrations-bus.
Взаимодействия
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
cms/commerce-receipts | 4 (requires) | receipts → ofd | запрос постановки чека в очередь (OfdService::enqueue()), сверка суммы |
cms/commerce-receipts | 1 (событие) | ofd → receipts | OfdReceiptSent/OfdReceiptFailed — receipts обновляет статус чека заказа |
provides ofd-provider | 3 (сервисный контракт) | receipts → реализация(и) ofd | абстракция фискализации, реализуется коннекторами модуля |
| Оператор ОФД (внешний) | 5 (очередь) + cms/integrations-bus | ofd → внешний API | отправка чека, обработка ответа асинхронно |
| Оператор ОФД → ofd (вебхук) | внешний канал (webhooks-in → integrations-bus) | внешний → ofd | подтверждение приёма/ошибка чека, подпись обязательна |
cms/health | health-чек + событие 1 | ofd → health | OfdKktOffline, эскалация переполнения очереди |
cms/audit (если включён) | 1 (событие) | ofd → audit | ручной ретрай, смена ККТ/провайдера, чек коррекции — в аудит |
Любая связь вне этой таблицы — скрытая зависимость, анти-паттерн (стандарт §5).
Фоновая работа
Фискализация — именованная очередь ofd, чек не отправляется синхронно из HTTP-запроса оформления заказа. Ретраи с backoff при временной ошибке оператора, circuit breaker на провайдера с автопереключением на fallback_provider. Мониторинг ККТ — по расписанию. Восстановительная команда cms:ofd:retry-failed — идемпотентна, с --dry-run/--json, допустима к запуску на живом сайте без даунтайма (стандарт §15).
Производительность и кеш
Ожидаемые объёмы (оценочно): небольшой/средний интернет-магазин — до 1–5 тыс. чеков/сутки, крупный ритейл/маркетплейс — до нескольких десятков тысяч/сутки; уточняется под конкретный проект на этапе разработки.
Горячие пути: постановка в очередь (OfdService::enqueue(), синхронный вызов из commerce-receipts) — ≤1 запрос (INSERT), без внешнего HTTP; чтение очереди/статусов ККТ в админке — keyset, ≤2 запроса на страницу (список + агрегат по статусам, без COUNT(*) по всей таблице).
Индексы: cms_ofd_receipts_queue — составной status, created_at (выборка просроченных для алерта), FK order_id (см. «Модель данных»); cms_ofd_kkt_status — kkt_id/ provider_code, kkt_id для мониторинга.
Внешние вызовы к оператору ОФД — только из очереди. В отличие от delivery-ru (где расчёт стоимости — синхронный на checkout с таймаутом), у cms/ofd синхронных внешних вызовов нет вовсе: постановка в очередь — чисто локальная операция, отправка оператору всегда асинхронна. Это упрощает бюджет: 0 внешних HTTP на публичном горячем пути.
Собственных тегов кеша нет — очередь и статусы чеков читаются напрямую (консистентность критична для фискального учёта).
Безопасность
- Повторная отправка одного и того же чека не создаёт дублирующий фискальный документ (идемпотентность по
order_id+provider_code; ретрай использует тот же ключ — оператор либо возвращает уже созданный документ, либо создаёт новый по своей дедупликации). - Вебхуки статуса чека — подпись HMAC (секрет per-провайдер из
.env, не в настройках) + проверкаtimestampпротив replay (окно приёма, аналогично интеграциям). - Секреты доступа к API оператора ОФД — только
.env. - Права:
ofd.view,ofd.manage.
Матрица ролей:
| Действие | админ | менеджер | studio |
|---|---|---|---|
Просмотр очереди/статусов ККТ (ofd.view) | ✅ | ✅ | ✅ |
Ручной ретрай чека (ofd.manage) | ✅ | — | ✅ |
Смена provider/fallback, kill-switch (ofd.manage+settings.manage) | ✅ | — | ✅ |
| Чек коррекции (необратимая фискальная операция) | ✅ | — | ✅ |
Стоимость чека у оператора (если тарификация «за чек») — видна в админке через ofd.price_per_receipt, справочно, без автоматического биллинга ядра.
UX-требования
Админ:
- Статус синхронизации: индикатор очереди фискализации (размер, доля просроченных) на дашборде модуля, не только в списке.
- Журнал обмена с провайдером (запросы/ответы, отдельно от бизнес-лога) — доступен для разбора инцидентов, без ПДн в открытом виде.
- Ошибки на человеческом языке: «ОФД временно недоступен, чеки поставлены в очередь» вместо
Error 500; «Не удалось отправить чек: исчерпаны попытки — требуется ручная проверка». - Подтверждение необратимых операций: чек коррекции — отдельная форма с явным предупреждением («создаст фискальный документ коррекции, отменить нельзя»); смена ККТ/оператора — предупреждение о переносе новых чеков на новую конфигурацию.
- Пустое состояние очереди — «Все чеки фискализированы», не пустая таблица без пояснения.
Крайние случаи и типовые баги
- Очередь чеков растёт при недоступном ОФД → отслеживается по составному индексу
status, created_at; при превышенииofd.queue_size_alertили приближении к законодательному сроку хранения нефискализированных данных в фискальном накопителе (по 54-ФЗ, точный срок — по паспорту конкретного ФН) алерт эскалируется вcms/healthуровнем critical, не просто пишется в лог — риск блокировки ККТ.kkt_offline_alert_minutesпокрывает другой, более быстрый сценарий — недоступность самой кассы. - Чек коррекции → отдельный тип операции (
operation_type = correction), не путается с обычным чеком в очереди; создаётся только явным действием администратора (форма с указанием причины расхождения), требует подтверждения и роли не нижеofd.manage. - Смена ККТ или ОФД посреди работы (например, перерегистрация кассы) → чеки, уже поставленные в очередь под старой конфигурацией (
provider_code/kkt_idзафиксированы в строке на момент постановки), продолжают уходить через прежнего провайдера до исчерпания очереди; новые чеки — на новую конфигурацию; append-only история не переписывается задним числом. - Тест-режим без боевой фискализации →
ofd.kkt_test_modeявно маркирует ККТ/коннектор как тестовый; отправка тестового чека боевым коннектором (несовпадение флагов) блокируется доменным исключением, не тихим пропуском. - Дубль вебхука о статусе чека от оператора → идемпотентность по
order_id+provider_code: повторный вебхук с тем же статусом не создаёт вторую запись в журнале переходов и не переиздаётOfdReceiptSentповторно (проверка текущего состояния перед переходом, не слепая запись). - Недоступность основного провайдера дольше порога → circuit breaker переключает на
ofd.fallback_provider; чеки, уже отправленные основному и повисшие без ответа, не переотправляются автоматически через fallback (риск задвоения) — остаются в статусе «ожидает ответа» до ручной проверки или подтверждённого таймаута, только новые постановки идут через fallback. - Таймаут ответа оператора при отправке чека → ретрай выполняется с тем же идемпотентным ключом (
order_id+provider_code); модуль используетupdateOrCreateпо этому ключу — вторая строка в очереди на один заказ не создаётся. - Смена схемы ответа API оператора (breaking change у провайдера) → парсер ответа деградирует контролируемо: чек остаётся в статусе «ошибка обработки ответа» (не «отправлен» по умолчанию), алерт разработчику через
cms/healthс телом неожиданного ответа в диагностическом логе (без ПДн). - Расхождение суммы чека с суммой заказа (данные разошлись до отправки) → сверка
payload.amountсorder.totalперед постановкой в очередь; расхождение блокирует постановку (ReceiptAmountMismatch), алерт — чек с неверной суммой оператору не уходит. - Пустая/резко выросшая очередь (0 записей — штатно; всплеск после простоя) → всплеск обрабатывается тем же батчингом ретраев с backoff, не заваливает оператора пиковой нагрузкой (rate-limit воркера).
Мини-ранбук (эксплуатация):
| Симптом | Что проверить | Команда |
|---|---|---|
| Очередь фискализации растёт, чеки не уходят | доступность оператора, статус circuit breaker, ofd.disabled_providers | cms:ofd:doctor --json |
ККТ офлайн дольше kkt_offline_alert_minutes | сеть до кассы, cms_ofd_kkt_status, физическое подключение | cms:ofd:doctor --json (секция kkt) |
| Чеки уходят, но статус не обновляется | доставка вебхуков в cms/webhooks-in, совпадение подписи | журнал вебхуков + cms:ofd:retry-failed --json |
Донорский код
Донор: — (новая разработка). Легаси-импорт (стандарт §16) не применим — исторических чеков у донора нет. Если в будущем появится перенос из внешней бухгалтерской/фискальной системы, используется тот же паттерн команды cms:ofd:import-legacy --source=<профиль>, идемпотентной по external_id, с --dry-run.
Тесты и приёмка
- [ ] Мок API оператора ОФД: тест успешной фискализации и обработки ошибки с ретраем
- [ ] Фискализация выполняется из очереди, не из HTTP-запроса оформления заказа
- [ ] При недоступности основного провайдера включается
fallback_provider(тест переключения) - [ ] Повторная отправка одного и того же чека не создаёт дублирующий фискальный документ
- [ ] При выключении модуля заказ оформляется без падения, чек остаётся в очереди
- [ ] Права
ofd.manageразграничивают просмотр очереди и ручной ретрай - [ ] Вебхук статуса чека: подпись проверяется, невалидная подпись/протухший timestamp отклоняются
- [ ] Дубль вебхука не создаёт повторную запись перехода статуса и не переиздаёт событие
- [ ] Расхождение суммы чека с заказом блокирует постановку в очередь (
ReceiptAmountMismatch) - [ ] Тест-режим ККТ не допускает боевую отправку тестового чека
- [ ] Превышение
ofd.queue_size_alertэскалирует алерт уровнем critical вcms/health - [ ] Матрица ролей:
ofd.view/ofd.manageпроверены тестами доступа - [ ] Контрактный набор
cms-testingзелёный, пакет тестируется в testbench-изоляции - [ ] Feature-тест на каждый роут; тестовая БД только
ofd_test,migrate:freshзапрещён