Files
lux_fiscal/.plans/checkbox-ettn-receipts.md
T
lauadminandClaude Opus 5.5 518a99197f Add Checkbox ETTN receipts for Nova Poshta COD waybills
Cashier creates an ETTN receipt in Checkbox bound to the TTN with payment
control; Checkbox fiscalizes it itself when the parcel is paid for.

- cash_registers (Fernet-encrypted license key / PIN) and receipts tables
- Checkbox HTTP client + stub (ETTN does not work on test registers)
- two-phase create via ARQ job, timeout reconciliation, cron status polling
- /receipts and /cash-registers API, audit records
- dashboard: per-order and bulk create, prepayment, cancel; cash registers page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 23:39:00 +03:00

13 KiB
Raw Blame History

Этап 4: чеки Checkbox по ТТН НП с послеплатой (ЕТТН)

Контекст

Заказы с наложенным платежом НП не пробиваем сами. Кассир создаёт в Checkbox ЕТТН-чек (шаблон чека, привязанный к ТТН). Когда клиент оплачивает посылку в отделении, НП шлёт webhook в Checkbox, и Checkbox сам фискализирует чек. Наша система только: создаёт ЕТТН-чек → отслеживает его статус → даёт отменить/пересоздать.

Checkbox API (из api.checkbox.ua/api/openapi.json)

  • Эндпоинты: POST/GET /api/v1/ettn, GET/PUT/DELETE /api/v1/ettn/{order_id}, PUT /api/v1/ettn/fiscalize-manually/{order_id}. (/api/v1/np/ettn устарел, не используем.)
  • Авторизация: Authorization: Bearer <cashier JWT> через POST /api/v1/cashier/signinPinCode (pin_code + заголовок X-License-Key). Всегда слать X-Client-Name, X-Client-Version, X-License-Key.
  • Тело ETTNCreateReceiptSchema: provider="novapost", receipt_body:
    • goods[]: good{code, name, price (коп. за 1000), tax[]}, quantity (тысячные), is_return=false, discounts[]
    • discounts[]: {type: "PRE_PAYMENT", mode: "VALUE", value} — предоплата
    • payments[]: {value: <сумма наложки, коп.>, ettn: <номер ТТН>}
    • delivery: phone / emails — куда отправить чек после фискализации
  • Ответ BaseEttnResponse: id, status, receiptId (после фискализации), rawError, totalSum.
  • Статусы: CREATED, CANCELLED, RECEIPT_ERROR, DONE, DONE_WITHOUT_SMS, RETURNED.
  • Инвариант: сумма ЕТТН = сумма товаров − предоплата = наложка НП.

Решения (согласовано)

  • Запуск только вручную: кнопка «Чек» в строке/модалке + массово «Создать чеки по выбранным».
  • Частичная предоплата бывает: кассир вводит её в поле «Предоплата» → PRE_PAYMENT скидка.
  • Коды налогов настраиваются на уровне кассы (по умолчанию пусто = не передаём tax).
  • Касса и секреты — в БД, license_key и PIN шифруются Fernet (app/core/crypto.py).

Предусловия (вне кода, проверить до старта)

  • В портале Checkbox касса подключена к НП (токен НП + телефон ФОП).
  • ТТН создаются с «Контролем оплаты» (AfterpaymentOnGoodsCost), а не с обычным денежным переводом.
  • ЕТТН-чек нужно создать до получения посылки клиентом.

