- ExoCrmClient.set_status: live SetStatus needs {Orders: [id], Status} and
replies per order, unlike the documented {ID, Status}
- receipts.crm_status_set_at (migration 0006); set right after creation,
retried by cron, row-locked to avoid a repeat PACKED overwriting a newer status
- CRM errors under capitalized 'Errors' and non-JSON replies are reported
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
14 KiB
14 KiB
Этап 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_idFK→orders,cash_register_idFK,created_by_idFK→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(defaultfalse); выбор реализации в одном месте (deps.get_checkbox_client+ worker startup). Вis_productionприtrue— ошибка старта. app/schemas/checkbox.py— pydantic-модели запроса/ответа ЕТТН.- Settings:
checkbox_base_url(defaulthttps://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], cronpoll_ettn_statuses(раз в 2–5 мин),ctx["checkbox_client"],ctx["redis"]для кэша токенов.- API ставит задачи через
app/services/task_queue.py(ленивый ARQ-пул; сбой enqueue не критичен — cronpoll_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(PayedvsPaid— фронт и стаб расходятся). - Обновить
CLAUDE.md/README.md/.env.example(статус, новые env-переменные). - Первый боевой чек (ТТН 20451543715206, 2026-09-24):
valueскидок — в копейках (подтверждено).PRE_PAYMENT→ 400third_party.generic; предоплата теперь идёт обычной скидкойDISCOUNT«Знижка», как в чеках из портала. Боевой API отдаёт статусы строчными (created/done) — нормализуются. Лимит списка — 50 за страницу; 429 «Занадто часто» — повторяемая ошибка. alembic upgrade headна живом Postgres (0004 → 0005 применена в Docker).- После создания чека — статус заказа в CRM
PACKED(SetStatus, миграция 0006receipts.crm_status_set_at, повтор cron'ом при сбое CRM). Проверено на боевой CRM: заказы 123901, 123793. - Позже (не в этом этапе):
fiscalize-manually, PDF/ссылка на фискальный чек, webhook вместо опроса.
Проверка
cd backend && .venv/Scripts/python -m pytest -qиruff check app tests— чисто, включаяtest_migration_matches_models.py..venv/Scripts/alembic upgrade headна локальном Postgres.cd frontend && npm run build && npm run lint.- Локальный E2E без Checkbox:
CHECKBOX_USE_STUB=true→ полный цикл в UI (создание →created→ стаб переводит вdone/returned/receipt_error, отмена, пересоздание). - Боевая проверка — только на реальной кассе (ЕТТН на тестовой не работает), аккуратно, на одном
реальном заказе с контролем оплаты: создать чек → убедиться, что он виден в портале Checkbox →
дождаться получения посылки и статуса
done+receiptId. Отмену проверять на этом же чеке до выдачи посылки (или на ТТН, которую всё равно отменяем) — лишних фискальных чеков не создавать.