Files
lux_fiscal/.plans/checkbox-ettn-receipts.md
T
2026-09-24 00:15:01 +03:00

154 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Этап 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 — Модели и миграция
- [x] `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 — одна касса по умолчанию).
- [x] `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')` — не больше одного
живого чека на заказ.
- [x] Импорт моделей в `app/db/models/__init__.py`.
- [x] Миграция `0005_cash_registers_and_receipts.py` вручную по образцу `0001`/`0002` (`op.f(...)`,
формат `op.create_table(\n "<table>",` — его парсит `test_migration_matches_models.py`).
- [x] `AuditAction`: `CASH_REGISTER_CREATED/UPDATED`, `RECEIPT_CREATE_REQUESTED`, `RECEIPT_CANCELLED`.
- [x] Добавить `pin`/`cashier_pin` в `_REDACTED_KEYS` (`app/services/audit.py`).
## Этап 4.2 — Checkbox-клиент
- [x] `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)`.
- [x] `app/services/checkbox/http_client.py` — httpx, заголовки `X-Client-*`, `X-License-Key`, Bearer;
при 401 — повторный `signinPinCode` и один retry. Токен кэшируется в памяти процесса по `cash_register_id`
(клиент — синглтон на процесс; Redis не понадобился).
- [x] `app/services/checkbox/stub_client.py` — стаб для тестов и локальной разработки
(ЕТТН на тестовой кассе Checkbox не работает — стаб единственный способ прогнать цикл без боевой кассы).
In-memory состояние: `create` → `CREATED`, последующие `get` умеют отдавать `DONE`/`RETURNED`/`RECEIPT_ERROR`.
- [x] Флаг `CHECKBOX_USE_STUB` (default `false`); выбор реализации в одном месте (`deps.get_checkbox_client` + worker startup).
В `is_production` при `true` — ошибка старта.
- [x] `app/schemas/checkbox.py` — pydantic-модели запроса/ответа ЕТТН.
- [x] Settings: `checkbox_base_url` (default `https://api.checkbox.ua`), `checkbox_client_name`,
`checkbox_client_version`; обновить `.env.example`.
- [x] Тесты `tests/test_checkbox_client.py` через `respx` (успех, 401→re-signin, 422, таймаут).
## Этап 4.3 — Сервис чеков (бизнес-логика)
- [x] `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`.
- [x] Тесты `tests/test_receipts_service.py`: маппинг сумм/количеств, предоплата, все ветки валидации,
переходы статусов.
## Этап 4.4 — Worker
- [x] `app/worker.py`: `functions=[create_ettn_receipt]`, cron `poll_ettn_statuses` (раз в 2–5 мин),
`ctx["checkbox_client"]`, `ctx["redis"]` для кэша токенов.
- [x] API ставит задачи через `app/services/task_queue.py` (ленивый ARQ-пул; сбой enqueue не критичен —
cron `poll_receipts` подхватит зависший `pending`).
## Этап 4.5 — API
- [x] `app/api/v1/cash_registers.py` (AdminUser): list / create / update; секреты только на запись,
в ответе — `mask(...)` из `core/crypto.py`. Проверка «подключиться» = пробный `signinPinCode`.
- [x] `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`.
- [x] `schemas/orders.py`: в `OrderRowOut` добавить `receipt_status`, `receipt_error`, `prepayment`.
- [x] Подключить роутеры в `app/api/v1/router.py`; тесты по образцу `tests/test_orders_router.py`
(`dependency_overrides`, стаб-клиенты, monkeypatch сервисов).
## Этап 4.6 — Frontend
- [x] `features/receipts/` — `api.ts`, `types.ts` (union-типы статусов, без `enum`), хуки react-query.
- [x] `DashboardPage.tsx`:
- поле «Предоплата» — контролируемое, в ₴, по умолчанию `сумма − наложка`; подсветка, если
`сумма − предоплата ≠ наложка`;
- кнопка «Чек» и «Создать чеки по выбранным» — включить, вызвать `POST /receipts`,
показать ошибки по заказам;
- вкладка «Выписаны чеки» — статус ЕТТН-чека (создан / фискализирован / возврат / ошибка),
кнопка «Отменить» для `created`.
- [x] `useOrders`: `refetchInterval` пока есть чеки в `pending`.
- [x] `OrderDetailModal.tsx` — блок «Чек»: статус, Checkbox ID, ошибка, отмена.
- [x] Админ-страница «Кассы» (name, license key, PIN, коды налогов, по умолчанию) — маршрут в `app/routes.tsx`, только admin.
## Этап 4.7 — Доработки и эксплуатация
- [ ] На первом боевом чеке (реальная касса): нужна ли открытая смена для ЕТТН; как ведёт себя `RECEIPT_ERROR`.
- [ ] Сверить реальное значение `np_payment_status` (`Payed` vs `Paid` — фронт и стаб расходятся).
- [x] Обновить `CLAUDE.md`/`README.md`/`.env.example` (статус, новые env-переменные).
- [ ] Проверить на первом боевом чеке, что `value` скидок (`DISCOUNT`/`PRE_PAYMENT`, mode `VALUE`) — в копейках.
- [x] `alembic upgrade head` на живом Postgres (0004 → 0005 применена в Docker).
- [ ] Позже (не в этом этапе): `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`. Отмену проверять на этом же чеке
до выдачи посылки (или на ТТН, которую всё равно отменяем) — лишних фискальных чеков не создавать.