Этап 4.1 — Модели и миграция

  • app/db/models/cash_register.py — CashRegister(UUIDPrimaryKeyMixin, TimestampMixin): name, fiscal_number, license_key_enc, cashier_pin_enc, tax_codes (JSONB, default []), is_active, is_default (partial unique index — одна касса по умолчанию).
  • app/db/models/receipt.py — Receipt(UUIDPrimaryKeyMixin, TimestampMixin): order_id FK→orders, cash_register_id FK, created_by_id FK→users, waybill_number, total_kopecks, prepayment_kopecks, cod_kopecks, status (StrEnum: pending, created, done, returned, cancelled, failed, receipt_error), checkbox_ettn_id, checkbox_receipt_id, error, request_body (JSONB — снимок отправленного), last_checked_at. Partial unique index (order_id) WHERE status NOT IN ('cancelled','failed') — не больше одного живого чека на заказ.
  • Импорт моделей в app/db/models/__init__.py.
  • Миграция 0005_cash_registers_and_receipts.py вручную по образцу 0001/0002 (op.f(...), формат op.create_table(\n "<table>", — его парсит test_migration_matches_models.py).
  • AuditAction: CASH_REGISTER_CREATED/UPDATED, RECEIPT_CREATE_REQUESTED, RECEIPT_CANCELLED.
  • Добавить pin/cashier_pin в _REDACTED_KEYS (app/services/audit.py).

Этап 4.2 — Checkbox-клиент

  • app/services/checkbox/client.py — Protocol CheckboxClient + CheckboxError (по образцу services/nova_poshta/client.py): sign_in(license_key, pin) -> token, create_ettn(...), get_ettn(id), delete_ettn(id).
  • app/services/checkbox/http_client.py — httpx, заголовки X-Client-*, X-License-Key, Bearer; при 401 — повторный signinPinCode и один retry. Токен кэшируется в памяти процесса по cash_register_id (клиент — синглтон на процесс; Redis не понадобился).
  • app/services/checkbox/stub_client.py — стаб для тестов и локальной разработки (ЕТТН на тестовой кассе Checkbox не работает — стаб единственный способ прогнать цикл без боевой кассы). In-memory состояние: create → CREATED, последующие get умеют отдавать DONE/RETURNED/RECEIPT_ERROR.
  • Флаг CHECKBOX_USE_STUB (default false); выбор реализации в одном месте (deps.get_checkbox_client + worker startup). В is_production при true — ошибка старта.
  • app/schemas/checkbox.py — pydantic-модели запроса/ответа ЕТТН.
  • Settings: checkbox_base_url (default https://api.checkbox.ua), checkbox_client_name, checkbox_client_version; обновить .env.example.
  • Тесты tests/test_checkbox_client.py через respx (успех, 401→re-signin, 422, таймаут).

Этап 4.3 — Сервис чеков (бизнес-логика)

  • app/services/receipts.py:
    • build_ettn_body(order, prepayment_kopecks, register) — маппинг CRM goods → Checkbox: price "600.00"→ коп., quantity "2.000"→ 2000, code = sku or id, построчная скидка CRM → DISCOUNT/VALUE, tax = register.tax_codes, payments=[{value: cod, ettn: waybill}], delivery из телефона/email получателя. Переиспользовать _to_kopecks из services/orders.py.
    • Валидация перед созданием: есть ТТН; np_cod_amount_kopecks > 0; посылка ещё не получена (NP status code ∉ получено/возврат); нет живого чека; сумма товаров − предоплата == np_cod_amount_kopecks — иначе понятная ошибка для UI.
    • request_receipts(order_ids, prepayments, user) — создаёт Receipt(status=pending) + audit в одной транзакции, commit, затем enqueue ARQ-задачи по каждому чеку.
    • create_ettn(receipt_id) (выполняет worker): вызов Checkbox → created + checkbox_ettn_id, order.receipt_created_at = now; при ошибке → failed + error. Идемпотентность: при таймауте не повторять вслепую — сверить через GET /api/v1/ettn (поиск по ettnNumber) перед повторной отправкой.
    • cancel_receipt(receipt_id, user) — DELETE /api/v1/ettn/{id} только из created, → cancelled, order.receipt_created_at = NULL (заказ возвращается в очередь).
    • sync_ettn_statuses() — опрос GET /api/v1/ettn/{id} для чеков в created: DONE/DONE_WITHOUT_SMS→done (+receiptId), RETURNED→returned, RECEIPT_ERROR→receipt_error (+rawError), CANCELLED→cancelled.
  • Тесты tests/test_receipts_service.py: маппинг сумм/количеств, предоплата, все ветки валидации, переходы статусов.

Этап 4.4 — Worker

  • app/worker.py: functions=[create_ettn_receipt], cron poll_ettn_statuses (раз в 2–5 мин), ctx["checkbox_client"], ctx["redis"] для кэша токенов.
  • API ставит задачи через app/services/task_queue.py (ленивый ARQ-пул; сбой enqueue не критичен — cron poll_receipts подхватит зависший pending).

Этап 4.5 — API

  • app/api/v1/cash_registers.py (AdminUser): list / create / update; секреты только на запись, в ответе — mask(...) из core/crypto.py. Проверка «подключиться» = пробный signinPinCode.
  • app/api/v1/receipts.py (CashierUser):
    • POST /receipts — {items: [{order_id, prepayment_kopecks}]} → 202 + список чеков/ошибок валидации по заказам.
    • GET /receipts?order_id= / GET /receipts/{id}.
    • POST /receipts/{id}/cancel.
  • schemas/orders.py: в OrderRowOut добавить receipt_status, receipt_error, prepayment.
  • Подключить роутеры в app/api/v1/router.py; тесты по образцу tests/test_orders_router.py (dependency_overrides, стаб-клиенты, monkeypatch сервисов).

Этап 4.6 — Frontend

  • features/receipts/ — api.ts, types.ts (union-типы статусов, без enum), хуки react-query.
  • DashboardPage.tsx:
    • поле «Предоплата» — контролируемое, в ₴, по умолчанию сумма − наложка; подсветка, если сумма − предоплата ≠ наложка;
    • кнопка «Чек» и «Создать чеки по выбранным» — включить, вызвать POST /receipts, показать ошибки по заказам;
    • вкладка «Выписаны чеки» — статус ЕТТН-чека (создан / фискализирован / возврат / ошибка), кнопка «Отменить» для created.
  • useOrders: refetchInterval пока есть чеки в pending.
  • OrderDetailModal.tsx — блок «Чек»: статус, Checkbox ID, ошибка, отмена.
  • Админ-страница «Кассы» (name, license key, PIN, коды налогов, по умолчанию) — маршрут в app/routes.tsx, только admin.

Этап 4.7 — Доработки и эксплуатация

  • На первом боевом чеке (реальная касса): нужна ли открытая смена для ЕТТН; как ведёт себя RECEIPT_ERROR.
  • Сверить реальное значение np_payment_status (Payed vs Paid — фронт и стаб расходятся).
  • Обновить CLAUDE.md/README.md/.env.example (статус, новые env-переменные).
  • Проверить на первом боевом чеке, что value скидок (DISCOUNT/PRE_PAYMENT, mode VALUE) — в копейках.
  • alembic upgrade head на живом Postgres (локально Docker не был запущен — проверен только offline SQL).
  • Позже (не в этом этапе): fiscalize-manually, PDF/ссылка на фискальный чек, webhook вместо опроса.

Проверка

  1. cd backend && .venv/Scripts/python -m pytest -q и ruff check app tests — чисто, включая test_migration_matches_models.py.
  2. .venv/Scripts/alembic upgrade head на локальном Postgres.
  3. cd frontend && npm run build && npm run lint.
  4. Локальный E2E без Checkbox: CHECKBOX_USE_STUB=true → полный цикл в UI (создание → created → стаб переводит в done/returned/receipt_error, отмена, пересоздание).
  5. Боевая проверка — только на реальной кассе (ЕТТН на тестовой не работает), аккуратно, на одном реальном заказе с контролем оплаты: создать чек → убедиться, что он виден в портале Checkbox → дождаться получения посылки и статуса done + receiptId. Отмену проверять на этом же чеке до выдачи посылки (или на ТТН, которую всё равно отменяем) — лишних фискальных чеков не создавать.