From 518a99197f4b66d74f40faa279e56781d9b44866 Mon Sep 17 00:00:00 2001 From: Liutenko Oleksandr Date: Wed, 23 Sep 2026 23:39:00 +0300 Subject: [PATCH] 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 --- .env.example | 10 + .plans/checkbox-ettn-receipts.md | 153 ++++++ CLAUDE.md | 11 +- README.md | 2 +- .../0005_cash_registers_and_receipts.py | 114 ++++ backend/app/api/deps.py | 6 + backend/app/api/v1/cash_registers.py | 117 +++++ backend/app/api/v1/orders.py | 6 +- backend/app/api/v1/receipts.py | 87 ++++ backend/app/api/v1/router.py | 4 +- backend/app/core/config.py | 8 + backend/app/db/models/__init__.py | 5 + backend/app/db/models/audit.py | 4 + backend/app/db/models/cash_register.py | 39 ++ backend/app/db/models/receipt.py | 83 +++ backend/app/main.py | 2 + backend/app/schemas/checkbox.py | 32 ++ backend/app/schemas/orders.py | 14 +- backend/app/schemas/receipts.py | 117 +++++ backend/app/services/audit.py | 3 + backend/app/services/checkbox/__init__.py | 0 backend/app/services/checkbox/client.py | 61 +++ backend/app/services/checkbox/http_client.py | 164 ++++++ backend/app/services/checkbox/stub_client.py | 73 +++ backend/app/services/receipts.py | 490 ++++++++++++++++++ backend/app/services/task_queue.py | 53 ++ backend/app/worker.py | 29 +- backend/tests/test_checkbox_client.py | 150 ++++++ backend/tests/test_orders_router.py | 7 + backend/tests/test_receipts_router.py | 78 +++ backend/tests/test_receipts_service.py | 289 +++++++++++ frontend/src/api/cashRegisters.ts | 22 + frontend/src/api/receipts.ts | 10 + frontend/src/app/routes.tsx | 2 + frontend/src/features/cashRegisters/types.ts | 22 + .../src/features/orders/OrderDetailModal.tsx | 22 + frontend/src/features/orders/types.ts | 7 + frontend/src/features/orders/useOrders.ts | 13 +- frontend/src/features/receipts/money.ts | 27 + frontend/src/features/receipts/types.ts | 56 ++ frontend/src/pages/CashRegistersPage.css | 46 ++ frontend/src/pages/CashRegistersPage.tsx | 278 ++++++++++ frontend/src/pages/DashboardPage.css | 65 +++ frontend/src/pages/DashboardPage.tsx | 170 +++++- 44 files changed, 2931 insertions(+), 20 deletions(-) create mode 100644 .plans/checkbox-ettn-receipts.md create mode 100644 backend/alembic/versions/0005_cash_registers_and_receipts.py create mode 100644 backend/app/api/v1/cash_registers.py create mode 100644 backend/app/api/v1/receipts.py create mode 100644 backend/app/db/models/cash_register.py create mode 100644 backend/app/db/models/receipt.py create mode 100644 backend/app/schemas/checkbox.py create mode 100644 backend/app/schemas/receipts.py create mode 100644 backend/app/services/checkbox/__init__.py create mode 100644 backend/app/services/checkbox/client.py create mode 100644 backend/app/services/checkbox/http_client.py create mode 100644 backend/app/services/checkbox/stub_client.py create mode 100644 backend/app/services/receipts.py create mode 100644 backend/app/services/task_queue.py create mode 100644 backend/tests/test_checkbox_client.py create mode 100644 backend/tests/test_receipts_router.py create mode 100644 backend/tests/test_receipts_service.py create mode 100644 frontend/src/api/cashRegisters.ts create mode 100644 frontend/src/api/receipts.ts create mode 100644 frontend/src/features/cashRegisters/types.ts create mode 100644 frontend/src/features/receipts/money.ts create mode 100644 frontend/src/features/receipts/types.ts create mode 100644 frontend/src/pages/CashRegistersPage.css create mode 100644 frontend/src/pages/CashRegistersPage.tsx diff --git a/.env.example b/.env.example index 995deb3..df605e9 100644 --- a/.env.example +++ b/.env.example @@ -60,3 +60,13 @@ CRM_SID=1 # --- Nova Poshta ---------------------------------------------------------- # Ключ API кабинета Nova Poshta (my.novaposhta.ua → Настройки → API). NOVA_POSHTA_API_KEY=change-me-novaposhta-apikey + +# --- Checkbox ------------------------------------------------------------- +# Ключ лицензии кассы и PIN кассира вводятся в админке «Кассы» и хранятся в БД +# зашифрованными (ENCRYPTION_KEY), а не здесь. +CHECKBOX_BASE_URL=https://api.checkbox.ua +CHECKBOX_CLIENT_NAME=lux_fiscal +CHECKBOX_CLIENT_VERSION=0.1.0 +# ЕТТН-чеки на тестовой кассе Checkbox не работают: локально весь цикл +# прогоняется через стаб (чек «фискализируется» на втором опросе). В production запрещено. +CHECKBOX_USE_STUB=false diff --git a/.plans/checkbox-ettn-receipts.md b/.plans/checkbox-ettn-receipts.md new file mode 100644 index 0000000..429d331 --- /dev/null +++ b/.plans/checkbox-ettn-receipts.md @@ -0,0 +1,153 @@ +# Этап 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 ` через `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 "",` — его парсит `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`) — в копейках. +- [ ] `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`. Отмену проверять на этом же чеке + до выдачи посылки (или на ТТН, которую всё равно отменяем) — лишних фискальных чеков не создавать. diff --git a/CLAUDE.md b/CLAUDE.md index 4c28541..686d46f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,7 @@ docker compose ps # migrate should show Exited(0) — it's a one `migrate` runs `alembic upgrade head` once and exits; `api`/`worker` `depends_on: migrate: condition: service_completed_successfully`. Don't add migration logic to the `api` container's startup — with multiple replicas that would race. -`worker` currently runs a placeholder `sleep` command (real ARQ worker lands at plan stage 4 — Nova Poshta). Its Dockerfile HEALTHCHECK is explicitly disabled in `docker-compose.yml` because the placeholder doesn't serve HTTP; don't be alarmed it's not "healthy", and don't re-enable the healthcheck without giving it something to check. +`worker` runs ARQ (`arq app.worker.WorkerSettings`): cron polls Nova Poshta statuses and Checkbox ETTN receipts every minute, plus the on-demand `create_ettn_receipt` job enqueued by the API. Its Dockerfile HEALTHCHECK is explicitly disabled in `docker-compose.yml` because the worker doesn't serve HTTP; don't re-enable it without giving it something to check. ## Backend architecture @@ -118,4 +118,11 @@ Not yet enforced by types anywhere in the current code, but is a hard project co ## Project status (see the plan for the full roadmap) -Stages 1–2 (scaffolding, auth/roles/audit log) and a minimal frontend shell (login page + protected placeholder dashboard) are done. Stage 3 (orders + a `CrmClient` `Protocol` with a fixture-backed `StubCrmClient`, since the real CRM API doesn't exist yet) is next. Don't build order/receipt/shipment features against a guessed CRM shape — the stub pattern exists specifically so this can proceed without the real API. +Stages 1–4 (scaffolding, auth/audit, CRM order queue, Nova Poshta tracking) are done. Checkbox ETTN receipts are implemented per `.plans/checkbox-ettn-receipts.md` but not yet verified on a real cash register. + +### Checkbox ETTN receipts + +- We never fiscalize ourselves: the cashier creates an **ETTN receipt** in Checkbox bound to a Nova Poshta TTN with payment control; Checkbox fiscalizes it when the customer pays at the NP branch. Invariant: `order total − prepayment == np_cod_amount_kopecks`. +- Two-phase create: `POST /receipts` writes `Receipt(pending)` + audit and commits, then enqueues `create_ettn_receipt`; the worker calls Checkbox. A timeout leaves the row `pending` with `error` set — the retry first looks the TTN up via `find_ettn` instead of blindly re-posting (would create a second receipt). Keep this. +- State machine and the "one live receipt per order" partial unique index live in `app/db/models/receipt.py`. `orders.receipt_created_at` is set on request and reset to NULL when a receipt ends `failed`/`cancelled` (order goes back to the queue). +- ETTN does **not** work on a Checkbox test cash register. Locally use `CHECKBOX_USE_STUB=true`; client selection is only in `services/checkbox/client.get_checkbox_client()`. diff --git a/README.md b/README.md index 3f0ae27..9d94442 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ | 2 | Аутентификация, роли, журнал аудита | ✅ готово | | 3 | Заказы + заглушка CRM | ✅ готово | | 4 | Нова Пошта: статусы и сумма послеоплаты | ✅ готово | -| 5 | Checkbox: смены, чеки, PDF | ⏳ | +| 5 | Checkbox: ЕТТН-чеки по ТТН с контролем оплаты (план: `.plans/checkbox-ettn-receipts.md`) | 🚧 код готов, ждёт проверки на боевой кассе | | 6 | Очередь и редактор чека (фронтенд) | ⏳ | | 7 | Админка | ⏳ | | 8 | Реальный API CRM | ⏳ | diff --git a/backend/alembic/versions/0005_cash_registers_and_receipts.py b/backend/alembic/versions/0005_cash_registers_and_receipts.py new file mode 100644 index 0000000..21e9573 --- /dev/null +++ b/backend/alembic/versions/0005_cash_registers_and_receipts.py @@ -0,0 +1,114 @@ +"""Кассы Checkbox и ЕТТН-чеки + +Revision ID: 0005 +Revises: 0004 +Create Date: 2026-09-23 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "0005" +down_revision: str | None = "0004" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +receipt_status = postgresql.ENUM( + "pending", + "created", + "done", + "returned", + "receipt_error", + "cancelled", + "failed", + name="receipt_status", + create_type=False, +) + + +def upgrade() -> None: + receipt_status.create(op.get_bind(), checkfirst=True) + + op.create_table( + "cash_registers", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("fiscal_number", sa.String(length=64), nullable=True), + sa.Column("license_key_enc", sa.String(length=512), nullable=False), + sa.Column("cashier_pin_enc", sa.String(length=512), nullable=False), + sa.Column("tax_codes", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("is_default", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_cash_registers")), + ) + op.create_index( + "uq_cash_registers_default", + "cash_registers", + ["is_default"], + unique=True, + postgresql_where=sa.text("is_default"), + ) + + op.create_table( + "receipts", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("order_id", sa.String(length=32), nullable=False), + sa.Column("cash_register_id", sa.Uuid(), nullable=False), + sa.Column("created_by_id", sa.Uuid(), nullable=True), + sa.Column("waybill_number", sa.String(length=64), nullable=False), + sa.Column("total_kopecks", sa.BigInteger(), nullable=False), + sa.Column("prepayment_kopecks", sa.BigInteger(), nullable=False), + sa.Column("cod_kopecks", sa.BigInteger(), nullable=False), + sa.Column("status", receipt_status, nullable=False), + sa.Column("checkbox_ettn_id", sa.String(length=64), nullable=True), + sa.Column("checkbox_status", sa.String(length=32), nullable=True), + sa.Column("checkbox_receipt_id", sa.String(length=64), nullable=True), + sa.Column("error", sa.Text(), nullable=True), + sa.Column("request_body", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column("last_checked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint( + ["order_id"], + ["orders.id"], + name=op.f("fk_receipts_order_id_orders"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["cash_register_id"], + ["cash_registers.id"], + name=op.f("fk_receipts_cash_register_id_cash_registers"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["created_by_id"], + ["users.id"], + name=op.f("fk_receipts_created_by_id_users"), + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_receipts")), + ) + op.create_index(op.f("ix_receipts_order_id"), "receipts", ["order_id"]) + op.create_index(op.f("ix_receipts_cash_register_id"), "receipts", ["cash_register_id"]) + op.create_index(op.f("ix_receipts_status"), "receipts", ["status"]) + op.create_index(op.f("ix_receipts_checkbox_ettn_id"), "receipts", ["checkbox_ettn_id"]) + op.create_index( + "uq_receipts_active_order", + "receipts", + ["order_id"], + unique=True, + postgresql_where=sa.text("status NOT IN ('cancelled', 'failed')"), + ) + + +def downgrade() -> None: + op.drop_table("receipts") + op.drop_table("cash_registers") + receipt_status.drop(op.get_bind(), checkfirst=True) diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index c669e8b..5c9eacd 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -14,8 +14,10 @@ from app.core.config import settings from app.core.security import TokenError, decode_access_token from app.db.models.user import User, UserRole from app.db.session import get_session +from app.services.checkbox.client import CheckboxClient, get_checkbox_client from app.services.crm.client import CrmClient from app.services.crm.exo_client import ExoCrmClient +from app.services.task_queue import TaskQueue, get_task_queue bearer_scheme = HTTPBearer(auto_error=False) @@ -95,3 +97,7 @@ def get_crm_client() -> CrmClient: CrmClientDep = Annotated[CrmClient, Depends(get_crm_client)] + + +CheckboxClientDep = Annotated[CheckboxClient, Depends(get_checkbox_client)] +TaskQueueDep = Annotated[TaskQueue, Depends(get_task_queue)] diff --git a/backend/app/api/v1/cash_registers.py b/backend/app/api/v1/cash_registers.py new file mode 100644 index 0000000..74638e4 --- /dev/null +++ b/backend/app/api/v1/cash_registers.py @@ -0,0 +1,117 @@ +"""Кассы Checkbox. Только администратор; секреты — только на запись.""" + +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from sqlalchemy import select, update + +from app.api.deps import AdminUser, CheckboxClientDep, SessionDep, require_admin +from app.core import crypto +from app.db.models.audit import AuditAction +from app.db.models.cash_register import CashRegister +from app.schemas.receipts import CashRegisterCreate, CashRegisterOut, CashRegisterUpdate +from app.services import audit +from app.services.checkbox.client import CheckboxError +from app.services.receipts import credentials + +router = APIRouter( + prefix="/cash-registers", tags=["cash-registers"], dependencies=[Depends(require_admin)] +) + + +async def _clear_default(session: SessionDep, keep_id: uuid.UUID) -> None: + await session.execute( + update(CashRegister).where(CashRegister.id != keep_id).values(is_default=False) + ) + + +@router.get("", response_model=list[CashRegisterOut]) +async def list_cash_registers(session: SessionDep) -> list[CashRegisterOut]: + registers = await session.scalars(select(CashRegister).order_by(CashRegister.created_at)) + return [CashRegisterOut.from_register(register) for register in registers] + + +@router.post("", response_model=CashRegisterOut, status_code=status.HTTP_201_CREATED) +async def create_cash_register( + payload: CashRegisterCreate, request: Request, session: SessionDep, actor: AdminUser +) -> CashRegisterOut: + register = CashRegister( + id=uuid.uuid4(), + name=payload.name, + fiscal_number=payload.fiscal_number, + license_key_enc=crypto.encrypt(payload.license_key), + cashier_pin_enc=crypto.encrypt(payload.pin_code), + tax_codes=payload.tax_codes, + is_active=True, + is_default=payload.is_default, + ) + if register.is_default: + await _clear_default(session, register.id) + session.add(register) + await audit.record( + session, + action=AuditAction.CASH_REGISTER_CREATED, + user=actor, + entity_type="cash_register", + entity_id=register.id, + payload={"name": register.name, "fiscal_number": register.fiscal_number}, + request=request, + ) + await session.commit() + return CashRegisterOut.from_register(register) + + +@router.patch("/{register_id}", response_model=CashRegisterOut) +async def update_cash_register( + register_id: uuid.UUID, + payload: CashRegisterUpdate, + request: Request, + session: SessionDep, + actor: AdminUser, +) -> CashRegisterOut: + register = await session.get(CashRegister, register_id) + if register is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Касса не найдена") + + changes = payload.model_dump(exclude_unset=True) + if "license_key" in changes: + register.license_key_enc = crypto.encrypt(changes["license_key"]) + if "pin_code" in changes: + register.cashier_pin_enc = crypto.encrypt(changes["pin_code"]) + if "fiscal_number" in changes: + register.fiscal_number = changes["fiscal_number"] + for field in ("name", "tax_codes", "is_active", "is_default"): + if changes.get(field) is not None: + setattr(register, field, changes[field]) + if changes.get("is_default"): + await _clear_default(session, register.id) + + await audit.record( + session, + action=AuditAction.CASH_REGISTER_UPDATED, + user=actor, + entity_type="cash_register", + entity_id=register.id, + payload=changes, # license_key / pin_code редактируются в audit._sanitize + request=request, + ) + await session.commit() + return CashRegisterOut.from_register(register) + + +@router.post("/{register_id}/check", status_code=status.HTTP_204_NO_CONTENT) +async def check_cash_register( + register_id: uuid.UUID, session: SessionDep, checkbox: CheckboxClientDep +) -> None: + """Пробный вход кассира в Checkbox — проверка ключа лицензии и PIN.""" + register = await session.get(CashRegister, register_id) + if register is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Касса не найдена") + try: + await checkbox.sign_in(credentials(register)) + except (CheckboxError, crypto.DecryptionError) as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Checkbox: {exc}" + ) from exc diff --git a/backend/app/api/v1/orders.py b/backend/app/api/v1/orders.py index f2355fd..0744734 100644 --- a/backend/app/api/v1/orders.py +++ b/backend/app/api/v1/orders.py @@ -9,6 +9,7 @@ from app.db.models.audit import AuditAction from app.schemas.orders import OrderRowOut from app.services import audit from app.services import orders as orders_service +from app.services import receipts as receipts_service router = APIRouter(prefix="/orders", tags=["orders"], dependencies=[Depends(require_any)]) @@ -21,7 +22,10 @@ async def list_orders( # CRM-алиасы (SKU/Name/...) вместо snake_case полей, которые ждёт фронтенд. await orders_service.sync_orders_from_crm(session, crm) orders = await orders_service.list_orders(session, has_receipt=has_receipt) - return [OrderRowOut.from_order(order) for order in orders] + receipts = await receipts_service.latest_receipts_by_order( + session, [order.id for order in orders] + ) + return [OrderRowOut.from_order(order, receipts.get(order.id)) for order in orders] @router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/api/v1/receipts.py b/backend/app/api/v1/receipts.py new file mode 100644 index 0000000..2b44e0c --- /dev/null +++ b/backend/app/api/v1/receipts.py @@ -0,0 +1,87 @@ +"""ЕТТН-чеки Checkbox: запрос создания (массово), просмотр, отмена.""" + +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from sqlalchemy import select + +from app.api.deps import ( + CashierUser, + CheckboxClientDep, + SessionDep, + TaskQueueDep, + require_any, +) +from app.db.models.receipt import Receipt +from app.schemas.receipts import ReceiptCreateRequest, ReceiptCreateResponse, ReceiptOut +from app.services import receipts as receipts_service +from app.services.checkbox.client import CheckboxError + +router = APIRouter(prefix="/receipts", tags=["receipts"], dependencies=[Depends(require_any)]) + + +@router.post("", response_model=ReceiptCreateResponse, status_code=status.HTTP_202_ACCEPTED) +async def create_receipts( + payload: ReceiptCreateRequest, + request: Request, + session: SessionDep, + queue: TaskQueueDep, + actor: CashierUser, +) -> ReceiptCreateResponse: + result = await receipts_service.request_receipts( + session, + [(item.order_id, item.prepayment_kopecks) for item in payload.items], + user=actor, + request=request, + ) + await session.commit() + + # После commit: worker должен увидеть строку `pending`. + for receipt in result.created: + await queue.enqueue("create_ettn_receipt", str(receipt.id)) + + return ReceiptCreateResponse( + created=[ReceiptOut.from_receipt(receipt) for receipt in result.created], + errors=result.errors, + ) + + +@router.get("", response_model=list[ReceiptOut]) +async def list_receipts(session: SessionDep, order_id: str | None = None) -> list[ReceiptOut]: + query = select(Receipt).order_by(Receipt.created_at.desc()).limit(500) + if order_id is not None: + query = query.where(Receipt.order_id == order_id) + return [ReceiptOut.from_receipt(receipt) for receipt in await session.scalars(query)] + + +@router.get("/{receipt_id}", response_model=ReceiptOut) +async def get_receipt(receipt_id: uuid.UUID, session: SessionDep) -> ReceiptOut: + receipt = await session.get(Receipt, receipt_id) + if receipt is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Чек не найден") + return ReceiptOut.from_receipt(receipt) + + +@router.post("/{receipt_id}/cancel", response_model=ReceiptOut) +async def cancel_receipt( + receipt_id: uuid.UUID, + request: Request, + session: SessionDep, + checkbox: CheckboxClientDep, + actor: CashierUser, +) -> ReceiptOut: + try: + receipt = await receipts_service.cancel_receipt( + session, checkbox, receipt_id, user=actor, request=request + ) + except receipts_service.ReceiptStateError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + except CheckboxError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Checkbox: {exc}" + ) from exc + if receipt is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Чек не найден") + return ReceiptOut.from_receipt(receipt) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 91ca6b6..d281b3d 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -2,10 +2,12 @@ from fastapi import APIRouter -from app.api.v1 import auth, health, orders, users +from app.api.v1 import auth, cash_registers, health, orders, receipts, users api_router = APIRouter() api_router.include_router(health.router) api_router.include_router(auth.router) api_router.include_router(users.router) api_router.include_router(orders.router) +api_router.include_router(receipts.router) +api_router.include_router(cash_registers.router) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index ac5a489..f26ef28 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -65,6 +65,14 @@ class Settings(BaseSettings): # --- Nova Poshta --- nova_poshta_api_key: str = "" + # --- Checkbox --- + checkbox_base_url: str = "https://api.checkbox.ua" + checkbox_client_name: str = "lux_fiscal" + checkbox_client_version: str = "0.1.0" + # Стаб вместо реального Checkbox: ЕТТН работает только на боевой кассе, + # поэтому локально весь цикл прогоняется через стаб. В проде запрещено. + checkbox_use_stub: bool = False + @property def cors_origins(self) -> list[str]: """Список разрешённых origin'ов из строки через запятую.""" diff --git a/backend/app/db/models/__init__.py b/backend/app/db/models/__init__.py index 5f1d3a2..3549514 100644 --- a/backend/app/db/models/__init__.py +++ b/backend/app/db/models/__init__.py @@ -6,14 +6,19 @@ Alembic автогенерирует миграции по `Base.metadata`, по from app.db.base import Base from app.db.models.audit import AuditAction, AuditLog +from app.db.models.cash_register import CashRegister from app.db.models.order import Order +from app.db.models.receipt import Receipt, ReceiptStatus from app.db.models.user import RefreshToken, User, UserRole __all__ = [ "AuditAction", "AuditLog", "Base", + "CashRegister", "Order", + "Receipt", + "ReceiptStatus", "RefreshToken", "User", "UserRole", diff --git a/backend/app/db/models/audit.py b/backend/app/db/models/audit.py index f348f0d..39a2a42 100644 --- a/backend/app/db/models/audit.py +++ b/backend/app/db/models/audit.py @@ -32,6 +32,10 @@ class AuditAction(str): USER_UPDATED = "user.updated" USER_DEACTIVATED = "user.deactivated" ORDER_DELETED = "order.deleted" + CASH_REGISTER_CREATED = "cash_register.created" + CASH_REGISTER_UPDATED = "cash_register.updated" + RECEIPT_CREATE_REQUESTED = "receipt.create_requested" + RECEIPT_CANCELLED = "receipt.cancelled" class AuditLog(UUIDPrimaryKeyMixin, Base): diff --git a/backend/app/db/models/cash_register.py b/backend/app/db/models/cash_register.py new file mode 100644 index 0000000..156f68e --- /dev/null +++ b/backend/app/db/models/cash_register.py @@ -0,0 +1,39 @@ +"""Кассы (ПРРО) Checkbox. + +Ключ лицензии и PIN кассира хранятся только в зашифрованном виде +(`app/core/crypto.py`): утечка дампа БД не должна давать доступ к кассе. +""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy import Boolean, Index, String, text +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin + + +class CashRegister(UUIDPrimaryKeyMixin, TimestampMixin, Base): + __tablename__ = "cash_registers" + + name: Mapped[str] = mapped_column(String(255), nullable=False) + fiscal_number: Mapped[str | None] = mapped_column(String(64)) + license_key_enc: Mapped[str] = mapped_column(String(512), nullable=False) + cashier_pin_enc: Mapped[str] = mapped_column(String(512), nullable=False) + # Коды налоговых ставок Checkbox для всех товаров чека; пусто — поле `tax` + # не передаётся (неплательщик ПДВ). + tax_codes: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + + __table_args__ = ( + # Касса по умолчанию может быть только одна. + Index( + "uq_cash_registers_default", + "is_default", + unique=True, + postgresql_where=text("is_default"), + ), + ) diff --git a/backend/app/db/models/receipt.py b/backend/app/db/models/receipt.py new file mode 100644 index 0000000..b42177b --- /dev/null +++ b/backend/app/db/models/receipt.py @@ -0,0 +1,83 @@ +"""ЕТТН-чеки Checkbox по ТТН Nova Poshta с контролем оплаты. + +Чек создаётся в Checkbox как шаблон, привязанный к ТТН; фискализирует его сам +Checkbox, когда клиент оплачивает посылку в отделении. Здесь — наша сторона: +кто и когда запросил, что отправили и в каком статусе чек сейчас. + +Машина состояний: + pending ──► created ──► done | returned | receipt_error | cancelled + └──────► failed + created ──(отмена кассиром)──► cancelled +Из `failed`/`cancelled` заказ возвращается в очередь и чек можно создать заново. +""" + +from __future__ import annotations + +import enum +import uuid +from datetime import datetime +from typing import Any + +from sqlalchemy import BigInteger, DateTime, Enum, ForeignKey, Index, String, Text, text +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin + + +class ReceiptStatus(enum.StrEnum): + PENDING = "pending" # запись создана, задача в очереди worker'а + CREATED = "created" # Checkbox принял ЕТТН-чек, ждём оплату посылки + DONE = "done" # Checkbox фискализировал чек + RETURNED = "returned" # посылка вернулась отправителю + RECEIPT_ERROR = "receipt_error" # Checkbox не смог фискализировать + CANCELLED = "cancelled" # отменён (кассиром или в Checkbox) + FAILED = "failed" # Checkbox отклонил создание + + +# Статусы, при которых у заказа нет «живого» чека и его можно создать заново. +CLOSED_STATUSES = (ReceiptStatus.CANCELLED, ReceiptStatus.FAILED) + + +class Receipt(UUIDPrimaryKeyMixin, TimestampMixin, Base): + __tablename__ = "receipts" + + order_id: Mapped[str] = mapped_column( + ForeignKey("orders.id", ondelete="RESTRICT"), index=True, nullable=False + ) + cash_register_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("cash_registers.id", ondelete="RESTRICT"), index=True, nullable=False + ) + created_by_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL") + ) + + waybill_number: Mapped[str] = mapped_column(String(64), nullable=False) + total_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False) + prepayment_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False) + cod_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False) + + status: Mapped[ReceiptStatus] = mapped_column( + Enum(ReceiptStatus, name="receipt_status", values_callable=lambda e: [i.value for i in e]), + nullable=False, + default=ReceiptStatus.PENDING, + index=True, + ) + checkbox_ettn_id: Mapped[str | None] = mapped_column(String(64), index=True) + checkbox_status: Mapped[str | None] = mapped_column(String(32)) + checkbox_receipt_id: Mapped[str | None] = mapped_column(String(64)) + error: Mapped[str | None] = mapped_column(Text) + # Снимок отправленного в Checkbox тела — для разбора спорных случаев. + request_body: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict) + last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + __table_args__ = ( + # Не больше одного «живого» чека на заказ — защита от двойного нажатия + # и гонки между кассирами на уровне БД. + Index( + "uq_receipts_active_order", + "order_id", + unique=True, + postgresql_where=text("status NOT IN ('cancelled', 'failed')"), + ), + ) diff --git a/backend/app/main.py b/backend/app/main.py index c0a13fb..ca94427 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -16,6 +16,7 @@ from app.api.v1.router import api_router from app.core.config import settings from app.core.logging import configure_logging, get_logger from app.db.session import engine +from app.services.task_queue import close_task_queue log = get_logger(__name__) @@ -25,6 +26,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: configure_logging() log.info("app_starting", environment=settings.environment, version=app.version) yield + await close_task_queue() await engine.dispose() log.info("app_stopped") diff --git a/backend/app/schemas/checkbox.py b/backend/app/schemas/checkbox.py new file mode 100644 index 0000000..7c475d1 --- /dev/null +++ b/backend/app/schemas/checkbox.py @@ -0,0 +1,32 @@ +"""Схемы Checkbox API для ЕТТН-чеков (`/api/v1/ettn`). + +Тело запроса собирается как обычный dict в `app/services/receipts.py` — +его снимок сохраняется в `receipts.request_body` без преобразований. +Здесь — только разбор ответа (`BaseEttnResponse`). +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class EttnStatus: + """Статусы ЕТТН-чека в Checkbox (`OrderStatus`).""" + + CREATED = "CREATED" + CANCELLED = "CANCELLED" + RECEIPT_ERROR = "RECEIPT_ERROR" + DONE = "DONE" + DONE_WITHOUT_SMS = "DONE_WITHOUT_SMS" + RETURNED = "RETURNED" + + +class EttnOut(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + id: str + status: str + ettn_number: str = Field(alias="ettnNumber") + total_sum: int | None = Field(default=None, alias="totalSum") + receipt_id: str | None = Field(default=None, alias="receiptId") + raw_error: str | None = Field(default=None, alias="rawError") diff --git a/backend/app/schemas/orders.py b/backend/app/schemas/orders.py index 8c1d66c..3eb51af 100644 --- a/backend/app/schemas/orders.py +++ b/backend/app/schemas/orders.py @@ -16,6 +16,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator if TYPE_CHECKING: from app.db.models.order import Order + from app.db.models.receipt import Receipt class OrderGoodOut(BaseModel): @@ -82,9 +83,14 @@ class OrderRowOut(BaseModel): np_status_code: str | None np_cod_amount: str | None np_payment_status: str | None + # Последний ЕТТН-чек по заказу (в т.ч. отменённый/неудачный — для показа причины). + receipt_id: str | None = None + receipt_status: str | None = None + receipt_error: str | None = None + receipt_prepayment: str | None = None @classmethod - def from_order(cls, order: Order) -> OrderRowOut: + def from_order(cls, order: Order, receipt: Receipt | None = None) -> OrderRowOut: return cls( id=order.id, create_date_time=order.create_date_time, @@ -104,4 +110,10 @@ class OrderRowOut(BaseModel): else None ), np_payment_status=order.np_payment_status, + receipt_id=str(receipt.id) if receipt else None, + receipt_status=receipt.status.value if receipt else None, + receipt_error=receipt.error if receipt else None, + receipt_prepayment=( + f"{receipt.prepayment_kopecks / 100:.2f}" if receipt else None + ), ) diff --git a/backend/app/schemas/receipts.py b/backend/app/schemas/receipts.py new file mode 100644 index 0000000..3a9f279 --- /dev/null +++ b/backend/app/schemas/receipts.py @@ -0,0 +1,117 @@ +"""Схемы API ЕТТН-чеков и касс Checkbox.""" + +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from app.core import crypto +from app.db.models.cash_register import CashRegister +from app.db.models.receipt import Receipt, ReceiptStatus + + +def _money(kopecks: int) -> str: + return f"{kopecks / 100:.2f}" + + +class ReceiptOut(BaseModel): + id: uuid.UUID + order_id: str + waybill_number: str + status: ReceiptStatus + total_amount: str + prepayment_amount: str + cod_amount: str + checkbox_ettn_id: str | None + checkbox_status: str | None + checkbox_receipt_id: str | None + error: str | None + created_at: datetime + last_checked_at: datetime | None + + @classmethod + def from_receipt(cls, receipt: Receipt) -> ReceiptOut: + return cls( + id=receipt.id, + order_id=receipt.order_id, + waybill_number=receipt.waybill_number, + status=receipt.status, + total_amount=_money(receipt.total_kopecks), + prepayment_amount=_money(receipt.prepayment_kopecks), + cod_amount=_money(receipt.cod_kopecks), + checkbox_ettn_id=receipt.checkbox_ettn_id, + checkbox_status=receipt.checkbox_status, + checkbox_receipt_id=receipt.checkbox_receipt_id, + error=receipt.error, + created_at=receipt.created_at, + last_checked_at=receipt.last_checked_at, + ) + + +class ReceiptRequestItem(BaseModel): + order_id: str = Field(min_length=1, max_length=32) + # None — предоплата = сумма заказа − наложка НП. + prepayment_kopecks: int | None = Field(default=None, ge=0) + + +class ReceiptCreateRequest(BaseModel): + items: list[ReceiptRequestItem] = Field(min_length=1, max_length=200) + + +class ReceiptCreateResponse(BaseModel): + created: list[ReceiptOut] + # order_id → причина, по которой чек не создан. + errors: dict[str, str] + + +# --- Кассы ------------------------------------------------------------------- + + +class CashRegisterOut(BaseModel): + id: uuid.UUID + name: str + fiscal_number: str | None + license_key_masked: str + tax_codes: list[Any] + is_active: bool + is_default: bool + + @classmethod + def from_register(cls, register: CashRegister) -> CashRegisterOut: + try: + masked = crypto.mask(crypto.decrypt(register.license_key_enc)) + except crypto.DecryptionError: + masked = "не расшифровывается — введите заново" + return cls( + id=register.id, + name=register.name, + fiscal_number=register.fiscal_number, + license_key_masked=masked, + tax_codes=register.tax_codes, + is_active=register.is_active, + is_default=register.is_default, + ) + + +class CashRegisterCreate(BaseModel): + name: str = Field(min_length=1, max_length=255) + fiscal_number: str | None = Field(default=None, max_length=64) + license_key: str = Field(min_length=1, max_length=255) + pin_code: str = Field(min_length=1, max_length=32) + tax_codes: list[int | str] = Field(default_factory=list, max_length=2) + is_default: bool = True + + +class CashRegisterUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str | None = Field(default=None, min_length=1, max_length=255) + fiscal_number: str | None = Field(default=None, max_length=64) + license_key: str | None = Field(default=None, min_length=1, max_length=255) + pin_code: str | None = Field(default=None, min_length=1, max_length=32) + tax_codes: list[int | str] | None = Field(default=None, max_length=2) + is_active: bool | None = None + is_default: bool | None = None diff --git a/backend/app/services/audit.py b/backend/app/services/audit.py index 79cfe98..8eea58f 100644 --- a/backend/app/services/audit.py +++ b/backend/app/services/audit.py @@ -22,6 +22,9 @@ _REDACTED_KEYS = { "secret", "license_key", "api_key", + "pin", + "pin_code", + "cashier_pin", } diff --git a/backend/app/services/checkbox/__init__.py b/backend/app/services/checkbox/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/checkbox/client.py b/backend/app/services/checkbox/client.py new file mode 100644 index 0000000..937def6 --- /dev/null +++ b/backend/app/services/checkbox/client.py @@ -0,0 +1,61 @@ +"""Protocol клиента Checkbox — позволяет подменять реализацию в тестах и локально. + +См. `StubCheckboxClient`. Выбор реализации — `get_checkbox_client()` ниже, +единственное место, читающее `CHECKBOX_USE_STUB`. +""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass +from functools import lru_cache +from typing import Any, Protocol + +from app.core.config import settings +from app.schemas.checkbox import EttnOut + + +class CheckboxError(Exception): + """Checkbox отклонил запрос (4xx) — повтор с тем же телом не поможет.""" + + +class CheckboxUnavailableError(CheckboxError): + """Сеть, таймаут или 5xx — исход запроса неизвестен, можно повторить.""" + + +@dataclass(frozen=True) +class CheckboxCredentials: + """Расшифрованные доступы одной кассы. Живут только в памяти.""" + + cash_register_id: uuid.UUID + license_key: str + pin_code: str + + +class CheckboxClient(Protocol): + async def sign_in(self, creds: CheckboxCredentials) -> None: ... + + async def create_ettn(self, creds: CheckboxCredentials, body: dict[str, Any]) -> EttnOut: ... + + async def get_ettn(self, creds: CheckboxCredentials, ettn_id: str) -> EttnOut: ... + + async def find_ettn( + self, creds: CheckboxCredentials, waybill_number: str + ) -> EttnOut | None: ... + + async def delete_ettn(self, creds: CheckboxCredentials, ettn_id: str) -> None: ... + + +@lru_cache +def get_checkbox_client() -> CheckboxClient: + """Один экземпляр на процесс: внутри — кэш токенов кассиров.""" + if settings.checkbox_use_stub: + if settings.is_production: + raise RuntimeError("CHECKBOX_USE_STUB=true запрещён в production") + from app.services.checkbox.stub_client import StubCheckboxClient + + return StubCheckboxClient(auto_complete_after=2) + + from app.services.checkbox.http_client import HttpCheckboxClient + + return HttpCheckboxClient(settings) diff --git a/backend/app/services/checkbox/http_client.py b/backend/app/services/checkbox/http_client.py new file mode 100644 index 0000000..3521239 --- /dev/null +++ b/backend/app/services/checkbox/http_client.py @@ -0,0 +1,164 @@ +"""Реальный клиент Checkbox (ЕТТН-чеки, `/api/v1/ettn`). + +Авторизация — токен кассира по PIN-коду (`/api/v1/cashier/signinPinCode`). +Токен кэшируется в памяти процесса по кассе; на 401 — один повторный вход. +""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime, timedelta +from typing import Any + +import httpx + +from app.core.config import Settings +from app.schemas.checkbox import EttnOut, EttnStatus +from app.services.checkbox.client import ( + CheckboxCredentials, + CheckboxError, + CheckboxUnavailableError, +) + +_PROVIDER = "novapost" +_TIMEOUT = httpx.Timeout(30, connect=10) +# Сколько страниц списка ЕТТН просматривать при сверке после таймаута. +_FIND_PAGES = 5 +_FIND_PAGE_SIZE = 100 + + +def _error_message(response: httpx.Response) -> str: + try: + data = response.json() + except ValueError: + return f"HTTP {response.status_code}: {response.text[:500]}" + if isinstance(data, dict): + message = data.get("message") + detail = data.get("detail") + if isinstance(detail, list) and detail: + parts = [ + f"{'.'.join(str(p) for p in item.get('loc', []))}: {item.get('msg')}" + for item in detail + if isinstance(item, dict) + ] + return "; ".join(filter(None, [message, *parts])) + if message: + return str(message) + if detail: + return str(detail) + return f"HTTP {response.status_code}" + + +class HttpCheckboxClient: + def __init__(self, settings: Settings) -> None: + self._base_url = settings.checkbox_base_url.rstrip("/") + self._client_headers = { + "X-Client-Name": settings.checkbox_client_name, + "X-Client-Version": settings.checkbox_client_version, + } + self._tokens: dict[uuid.UUID, str] = {} + + async def _send( + self, + method: str, + path: str, + *, + headers: dict[str, str], + json: Any = None, + params: dict[str, Any] | None = None, + ) -> httpx.Response: + try: + async with httpx.AsyncClient(base_url=self._base_url, timeout=_TIMEOUT) as client: + response = await client.request( + method, path, headers=headers, json=json, params=params + ) + except httpx.HTTPError as exc: + raise CheckboxUnavailableError(f"Checkbox недоступен: {exc!r}") from exc + if response.status_code >= 500: + raise CheckboxUnavailableError(_error_message(response)) + return response + + async def _sign_in(self, creds: CheckboxCredentials) -> str: + response = await self._send( + "POST", + "/api/v1/cashier/signinPinCode", + headers={**self._client_headers, "X-License-Key": creds.license_key}, + json={"pin_code": creds.pin_code}, + ) + if response.status_code != 200: + raise CheckboxError(f"Вход кассира не удался: {_error_message(response)}") + token = response.json()["access_token"] + self._tokens[creds.cash_register_id] = token + return token + + async def _request( + self, + creds: CheckboxCredentials, + method: str, + path: str, + *, + json: Any = None, + params: dict[str, Any] | None = None, + ) -> httpx.Response: + token = self._tokens.get(creds.cash_register_id) or await self._sign_in(creds) + for attempt in range(2): + response = await self._send( + method, + path, + headers={ + **self._client_headers, + "X-License-Key": creds.license_key, + "Authorization": f"Bearer {token}", + }, + json=json, + params=params, + ) + if response.status_code == 401 and attempt == 0: + token = await self._sign_in(creds) + continue + break + if response.status_code >= 400: + raise CheckboxError(_error_message(response)) + return response + + async def sign_in(self, creds: CheckboxCredentials) -> None: + await self._sign_in(creds) + + async def create_ettn(self, creds: CheckboxCredentials, body: dict[str, Any]) -> EttnOut: + response = await self._request(creds, "POST", "/api/v1/ettn", json=body) + return EttnOut.model_validate(response.json()) + + async def get_ettn(self, creds: CheckboxCredentials, ettn_id: str) -> EttnOut: + response = await self._request(creds, "GET", f"/api/v1/ettn/{ettn_id}") + return EttnOut.model_validate(response.json()) + + async def find_ettn(self, creds: CheckboxCredentials, waybill_number: str) -> EttnOut | None: + """Ищет неотменённый ЕТТН-чек по номеру ТТН за последнюю неделю. + + Нужен после таймаута создания: POST мог дойти до Checkbox, и слепой + повтор создал бы второй чек на ту же ТТН. + """ + date_from = (datetime.now(UTC) - timedelta(days=7)).isoformat() + for page in range(_FIND_PAGES): + response = await self._request( + creds, + "GET", + "/api/v1/ettn", + params={ + "provider": _PROVIDER, + "date_from": date_from, + "limit": _FIND_PAGE_SIZE, + "offset": page * _FIND_PAGE_SIZE, + }, + ) + items = response.json() + for item in items: + ettn = EttnOut.model_validate(item) + if ettn.ettn_number == waybill_number and ettn.status != EttnStatus.CANCELLED: + return ettn + if len(items) < _FIND_PAGE_SIZE: + break + return None + + async def delete_ettn(self, creds: CheckboxCredentials, ettn_id: str) -> None: + await self._request(creds, "DELETE", f"/api/v1/ettn/{ettn_id}") diff --git a/backend/app/services/checkbox/stub_client.py b/backend/app/services/checkbox/stub_client.py new file mode 100644 index 0000000..71d66fa --- /dev/null +++ b/backend/app/services/checkbox/stub_client.py @@ -0,0 +1,73 @@ +"""Стаб Checkbox для тестов и локальной разработки. + +ЕТТН-чеки на тестовой кассе Checkbox не работают, поэтому полный цикл без +боевой кассы прогоняется только через стаб. Состояние — в памяти процесса. + +`auto_complete_after=N`: чек переходит в `DONE` на N-м вызове `get_ettn` — +имитирует получение посылки клиентом. +""" + +from __future__ import annotations + +import uuid +from typing import Any + +from app.schemas.checkbox import EttnOut, EttnStatus +from app.services.checkbox.client import CheckboxCredentials, CheckboxError + + +class StubCheckboxClient: + def __init__(self, *, auto_complete_after: int | None = None) -> None: + self.auto_complete_after = auto_complete_after + self.orders: dict[str, EttnOut] = {} + self.bodies: dict[str, dict[str, Any]] = {} + self._polls: dict[str, int] = {} + + def set_status(self, ettn_id: str, status: str, *, raw_error: str | None = None) -> None: + ettn = self.orders[ettn_id] + receipt_id = str(uuid.uuid4()) if status.startswith(EttnStatus.DONE) else ettn.receipt_id + self.orders[ettn_id] = ettn.model_copy( + update={"status": status, "receipt_id": receipt_id, "raw_error": raw_error} + ) + + async def sign_in(self, creds: CheckboxCredentials) -> None: + if not creds.license_key or not creds.pin_code: + raise CheckboxError("Вход кассира не удался: пустой ключ или PIN") + + async def create_ettn(self, creds: CheckboxCredentials, body: dict[str, Any]) -> EttnOut: + waybill = body["receipt_body"]["payments"][0]["ettn"] + if await self.find_ettn(creds, waybill) is not None: + raise CheckboxError(f"ЕТТН {waybill} уже привязана к чеку") + ettn = EttnOut( + id=str(uuid.uuid4()), + status=EttnStatus.CREATED, + ettn_number=waybill, + total_sum=body["receipt_body"]["payments"][0]["value"], + ) + self.orders[ettn.id] = ettn + self.bodies[ettn.id] = body + return ettn + + async def get_ettn(self, creds: CheckboxCredentials, ettn_id: str) -> EttnOut: + if ettn_id not in self.orders: + raise CheckboxError(f"ЕТТН-чек {ettn_id} не найден") + self._polls[ettn_id] = self._polls.get(ettn_id, 0) + 1 + if ( + self.auto_complete_after is not None + and self.orders[ettn_id].status == EttnStatus.CREATED + and self._polls[ettn_id] >= self.auto_complete_after + ): + self.set_status(ettn_id, EttnStatus.DONE) + return self.orders[ettn_id] + + async def find_ettn(self, creds: CheckboxCredentials, waybill_number: str) -> EttnOut | None: + for ettn in self.orders.values(): + if ettn.ettn_number == waybill_number and ettn.status != EttnStatus.CANCELLED: + return ettn + return None + + async def delete_ettn(self, creds: CheckboxCredentials, ettn_id: str) -> None: + # API и worker — разные процессы с разными стабами: неизвестный id + # считаем уже удалённым, иначе локально отмена не работала бы. + if ettn_id in self.orders: + self.set_status(ettn_id, EttnStatus.CANCELLED) diff --git a/backend/app/services/receipts.py b/backend/app/services/receipts.py new file mode 100644 index 0000000..c073d1f --- /dev/null +++ b/backend/app/services/receipts.py @@ -0,0 +1,490 @@ +"""ЕТТН-чеки Checkbox: создание по ТТН с контролем оплаты, отмена, опрос статусов. + +Чек не фискализируется нами — Checkbox делает это сам, когда клиент оплачивает +посылку в отделении НП. Поэтому ЕТТН-чек должен быть создан до получения посылки, +а сумма к оплате (наложка НП) обязана совпасть с суммой чека: + + сумма товаров − скидки − предоплата == np_cod_amount_kopecks + +Создание двухфазное: API пишет `Receipt(pending)` (+ аудит) и ставит задачу, +worker вызывает Checkbox (`create_ettn_for_receipt`). Так кассир не ждёт сеть, +а сбой Checkbox не теряет запрос — `retry_pending_receipts` его повторит. +""" + +from __future__ import annotations + +import re +import uuid +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from decimal import ROUND_HALF_UP, Decimal +from typing import Any + +from fastapi import Request +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core import crypto +from app.core.logging import get_logger +from app.db.models.audit import AuditAction +from app.db.models.cash_register import CashRegister +from app.db.models.order import Order +from app.db.models.receipt import CLOSED_STATUSES, Receipt, ReceiptStatus +from app.db.models.user import User +from app.schemas.checkbox import EttnOut, EttnStatus +from app.services import audit +from app.services.checkbox.client import ( + CheckboxClient, + CheckboxCredentials, + CheckboxError, + CheckboxUnavailableError, +) + +log = get_logger(__name__) + +# Коды статусов НП, при которых ЕТТН-чек создавать поздно или бессмысленно: +# посылка уже получена, возвращается/возвращена или ТТН удалена. +NP_FINAL_STATUS_CODES = frozenset( + {"2", "9", "10", "11", "102", "103", "105", "106", "108"} +) + +# Статус Checkbox → наш статус. CREATED не меняет ничего. +_CHECKBOX_TO_STATUS = { + EttnStatus.DONE: ReceiptStatus.DONE, + EttnStatus.DONE_WITHOUT_SMS: ReceiptStatus.DONE, + EttnStatus.RETURNED: ReceiptStatus.RETURNED, + EttnStatus.RECEIPT_ERROR: ReceiptStatus.RECEIPT_ERROR, + EttnStatus.CANCELLED: ReceiptStatus.CANCELLED, +} + +# Сколько ждать, прежде чем считать `pending`-чек зависшим (задача потеряна +# или Checkbox был недоступен) и повторить его из cron'а. +_PENDING_RETRY_AFTER = timedelta(minutes=1) + + +class ReceiptValidationError(Exception): + """Заказ нельзя отправить в Checkbox — сообщение показывается кассиру.""" + + +# --- Деньги и количества ----------------------------------------------------- + + +def to_kopecks(amount: str | None) -> int: + if not amount: + return 0 + return int((Decimal(amount) * 100).to_integral_value(ROUND_HALF_UP)) + + +def to_thousandths(quantity: str) -> int: + return int((Decimal(quantity) * 1000).to_integral_value(ROUND_HALF_UP)) + + +def _line_sum(price_kopecks: int, quantity: int) -> int: + return int((Decimal(price_kopecks) * quantity / 1000).to_integral_value(ROUND_HALF_UP)) + + +def _normalize_phone(phone: str | None) -> str | None: + """Телефон для отправки чека: Checkbox принимает только `+?380\\d{9}`.""" + if not phone: + return None + digits = re.sub(r"\D", "", phone) + if len(digits) == 10 and digits.startswith("0"): + digits = "38" + digits + return f"+{digits}" if re.fullmatch(r"380\d{9}", digits) else None + + +# --- Тело запроса ------------------------------------------------------------ + + +@dataclass(frozen=True) +class ReceiptAmounts: + total_kopecks: int # к оплате по заказу после всех скидок + prepayment_kopecks: int + cod_kopecks: int # наложка НП = total − prepayment + + +def build_goods(order: Order, tax_codes: list[Any]) -> tuple[list[dict[str, Any]], int]: + """Товары CRM → `goods` Checkbox. Возвращает позиции и их сумму после скидок.""" + items: list[dict[str, Any]] = [] + total = 0 + for good in order.goods: + price = to_kopecks(good["price"]) + quantity = to_thousandths(good["quantity"]) + if quantity <= 0: + raise ReceiptValidationError(f"Товар «{good['name']}»: количество должно быть больше 0") + gross = _line_sum(price, quantity) + # `amount` — сумма строки после скидки CRM; скидку выводим из неё, + # а не из discount_amount/percent, чтобы не расходиться с итогом CRM. + net = to_kopecks(good.get("amount")) if good.get("amount") else gross + discount = gross - net + if discount < 0: + raise ReceiptValidationError( + f"Товар «{good['name']}»: сумма строки больше цены × количество" + ) + + payload: dict[str, Any] = { + "code": good.get("sku") or good["id"], + "name": good["name"], + "price": price, + } + if tax_codes: + payload["tax"] = list(tax_codes) + item: dict[str, Any] = {"good": payload, "quantity": quantity, "is_return": False} + if discount: + item["discounts"] = [{"type": "DISCOUNT", "mode": "VALUE", "value": discount}] + items.append(item) + total += net + return items, total + + +def build_ettn_body( + order: Order, register: CashRegister, amounts: ReceiptAmounts +) -> dict[str, Any]: + goods, goods_total = build_goods(order, register.tax_codes) + discounts: list[dict[str, Any]] = [] + + # Скидка на весь заказ в CRM (не распределённая по строкам). + order_discount = goods_total - amounts.total_kopecks + if order_discount < 0: + raise ReceiptValidationError( + "Сумма товаров меньше суммы заказа — проверьте заказ в CRM" + ) + if order_discount: + discounts.append( + {"type": "DISCOUNT", "mode": "VALUE", "value": order_discount, "name": "Знижка"} + ) + if amounts.prepayment_kopecks: + discounts.append( + { + "type": "PRE_PAYMENT", + "mode": "VALUE", + "value": amounts.prepayment_kopecks, + "name": "Передоплата", + } + ) + + receipt_body: dict[str, Any] = { + "goods": goods, + "payments": [{"value": amounts.cod_kopecks, "ettn": order.waybill_number}], + } + if discounts: + receipt_body["discounts"] = discounts + + delivery: dict[str, Any] = {} + if phone := _normalize_phone(order.recipient_phone): + delivery["phone"] = phone + if order.recipient_email: + delivery["emails"] = [order.recipient_email] + if delivery: + receipt_body["delivery"] = delivery + + return {"provider": "novapost", "receipt_body": receipt_body} + + +def resolve_amounts(order: Order, prepayment_kopecks: int | None) -> ReceiptAmounts: + """Проверяет, что по заказу можно создать ЕТТН-чек, и считает суммы. + + `prepayment_kopecks=None` — взять разницу между суммой заказа и наложкой. + """ + if order.is_deleted: + raise ReceiptValidationError("Заказ удалён") + if not order.waybill_number: + raise ReceiptValidationError("У заказа нет ТТН") + if not order.np_cod_amount_kopecks: + raise ReceiptValidationError("По ТТН нет суммы контроля оплаты (наложки)") + if order.np_status_code in NP_FINAL_STATUS_CODES: + raise ReceiptValidationError( + f"Посылка уже не в пути ({order.np_status or order.np_status_code}) — " + "ЕТТН-чек создать нельзя" + ) + if not order.goods: + raise ReceiptValidationError("В заказе нет товаров") + + total = order.total_amount_kopecks + cod = order.np_cod_amount_kopecks + prepayment = total - cod if prepayment_kopecks is None else prepayment_kopecks + if prepayment < 0: + raise ReceiptValidationError( + f"Наложка {cod / 100:.2f} ₴ больше суммы заказа {total / 100:.2f} ₴" + ) + if total - prepayment != cod: + raise ReceiptValidationError( + f"Сумма заказа {total / 100:.2f} ₴ − предоплата {prepayment / 100:.2f} ₴ " + f"≠ наложка {cod / 100:.2f} ₴" + ) + return ReceiptAmounts(total_kopecks=total, prepayment_kopecks=prepayment, cod_kopecks=cod) + + +# --- Кассы ------------------------------------------------------------------- + + +def credentials(register: CashRegister) -> CheckboxCredentials: + return CheckboxCredentials( + cash_register_id=register.id, + license_key=crypto.decrypt(register.license_key_enc), + pin_code=crypto.decrypt(register.cashier_pin_enc), + ) + + +async def get_default_register(session: AsyncSession) -> CashRegister | None: + return await session.scalar( + select(CashRegister) + .where(CashRegister.is_active.is_(True)) + .order_by(CashRegister.is_default.desc(), CashRegister.created_at) + .limit(1) + ) + + +# --- Создание ---------------------------------------------------------------- + + +@dataclass +class RequestResult: + created: list[Receipt] + errors: dict[str, str] + + +async def _active_receipt_order_ids(session: AsyncSession, order_ids: list[str]) -> set[str]: + result = await session.scalars( + select(Receipt.order_id) + .where(Receipt.order_id.in_(order_ids)) + .where(Receipt.status.not_in(CLOSED_STATUSES)) + ) + return set(result) + + +async def request_receipts( + session: AsyncSession, + items: list[tuple[str, int | None]], + *, + user: User, + request: Request | None = None, +) -> RequestResult: + """Создаёт `Receipt(pending)` по каждому подходящему заказу. + + Без commit и без обращения к Checkbox: вызывающий код коммитит и ставит + задачи worker'у (`create_ettn_for_receipt`) по `result.created`. + Ошибки по отдельным заказам не мешают остальным — массовое действие. + """ + result = RequestResult(created=[], errors={}) + register = await get_default_register(session) + if register is None: + for order_id, _ in items: + result.errors[order_id] = "Не настроена касса Checkbox" + return result + + order_ids = [order_id for order_id, _ in items] + orders = { + order.id: order + for order in await session.scalars(select(Order).where(Order.id.in_(order_ids))) + } + busy = await _active_receipt_order_ids(session, order_ids) + + for order_id, prepayment in items: + order = orders.get(order_id) + if order is None: + result.errors[order_id] = "Заказ не найден" + continue + if order_id in busy: + result.errors[order_id] = "По заказу уже есть чек" + continue + try: + amounts = resolve_amounts(order, prepayment) + body = build_ettn_body(order, register, amounts) + except ReceiptValidationError as exc: + result.errors[order_id] = str(exc) + continue + + now = datetime.now(UTC) + receipt = Receipt( + id=uuid.uuid4(), + # Явно, а не server_default: после commit ответ API читает created_at, + # а незагруженный server_default в async-сессии не подгружается лениво. + created_at=now, + updated_at=now, + order_id=order.id, + cash_register_id=register.id, + created_by_id=user.id, + waybill_number=order.waybill_number, + total_kopecks=amounts.total_kopecks, + prepayment_kopecks=amounts.prepayment_kopecks, + cod_kopecks=amounts.cod_kopecks, + status=ReceiptStatus.PENDING, + request_body=body, + ) + session.add(receipt) + order.receipt_created_at = now + busy.add(order_id) + await audit.record( + session, + action=AuditAction.RECEIPT_CREATE_REQUESTED, + user=user, + entity_type="receipt", + entity_id=receipt.id, + payload={ + "order_id": order.id, + "waybill_number": order.waybill_number, + "cod_kopecks": amounts.cod_kopecks, + "prepayment_kopecks": amounts.prepayment_kopecks, + }, + request=request, + ) + result.created.append(receipt) + + return result + + +async def _release_order(session: AsyncSession, order_id: str) -> None: + """Возвращает заказ в очередь «Без чека».""" + order = await session.get(Order, order_id) + if order is not None: + order.receipt_created_at = None + + +def _apply_ettn(receipt: Receipt, ettn: EttnOut) -> None: + receipt.checkbox_ettn_id = ettn.id + receipt.checkbox_status = ettn.status + receipt.last_checked_at = datetime.now(UTC) + if ettn.receipt_id: + receipt.checkbox_receipt_id = ettn.receipt_id + new_status = _CHECKBOX_TO_STATUS.get(ettn.status) + if new_status is not None: + receipt.status = new_status + elif receipt.status == ReceiptStatus.PENDING: + receipt.status = ReceiptStatus.CREATED + receipt.error = ettn.raw_error if new_status == ReceiptStatus.RECEIPT_ERROR else None + + +async def create_ettn_for_receipt( + session: AsyncSession, client: CheckboxClient, receipt_id: uuid.UUID +) -> Receipt | None: + """Отправляет `pending`-чек в Checkbox. Идемпотентна — безопасно вызывать повторно.""" + receipt = await session.get(Receipt, receipt_id, with_for_update=True) + if receipt is None or receipt.status != ReceiptStatus.PENDING: + return receipt + + register = await session.get(CashRegister, receipt.cash_register_id) + assert register is not None # FK + creds = credentials(register) + + try: + # Непустой `error` у pending-чека — прошлая попытка закончилась + # неизвестным исходом: сначала ищем, не создан ли чек уже. + existing = ( + await client.find_ettn(creds, receipt.waybill_number) if receipt.error else None + ) + ettn = existing or await client.create_ettn(creds, receipt.request_body) + except CheckboxUnavailableError as exc: + receipt.error = str(exc) + await session.commit() + log.warning("checkbox_unavailable", receipt_id=str(receipt.id), error=str(exc)) + return receipt + except CheckboxError as exc: + receipt.status = ReceiptStatus.FAILED + receipt.error = str(exc) + await _release_order(session, receipt.order_id) + await session.commit() + log.warning("ettn_create_failed", receipt_id=str(receipt.id), error=str(exc)) + return receipt + + _apply_ettn(receipt, ettn) + if receipt.status in CLOSED_STATUSES: + await _release_order(session, receipt.order_id) + await session.commit() + log.info("ettn_created", receipt_id=str(receipt.id), ettn_id=ettn.id) + return receipt + + +async def retry_pending_receipts(session: AsyncSession, client: CheckboxClient) -> None: + """Повторяет зависшие `pending`-чеки (потерянная задача, недоступный Checkbox).""" + threshold = datetime.now(UTC) - _PENDING_RETRY_AFTER + receipt_ids = list( + await session.scalars( + select(Receipt.id) + .where(Receipt.status == ReceiptStatus.PENDING) + .where(Receipt.updated_at < threshold) + ) + ) + for receipt_id in receipt_ids: + await create_ettn_for_receipt(session, client, receipt_id) + + +# --- Отмена и опрос ---------------------------------------------------------- + + +class ReceiptStateError(Exception): + """Действие недопустимо в текущем статусе чека.""" + + +async def cancel_receipt( + session: AsyncSession, + client: CheckboxClient, + receipt_id: uuid.UUID, + *, + user: User, + request: Request | None = None, +) -> Receipt | None: + """Удаляет ЕТТН-чек в Checkbox и возвращает заказ в очередь. Коммитит сам.""" + receipt = await session.get(Receipt, receipt_id, with_for_update=True) + if receipt is None: + return None + if receipt.status not in (ReceiptStatus.CREATED, ReceiptStatus.RECEIPT_ERROR): + raise ReceiptStateError(f"Чек в статусе «{receipt.status}» отменить нельзя") + + if receipt.checkbox_ettn_id: + register = await session.get(CashRegister, receipt.cash_register_id) + assert register is not None + await client.delete_ettn(credentials(register), receipt.checkbox_ettn_id) + + receipt.status = ReceiptStatus.CANCELLED + receipt.checkbox_status = EttnStatus.CANCELLED + await _release_order(session, receipt.order_id) + await audit.record( + session, + action=AuditAction.RECEIPT_CANCELLED, + user=user, + entity_type="receipt", + entity_id=receipt.id, + payload={"order_id": receipt.order_id, "waybill_number": receipt.waybill_number}, + request=request, + ) + await session.commit() + return receipt + + +async def sync_ettn_statuses(session: AsyncSession, client: CheckboxClient) -> None: + """Опрашивает Checkbox по чекам, ожидающим оплаты посылки (`created`).""" + receipts = list( + await session.scalars(select(Receipt).where(Receipt.status == ReceiptStatus.CREATED)) + ) + registers: dict[uuid.UUID, CheckboxCredentials] = {} + for receipt in receipts: + if not receipt.checkbox_ettn_id: + continue + creds = registers.get(receipt.cash_register_id) + if creds is None: + register = await session.get(CashRegister, receipt.cash_register_id) + assert register is not None + creds = registers[receipt.cash_register_id] = credentials(register) + try: + ettn = await client.get_ettn(creds, receipt.checkbox_ettn_id) + except CheckboxError as exc: + log.warning("ettn_poll_failed", receipt_id=str(receipt.id), error=str(exc)) + continue + _apply_ettn(receipt, ettn) + if receipt.status in CLOSED_STATUSES: + await _release_order(session, receipt.order_id) + + await session.commit() + + +async def latest_receipts_by_order( + session: AsyncSession, order_ids: list[str] +) -> dict[str, Receipt]: + """Последний чек по каждому заказу — для колонок очереди.""" + if not order_ids: + return {} + receipts = await session.scalars( + select(Receipt).where(Receipt.order_id.in_(order_ids)).order_by(Receipt.created_at) + ) + return {receipt.order_id: receipt for receipt in receipts} diff --git a/backend/app/services/task_queue.py b/backend/app/services/task_queue.py new file mode 100644 index 0000000..a07b5ab --- /dev/null +++ b/backend/app/services/task_queue.py @@ -0,0 +1,53 @@ +"""Постановка задач ARQ worker'у из API. + +Пул Redis создаётся лениво при первой задаче, а не в lifespan: недоступный +Redis не должен ронять старт API. Если задачу поставить не удалось, чек +остаётся `pending`, и cron `poll_receipts` подхватит его в течение минуты. +""" + +from __future__ import annotations + +from typing import Any, Protocol + +from arq import ArqRedis, create_pool +from arq.connections import RedisSettings + +from app.core.config import settings +from app.core.logging import get_logger + +log = get_logger(__name__) + + +class TaskQueue(Protocol): + async def enqueue(self, function: str, *args: Any) -> None: ... + + +class ArqTaskQueue: + def __init__(self) -> None: + self._pool: ArqRedis | None = None + + async def enqueue(self, function: str, *args: Any) -> None: + try: + if self._pool is None: + self._pool = await create_pool( + RedisSettings.from_dsn(settings.redis_url), retry=0 + ) + await self._pool.enqueue_job(function, *args) + except Exception as exc: # noqa: BLE001 — см. докстринг модуля + log.warning("enqueue_failed", function=function, error=repr(exc)) + + async def close(self) -> None: + if self._pool is not None: + await self._pool.aclose() + self._pool = None + + +_queue = ArqTaskQueue() + + +def get_task_queue() -> TaskQueue: + return _queue + + +async def close_task_queue() -> None: + await _queue.close() diff --git a/backend/app/worker.py b/backend/app/worker.py index 96c9bed..b7e9069 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -1,10 +1,14 @@ -"""ARQ worker: раз в минуту опрашивает Nova Poshta по заказам без чека. +"""ARQ worker: опрос Nova Poshta и ЕТТН-чеки Checkbox. Запускается отдельным процессом: `arq app.worker.WorkerSettings`. +- `create_ettn_receipt` — задача, которую ставит API после запроса кассира; +- `poll_np_statuses` — раз в минуту статусы ТТН по заказам без чека; +- `poll_receipts` — раз в минуту повтор зависших `pending` и статусы `created`-чеков. """ from __future__ import annotations +import uuid from typing import Any from arq import cron @@ -13,6 +17,8 @@ from arq.connections import RedisSettings from app.core.config import settings from app.core.logging import configure_logging, get_logger from app.db.session import SessionFactory +from app.services import receipts as receipts_service +from app.services.checkbox.client import get_checkbox_client from app.services.nova_poshta.np_client import NpTrackingClient from app.services.orders import sync_np_statuses @@ -22,6 +28,7 @@ log = get_logger(__name__) async def startup(ctx: dict[str, Any]) -> None: configure_logging() ctx["np_client"] = NpTrackingClient(settings) + ctx["checkbox_client"] = get_checkbox_client() log.info("worker_starting", environment=settings.environment) @@ -31,7 +38,25 @@ async def poll_np_statuses(ctx: dict[str, Any]) -> None: log.info("np_statuses_polled") +async def create_ettn_receipt(ctx: dict[str, Any], receipt_id: str) -> None: + async with SessionFactory() as session: + await receipts_service.create_ettn_for_receipt( + session, ctx["checkbox_client"], uuid.UUID(receipt_id) + ) + + +async def poll_receipts(ctx: dict[str, Any]) -> None: + async with SessionFactory() as session: + await receipts_service.retry_pending_receipts(session, ctx["checkbox_client"]) + await receipts_service.sync_ettn_statuses(session, ctx["checkbox_client"]) + log.info("receipts_polled") + + class WorkerSettings: redis_settings = RedisSettings.from_dsn(settings.redis_url) on_startup = startup - cron_jobs = [cron(poll_np_statuses, minute=set(range(60)), run_at_startup=True)] + functions = [create_ettn_receipt] + cron_jobs = [ + cron(poll_np_statuses, minute=set(range(60)), run_at_startup=True), + cron(poll_receipts, minute=set(range(60)), second=30), + ] diff --git a/backend/tests/test_checkbox_client.py b/backend/tests/test_checkbox_client.py new file mode 100644 index 0000000..d9b9652 --- /dev/null +++ b/backend/tests/test_checkbox_client.py @@ -0,0 +1,150 @@ +"""Тесты HttpCheckboxClient. Сеть замокана через respx — реальных запросов не делает.""" + +from __future__ import annotations + +import json +import uuid + +import httpx +import pytest +import respx +from httpx import Response + +from app.core.config import Settings +from app.services.checkbox.client import ( + CheckboxCredentials, + CheckboxError, + CheckboxUnavailableError, +) +from app.services.checkbox.http_client import HttpCheckboxClient + +BASE = "https://api.checkbox.test" +CREDS = CheckboxCredentials(cash_register_id=uuid.uuid4(), license_key="lic-123", pin_code="1111") + +ETTN = { + "id": "e-1", + "status": "CREATED", + "ettnNumber": "20450123456789", + "totalSum": 120000, + "description": "", + "recipientPhone": "", + "employee": {"id": "x", "dateCreated": "2026-09-23"}, + "cashRegister": {"id": "y", "dateCreated": "2026-09-23"}, + "lastCheckDate": "2026-09-23", + "dateCreated": "2026-09-23", +} + + +def _client() -> HttpCheckboxClient: + settings = Settings( + secret_key="test-secret-key", + encryption_key="dGVzdC1lbmNyeXB0aW9uLWtleS0zMi1ieXRlcyEh", + checkbox_base_url=BASE, + ) # type: ignore[arg-type] + return HttpCheckboxClient(settings) + + +def _signin(token: str = "tok-1") -> respx.Route: + return respx.post(f"{BASE}/api/v1/cashier/signinPinCode").mock( + return_value=Response(200, json={"access_token": token}) + ) + + +@respx.mock +async def test_create_signs_in_and_sends_headers() -> None: + signin = _signin() + create = respx.post(f"{BASE}/api/v1/ettn").mock(return_value=Response(200, json=ETTN)) + + ettn = await _client().create_ettn(CREDS, {"provider": "novapost"}) + + assert ettn.id == "e-1" and ettn.ettn_number == "20450123456789" + assert json.loads(signin.calls.last.request.content) == {"pin_code": "1111"} + assert signin.calls.last.request.headers["X-License-Key"] == "lic-123" + sent = create.calls.last.request + assert sent.headers["Authorization"] == "Bearer tok-1" + assert sent.headers["X-License-Key"] == "lic-123" + assert sent.headers["X-Client-Name"] == "lux_fiscal" + + +@respx.mock +async def test_token_is_cached_between_calls() -> None: + signin = _signin() + respx.get(f"{BASE}/api/v1/ettn/e-1").mock(return_value=Response(200, json=ETTN)) + client = _client() + + await client.get_ettn(CREDS, "e-1") + await client.get_ettn(CREDS, "e-1") + + assert signin.call_count == 1 + + +@respx.mock +async def test_401_triggers_single_resign_in() -> None: + signin = _signin("tok-2") + route = respx.get(f"{BASE}/api/v1/ettn/e-1").mock( + side_effect=[Response(401, json={"message": "expired"}), Response(200, json=ETTN)] + ) + + ettn = await _client().get_ettn(CREDS, "e-1") + + assert ettn.status == "CREATED" + assert signin.call_count == 2 + assert route.calls.last.request.headers["Authorization"] == "Bearer tok-2" + + +@respx.mock +async def test_422_raises_checkbox_error_with_message() -> None: + _signin() + respx.post(f"{BASE}/api/v1/ettn").mock( + return_value=Response( + 422, + json={ + "message": "Validation error", + "detail": [{"loc": ["body", "payments"], "msg": "bad", "type": "x"}], + }, + ) + ) + + with pytest.raises(CheckboxError, match="body.payments: bad") as exc_info: + await _client().create_ettn(CREDS, {}) + assert not isinstance(exc_info.value, CheckboxUnavailableError) + + +@respx.mock +async def test_timeout_and_5xx_are_unavailable() -> None: + _signin() + respx.post(f"{BASE}/api/v1/ettn").mock(side_effect=httpx.ReadTimeout("slow")) + with pytest.raises(CheckboxUnavailableError): + await _client().create_ettn(CREDS, {}) + + respx.post(f"{BASE}/api/v1/ettn").mock(return_value=Response(503, text="down")) + with pytest.raises(CheckboxUnavailableError): + await _client().create_ettn(CREDS, {}) + + +@respx.mock +async def test_find_skips_cancelled_and_other_waybills() -> None: + _signin() + respx.get(f"{BASE}/api/v1/ettn").mock( + return_value=Response( + 200, + json=[ + {**ETTN, "id": "old", "status": "CANCELLED"}, + {**ETTN, "id": "other", "ettnNumber": "111"}, + {**ETTN, "id": "match"}, + ], + ) + ) + + found = await _client().find_ettn(CREDS, "20450123456789") + + assert found is not None and found.id == "match" + + +@respx.mock +async def test_bad_pin_raises() -> None: + respx.post(f"{BASE}/api/v1/cashier/signinPinCode").mock( + return_value=Response(400, json={"message": "Невірний пін-код"}) + ) + with pytest.raises(CheckboxError, match="Невірний пін-код"): + await _client().sign_in(CREDS) diff --git a/backend/tests/test_orders_router.py b/backend/tests/test_orders_router.py index 025e170..f42b454 100644 --- a/backend/tests/test_orders_router.py +++ b/backend/tests/test_orders_router.py @@ -79,6 +79,13 @@ def _patch_orders_service(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(orders_router.orders_service, "list_orders", fake_list) monkeypatch.setattr(orders_router.orders_service, "delete_order", fake_delete) + async def fake_latest(session: object, order_ids: list[str]) -> dict: + return {} + + monkeypatch.setattr( + orders_router.receipts_service, "latest_receipts_by_order", fake_latest + ) + async def fake_record(session: object, **kwargs: object) -> None: return None diff --git a/backend/tests/test_receipts_router.py b/backend/tests/test_receipts_router.py new file mode 100644 index 0000000..88a4dd6 --- /dev/null +++ b/backend/tests/test_receipts_router.py @@ -0,0 +1,78 @@ +"""Тесты роутера чеков: роли, постановка задач после commit, ответ с ошибками по заказам.""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime +from typing import Any + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_user +from app.db.models.receipt import Receipt, ReceiptStatus +from app.db.models.user import User, UserRole +from app.main import app +from app.services import receipts as receipts_service +from app.services.task_queue import get_task_queue + + +class FakeQueue: + def __init__(self) -> None: + self.jobs: list[tuple[str, tuple[Any, ...]]] = [] + + async def enqueue(self, function: str, *args: Any) -> None: + self.jobs.append((function, args)) + + +def _user(role: UserRole) -> User: + return User(id=uuid.uuid4(), email="t@e.ua", password_hash="x", full_name="Т", role=role) + + +@pytest.fixture +def queue(monkeypatch: pytest.MonkeyPatch) -> FakeQueue: + fake = FakeQueue() + receipt = Receipt( + id=uuid.uuid4(), + order_id="1", + waybill_number="20450123456789", + total_kopecks=120000, + prepayment_kopecks=0, + cod_kopecks=120000, + status=ReceiptStatus.PENDING, + created_at=datetime.now(UTC), + ) + + async def fake_request(session: Any, items: Any, *, user: Any, request: Any) -> Any: + return receipts_service.RequestResult(created=[receipt], errors={"2": "У заказа нет ТТН"}) + + async def fake_commit(self: AsyncSession) -> None: + return None + + monkeypatch.setattr(receipts_service, "request_receipts", fake_request) + monkeypatch.setattr(AsyncSession, "commit", fake_commit) + app.dependency_overrides[get_task_queue] = lambda: fake + yield fake + app.dependency_overrides.clear() + + +def test_viewer_cannot_create(queue: FakeQueue) -> None: + app.dependency_overrides[get_current_user] = lambda: _user(UserRole.VIEWER) + response = TestClient(app).post("/api/v1/receipts", json={"items": [{"order_id": "1"}]}) + assert response.status_code == 403 + assert queue.jobs == [] + + +def test_cashier_creates_and_enqueues(queue: FakeQueue) -> None: + app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER) + + response = TestClient(app).post( + "/api/v1/receipts", json={"items": [{"order_id": "1"}, {"order_id": "2"}]} + ) + + assert response.status_code == 202 + body = response.json() + assert [r["order_id"] for r in body["created"]] == ["1"] + assert body["errors"] == {"2": "У заказа нет ТТН"} + assert queue.jobs == [("create_ettn_receipt", (body["created"][0]["id"],))] diff --git a/backend/tests/test_receipts_service.py b/backend/tests/test_receipts_service.py new file mode 100644 index 0000000..2a577e8 --- /dev/null +++ b/backend/tests/test_receipts_service.py @@ -0,0 +1,289 @@ +"""Тесты сервиса ЕТТН-чеков: суммы, валидация, машина состояний. + +БД заменена минимальной фейковой сессией (`get` по словарю + счётчик commit), +Checkbox — `StubCheckboxClient`. +""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime +from typing import Any + +import pytest + +from app.core import crypto +from app.db.models.cash_register import CashRegister +from app.db.models.order import Order +from app.db.models.receipt import Receipt, ReceiptStatus +from app.schemas.checkbox import EttnStatus +from app.services import receipts as svc +from app.services.checkbox.client import CheckboxUnavailableError +from app.services.checkbox.stub_client import StubCheckboxClient + + +def _good(**overrides: Any) -> dict[str, Any]: + good = { + "id": "1", + "sku": "SKU-1", + "name": "Товар 1", + "price": "600.00", + "quantity": "2.000", + "discount_amount": "0.00", + "discount_percent": "0.00", + "amount": "1200.00", + } + return {**good, **overrides} + + +def _order(**overrides: Any) -> Order: + fields: dict[str, Any] = { + "id": "100", + "create_date_time": datetime(2026, 9, 20, tzinfo=UTC), + "recipient_name": "Тест", + "recipient_phone": "0501112233", + "recipient_email": "a@b.ua", + "waybill_number": "20450123456789", + "notes": None, + "total_amount_kopecks": 120000, + "goods": [_good()], + "is_deleted": False, + "receipt_created_at": None, + "np_status": "В дорозі", + "np_status_code": "5", + "np_cod_amount_kopecks": 120000, + } + fields.update(overrides) + return Order(**fields) + + +def _register(tax_codes: list[Any] | None = None) -> CashRegister: + return CashRegister( + id=uuid.uuid4(), + name="Каса", + license_key_enc=crypto.encrypt("lic"), + cashier_pin_enc=crypto.encrypt("1111"), + tax_codes=tax_codes or [], + is_active=True, + is_default=True, + ) + + +class TestResolveAmounts: + def test_default_prepayment_is_total_minus_cod(self) -> None: + amounts = svc.resolve_amounts(_order(np_cod_amount_kopecks=100000), None) + assert amounts.prepayment_kopecks == 20000 + assert amounts.cod_kopecks == 100000 + + def test_explicit_prepayment_must_match_cod(self) -> None: + with pytest.raises(svc.ReceiptValidationError, match="≠ наложка"): + svc.resolve_amounts(_order(np_cod_amount_kopecks=100000), 10000) + + @pytest.mark.parametrize( + ("overrides", "message"), + [ + ({"waybill_number": None}, "нет ТТН"), + ({"np_cod_amount_kopecks": None}, "контроля оплаты"), + ({"np_status_code": "9"}, "не в пути"), + ({"np_cod_amount_kopecks": 130000}, "больше суммы заказа"), + ({"is_deleted": True}, "удалён"), + ], + ) + def test_rejects(self, overrides: dict[str, Any], message: str) -> None: + with pytest.raises(svc.ReceiptValidationError, match=message): + svc.resolve_amounts(_order(**overrides), None) + + +class TestBuildBody: + def test_goods_payments_delivery(self) -> None: + order = _order() + body = svc.build_ettn_body(order, _register(), svc.resolve_amounts(order, None)) + + assert body["provider"] == "novapost" + rb = body["receipt_body"] + assert rb["goods"] == [ + { + "good": {"code": "SKU-1", "name": "Товар 1", "price": 60000}, + "quantity": 2000, + "is_return": False, + } + ] + assert rb["payments"] == [{"value": 120000, "ettn": "20450123456789"}] + assert "discounts" not in rb + assert rb["delivery"] == {"phone": "+380501112233", "emails": ["a@b.ua"]} + + def test_prepayment_line_and_order_discounts_and_tax(self) -> None: + order = _order( + goods=[_good(amount="1100.00")], # скидка по строке 100 ₴ + total_amount_kopecks=105000, # ещё 50 ₴ скидки на заказ + np_cod_amount_kopecks=85000, # 200 ₴ предоплаты + ) + body = svc.build_ettn_body(order, _register([8]), svc.resolve_amounts(order, 20000)) + rb = body["receipt_body"] + + assert rb["goods"][0]["good"]["tax"] == [8] + assert rb["goods"][0]["discounts"] == [ + {"type": "DISCOUNT", "mode": "VALUE", "value": 10000} + ] + assert [(d["type"], d["value"]) for d in rb["discounts"]] == [ + ("DISCOUNT", 5000), + ("PRE_PAYMENT", 20000), + ] + assert rb["payments"][0]["value"] == 85000 + + def test_fractional_quantity(self) -> None: + order = _order( + goods=[_good(price="100.00", quantity="2.250", amount="225.00")], + total_amount_kopecks=22500, + np_cod_amount_kopecks=22500, + ) + body = svc.build_ettn_body(order, _register(), svc.resolve_amounts(order, None)) + assert body["receipt_body"]["goods"][0]["quantity"] == 2250 + + def test_bad_phone_is_skipped(self) -> None: + order = _order(recipient_phone="12345", recipient_email=None) + body = svc.build_ettn_body(order, _register(), svc.resolve_amounts(order, None)) + assert "delivery" not in body["receipt_body"] + + +class FakeSession: + def __init__(self, *objects: Any) -> None: + self.objects = {(type(o), o.id): o for o in objects} + self.commits = 0 + + async def get(self, model: type, key: Any, **_: Any) -> Any: + return self.objects.get((model, key)) + + async def commit(self) -> None: + self.commits += 1 + + +def _pending(order: Order, register: CashRegister) -> Receipt: + amounts = svc.resolve_amounts(order, None) + return Receipt( + id=uuid.uuid4(), + order_id=order.id, + cash_register_id=register.id, + waybill_number=order.waybill_number, + total_kopecks=amounts.total_kopecks, + prepayment_kopecks=amounts.prepayment_kopecks, + cod_kopecks=amounts.cod_kopecks, + status=ReceiptStatus.PENDING, + request_body=svc.build_ettn_body(order, register, amounts), + ) + + +class TestCreateEttn: + async def test_pending_becomes_created(self) -> None: + order, register = _order(receipt_created_at=datetime.now(UTC)), _register() + receipt = _pending(order, register) + client = StubCheckboxClient() + + await svc.create_ettn_for_receipt(FakeSession(order, register, receipt), client, receipt.id) + + assert receipt.status == ReceiptStatus.CREATED + assert receipt.checkbox_ettn_id in client.orders + assert order.receipt_created_at is not None + + async def test_second_call_is_noop(self) -> None: + order, register = _order(), _register() + receipt = _pending(order, register) + client = StubCheckboxClient() + session = FakeSession(order, register, receipt) + + await svc.create_ettn_for_receipt(session, client, receipt.id) + await svc.create_ettn_for_receipt(session, client, receipt.id) + + assert len(client.orders) == 1 + + async def test_rejection_fails_and_releases_order(self) -> None: + order, register = _order(receipt_created_at=datetime.now(UTC)), _register() + receipt = _pending(order, register) + client = StubCheckboxClient() + # Та же ТТН уже привязана — стаб отвечает ошибкой, как Checkbox. + await client.create_ettn(svc.credentials(register), receipt.request_body) + + await svc.create_ettn_for_receipt(FakeSession(order, register, receipt), client, receipt.id) + + assert receipt.status == ReceiptStatus.FAILED + assert "уже привязана" in (receipt.error or "") + assert order.receipt_created_at is None + + async def test_unavailable_keeps_pending_then_reconciles(self) -> None: + order, register = _order(), _register() + receipt = _pending(order, register) + client = StubCheckboxClient() + session = FakeSession(order, register, receipt) + real_create = client.create_ettn + + async def create_then_timeout(creds: Any, body: dict[str, Any]) -> Any: + await real_create(creds, body) # запрос дошёл до Checkbox… + raise CheckboxUnavailableError("timeout") # …но ответа мы не получили + + client.create_ettn = create_then_timeout # type: ignore[method-assign] + await svc.create_ettn_for_receipt(session, client, receipt.id) + assert receipt.status == ReceiptStatus.PENDING and receipt.error + + client.create_ettn = real_create # type: ignore[method-assign] + await svc.create_ettn_for_receipt(session, client, receipt.id) + + assert receipt.status == ReceiptStatus.CREATED + assert len(client.orders) == 1 # второго чека на ту же ТТН нет + + +class TestApplyEttn: + @pytest.mark.parametrize( + ("checkbox_status", "expected"), + [ + (EttnStatus.CREATED, ReceiptStatus.CREATED), + (EttnStatus.DONE, ReceiptStatus.DONE), + (EttnStatus.DONE_WITHOUT_SMS, ReceiptStatus.DONE), + (EttnStatus.RETURNED, ReceiptStatus.RETURNED), + (EttnStatus.RECEIPT_ERROR, ReceiptStatus.RECEIPT_ERROR), + (EttnStatus.CANCELLED, ReceiptStatus.CANCELLED), + ], + ) + async def test_status_mapping(self, checkbox_status: str, expected: ReceiptStatus) -> None: + order, register = _order(), _register() + receipt = _pending(order, register) + client = StubCheckboxClient() + await svc.create_ettn_for_receipt(FakeSession(order, register, receipt), client, receipt.id) + + client.set_status(receipt.checkbox_ettn_id or "", checkbox_status, raw_error="boom") + ettn = await client.get_ettn(svc.credentials(register), receipt.checkbox_ettn_id or "") + svc._apply_ettn(receipt, ettn) + + assert receipt.status == expected + if expected == ReceiptStatus.DONE: + assert receipt.checkbox_receipt_id + if expected == ReceiptStatus.RECEIPT_ERROR: + assert receipt.error == "boom" + + +class TestCancel: + async def test_cancel_created(self, monkeypatch: pytest.MonkeyPatch) -> None: + order, register = _order(receipt_created_at=datetime.now(UTC)), _register() + receipt = _pending(order, register) + client = StubCheckboxClient() + session = FakeSession(order, register, receipt) + session.add = lambda obj: None # type: ignore[attr-defined] + await svc.create_ettn_for_receipt(session, client, receipt.id) + order.receipt_created_at = datetime.now(UTC) + + await svc.cancel_receipt(session, client, receipt.id, user=None) # type: ignore[arg-type] + + assert receipt.status == ReceiptStatus.CANCELLED + assert client.orders[receipt.checkbox_ettn_id or ""].status == EttnStatus.CANCELLED + assert order.receipt_created_at is None + + async def test_cannot_cancel_done(self) -> None: + order, register = _order(), _register() + receipt = _pending(order, register) + receipt.status = ReceiptStatus.DONE + with pytest.raises(svc.ReceiptStateError): + await svc.cancel_receipt( + FakeSession(order, register, receipt), # type: ignore[arg-type] + StubCheckboxClient(), + receipt.id, + user=None, # type: ignore[arg-type] + ) diff --git a/frontend/src/api/cashRegisters.ts b/frontend/src/api/cashRegisters.ts new file mode 100644 index 0000000..59f1d59 --- /dev/null +++ b/frontend/src/api/cashRegisters.ts @@ -0,0 +1,22 @@ +import { apiFetch } from '@/api/client' +import type { + CashRegister, + CashRegisterCreate, + CashRegisterUpdate, +} from '@/features/cashRegisters/types' + +export function getCashRegisters(): Promise { + return apiFetch('/cash-registers') +} + +export function createCashRegister(payload: CashRegisterCreate): Promise { + return apiFetch('/cash-registers', { method: 'POST', body: payload }) +} + +export function updateCashRegister(id: string, payload: CashRegisterUpdate): Promise { + return apiFetch(`/cash-registers/${id}`, { method: 'PATCH', body: payload }) +} + +export function checkCashRegister(id: string): Promise { + return apiFetch(`/cash-registers/${id}/check`, { method: 'POST' }) +} diff --git a/frontend/src/api/receipts.ts b/frontend/src/api/receipts.ts new file mode 100644 index 0000000..bead12c --- /dev/null +++ b/frontend/src/api/receipts.ts @@ -0,0 +1,10 @@ +import { apiFetch } from '@/api/client' +import type { Receipt, ReceiptCreateResponse, ReceiptRequestItem } from '@/features/receipts/types' + +export function createReceipts(items: ReceiptRequestItem[]): Promise { + return apiFetch('/receipts', { method: 'POST', body: { items } }) +} + +export function cancelReceipt(receiptId: string): Promise { + return apiFetch(`/receipts/${receiptId}/cancel`, { method: 'POST' }) +} diff --git a/frontend/src/app/routes.tsx b/frontend/src/app/routes.tsx index a028037..5358585 100644 --- a/frontend/src/app/routes.tsx +++ b/frontend/src/app/routes.tsx @@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom' import { LoginPage } from '@/features/auth/LoginPage' import { ProtectedRoute } from '@/features/auth/ProtectedRoute' +import { CashRegistersPage } from '@/pages/CashRegistersPage' import { DashboardPage } from '@/pages/DashboardPage' export function AppRoutes() { @@ -11,6 +12,7 @@ export function AppRoutes() { }> } /> + } /> } /> diff --git a/frontend/src/features/cashRegisters/types.ts b/frontend/src/features/cashRegisters/types.ts new file mode 100644 index 0000000..72dbbdd --- /dev/null +++ b/frontend/src/features/cashRegisters/types.ts @@ -0,0 +1,22 @@ +/** Зеркало `CashRegisterOut`/`CashRegisterCreate`/`CashRegisterUpdate` из backend/app/schemas/receipts.py. */ + +export interface CashRegister { + id: string + name: string + fiscal_number: string | null + license_key_masked: string + tax_codes: (number | string)[] + is_active: boolean + is_default: boolean +} + +export interface CashRegisterCreate { + name: string + fiscal_number: string | null + license_key: string + pin_code: string + tax_codes: (number | string)[] + is_default: boolean +} + +export type CashRegisterUpdate = Partial & { is_active?: boolean } diff --git a/frontend/src/features/orders/OrderDetailModal.tsx b/frontend/src/features/orders/OrderDetailModal.tsx index ddb6a7a..58d18c9 100644 --- a/frontend/src/features/orders/OrderDetailModal.tsx +++ b/frontend/src/features/orders/OrderDetailModal.tsx @@ -1,6 +1,7 @@ import { Modal } from '@/components/Modal' import '@/features/orders/OrderDetailModal.css' import type { Order } from '@/features/orders/types' +import { RECEIPT_STATUS } from '@/features/receipts/types' interface OrderDetailModalProps { order: Order @@ -105,6 +106,27 @@ export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) { {order.total_amount} ₴ + {order.receipt_status && ( +
+
+ ЕТТН-чек Checkbox + + {RECEIPT_STATUS[order.receipt_status].label} + +
+
+ Предоплата в чеке + {order.receipt_prepayment ? `${order.receipt_prepayment} ₴` : '—'} +
+ {order.receipt_error && ( +
+ Ошибка + {order.receipt_error} +
+ )} +
+ )} +
+
+ )} + +
+
+ + + + + + + + + + + + {isLoading && ( + + + + )} + {isError && ( + + + + )} + {registers?.length === 0 && ( + + + + )} + {registers?.map((register) => ( + + + + + + + + + ))} + +
НазваниеФиск. номерКлюч лицензииНалогиСтатусДействия
+ Загрузка… +
+ Не удалось загрузить кассы +
+ Кассы не настроены — чеки создавать нельзя +
+ {register.name} + {register.is_default && основная} + {register.fiscal_number || '—'}{register.license_key_masked}{register.tax_codes.length ? register.tax_codes.join(', ') : 'без налога'} + + {register.is_active ? 'Активна' : 'Отключена'} + + + + + +
+ + +
void handleSubmit(e)}> +

