154 lines
13 KiB
Markdown
154 lines
13 KiB
Markdown
# Этап 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`. Отмену проверять на этом же чеке
|
||
до выдачи посылки (или на ТТН, которую всё равно отменяем) — лишних фискальных чеков не создавать.
|