From 518a99197f4b66d74f40faa279e56781d9b44866 Mon Sep 17 00:00:00 2001 From: Liutenko Oleksandr Date: Wed, 23 Sep 2026 23:39:00 +0300 Subject: [PATCH 01/10] 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)} + /> + )} ) } -- 2.54.0 From cedcd3caffdf3d4e1a80d1ecb6c3e77fbaf437fd Mon Sep 17 00:00:00 2001 From: Liutenko Oleksandr Date: Thu, 24 Sep 2026 00:15:01 +0300 Subject: [PATCH 02/10] Mark migration 0005 as applied in plan Co-Authored-By: Claude Opus 5.5 --- .plans/checkbox-ettn-receipts.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.plans/checkbox-ettn-receipts.md b/.plans/checkbox-ettn-receipts.md index 429d331..db6b5a2 100644 --- a/.plans/checkbox-ettn-receipts.md +++ b/.plans/checkbox-ettn-receipts.md @@ -134,7 +134,7 @@ webhook в Checkbox, и Checkbox **сам фискализирует** чек. - [ ] Сверить реальное значение `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). +- [x] `alembic upgrade head` на живом Postgres (0004 → 0005 применена в Docker). - [ ] Позже (не в этом этапе): `fiscalize-manually`, PDF/ссылка на фискальный чек, webhook вместо опроса. --- -- 2.54.0 From 089e0a4011723f797670f3252e853074364f8f14 Mon Sep 17 00:00:00 2001 From: Liutenko Oleksandr Date: Thu, 24 Sep 2026 13:33:29 +0300 Subject: [PATCH 03/10] Send ETTN payment type and label explicitly, verify receipt total MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Payments now carry type=ETTN / label=Експрес-накладна instead of relying on Checkbox defaults; body build fails fast if goods - discounts - prepayment does not equal the COD amount. Co-Authored-By: Claude Opus 5.5 --- backend/app/services/receipts.py | 24 +++++++++++++++- backend/tests/test_receipts_service.py | 40 +++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/backend/app/services/receipts.py b/backend/app/services/receipts.py index c073d1f..a97dc67 100644 --- a/backend/app/services/receipts.py +++ b/backend/app/services/receipts.py @@ -48,6 +48,10 @@ NP_FINAL_STATUS_CODES = frozenset( {"2", "9", "10", "11", "102", "103", "105", "106", "108"} ) +# Способ оплаты в ЕТТН-чеке (`ETTNPaymentSchema`). +ETTN_PAYMENT_TYPE = "ETTN" +ETTN_PAYMENT_LABEL = "Експрес-накладна" + # Статус Checkbox → наш статус. CREATED не меняет ничего. _CHECKBOX_TO_STATUS = { EttnStatus.DONE: ReceiptStatus.DONE, @@ -163,9 +167,27 @@ def build_ettn_body( } ) + # Последняя страховка перед отправкой: сумма чека (товары − скидки − предоплата) + # обязана совпасть с оплатой по ЕТТН, иначе Checkbox не сможет фискализировать. + receipt_total = goods_total - order_discount - amounts.prepayment_kopecks + if receipt_total != amounts.cod_kopecks: + raise ReceiptValidationError( + f"Сумма чека {receipt_total / 100:.2f} ₴ ≠ наложка {amounts.cod_kopecks / 100:.2f} ₴" + ) + receipt_body: dict[str, Any] = { "goods": goods, - "payments": [{"value": amounts.cod_kopecks, "ettn": order.waybill_number}], + # Оплата «Експрес-накладна»: деньги примет НП при выдаче посылки, + # тогда Checkbox и фискализирует чек. Тип и подпись — явно, не полагаясь + # на значения по умолчанию Checkbox. + "payments": [ + { + "type": ETTN_PAYMENT_TYPE, + "label": ETTN_PAYMENT_LABEL, + "value": amounts.cod_kopecks, + "ettn": order.waybill_number, + } + ], } if discounts: receipt_body["discounts"] = discounts diff --git a/backend/tests/test_receipts_service.py b/backend/tests/test_receipts_service.py index 2a577e8..61648c1 100644 --- a/backend/tests/test_receipts_service.py +++ b/backend/tests/test_receipts_service.py @@ -108,7 +108,14 @@ class TestBuildBody: "is_return": False, } ] - assert rb["payments"] == [{"value": 120000, "ettn": "20450123456789"}] + assert rb["payments"] == [ + { + "type": "ETTN", + "label": "Експрес-накладна", + "value": 120000, + "ettn": "20450123456789", + } + ] assert "discounts" not in rb assert rb["delivery"] == {"phone": "+380501112233", "emails": ["a@b.ua"]} @@ -131,6 +138,37 @@ class TestBuildBody: ] assert rb["payments"][0]["value"] == 85000 + def test_all_goods_with_prices_quantities_and_sums(self) -> None: + order = _order( + goods=[ + _good(id="1", sku="A", name="Сукня", price="600.00", amount="1200.00"), + _good(id="2", sku="", name="Пояс", price="150.50", quantity="1.000", + amount="150.50"), + _good(id="3", sku="C", name="Тканина", price="99.99", quantity="1.500", + amount="149.99"), + ], + total_amount_kopecks=150049, + np_cod_amount_kopecks=130049, # 200 ₴ предоплаты + ) + body = svc.build_ettn_body(order, _register(), svc.resolve_amounts(order, 20000)) + rb = body["receipt_body"] + + assert [ + (g["good"]["code"], g["good"]["name"], g["good"]["price"], g["quantity"]) + for g in rb["goods"] + ] == [ + ("A", "Сукня", 60000, 2000), + ("2", "Пояс", 15050, 1000), # пустой SKU → ID товара + ("C", "Тканина", 9999, 1500), + ] + # 99.99 × 1.5 = 149.985 → CRM округлила до 149.99, скидки по строке нет. + assert all("discounts" not in g for g in rb["goods"]) + goods_total = sum(svc._line_sum(g["good"]["price"], g["quantity"]) for g in rb["goods"]) + assert goods_total - 20000 == rb["payments"][0]["value"] == 130049 + assert rb["discounts"] == [ + {"type": "PRE_PAYMENT", "mode": "VALUE", "value": 20000, "name": "Передоплата"} + ] + def test_fractional_quantity(self) -> None: order = _order( goods=[_good(price="100.00", quantity="2.250", amount="225.00")], -- 2.54.0 From 912a37c00ccea97c652dda0d9a8917e440de0443 Mon Sep 17 00:00:00 2001 From: Liutenko Oleksandr Date: Thu, 24 Sep 2026 13:36:15 +0300 Subject: [PATCH 04/10] Resolve api upstream per request in frontend nginx nginx resolved api:8000 once at startup, so recreating the api container (new IP) left the proxy pointing at a dead address and every /api call returned 502. Co-Authored-By: Claude Opus 5.5 --- docker/nginx.frontend.conf | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docker/nginx.frontend.conf b/docker/nginx.frontend.conf index 1b2fc1d..959e9b1 100644 --- a/docker/nginx.frontend.conf +++ b/docker/nginx.frontend.conf @@ -10,8 +10,15 @@ server { root /usr/share/nginx/html; index index.html; + # Имя `api` резолвится через DNS docker на каждый запрос (resolver + переменная), + # а не один раз при старте nginx: иначе после пересоздания контейнера api + # (новый IP) nginx продолжает стучаться на старый адрес и отдаёт 502. + resolver 127.0.0.11 valid=10s ipv6=off; + set $api_upstream http://api:8000; + location /api/ { - proxy_pass http://api:8000/api/; + # С переменной proxy_pass без URI передаёт исходный путь /api/... как есть. + proxy_pass $api_upstream; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; -- 2.54.0 From 3a6ad85d118424b9659a1f60c327c36b0e19d67e Mon Sep 17 00:00:00 2001 From: Liutenko Oleksandr Date: Thu, 24 Sep 2026 13:49:50 +0300 Subject: [PATCH 05/10] Fix ETTN creation on live Checkbox - prepayment as DISCOUNT: live API rejects PRE_PAYMENT with 400 third_party.generic - normalize lowercase ETTN statuses returned by the live API - show Checkbox error code instead of bare 'Internal Server Error' - page size 50 for ETTN list, 429 rate limit is retryable, pause polling on it - phone in 380XXXXXXXXX format like portal receipts Co-Authored-By: Claude Opus 5.5 --- .plans/checkbox-ettn-receipts.md | 5 ++- CLAUDE.md | 2 +- backend/app/schemas/checkbox.py | 9 +++++- backend/app/services/checkbox/http_client.py | 13 ++++++-- backend/app/services/receipts.py | 28 ++++++++--------- backend/tests/test_checkbox_client.py | 32 ++++++++++++++++++++ backend/tests/test_receipts_service.py | 10 +++--- 7 files changed, 73 insertions(+), 26 deletions(-) diff --git a/.plans/checkbox-ettn-receipts.md b/.plans/checkbox-ettn-receipts.md index db6b5a2..8751775 100644 --- a/.plans/checkbox-ettn-receipts.md +++ b/.plans/checkbox-ettn-receipts.md @@ -133,7 +133,10 @@ webhook в Checkbox, и Checkbox **сам фискализирует** чек. - [ ] На первом боевом чеке (реальная касса): нужна ли открытая смена для ЕТТН; как ведёт себя `RECEIPT_ERROR`. - [ ] Сверить реальное значение `np_payment_status` (`Payed` vs `Paid` — фронт и стаб расходятся). - [x] Обновить `CLAUDE.md`/`README.md`/`.env.example` (статус, новые env-переменные). -- [ ] Проверить на первом боевом чеке, что `value` скидок (`DISCOUNT`/`PRE_PAYMENT`, mode `VALUE`) — в копейках. +- [x] Первый боевой чек (ТТН 20451543715206, 2026-09-24): `value` скидок — в копейках (подтверждено). + `PRE_PAYMENT` → 400 `third_party.generic`; предоплата теперь идёт обычной скидкой `DISCOUNT` «Знижка», + как в чеках из портала. Боевой API отдаёт статусы строчными (`created`/`done`) — нормализуются. + Лимит списка — 50 за страницу; 429 «Занадто часто» — повторяемая ошибка. - [x] `alembic upgrade head` на живом Postgres (0004 → 0005 применена в Docker). - [ ] Позже (не в этом этапе): `fiscalize-manually`, PDF/ссылка на фискальный чек, webhook вместо опроса. diff --git a/CLAUDE.md b/CLAUDE.md index 686d46f..28f137a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,7 +122,7 @@ Stages 1–4 (scaffolding, auth/audit, CRM order queue, Nova Poshta tracking) ar ### 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`. +- 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`. Prepayment goes into the receipt as a plain `DISCOUNT` («Знижка»), **not** `PRE_PAYMENT` — the live API rejects `PRE_PAYMENT` on ETTN with 400 `third_party.generic`. The live API also returns statuses lowercase (`EttnOut` upper-cases them). - 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/backend/app/schemas/checkbox.py b/backend/app/schemas/checkbox.py index 7c475d1..73c76ab 100644 --- a/backend/app/schemas/checkbox.py +++ b/backend/app/schemas/checkbox.py @@ -7,7 +7,7 @@ from __future__ import annotations -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator class EttnStatus: @@ -30,3 +30,10 @@ class EttnOut(BaseModel): 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") + + # В OpenAPI статусы заглавные, а боевой API отдаёт строчные (`"done"`, + # `"created"`) — приводим к одному регистру, иначе статусы не сопоставятся. + @field_validator("status", mode="before") + @classmethod + def _upper_status(cls, value: object) -> object: + return value.upper() if isinstance(value, str) else value diff --git a/backend/app/services/checkbox/http_client.py b/backend/app/services/checkbox/http_client.py index 3521239..c567ca0 100644 --- a/backend/app/services/checkbox/http_client.py +++ b/backend/app/services/checkbox/http_client.py @@ -23,8 +23,9 @@ from app.services.checkbox.client import ( _PROVIDER = "novapost" _TIMEOUT = httpx.Timeout(30, connect=10) # Сколько страниц списка ЕТТН просматривать при сверке после таймаута. -_FIND_PAGES = 5 -_FIND_PAGE_SIZE = 100 +# Список отдаётся от новых к старым; больше 50 за страницу Checkbox не принимает. +_FIND_PAGES = 3 +_FIND_PAGE_SIZE = 50 def _error_message(response: httpx.Response) -> str: @@ -35,6 +36,11 @@ def _error_message(response: httpx.Response) -> str: if isinstance(data, dict): message = data.get("message") detail = data.get("detail") + # Ошибки со стороны Новой Почты Checkbox отдаёт как + # {"code": "third_party.*", "message": "Internal Server Error"} — + # без кода кассир видит только бесполезное «Internal Server Error». + if code := data.get("code"): + message = f"{message or 'Ошибка'} ({code})" if isinstance(detail, list) and detail: parts = [ f"{'.'.join(str(p) for p in item.get('loc', []))}: {item.get('msg')}" @@ -74,7 +80,8 @@ class HttpCheckboxClient: ) except httpx.HTTPError as exc: raise CheckboxUnavailableError(f"Checkbox недоступен: {exc!r}") from exc - if response.status_code >= 500: + # 429 — «Занадто часто виконуються запити»: исход не ошибка данных, повторяемо. + if response.status_code >= 500 or response.status_code == 429: raise CheckboxUnavailableError(_error_message(response)) return response diff --git a/backend/app/services/receipts.py b/backend/app/services/receipts.py index a97dc67..d8b857b 100644 --- a/backend/app/services/receipts.py +++ b/backend/app/services/receipts.py @@ -88,13 +88,13 @@ def _line_sum(price_kopecks: int, quantity: int) -> int: def _normalize_phone(phone: str | None) -> str | None: - """Телефон для отправки чека: Checkbox принимает только `+?380\\d{9}`.""" + """Телефон для отправки чека в формате `380XXXXXXXXX`, как в чеках из портала Checkbox.""" 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 + return digits if re.fullmatch(r"380\d{9}", digits) else None # --- Тело запроса ------------------------------------------------------------ @@ -145,7 +145,6 @@ 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 @@ -153,18 +152,14 @@ def build_ettn_body( raise ReceiptValidationError( "Сумма товаров меньше суммы заказа — проверьте заказ в CRM" ) - if order_discount: + # Предоплата и скидка заказа — одной обычной скидкой «Знижка», как в чеках из + # портала Checkbox. Тип `PRE_PAYMENT` для ЕТТН не годится: Checkbox отвечает + # 400 `third_party.generic` (проверено на боевой кассе). + discount_total = order_discount + amounts.prepayment_kopecks + discounts: list[dict[str, Any]] = [] + if discount_total: 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": "Передоплата", - } + {"type": "DISCOUNT", "mode": "VALUE", "value": discount_total, "name": "Знижка"} ) # Последняя страховка перед отправкой: сумма чека (товары − скидки − предоплата) @@ -490,6 +485,11 @@ async def sync_ettn_statuses(session: AsyncSession, client: CheckboxClient) -> N creds = registers[receipt.cash_register_id] = credentials(register) try: ettn = await client.get_ettn(creds, receipt.checkbox_ettn_id) + except CheckboxUnavailableError as exc: + # Недоступен или лимит частоты запросов — остальные чеки опросим + # в следующий раз, а не будем добивать Checkbox прямо сейчас. + log.warning("ettn_poll_paused", receipt_id=str(receipt.id), error=str(exc)) + break except CheckboxError as exc: log.warning("ettn_poll_failed", receipt_id=str(receipt.id), error=str(exc)) continue diff --git a/backend/tests/test_checkbox_client.py b/backend/tests/test_checkbox_client.py index d9b9652..93b6f84 100644 --- a/backend/tests/test_checkbox_client.py +++ b/backend/tests/test_checkbox_client.py @@ -148,3 +148,35 @@ async def test_bad_pin_raises() -> None: ) with pytest.raises(CheckboxError, match="Невірний пін-код"): await _client().sign_in(CREDS) + + +@respx.mock +async def test_lowercase_status_from_live_api_is_normalized() -> None: + _signin() + respx.get(f"{BASE}/api/v1/ettn/e-1").mock( + return_value=Response(200, json={**ETTN, "status": "done"}) + ) + ettn = await _client().get_ettn(CREDS, "e-1") + assert ettn.status == "DONE" + + +@respx.mock +async def test_third_party_error_code_is_shown() -> None: + _signin() + respx.post(f"{BASE}/api/v1/ettn").mock( + return_value=Response( + 400, json={"code": "third_party.generic", "message": "Internal Server Error"} + ) + ) + with pytest.raises(CheckboxError, match=r"third_party\.generic"): + await _client().create_ettn(CREDS, {}) + + +@respx.mock +async def test_rate_limit_is_retryable() -> None: + _signin() + respx.post(f"{BASE}/api/v1/ettn").mock( + return_value=Response(429, json={"message": "Занадто часто виконуються запити"}) + ) + with pytest.raises(CheckboxUnavailableError): + await _client().create_ettn(CREDS, {}) diff --git a/backend/tests/test_receipts_service.py b/backend/tests/test_receipts_service.py index 61648c1..18b0098 100644 --- a/backend/tests/test_receipts_service.py +++ b/backend/tests/test_receipts_service.py @@ -117,7 +117,7 @@ class TestBuildBody: } ] assert "discounts" not in rb - assert rb["delivery"] == {"phone": "+380501112233", "emails": ["a@b.ua"]} + assert rb["delivery"] == {"phone": "380501112233", "emails": ["a@b.ua"]} def test_prepayment_line_and_order_discounts_and_tax(self) -> None: order = _order( @@ -132,10 +132,8 @@ class TestBuildBody: 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), - ] + # Скидка заказа 50 ₴ + предоплата 200 ₴ — одной «Знижкой», без PRE_PAYMENT. + assert [(d["type"], d["value"]) for d in rb["discounts"]] == [("DISCOUNT", 25000)] assert rb["payments"][0]["value"] == 85000 def test_all_goods_with_prices_quantities_and_sums(self) -> None: @@ -166,7 +164,7 @@ class TestBuildBody: goods_total = sum(svc._line_sum(g["good"]["price"], g["quantity"]) for g in rb["goods"]) assert goods_total - 20000 == rb["payments"][0]["value"] == 130049 assert rb["discounts"] == [ - {"type": "PRE_PAYMENT", "mode": "VALUE", "value": 20000, "name": "Передоплата"} + {"type": "DISCOUNT", "mode": "VALUE", "value": 20000, "name": "Знижка"} ] def test_fractional_quantity(self) -> None: -- 2.54.0 From cbf9832e5b848db0df7494cee2668aee72139bd8 Mon Sep 17 00:00:00 2001 From: Liutenko Oleksandr Date: Thu, 24 Sep 2026 14:04:26 +0300 Subject: [PATCH 06/10] Set CRM order status to PACKED after Checkbox accepts the receipt - ExoCrmClient.set_status: live SetStatus needs {Orders: [id], Status} and replies per order, unlike the documented {ID, Status} - receipts.crm_status_set_at (migration 0006); set right after creation, retried by cron, row-locked to avoid a repeat PACKED overwriting a newer status - CRM errors under capitalized 'Errors' and non-JSON replies are reported Co-Authored-By: Claude Opus 5.5 --- .plans/checkbox-ettn-receipts.md | 2 + CLAUDE.md | 1 + .../0006_receipt_crm_status_set_at.py | 28 ++++++++ backend/app/db/models/audit.py | 1 + backend/app/db/models/receipt.py | 3 + backend/app/services/crm/client.py | 2 + backend/app/services/crm/exo_client.py | 68 ++++++++++++++----- backend/app/services/crm/stub_client.py | 5 ++ backend/app/services/receipts.py | 60 ++++++++++++++++ backend/app/worker.py | 8 ++- backend/tests/test_crm_client.py | 40 +++++++++++ backend/tests/test_receipts_service.py | 67 ++++++++++++++++++ 12 files changed, 266 insertions(+), 19 deletions(-) create mode 100644 backend/alembic/versions/0006_receipt_crm_status_set_at.py diff --git a/.plans/checkbox-ettn-receipts.md b/.plans/checkbox-ettn-receipts.md index 8751775..0931193 100644 --- a/.plans/checkbox-ettn-receipts.md +++ b/.plans/checkbox-ettn-receipts.md @@ -138,6 +138,8 @@ webhook в Checkbox, и Checkbox **сам фискализирует** чек. как в чеках из портала. Боевой API отдаёт статусы строчными (`created`/`done`) — нормализуются. Лимит списка — 50 за страницу; 429 «Занадто часто» — повторяемая ошибка. - [x] `alembic upgrade head` на живом Postgres (0004 → 0005 применена в Docker). +- [x] После создания чека — статус заказа в CRM `PACKED` (`SetStatus`, миграция 0006 `receipts.crm_status_set_at`, + повтор cron'ом при сбое CRM). Проверено на боевой CRM: заказы 123901, 123793. - [ ] Позже (не в этом этапе): `fiscalize-manually`, PDF/ссылка на фискальный чек, webhook вместо опроса. --- diff --git a/CLAUDE.md b/CLAUDE.md index 28f137a..d1ea60d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,4 +125,5 @@ Stages 1–4 (scaffolding, auth/audit, CRM order queue, Nova Poshta tracking) ar - 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`. Prepayment goes into the receipt as a plain `DISCOUNT` («Знижка»), **not** `PRE_PAYMENT` — the live API rejects `PRE_PAYMENT` on ETTN with 400 `third_party.generic`. The live API also returns statuses lowercase (`EttnOut` upper-cases them). - 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). +- Once Checkbox accepts the receipt, the order is moved to `PACKED` in the CRM (`services/receipts.sync_crm_statuses`, marked by `receipts.crm_status_set_at`; runs right after creation and is retried by cron). The live exoCRM `SetStatus` differs from its docs: params must be `{"Orders": [id], "Status": ...}` (the documented `{"ID": ...}` returns "Undefined order list."), and the reply has no `status: OK` — success is `{"": {"Status": "Success"}}`. - 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/backend/alembic/versions/0006_receipt_crm_status_set_at.py b/backend/alembic/versions/0006_receipt_crm_status_set_at.py new file mode 100644 index 0000000..e3d7d4e --- /dev/null +++ b/backend/alembic/versions/0006_receipt_crm_status_set_at.py @@ -0,0 +1,28 @@ +"""Отметка о смене статуса заказа в CRM после создания чека + +Revision ID: 0006 +Revises: 0005 +Create Date: 2026-09-24 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0006" +down_revision: str | None = "0005" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "receipts", sa.Column("crm_status_set_at", sa.DateTime(timezone=True), nullable=True) + ) + + +def downgrade() -> None: + op.drop_column("receipts", "crm_status_set_at") diff --git a/backend/app/db/models/audit.py b/backend/app/db/models/audit.py index 39a2a42..41164f9 100644 --- a/backend/app/db/models/audit.py +++ b/backend/app/db/models/audit.py @@ -36,6 +36,7 @@ class AuditAction(str): CASH_REGISTER_UPDATED = "cash_register.updated" RECEIPT_CREATE_REQUESTED = "receipt.create_requested" RECEIPT_CANCELLED = "receipt.cancelled" + ORDER_CRM_STATUS_SET = "order.crm_status_set" class AuditLog(UUIDPrimaryKeyMixin, Base): diff --git a/backend/app/db/models/receipt.py b/backend/app/db/models/receipt.py index b42177b..27b33a5 100644 --- a/backend/app/db/models/receipt.py +++ b/backend/app/db/models/receipt.py @@ -70,6 +70,9 @@ class Receipt(UUIDPrimaryKeyMixin, TimestampMixin, Base): # Снимок отправленного в 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)) + # Когда заказу в CRM выставлен статус после создания чека (PACKED). NULL при + # живом чеке — ещё не выставлен (CRM была недоступна), cron повторит. + crm_status_set_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) __table_args__ = ( # Не больше одного «живого» чека на заказ — защита от двойного нажатия diff --git a/backend/app/services/crm/client.py b/backend/app/services/crm/client.py index 91d1b26..1e53f0e 100644 --- a/backend/app/services/crm/client.py +++ b/backend/app/services/crm/client.py @@ -13,3 +13,5 @@ class CrmError(Exception): class CrmClient(Protocol): async def get_orders(self, *, status: str) -> list[OrderOut]: ... + + async def set_status(self, *, order_id: str, status: str) -> None: ... diff --git a/backend/app/services/crm/exo_client.py b/backend/app/services/crm/exo_client.py index 074f49d..ca7ccb3 100644 --- a/backend/app/services/crm/exo_client.py +++ b/backend/app/services/crm/exo_client.py @@ -1,7 +1,9 @@ -"""Реальный клиент exoCRM (`GetOrders`).""" +"""Реальный клиент exoCRM (`GetOrders`, `SetStatus`).""" from __future__ import annotations +from typing import Any + import httpx from app.core.config import Settings @@ -10,6 +12,14 @@ from app.services.crm.checksum import compute_md5sum from app.services.crm.client import CrmError +def _errors_text(data: dict[str, Any]) -> str: + # Ключ бывает и `errors` (dict код → текст), и `Errors` (список строк). + errors = data.get("errors") or data.get("Errors") or {} + if isinstance(errors, dict): + return "; ".join(f"{code}: {text}" for code, text in errors.items()) + return "; ".join(str(error) for error in errors) + + class ExoCrmClient: def __init__(self, settings: Settings) -> None: self._base_url = settings.crm_base_url @@ -18,29 +28,51 @@ class ExoCrmClient: self._shop_key = settings.crm_shop_key self._sid = settings.crm_sid + async def _post(self, method: str, params: dict[str, Any]) -> dict[str, Any]: + body = {"apikey": self._api_key, "object": "Orders", "method": method, "params": params} + body["md5sum"] = compute_md5sum(body, self._secret_key) + + async with httpx.AsyncClient(timeout=30) as client: + response = await client.post(self._base_url, json=body) + response.raise_for_status() + try: + data = response.json() + except ValueError as exc: + # PHP-notice'ы CRM перед JSON — признак неверных параметров. + raise CrmError(f"CRM вернула не JSON: {response.text[:300]}") from exc + + if not isinstance(data, dict): + raise CrmError(f"Неожиданный ответ CRM: {str(data)[:300]}") + return data + async def get_orders(self, *, status: str) -> list[OrderOut]: - body = { - "apikey": self._api_key, - "object": "Orders", - "method": "GetOrders", - "params": { + data = await self._post( + "GetOrders", + { "sid": self._sid, "key": self._shop_key, "Status": status, "ReturnGoods": True, "ReturnTotals": True, }, - } - body["md5sum"] = compute_md5sum(body, self._secret_key) - - async with httpx.AsyncClient(timeout=30) as client: - response = await client.post(self._base_url, json=body) - response.raise_for_status() - data = response.json() - + ) if data.get("status") != "OK": - errors = data.get("errors") or {} - message = "; ".join(f"{code}: {text}" for code, text in errors.items()) - raise CrmError(f"CRM вернула ошибку: {message or 'неизвестная ошибка'}") + raise CrmError(f"CRM вернула ошибку: {_errors_text(data) or 'неизвестная ошибка'}") + return [OrderOut.model_validate(order) for order in data.get("result") or []] - return [OrderOut.model_validate(order) for order in data.get("result", [])] + async def set_status(self, *, order_id: str, status: str) -> None: + """`SetStatus`. Формат проверен на боевой CRM и расходится с документацией. + + Документация показывает `params: {"ID": ..., "Status": ...}`, но CRM на это + отвечает «Undefined order list.». Рабочий вариант — список ID в `Orders`, + а ответ — результат по каждому заказу без общего `status: OK`: + {"123901": {"Status": "Success", "ChangeStatus": "Success"}} + """ + data = await self._post("SetStatus", {"Orders": [order_id], "Status": status}) + result = data.get(order_id) + if isinstance(result, dict) and result.get("Status") == "Success": + return + details = _errors_text(data) or ( + str(result) if result is not None else "нет ответа по заказу" + ) + raise CrmError(f"CRM не сменила статус заказа {order_id} на {status}: {details}") diff --git a/backend/app/services/crm/stub_client.py b/backend/app/services/crm/stub_client.py index 75baf70..04b4d88 100644 --- a/backend/app/services/crm/stub_client.py +++ b/backend/app/services/crm/stub_client.py @@ -40,6 +40,11 @@ _FIXTURE_ORDERS: list[dict] = [ class StubCrmClient: def __init__(self, orders: list[dict] | None = None) -> None: self._orders = orders if orders is not None else _FIXTURE_ORDERS + # order_id → статус, выставленный через set_status (для проверок в тестах). + self.statuses: dict[str, str] = {} + + async def set_status(self, *, order_id: str, status: str) -> None: + self.statuses[order_id] = status async def get_orders(self, *, status: str) -> list[OrderOut]: return [ diff --git a/backend/app/services/receipts.py b/backend/app/services/receipts.py index d8b857b..5668ccd 100644 --- a/backend/app/services/receipts.py +++ b/backend/app/services/receipts.py @@ -20,6 +20,7 @@ from datetime import UTC, datetime, timedelta from decimal import ROUND_HALF_UP, Decimal from typing import Any +import httpx from fastapi import Request from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -39,6 +40,7 @@ from app.services.checkbox.client import ( CheckboxError, CheckboxUnavailableError, ) +from app.services.crm.client import CrmClient, CrmError log = get_logger(__name__) @@ -65,6 +67,16 @@ _CHECKBOX_TO_STATUS = { # или Checkbox был недоступен) и повторить его из cron'а. _PENDING_RETRY_AFTER = timedelta(minutes=1) +# Статус заказа в CRM, когда Checkbox принял ЕТТН-чек: заказ можно собирать. +CRM_STATUS_AFTER_RECEIPT = "PACKED" +# Чеки, при которых заказу нужен этот статус в CRM (Checkbox чек принял). +_CRM_STATUS_RECEIPT_STATUSES = ( + ReceiptStatus.CREATED, + ReceiptStatus.DONE, + ReceiptStatus.RECEIPT_ERROR, + ReceiptStatus.RETURNED, +) + class ReceiptValidationError(Exception): """Заказ нельзя отправить в Checkbox — сообщение показывается кассиру.""" @@ -500,6 +512,54 @@ async def sync_ettn_statuses(session: AsyncSession, client: CheckboxClient) -> N await session.commit() +async def sync_crm_statuses(session: AsyncSession, crm: CrmClient) -> None: + """Переводит в CRM заказы с принятым Checkbox чеком в статус PACKED. + + Отдельно от создания чека и идемпотентно по `crm_status_set_at`: сбой CRM + не должен ни откатывать уже созданный в Checkbox чек, ни теряться — + вызывается сразу после создания и повторяется cron'ом до успеха. + """ + receipt_ids = list( + await session.scalars( + select(Receipt.id) + .where(Receipt.status.in_(_CRM_STATUS_RECEIPT_STATUSES)) + .where(Receipt.crm_status_set_at.is_(None)) + ) + ) + for receipt_id in receipt_ids: + # Строка блокируется на время вызова CRM: задача создания и cron могут + # сработать одновременно, а повторный PACKED откатил бы статус, который + # менеджер уже успел сменить дальше. Занято или уже выставлено — пропуск. + receipt = await session.scalar( + select(Receipt) + .where(Receipt.id == receipt_id) + .where(Receipt.crm_status_set_at.is_(None)) + .with_for_update(skip_locked=True) + ) + if receipt is None: + continue + order_id = receipt.order_id # после rollback атрибуты истекают + try: + await crm.set_status(order_id=order_id, status=CRM_STATUS_AFTER_RECEIPT) + except (CrmError, httpx.HTTPError) as exc: + await session.rollback() # снять блокировку строки + log.warning("crm_status_set_failed", order_id=order_id, error=repr(exc)) + continue + receipt.crm_status_set_at = datetime.now(UTC) + await audit.record( + session, + action=AuditAction.ORDER_CRM_STATUS_SET, + actor_label="worker", + entity_type="order", + entity_id=receipt.order_id, + payload={"status": CRM_STATUS_AFTER_RECEIPT, "receipt_id": str(receipt.id)}, + ) + # Коммит на каждый заказ: статус в CRM уже сменён, отметку нельзя терять + # из-за сбоя на следующем заказе. + await session.commit() + log.info("crm_status_set", order_id=receipt.order_id, status=CRM_STATUS_AFTER_RECEIPT) + + async def latest_receipts_by_order( session: AsyncSession, order_ids: list[str] ) -> dict[str, Receipt]: diff --git a/backend/app/worker.py b/backend/app/worker.py index b7e9069..7a41573 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -3,7 +3,8 @@ Запускается отдельным процессом: `arq app.worker.WorkerSettings`. - `create_ettn_receipt` — задача, которую ставит API после запроса кассира; - `poll_np_statuses` — раз в минуту статусы ТТН по заказам без чека; -- `poll_receipts` — раз в минуту повтор зависших `pending` и статусы `created`-чеков. +- `poll_receipts` — раз в минуту повтор зависших `pending`, статусы `created`-чеков + и повтор смены статуса заказа в CRM (PACKED), если CRM была недоступна. """ from __future__ import annotations @@ -19,6 +20,7 @@ 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.crm.exo_client import ExoCrmClient from app.services.nova_poshta.np_client import NpTrackingClient from app.services.orders import sync_np_statuses @@ -29,6 +31,7 @@ async def startup(ctx: dict[str, Any]) -> None: configure_logging() ctx["np_client"] = NpTrackingClient(settings) ctx["checkbox_client"] = get_checkbox_client() + ctx["crm_client"] = ExoCrmClient(settings) log.info("worker_starting", environment=settings.environment) @@ -43,12 +46,15 @@ async def create_ettn_receipt(ctx: dict[str, Any], receipt_id: str) -> None: await receipts_service.create_ettn_for_receipt( session, ctx["checkbox_client"], uuid.UUID(receipt_id) ) + # Чек принят Checkbox — сразу переводим заказ в CRM в PACKED. + await receipts_service.sync_crm_statuses(session, ctx["crm_client"]) 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"]) + await receipts_service.sync_crm_statuses(session, ctx["crm_client"]) log.info("receipts_polled") diff --git a/backend/tests/test_crm_client.py b/backend/tests/test_crm_client.py index 08b2974..0e5cbfc 100644 --- a/backend/tests/test_crm_client.py +++ b/backend/tests/test_crm_client.py @@ -106,3 +106,43 @@ class TestExoCrmClientGetOrders: with pytest.raises(CrmError, match="Checksum Error"): await client.get_orders(status="APPROVED") + + +class TestExoCrmClientSetStatus: + @respx.mock + async def test_sends_order_list_and_parses_per_order_result(self) -> None: + import json + + route = respx.post(BASE_URL).mock( + return_value=Response( + 200, + json={ + "<": "<", + "123901": {"Status": "Success", "ChangeStatus": "Success"}, + ">": ">", + }, + ) + ) + + await ExoCrmClient(_settings()).set_status(order_id="123901", status="PACKED") + + body = json.loads(route.calls.last.request.content) + assert body["object"] == "Orders" + assert body["method"] == "SetStatus" + # Не {"ID": ...} из документации — боевая CRM принимает только список в Orders. + assert body["params"] == {"Orders": ["123901"], "Status": "PACKED"} + assert "md5sum" in body + + @respx.mock + async def test_raises_on_capitalized_errors(self) -> None: + respx.post(BASE_URL).mock( + return_value=Response(200, json={"<": "<", "Errors": ["Undefined order list."]}) + ) + with pytest.raises(CrmError, match="Undefined order list"): + await ExoCrmClient(_settings()).set_status(order_id="1", status="PACKED") + + @respx.mock + async def test_raises_on_non_json_reply(self) -> None: + respx.post(BASE_URL).mock(return_value=Response(200, text="Notice: ...")) + with pytest.raises(CrmError, match="не JSON"): + await ExoCrmClient(_settings()).set_status(order_id="1", status="PACKED") diff --git a/backend/tests/test_receipts_service.py b/backend/tests/test_receipts_service.py index 18b0098..5e33dc5 100644 --- a/backend/tests/test_receipts_service.py +++ b/backend/tests/test_receipts_service.py @@ -323,3 +323,70 @@ class TestCancel: receipt.id, user=None, # type: ignore[arg-type] ) + + +class CrmSession: + """Фейковая сессия для sync_crm_statuses: scalars → id, scalar → строка с «блокировкой».""" + + def __init__(self, *receipts: Receipt) -> None: + self.receipts = {r.id: r for r in receipts} + self.added: list[Any] = [] + self.commits = 0 + self.rollbacks = 0 + + async def scalars(self, _: Any) -> list[uuid.UUID]: + return [r.id for r in self.receipts.values() if r.crm_status_set_at is None] + + async def scalar(self, query: Any) -> Receipt | None: + receipt_id = query.whereclause.clauses[0].right.value + receipt = self.receipts[receipt_id] + return receipt if receipt.crm_status_set_at is None else None + + def add(self, obj: Any) -> None: + self.added.append(obj) + + async def commit(self) -> None: + self.commits += 1 + + async def rollback(self) -> None: + self.rollbacks += 1 + + +class TestSyncCrmStatuses: + async def test_sets_packed_once(self) -> None: + from app.services.crm.stub_client import StubCrmClient + + order, register = _order(), _register() + receipt = _pending(order, register) + receipt.status = ReceiptStatus.CREATED + session = CrmSession(receipt) + crm = StubCrmClient() + + await svc.sync_crm_statuses(session, crm) # type: ignore[arg-type] + await svc.sync_crm_statuses(session, crm) # type: ignore[arg-type] + + assert crm.statuses == {"100": "PACKED"} + assert receipt.crm_status_set_at is not None + assert session.commits == 1 # второй проход ничего не делает + + async def test_crm_failure_is_retried_later(self) -> None: + from app.services.crm.client import CrmError + from app.services.crm.stub_client import StubCrmClient + + order, register = _order(), _register() + receipt = _pending(order, register) + receipt.status = ReceiptStatus.CREATED + session = CrmSession(receipt) + crm = StubCrmClient() + + async def broken(**_: Any) -> None: + raise CrmError("CRM вернула ошибку") + + real_set_status = crm.set_status + crm.set_status = broken # type: ignore[method-assign] + await svc.sync_crm_statuses(session, crm) # type: ignore[arg-type] + assert receipt.crm_status_set_at is None and session.rollbacks == 1 + + crm.set_status = real_set_status # type: ignore[method-assign] + await svc.sync_crm_statuses(session, crm) # type: ignore[arg-type] + assert crm.statuses == {"100": "PACKED"} -- 2.54.0 From 56ac0fc3702934255a34038a490c968fc7b2a5fc Mon Sep 17 00:00:00 2001 From: Liutenko Oleksandr Date: Thu, 24 Sep 2026 17:23:41 +0300 Subject: [PATCH 07/10] Allow editing orders in the order card Cashiers can edit recipient, TTN, notes, goods and total in the order modal and save via PATCH /orders/{id}. Edited orders get edited_at and are no longer overwritten by CRM sync. Editing is blocked once a receipt exists; changing the TTN resets Nova Poshta tracking fields. Co-Authored-By: Claude Opus 5.5 --- .../alembic/versions/0007_order_edited_at.py | 26 ++ backend/app/api/v1/orders.py | 36 +- backend/app/db/models/audit.py | 1 + backend/app/db/models/order.py | 4 + backend/app/schemas/orders.py | 41 ++ backend/app/services/orders.py | 112 +++++- .../tests/test_migration_matches_models.py | 16 +- backend/tests/test_orders_router.py | 66 +++ backend/tests/test_orders_service.py | 130 +++++- frontend/src/api/orders.ts | 6 +- frontend/src/components/Modal.css | 4 + frontend/src/components/Modal.tsx | 5 +- .../src/features/orders/OrderDetailModal.css | 154 ++++++- .../src/features/orders/OrderDetailModal.tsx | 380 ++++++++++++++++-- frontend/src/features/orders/types.ts | 22 + frontend/src/pages/DashboardPage.tsx | 1 + 16 files changed, 946 insertions(+), 58 deletions(-) create mode 100644 backend/alembic/versions/0007_order_edited_at.py diff --git a/backend/alembic/versions/0007_order_edited_at.py b/backend/alembic/versions/0007_order_edited_at.py new file mode 100644 index 0000000..9bdd427 --- /dev/null +++ b/backend/alembic/versions/0007_order_edited_at.py @@ -0,0 +1,26 @@ +"""Отметка о ручном редактировании заказа + +Revision ID: 0007 +Revises: 0006 +Create Date: 2026-09-24 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0007" +down_revision: str | None = "0006" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("orders", sa.Column("edited_at", sa.DateTime(timezone=True), nullable=True)) + + +def downgrade() -> None: + op.drop_column("orders", "edited_at") diff --git a/backend/app/api/v1/orders.py b/backend/app/api/v1/orders.py index 0744734..4ea3057 100644 --- a/backend/app/api/v1/orders.py +++ b/backend/app/api/v1/orders.py @@ -1,4 +1,4 @@ -"""Локальная очередь заказов (синхронизируется из CRM) — список, вкладки, удаление.""" +"""Локальная очередь заказов (синхронизируется из CRM) — список, вкладки, правка, удаление.""" from __future__ import annotations @@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status from app.api.deps import CashierUser, CrmClientDep, SessionDep, require_any from app.db.models.audit import AuditAction -from app.schemas.orders import OrderRowOut +from app.schemas.orders import OrderRowOut, OrderUpdateIn from app.services import audit from app.services import orders as orders_service from app.services import receipts as receipts_service @@ -28,6 +28,38 @@ async def list_orders( return [OrderRowOut.from_order(order, receipts.get(order.id)) for order in orders] +@router.patch("/{order_id}", response_model=OrderRowOut, response_model_by_alias=False) +async def update_order( + order_id: str, + payload: OrderUpdateIn, + request: Request, + session: SessionDep, + actor: CashierUser, +) -> OrderRowOut: + try: + result = await orders_service.update_order(session, order_id, payload) + except orders_service.OrderEditError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + if result is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Заказ не найден") + + order, changed = result + if changed: + await audit.record( + session, + action=AuditAction.ORDER_UPDATED, + user=actor, + entity_type="order", + entity_id=order.id, + payload={"fields": changed}, + request=request, + ) + await session.commit() + + receipts = await receipts_service.latest_receipts_by_order(session, [order.id]) + return OrderRowOut.from_order(order, receipts.get(order.id)) + + @router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_order( order_id: str, request: Request, session: SessionDep, actor: CashierUser diff --git a/backend/app/db/models/audit.py b/backend/app/db/models/audit.py index 41164f9..12fe074 100644 --- a/backend/app/db/models/audit.py +++ b/backend/app/db/models/audit.py @@ -32,6 +32,7 @@ class AuditAction(str): USER_UPDATED = "user.updated" USER_DEACTIVATED = "user.deactivated" ORDER_DELETED = "order.deleted" + ORDER_UPDATED = "order.updated" CASH_REGISTER_CREATED = "cash_register.created" CASH_REGISTER_UPDATED = "cash_register.updated" RECEIPT_CREATE_REQUESTED = "receipt.create_requested" diff --git a/backend/app/db/models/order.py b/backend/app/db/models/order.py index 2c07efe..b394017 100644 --- a/backend/app/db/models/order.py +++ b/backend/app/db/models/order.py @@ -41,6 +41,10 @@ class Order(TimestampMixin, Base): is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + # Заказ правили вручную в карточке — синхронизация из CRM его больше не + # перезаписывает, иначе правки кассира затирались бы при следующем открытии. + edited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + # Заполняется будущей интеграцией с Checkbox — сейчас всегда NULL. receipt_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) diff --git a/backend/app/schemas/orders.py b/backend/app/schemas/orders.py index 3eb51af..0083537 100644 --- a/backend/app/schemas/orders.py +++ b/backend/app/schemas/orders.py @@ -10,6 +10,7 @@ integer-kopecks из CLAUDE.md относится к будущим персис from __future__ import annotations from datetime import datetime +from decimal import Decimal from typing import TYPE_CHECKING, Any from pydantic import BaseModel, ConfigDict, Field, field_validator @@ -88,6 +89,7 @@ class OrderRowOut(BaseModel): receipt_status: str | None = None receipt_error: str | None = None receipt_prepayment: str | None = None + edited: bool = False @classmethod def from_order(cls, order: Order, receipt: Receipt | None = None) -> OrderRowOut: @@ -116,4 +118,43 @@ class OrderRowOut(BaseModel): receipt_prepayment=( f"{receipt.prepayment_kopecks / 100:.2f}" if receipt else None ), + edited=order.edited_at is not None, ) + + +class OrderGoodIn(BaseModel): + """Позиция заказа из формы редактирования. `amount` считает сервер.""" + + id: str | None = Field(default=None, max_length=64) + sku: str = Field(default="", max_length=128) + name: str = Field(min_length=1, max_length=512) + price: Decimal = Field(ge=0, max_digits=12, decimal_places=2) + quantity: Decimal = Field(gt=0, max_digits=12, decimal_places=3) + discount_amount: Decimal = Field(default=Decimal(0), ge=0, max_digits=12, decimal_places=2) + + @field_validator("sku", "name", mode="after") + @classmethod + def _strip(cls, value: str) -> str: + return value.strip() + + +class OrderUpdateIn(BaseModel): + """`PATCH /orders/{id}` — полная замена редактируемых полей заказа.""" + + recipient_name: str | None = Field(default=None, max_length=255) + recipient_phone: str | None = Field(default=None, max_length=32) + recipient_email: str | None = Field(default=None, max_length=320) + waybill_number: str | None = Field(default=None, max_length=64) + notes: str | None = None + total_amount: Decimal = Field(ge=0, max_digits=12, decimal_places=2) + goods: list[OrderGoodIn] = Field(min_length=1) + + # Пустая строка из формы = «не указано». + @field_validator( + "recipient_name", "recipient_phone", "recipient_email", "waybill_number", "notes" + ) + @classmethod + def _blank_to_none(cls, value: str | None) -> str | None: + if value is None: + return None + return value.strip() or None diff --git a/backend/app/services/orders.py b/backend/app/services/orders.py index 7f049b3..b86dee4 100644 --- a/backend/app/services/orders.py +++ b/backend/app/services/orders.py @@ -2,13 +2,16 @@ from __future__ import annotations +import uuid from datetime import UTC, datetime -from decimal import Decimal +from decimal import ROUND_HALF_UP, Decimal +from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.db.models.order import Order +from app.schemas.orders import OrderUpdateIn from app.services.crm.client import CrmClient from app.services.nova_poshta.client import NovaPoshtaClient @@ -17,6 +20,19 @@ _CRM_STATUS = "APPROVED" # NP отклоняет запросы с более чем 100 накладными за раз (см. np_client.py). _NP_BATCH_SIZE = 100 +# Поля, которые кассир может править в карточке заказа (кроме goods/total). +_EDITABLE_FIELDS = ( + "recipient_name", + "recipient_phone", + "recipient_email", + "waybill_number", + "notes", +) + + +class OrderEditError(Exception): + """Заказ нельзя сохранить — сообщение показывается кассиру.""" + def _to_kopecks(amount: str) -> int: return int((Decimal(amount) * 100).to_integral_value()) @@ -31,7 +47,8 @@ async def sync_orders_from_crm(session: AsyncSession, crm: CrmClient) -> None: Уже скрытые (`is_deleted`) заказы не восстанавливаются и не перезаписываются — иначе кнопка «Удалить» переставала бы работать при следующем открытии - дашборда, т.к. CRM продолжает возвращать эти заказы как есть. + дашборда, т.к. CRM продолжает возвращать эти заказы как есть. По той же + причине не трогаются заказы, отредактированные вручную (`edited_at`). """ crm_orders = await crm.get_orders(status=_CRM_STATUS) if not crm_orders: @@ -45,7 +62,7 @@ async def sync_orders_from_crm(session: AsyncSession, crm: CrmClient) -> None: for crm_order in crm_orders: local = existing_by_id.get(crm_order.id) if local is not None: - if local.is_deleted: + if local.is_deleted or local.edited_at is not None: continue else: local = Order(id=crm_order.id) @@ -119,3 +136,92 @@ async def delete_order(session: AsyncSession, order_id: str) -> Order | None: order.is_deleted = True order.deleted_at = datetime.now(UTC) return order + + +def _good_key(good: dict[str, Any]) -> tuple[Any, ...]: + """Позиция без учёта формата строк: CRM пишет "1", форма — "1.000".""" + return ( + good["id"], + good.get("sku") or "", + good["name"], + _to_kopecks(good["price"]), + Decimal(good["quantity"]), + _to_kopecks(good["amount"]) if good.get("amount") else None, + ) + + +def _build_goods(data: OrderUpdateIn) -> tuple[list[dict[str, Any]], int]: + """Позиции формы → снимок `orders.goods` в формате CRM и их сумма в копейках.""" + goods: list[dict[str, Any]] = [] + total = 0 + for good in data.goods: + price = int(good.price * 100) + discount = int(good.discount_amount * 100) + gross = int((price * good.quantity).to_integral_value(ROUND_HALF_UP)) + net = gross - discount + if net < 0: + raise OrderEditError(f"Товар «{good.name}»: скидка больше суммы строки") + goods.append( + { + "id": good.id or f"new-{uuid.uuid4().hex[:8]}", + "sku": good.sku, + "name": good.name, + "price": f"{good.price:.2f}", + "quantity": f"{good.quantity:.3f}", + "discount_amount": f"{good.discount_amount:.2f}", + "discount_percent": None, + "amount": f"{net / 100:.2f}", + } + ) + total += net + return goods, total + + +async def update_order( + session: AsyncSession, order_id: str, data: OrderUpdateIn +) -> tuple[Order, list[str]] | None: + """Сохраняет ручные правки заказа. Возвращает заказ и список изменённых полей. + + Править можно только заказ без чека: у заказа с чеком (в т.ч. `pending`) + данные уже ушли или уходят в Checkbox. Строка блокируется, чтобы + параллельный запрос на создание чека не прочитал заказ посреди правки. + """ + order = await session.scalar(select(Order).where(Order.id == order_id).with_for_update()) + if order is None or order.is_deleted: + return None + if order.receipt_created_at is not None: + raise OrderEditError("По заказу уже создан чек — редактирование недоступно") + + goods, goods_total = _build_goods(data) + total = _to_kopecks(str(data.total_amount)) + if total > goods_total: + raise OrderEditError( + f"Сумма заказа {total / 100:.2f} ₴ больше суммы товаров {goods_total / 100:.2f} ₴" + ) + + changed = [ + field for field in _EDITABLE_FIELDS if getattr(order, field) != getattr(data, field) + ] + goods_changed = [_good_key(good) for good in order.goods] != [_good_key(good) for good in goods] + if goods_changed: + changed.append("goods") + if order.total_amount_kopecks != total: + changed.append("total_amount") + if not changed: + return order, changed + + if "waybill_number" in changed: + # Статус старой ТТН к новой не относится — worker опросит новую за минуту. + order.np_status = None + order.np_status_code = None + order.np_cod_amount_kopecks = None + order.np_payment_status = None + + for field in _EDITABLE_FIELDS: + setattr(order, field, getattr(data, field)) + if goods_changed: + # Иначе оставляем снимок CRM как есть — форматы строк у CRM свои ("1" vs "1.000"). + order.goods = goods + order.total_amount_kopecks = total + order.edited_at = datetime.now(UTC) + return order, changed diff --git a/backend/tests/test_migration_matches_models.py b/backend/tests/test_migration_matches_models.py index f8b396c..c815eeb 100644 --- a/backend/tests/test_migration_matches_models.py +++ b/backend/tests/test_migration_matches_models.py @@ -24,14 +24,18 @@ MODEL_TABLES = set(Base.metadata.tables) def _migration_block(table: str) -> str: - """Тело вызова op.create_table для указанной таблицы. + """Тело вызова op.create_table для указанной таблицы плюс её поздние op.add_column. - Границей служит следующий op.create_table; для последней таблицы — конец файла. + Границей create_table служит следующий op.create_table или конец файла миграции. """ - start = COMBINED_SOURCE.index(f'op.create_table(\n "{table}",') - next_table = COMBINED_SOURCE.find("op.create_table(", start + 1) - end = next_table if next_table != -1 else len(COMBINED_SOURCE) - return COMBINED_SOURCE[start:end] + marker = f'op.create_table(\n "{table}",' + source = next(text for text in SOURCES.values() if marker in text) + start = source.index(marker) + next_table = source.find("op.create_table(", start + 1) + end = next_table if next_table != -1 else len(source) + # Колонки, добавленные позже через op.add_column, — в любой миграции. + added = re.findall(rf'op\.add_column\(\s*"{table}",\s*(sa\.Column\(.*?\n)', COMBINED_SOURCE) + return source[start:end] + "\n" + "\n".join(added) def test_migrations_create_every_model_table() -> None: diff --git a/backend/tests/test_orders_router.py b/backend/tests/test_orders_router.py index f42b454..3d3a5d8 100644 --- a/backend/tests/test_orders_router.py +++ b/backend/tests/test_orders_router.py @@ -79,6 +79,19 @@ 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_update( + session: object, order_id: str, data: object + ) -> tuple[Order, list[str]] | None: + if order_id == "locked": + raise orders_router.orders_service.OrderEditError("По заказу уже создан чек") + if order_id != "1": + return None + order = _order(order_id) + order.edited_at = datetime(2026, 9, 24, tzinfo=UTC) + return order, ["notes"] + + monkeypatch.setattr(orders_router.orders_service, "update_order", fake_update) + async def fake_latest(session: object, order_ids: list[str]) -> dict: return {} @@ -164,3 +177,56 @@ class TestDeleteOrder: response = client.delete("/api/v1/orders/does-not-exist") assert response.status_code == 404 + + +_UPDATE_BODY = { + "recipient_name": "Тест Тестов", + "recipient_phone": "+380501112233", + "waybill_number": "20450123456789", + "notes": "правка", + "total_amount": "1200.00", + "goods": [ + {"id": "1", "sku": "SKU-1", "name": "Товар 1", "price": "1200.00", "quantity": "1"} + ], +} + + +class TestUpdateOrder: + @pytest.mark.parametrize("role", [UserRole.ADMIN, UserRole.CASHIER]) + def test_admin_and_cashier_can_update(self, client: TestClient, role: UserRole) -> None: + app.dependency_overrides[get_current_user] = lambda: _user(role) + + response = client.patch("/api/v1/orders/1", json=_UPDATE_BODY) + + assert response.status_code == 200 + body = response.json() + assert body["edited"] is True + assert body["goods"][0]["sku"] == "SKU-1" + + def test_viewer_cannot_update(self, client: TestClient) -> None: + app.dependency_overrides[get_current_user] = lambda: _user(UserRole.VIEWER) + + response = client.patch("/api/v1/orders/1", json=_UPDATE_BODY) + + assert response.status_code == 403 + + def test_unknown_order_is_404(self, client: TestClient) -> None: + app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER) + + response = client.patch("/api/v1/orders/nope", json=_UPDATE_BODY) + + assert response.status_code == 404 + + def test_order_with_receipt_is_409(self, client: TestClient) -> None: + app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER) + + response = client.patch("/api/v1/orders/locked", json=_UPDATE_BODY) + + assert response.status_code == 409 + + def test_empty_goods_is_rejected(self, client: TestClient) -> None: + app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER) + + response = client.patch("/api/v1/orders/1", json={**_UPDATE_BODY, "goods": []}) + + assert response.status_code == 422 diff --git a/backend/tests/test_orders_service.py b/backend/tests/test_orders_service.py index 6856e43..b7285aa 100644 --- a/backend/tests/test_orders_service.py +++ b/backend/tests/test_orders_service.py @@ -1,10 +1,16 @@ -"""Юнит-тесты чистых хелперов сервиса заказов. Без БД и без сети.""" +"""Юнит-тесты сервиса заказов. Без БД и без сети — сессия подменяется заглушкой.""" from __future__ import annotations -from datetime import datetime +import asyncio +from datetime import UTC, datetime +from typing import Any -from app.services.orders import _parse_crm_datetime, _to_kopecks +import pytest + +from app.db.models.order import Order +from app.schemas.orders import OrderUpdateIn +from app.services.orders import OrderEditError, _parse_crm_datetime, _to_kopecks, update_order class TestToKopecks: @@ -24,3 +30,121 @@ class TestToKopecks: class TestParseCrmDatetime: def test_parses_crm_format(self) -> None: assert _parse_crm_datetime("2026-09-20 10:00:00") == datetime(2026, 9, 20, 10, 0, 0) + + +class _FakeSession: + def __init__(self, order: Order | None) -> None: + self.order = order + + async def scalar(self, query: Any) -> Order | None: + return self.order + + +def _order(**overrides: Any) -> Order: + fields: dict[str, Any] = { + "id": "1", + "recipient_name": "Тест", + "recipient_phone": "0501112233", + "recipient_email": None, + "waybill_number": "20450000000001", + "notes": None, + "total_amount_kopecks": 120000, + "goods": [], + "is_deleted": False, + "receipt_created_at": None, + "edited_at": None, + "np_status": "Прибув", + "np_status_code": "7", + "np_cod_amount_kopecks": 120000, + "np_payment_status": "NeedPayment", + } + fields.update(overrides) + return Order(**fields) + + +_GOOD = { + "id": "1", + "sku": "A", + "name": "Товар", + "price": "600.00", + "quantity": "2", + "discount_amount": "50.00", +} + + +def _payload(**overrides: Any) -> OrderUpdateIn: + data: dict[str, Any] = { + "recipient_name": "Тест", + "recipient_phone": "0501112233", + "recipient_email": "", + "waybill_number": "20450000000001", + "notes": "", + "total_amount": "1100.00", + "goods": [_GOOD], + } + data.update(overrides) + return OrderUpdateIn.model_validate(data) + + +def _update(order: Order | None, payload: OrderUpdateIn) -> tuple[Order, list[str]]: + result = asyncio.run(update_order(_FakeSession(order), "1", payload)) # type: ignore[arg-type] + assert result is not None + return result + + +class TestUpdateOrder: + def test_recomputes_line_amount_and_marks_edited(self) -> None: + order, changed = _update(_order(), _payload()) + + assert order.goods[0]["amount"] == "1150.00" + assert order.goods[0]["quantity"] == "2.000" + assert order.total_amount_kopecks == 110000 + assert order.edited_at is not None + assert set(changed) == {"goods", "total_amount"} + + def test_blank_strings_become_null(self) -> None: + order, _ = _update(_order(), _payload(notes=" ")) + + assert order.notes is None + assert order.recipient_email is None + + def test_new_line_gets_generated_id(self) -> None: + good = {"sku": "", "name": "Новый", "price": "10.00", "quantity": "1"} + order, _ = _update(_order(), _payload(goods=[good], total_amount="10.00")) + + assert order.goods[0]["id"].startswith("new-") + + def test_waybill_change_resets_np_tracking(self) -> None: + order, changed = _update(_order(), _payload(waybill_number="20450000000002")) + + assert "waybill_number" in changed + assert order.np_status is None + assert order.np_cod_amount_kopecks is None + + def test_no_changes_does_not_mark_edited(self) -> None: + # Снимок в формате CRM ("2", а не "2.000") — формат строк не считается правкой. + stored = {**_GOOD, "discount_percent": "4.17", "amount": "1150.00"} + order, changed = _update(_order(goods=[stored], total_amount_kopecks=110000), _payload()) + + assert changed == [] + assert order.edited_at is None + assert order.goods == [stored] + + def test_total_above_goods_sum_is_rejected(self) -> None: + with pytest.raises(OrderEditError, match="больше суммы товаров"): + _update(_order(), _payload(total_amount="1200.00")) + + def test_discount_above_line_sum_is_rejected(self) -> None: + good = {"name": "Товар", "price": "10.00", "quantity": "1", "discount_amount": "11"} + with pytest.raises(OrderEditError, match="скидка больше"): + _update(_order(), _payload(goods=[good], total_amount="0")) + + def test_order_with_receipt_is_locked(self) -> None: + with pytest.raises(OrderEditError, match="уже создан чек"): + _update(_order(receipt_created_at=datetime.now(UTC)), _payload()) + + def test_missing_or_deleted_order_returns_none(self) -> None: + payload = _payload() + assert asyncio.run(update_order(_FakeSession(None), "1", payload)) is None # type: ignore[arg-type] + deleted = _FakeSession(_order(is_deleted=True)) + assert asyncio.run(update_order(deleted, "1", payload)) is None # type: ignore[arg-type] diff --git a/frontend/src/api/orders.ts b/frontend/src/api/orders.ts index 39fa290..3e19e3c 100644 --- a/frontend/src/api/orders.ts +++ b/frontend/src/api/orders.ts @@ -1,5 +1,5 @@ import { apiFetch } from '@/api/client' -import type { Order } from '@/features/orders/types' +import type { Order, OrderUpdate } from '@/features/orders/types' export function getOrders(hasReceipt: boolean): Promise { return apiFetch(`/orders?has_receipt=${hasReceipt}`) @@ -8,3 +8,7 @@ export function getOrders(hasReceipt: boolean): Promise { export function deleteOrder(orderId: string): Promise { return apiFetch(`/orders/${orderId}`, { method: 'DELETE' }) } + +export function updateOrder(orderId: string, data: OrderUpdate): Promise { + return apiFetch(`/orders/${orderId}`, { method: 'PATCH', body: data }) +} diff --git a/frontend/src/components/Modal.css b/frontend/src/components/Modal.css index 3223351..c1b00a4 100644 --- a/frontend/src/components/Modal.css +++ b/frontend/src/components/Modal.css @@ -19,3 +19,7 @@ max-height: 90vh; overflow-y: auto; } + +.modal-content--wide { + max-width: 920px; +} diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx index 725ca67..071f129 100644 --- a/frontend/src/components/Modal.tsx +++ b/frontend/src/components/Modal.tsx @@ -6,9 +6,10 @@ import '@/components/Modal.css' interface ModalProps { onClose: () => void children: ReactNode + wide?: boolean } -export function Modal({ onClose, children }: ModalProps) { +export function Modal({ onClose, children, wide = false }: ModalProps) { useEffect(() => { function onKeyDown(event: KeyboardEvent) { if (event.key === 'Escape') onClose() @@ -19,7 +20,7 @@ export function Modal({ onClose, children }: ModalProps) { return (
-
event.stopPropagation()}> +
event.stopPropagation()}> {children}
diff --git a/frontend/src/features/orders/OrderDetailModal.css b/frontend/src/features/orders/OrderDetailModal.css index c74a55c..319f0e3 100644 --- a/frontend/src/features/orders/OrderDetailModal.css +++ b/frontend/src/features/orders/OrderDetailModal.css @@ -37,7 +37,8 @@ font-size: 14px; } -.order-modal-meta div { +.order-modal-meta div, +.order-modal-meta label { display: flex; flex-direction: column; gap: 2px; @@ -111,3 +112,154 @@ border-color: var(--color-primary); color: var(--color-primary); } + +.order-modal-edited { + margin-left: 10px; + padding: 2px 8px; + border-radius: 999px; + background: var(--color-bg); + border: 1px solid var(--color-border); + color: var(--color-text-muted); + font-size: 11px; + font-weight: 500; + vertical-align: middle; +} + +.order-modal-notes { + display: flex; + flex-direction: column; + gap: 2px; +} + +.order-modal-input { + width: 100%; + box-sizing: border-box; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text); + font: inherit; + font-size: 13px; +} + +.order-modal-input:focus { + outline: none; + border-color: var(--color-primary); +} + +textarea.order-modal-input { + resize: vertical; +} + +.order-modal-input--sku { + min-width: 80px; +} + +.order-modal-input--num { + width: 84px; + text-align: right; +} + +.order-modal-goods td:has(.order-modal-input) { + padding: 4px 4px; +} + +.order-modal-invalid { + color: var(--color-danger); +} + +.order-modal-remove { + border: none; + background: none; + color: var(--color-text-muted); + font-size: 18px; + line-height: 1; + cursor: pointer; + padding: 2px 6px; +} + +.order-modal-remove:hover { + color: var(--color-danger); +} + +.order-modal-add { + align-self: flex-start; + border: 1px dashed var(--color-border); + background: none; + color: var(--color-primary); + border-radius: 8px; + padding: 6px 12px; + font-size: 13px; + cursor: pointer; +} + +.order-modal-add:hover { + border-color: var(--color-primary); +} + +.order-modal-total { + align-items: center; +} + +.order-modal-message { + padding: 8px 12px; + border-radius: 8px; + font-size: 13px; +} + +.order-modal-message--ok { + background: #dcfce7; + color: #166534; +} + +.order-modal-message--error { + background: var(--color-danger-bg); + color: var(--color-danger); + border: 1px solid var(--color-danger-border); +} + +.order-modal-actions { + gap: 8px; +} + +.order-modal-save-btn { + border: 1px solid var(--color-primary); + background: var(--color-primary); + color: #fff; + border-radius: 8px; + padding: 8px 16px; + font-size: 14px; + cursor: pointer; +} + +.order-modal-save-btn:hover:not(:disabled) { + background: var(--color-primary-hover); +} + +.order-modal-save-btn:disabled { + opacity: 0.5; + cursor: default; +} + +@media (prefers-color-scheme: dark) { + .order-modal-message--ok { + background: rgba(22, 163, 74, 0.18); + color: #86efac; + } +} + +@media (max-width: 640px) { + .order-modal { + padding: 16px; + } + + .order-modal-meta { + grid-template-columns: minmax(0, 1fr); + } + + .order-modal-goods { + display: block; + overflow-x: auto; + } +} diff --git a/frontend/src/features/orders/OrderDetailModal.tsx b/frontend/src/features/orders/OrderDetailModal.tsx index 58d18c9..67a257e 100644 --- a/frontend/src/features/orders/OrderDetailModal.tsx +++ b/frontend/src/features/orders/OrderDetailModal.tsx @@ -1,20 +1,226 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useMemo, useState } from 'react' + +import { updateOrder } from '@/api/orders' import { Modal } from '@/components/Modal' import '@/features/orders/OrderDetailModal.css' -import type { Order } from '@/features/orders/types' +import type { Order, OrderUpdate } from '@/features/orders/types' +import { formatKopecks, toKopecks } from '@/features/receipts/money' import { RECEIPT_STATUS } from '@/features/receipts/types' interface OrderDetailModalProps { order: Order + /** Кассир/админ и по заказу ещё нет чека — карточку можно править. */ + canEdit: boolean onClose: () => void } -export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) { +interface GoodDraft { + key: string + id: string | null + sku: string + name: string + price: string + quantity: string + discount: string +} + +interface Draft { + recipient_name: string + recipient_phone: string + recipient_email: string + waybill_number: string + notes: string + total: string + goods: GoodDraft[] +} + +type TextField = Exclude + +/** Количество в тысячных (конвенция Checkbox: 1 шт = 1000). */ +function toThousandths(value: string): number | null { + const normalized = value.trim().replace(',', '.') + if (!/^\d+(\.\d{1,3})?$/.test(normalized)) return null + return Math.round(Number(normalized) * 1000) +} + +/** Сумма строки после скидки в копейках; null — в строке некорректные числа. */ +function lineAmount(good: GoodDraft): number | null { + const price = toKopecks(good.price) + const quantity = toThousandths(good.quantity) + const discount = toKopecks(good.discount) + if (price === null || quantity === null || discount === null) return null + return Math.round((price * quantity) / 1000) - discount +} + +function goodsSum(goods: GoodDraft[]): number | null { + let sum = 0 + for (const good of goods) { + const amount = lineAmount(good) + if (amount === null) return null + sum += amount + } + return sum +} + +let newGoodSeq = 0 + +function toDraft(order: Order): Draft { + return { + recipient_name: order.recipient_name ?? '', + recipient_phone: order.recipient_phone ?? '', + recipient_email: order.recipient_email ?? '', + waybill_number: order.waybill_number ?? '', + notes: order.notes ?? '', + total: order.total_amount, + goods: order.goods.map((good) => { + // Скидку строки выводим из amount, как бэкенд при сборке чека, — не из discount_amount/percent. + const gross = Math.round(((toKopecks(good.price) ?? 0) * (toThousandths(good.quantity) ?? 0)) / 1000) + const discount = Math.max(gross - (toKopecks(good.amount) ?? gross), 0) + return { + key: good.id, + id: good.id, + sku: good.sku, + name: good.name, + price: good.price, + quantity: good.quantity, + discount: formatKopecks(discount), + } + }), + } +} + +function orderDiscountOf(draft: Draft): number { + return (goodsSum(draft.goods) ?? 0) - (toKopecks(draft.total) ?? 0) +} + +/** Проверяет черновик и собирает тело PATCH; строка — текст ошибки для кассира. */ +function toPayload(draft: Draft): OrderUpdate | string { + if (draft.goods.length === 0) return 'Добавьте хотя бы один товар' + const goods = [] + for (const good of draft.goods) { + const label = good.name.trim() || 'без названия' + if (!good.name.trim()) return 'У товара не указано наименование' + const price = toKopecks(good.price) + const quantity = toThousandths(good.quantity) + const discount = toKopecks(good.discount) + if (price === null) return `Товар «${label}»: некорректная цена` + if (quantity === null || quantity <= 0) return `Товар «${label}»: некорректное количество` + if (discount === null) return `Товар «${label}»: некорректная скидка` + if ((lineAmount(good) ?? 0) < 0) return `Товар «${label}»: скидка больше суммы строки` + goods.push({ + id: good.id, + sku: good.sku.trim(), + name: good.name.trim(), + price: formatKopecks(price), + quantity: (quantity / 1000).toFixed(3), + discount_amount: formatKopecks(discount), + }) + } + const total = toKopecks(draft.total) + if (total === null) return 'Некорректная сумма заказа' + const sum = goodsSum(draft.goods) ?? 0 + if (total > sum) return `Сумма заказа больше суммы товаров (${formatKopecks(sum)} ₴)` + + return { + recipient_name: draft.recipient_name, + recipient_phone: draft.recipient_phone, + recipient_email: draft.recipient_email, + waybill_number: draft.waybill_number, + notes: draft.notes, + total_amount: formatKopecks(total), + goods, + } +} + +const TEXT_FIELDS: { field: TextField; label: string; type?: string }[] = [ + { field: 'recipient_name', label: 'Клиент' }, + { field: 'recipient_phone', label: 'Телефон', type: 'tel' }, + { field: 'recipient_email', label: 'Email', type: 'email' }, + { field: 'waybill_number', label: 'Номер ТТН' }, +] + +export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalProps) { + const queryClient = useQueryClient() + const [draft, setDraft] = useState(() => toDraft(order)) + const [saved, setSaved] = useState(draft) + // Скидка на весь заказ (итог товаров − сумма заказа): держим её при правке товаров. + const [orderDiscount, setOrderDiscount] = useState(() => orderDiscountOf(draft)) + const [saving, setSaving] = useState(false) + const [message, setMessage] = useState<{ tone: 'ok' | 'error'; text: string } | null>(null) + + const dirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(saved), [draft, saved]) + const editable = canEdit && !order.has_receipt + + function requestClose() { + if (dirty && !window.confirm('Есть несохранённые изменения. Закрыть без сохранения?')) return + onClose() + } + + function setField(field: TextField, value: string) { + setDraft((prev) => ({ ...prev, [field]: value })) + setMessage(null) + } + + /** Меняет товары и пересчитывает итог с сохранением скидки на заказ. */ + function setGoods(update: (goods: GoodDraft[]) => GoodDraft[]) { + const goods = update(draft.goods) + const sum = goodsSum(goods) + setDraft({ ...draft, goods, total: sum === null ? draft.total : formatKopecks(Math.max(sum - orderDiscount, 0)) }) + setMessage(null) + } + + function setTotal(value: string) { + setDraft({ ...draft, total: value }) + const sum = goodsSum(draft.goods) + const total = toKopecks(value) + if (sum !== null && total !== null) setOrderDiscount(sum - total) + setMessage(null) + } + + function setGood(key: string, field: keyof GoodDraft, value: string) { + setGoods((goods) => goods.map((good) => (good.key === key ? { ...good, [field]: value } : good))) + } + + function addGood() { + newGoodSeq += 1 + setGoods((goods) => [ + ...goods, + { key: `new-${newGoodSeq}`, id: null, sku: '', name: '', price: '0.00', quantity: '1', discount: '0.00' }, + ]) + } + + async function handleSave() { + const payload = toPayload(draft) + if (typeof payload === 'string') { + setMessage({ tone: 'error', text: payload }) + return + } + setSaving(true) + try { + const updated = await updateOrder(order.id, payload) + const next = toDraft(updated) + setDraft(next) + setSaved(next) + setOrderDiscount(orderDiscountOf(next)) + setMessage({ tone: 'ok', text: 'Изменения сохранены' }) + await queryClient.invalidateQueries({ queryKey: ['orders'] }) + } catch (err) { + setMessage({ tone: 'error', text: err instanceof Error ? err.message : 'Не удалось сохранить заказ' }) + } finally { + setSaving(false) + } + } + return ( - +
-

Просмотр заказа {order.id}

-
@@ -24,10 +230,6 @@ export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) { Дата {order.create_date_time}
-
- Номер ТТН - {order.waybill_number || '—'} -
Статус ТТН {order.np_status || 'Нет данных'} @@ -46,27 +248,45 @@ export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) { : '—'}
-
- Клиент - {order.recipient_name || '—'} -
-
- Телефон - {order.recipient_phone || '—'} -
- {order.recipient_email && ( -
- Email - {order.recipient_email} -
+ {TEXT_FIELDS.map(({ field, label, type }) => + editable ? ( + + ) : ( + (field !== 'recipient_email' || draft[field]) && ( +
+ {label} + {draft[field] || '—'} +
+ ) + ), )}
- {order.notes && ( -
+ {editable ? ( +
+