{editingId ? 'Изменить кассу' : 'Добавить кассу'}

+ + + + + + +
+ + {editingId && ( + + )} +
+
+ + + ) +} diff --git a/frontend/src/pages/DashboardPage.css b/frontend/src/pages/DashboardPage.css index a0da5c7..63e57d5 100644 --- a/frontend/src/pages/DashboardPage.css +++ b/frontend/src/pages/DashboardPage.css @@ -325,3 +325,68 @@ color: var(--color-text-muted); padding: 32px 0; } + +.orders-toolbar-actions { + display: flex; + gap: 8px; + align-items: center; +} + +.orders-link-btn { + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 9px 14px; + font-size: 14px; + color: var(--color-text); + text-decoration: none; +} + +.orders-prepayment-input--invalid { + border-color: #dc2626; +} + +.orders-notice { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + margin-bottom: 12px; + padding: 10px 14px; + border-radius: 8px; + font-size: 14px; +} + +.orders-notice ul { + margin: 0; + padding-left: 18px; +} + +.orders-notice--ok { + background: #dcfce7; + color: #166534; +} + +.orders-notice--error { + background: #fee2e2; + color: #991b1b; +} + +.orders-notice-close { + border: none; + background: none; + color: inherit; + font-size: 18px; + cursor: pointer; +} + +@media (prefers-color-scheme: dark) { + .orders-notice--ok { + background: rgba(22, 163, 74, 0.18); + color: #86efac; + } + + .orders-notice--error { + background: rgba(220, 38, 38, 0.18); + color: #fca5a5; + } +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 08737b3..0255f90 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,12 +1,17 @@ import { useQueryClient } from '@tanstack/react-query' import { useMemo, useState } from 'react' +import { Link } from 'react-router-dom' import { deleteOrder } from '@/api/orders' +import { cancelReceipt, createReceipts } from '@/api/receipts' import '@/pages/DashboardPage.css' import { useAuth } from '@/features/auth/useAuth' import { OrderDetailModal } from '@/features/orders/OrderDetailModal' import type { Order } from '@/features/orders/types' import { useOrders } from '@/features/orders/useOrders' +import { defaultPrepayment, prepaymentMatches, toKopecks } from '@/features/receipts/money' +import { CANCELLABLE, RECEIPT_STATUS } from '@/features/receipts/types' +import type { ReceiptRequestItem } from '@/features/receipts/types' const ROLE_LABEL: Record = { admin: 'Администратор', @@ -29,6 +34,11 @@ function npStatusTone(order: Order): 'delivered' | 'processing' | 'danger' | 'ne return 'new' } +/** Заказ можно отправить в Checkbox: есть ТТН и сумма контроля оплаты. */ +function canCreateReceipt(order: Order): boolean { + return Boolean(order.waybill_number && order.np_cod_amount) +} + function paymentBadge(order: Order): { label: string; tone: 'delivered' | 'processing' } | null { if (order.np_payment_status === 'Payed') return { label: 'Оплачено', tone: 'delivered' } if (order.np_payment_status === 'NeedPayment') return { label: 'Не оплачено', tone: 'processing' } @@ -44,8 +54,67 @@ export function DashboardPage() { const [search, setSearch] = useState('') const [viewingOrder, setViewingOrder] = useState(null) const [deletingId, setDeletingId] = useState(null) + // Введённая кассиром предоплата (₴) по заказу; нет ключа — значение по умолчанию. + const [prepayments, setPrepayments] = useState>({}) + const [submitting, setSubmitting] = useState(false) + const [cancellingId, setCancellingId] = useState(null) + const [notice, setNotice] = useState<{ tone: 'ok' | 'error'; lines: string[] } | null>(null) const canDelete = user?.role !== 'viewer' + const canFiscalize = user?.role === 'admin' || user?.role === 'cashier' + + function prepaymentOf(order: Order): string { + return prepayments[order.id] ?? defaultPrepayment(order.total_amount, order.np_cod_amount) + } + + async function submitReceipts(targets: Order[]) { + const items: ReceiptRequestItem[] = [] + const localErrors: string[] = [] + for (const order of targets) { + const prepayment = toKopecks(prepaymentOf(order)) + if (prepayment === null) { + localErrors.push(`${order.id}: некорректная сумма предоплаты`) + continue + } + items.push({ order_id: order.id, prepayment_kopecks: prepayment }) + } + if (items.length === 0) { + setNotice({ tone: 'error', lines: localErrors }) + return + } + + setSubmitting(true) + try { + const result = await createReceipts(items) + const errors = [...localErrors, ...Object.entries(result.errors).map(([id, msg]) => `${id}: ${msg}`)] + const lines = [ + ...(result.created.length ? [`Отправлено в Checkbox: ${result.created.length}`] : []), + ...errors, + ] + setNotice({ tone: errors.length ? 'error' : 'ok', lines }) + setSelected(new Set()) + await queryClient.invalidateQueries({ queryKey: ['orders'] }) + } catch (err) { + setNotice({ tone: 'error', lines: [err instanceof Error ? err.message : 'Не удалось создать чеки'] }) + } finally { + setSubmitting(false) + } + } + + async function handleCancel(order: Order) { + if (!order.receipt_id) return + if (!window.confirm(`Отменить ЕТТН-чек по заказу ${order.id}? Заказ вернётся в очередь.`)) return + setCancellingId(order.id) + try { + await cancelReceipt(order.receipt_id) + setNotice({ tone: 'ok', lines: [`Чек по заказу ${order.id} отменён`] }) + await queryClient.invalidateQueries({ queryKey: ['orders'] }) + } catch (err) { + setNotice({ tone: 'error', lines: [err instanceof Error ? err.message : 'Не удалось отменить чек'] }) + } finally { + setCancellingId(null) + } + } const filtered = useMemo(() => { const query = search.trim().toLowerCase() @@ -105,18 +174,48 @@ export function DashboardPage() {

Заказы

- +
+ {user?.role === 'admin' && ( + + Кассы + + )} + {tab === 'no_receipt' && canFiscalize && ( + + )} +
+ {notice && ( +
+
    + {notice.lines.map((line) => ( +
  • {line}
  • + ))} +
+ +
+ )} +
{TABS.map((t) => ( @@ -150,7 +249,7 @@ export function DashboardPage() { Сумма Наложка Оплачено - Предоплата + Предоплата, ₴ Статус ТТН Действия @@ -197,7 +296,25 @@ export function DashboardPage() { )} - + {tab === 'no_receipt' ? ( + setPrepayments((prev) => ({ ...prev, [order.id]: e.target.value }))} + /> + ) : ( + (order.receipt_prepayment ?? '—') + )} @@ -208,10 +325,36 @@ export function DashboardPage() { - - {canDelete && ( + {tab === 'no_receipt' && canFiscalize && ( + + )} + {order.receipt_status && (tab === 'has_receipt' || !CANCELLABLE.has(order.receipt_status)) && ( + + {RECEIPT_STATUS[order.receipt_status].label} + + )} + {tab === 'has_receipt' && canFiscalize && order.receipt_status && CANCELLABLE.has(order.receipt_status) && ( + + )} + {canDelete && tab === 'no_receipt' && (
- {viewingOrder && setViewingOrder(null)} />} + {viewingOrder && ( + order.id === viewingOrder.id) ?? viewingOrder} + onClose={() => setViewingOrder(null)} + /> + )} ) }