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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 <cashier JWT>` через `POST /api/v1/cashier/signinPinCode`
|
||||
(`pin_code` + заголовок `X-License-Key`). Всегда слать `X-Client-Name`, `X-Client-Version`, `X-License-Key`.
|
||||
- Тело `ETTNCreateReceiptSchema`: `provider="novapost"`, `receipt_body`:
|
||||
- `goods[]`: `good{code, name, price (коп. за 1000), tax[]}`, `quantity` (тысячные), `is_return=false`, `discounts[]`
|
||||
- `discounts[]`: `{type: "PRE_PAYMENT", mode: "VALUE", value}` — предоплата
|
||||
- `payments[]`: `{value: <сумма наложки, коп.>, ettn: <номер ТТН>}`
|
||||
- `delivery`: `phone` / `emails` — куда отправить чек после фискализации
|
||||
- Ответ `BaseEttnResponse`: `id`, `status`, `receiptId` (после фискализации), `rawError`, `totalSum`.
|
||||
- Статусы: `CREATED`, `CANCELLED`, `RECEIPT_ERROR`, `DONE`, `DONE_WITHOUT_SMS`, `RETURNED`.
|
||||
- Инвариант: сумма ЕТТН = сумма товаров − предоплата = наложка НП.
|
||||
|
||||
### Решения (согласовано)
|
||||
- Запуск только вручную: кнопка «Чек» в строке/модалке + массово «Создать чеки по выбранным».
|
||||
- Частичная предоплата бывает: кассир вводит её в поле «Предоплата» → `PRE_PAYMENT` скидка.
|
||||
- Коды налогов настраиваются на уровне кассы (по умолчанию пусто = не передаём `tax`).
|
||||
- Касса и секреты — в БД, `license_key` и PIN шифруются Fernet (`app/core/crypto.py`).
|
||||
|
||||
### Предусловия (вне кода, проверить до старта)
|
||||
- В портале Checkbox касса подключена к НП (токен НП + телефон ФОП).
|
||||
- ТТН создаются с **«Контролем оплаты»** (AfterpaymentOnGoodsCost), а не с обычным денежным переводом.
|
||||
- ЕТТН-чек нужно создать **до** получения посылки клиентом.
|
||||
|
||||
---
|
||||
|
||||
## Этап 4.1 — Модели и миграция
|
||||
|
||||
- [x] `app/db/models/cash_register.py` — `CashRegister(UUIDPrimaryKeyMixin, TimestampMixin)`:
|
||||
`name`, `fiscal_number`, `license_key_enc`, `cashier_pin_enc`, `tax_codes` (JSONB, default `[]`),
|
||||
`is_active`, `is_default` (partial unique index — одна касса по умолчанию).
|
||||
- [x] `app/db/models/receipt.py` — `Receipt(UUIDPrimaryKeyMixin, TimestampMixin)`:
|
||||
`order_id` FK→orders, `cash_register_id` FK, `created_by_id` FK→users,
|
||||
`waybill_number`, `total_kopecks`, `prepayment_kopecks`, `cod_kopecks`,
|
||||
`status` (StrEnum: `pending`, `created`, `done`, `returned`, `cancelled`, `failed`, `receipt_error`),
|
||||
`checkbox_ettn_id`, `checkbox_receipt_id`, `error`, `request_body` (JSONB — снимок отправленного),
|
||||
`last_checked_at`.
|
||||
Partial unique index `(order_id) WHERE status NOT IN ('cancelled','failed')` — не больше одного
|
||||
живого чека на заказ.
|
||||
- [x] Импорт моделей в `app/db/models/__init__.py`.
|
||||
- [x] Миграция `0005_cash_registers_and_receipts.py` вручную по образцу `0001`/`0002` (`op.f(...)`,
|
||||
формат `op.create_table(\n "<table>",` — его парсит `test_migration_matches_models.py`).
|
||||
- [x] `AuditAction`: `CASH_REGISTER_CREATED/UPDATED`, `RECEIPT_CREATE_REQUESTED`, `RECEIPT_CANCELLED`.
|
||||
- [x] Добавить `pin`/`cashier_pin` в `_REDACTED_KEYS` (`app/services/audit.py`).
|
||||
|
||||
## Этап 4.2 — Checkbox-клиент
|
||||
|
||||
- [x] `app/services/checkbox/client.py` — `Protocol CheckboxClient` + `CheckboxError`
|
||||
(по образцу `services/nova_poshta/client.py`):
|
||||
`sign_in(license_key, pin) -> token`, `create_ettn(...)`, `get_ettn(id)`, `delete_ettn(id)`.
|
||||
- [x] `app/services/checkbox/http_client.py` — httpx, заголовки `X-Client-*`, `X-License-Key`, Bearer;
|
||||
при 401 — повторный `signinPinCode` и один retry. Токен кэшируется в памяти процесса по `cash_register_id`
|
||||
(клиент — синглтон на процесс; Redis не понадобился).
|
||||
- [x] `app/services/checkbox/stub_client.py` — стаб для тестов и локальной разработки
|
||||
(ЕТТН на тестовой кассе Checkbox не работает — стаб единственный способ прогнать цикл без боевой кассы).
|
||||
In-memory состояние: `create` → `CREATED`, последующие `get` умеют отдавать `DONE`/`RETURNED`/`RECEIPT_ERROR`.
|
||||
- [x] Флаг `CHECKBOX_USE_STUB` (default `false`); выбор реализации в одном месте (`deps.get_checkbox_client` + worker startup).
|
||||
В `is_production` при `true` — ошибка старта.
|
||||
- [x] `app/schemas/checkbox.py` — pydantic-модели запроса/ответа ЕТТН.
|
||||
- [x] Settings: `checkbox_base_url` (default `https://api.checkbox.ua`), `checkbox_client_name`,
|
||||
`checkbox_client_version`; обновить `.env.example`.
|
||||
- [x] Тесты `tests/test_checkbox_client.py` через `respx` (успех, 401→re-signin, 422, таймаут).
|
||||
|
||||
## Этап 4.3 — Сервис чеков (бизнес-логика)
|
||||
|
||||
- [x] `app/services/receipts.py`:
|
||||
- `build_ettn_body(order, prepayment_kopecks, register)` — маппинг CRM goods → Checkbox:
|
||||
`price "600.00"`→ коп., `quantity "2.000"`→ 2000, `code = sku or id`, построчная скидка CRM →
|
||||
`DISCOUNT/VALUE`, `tax = register.tax_codes`, `payments=[{value: cod, ettn: waybill}]`,
|
||||
`delivery` из телефона/email получателя. Переиспользовать `_to_kopecks` из `services/orders.py`.
|
||||
- Валидация перед созданием: есть ТТН; `np_cod_amount_kopecks > 0`; посылка ещё не получена
|
||||
(NP status code ∉ получено/возврат); нет живого чека;
|
||||
`сумма товаров − предоплата == np_cod_amount_kopecks` — иначе понятная ошибка для UI.
|
||||
- `request_receipts(order_ids, prepayments, user)` — создаёт `Receipt(status=pending)` +
|
||||
audit в одной транзакции, commit, затем enqueue ARQ-задачи по каждому чеку.
|
||||
- `create_ettn(receipt_id)` (выполняет worker): вызов Checkbox → `created` + `checkbox_ettn_id`,
|
||||
`order.receipt_created_at = now`; при ошибке → `failed` + `error`.
|
||||
Идемпотентность: при таймауте не повторять вслепую — сверить через `GET /api/v1/ettn`
|
||||
(поиск по `ettnNumber`) перед повторной отправкой.
|
||||
- `cancel_receipt(receipt_id, user)` — `DELETE /api/v1/ettn/{id}` только из `created`,
|
||||
→ `cancelled`, `order.receipt_created_at = NULL` (заказ возвращается в очередь).
|
||||
- `sync_ettn_statuses()` — опрос `GET /api/v1/ettn/{id}` для чеков в `created`:
|
||||
`DONE/DONE_WITHOUT_SMS`→`done` (+`receiptId`), `RETURNED`→`returned`,
|
||||
`RECEIPT_ERROR`→`receipt_error` (+`rawError`), `CANCELLED`→`cancelled`.
|
||||
- [x] Тесты `tests/test_receipts_service.py`: маппинг сумм/количеств, предоплата, все ветки валидации,
|
||||
переходы статусов.
|
||||
|
||||
## Этап 4.4 — Worker
|
||||
|
||||
- [x] `app/worker.py`: `functions=[create_ettn_receipt]`, cron `poll_ettn_statuses` (раз в 2–5 мин),
|
||||
`ctx["checkbox_client"]`, `ctx["redis"]` для кэша токенов.
|
||||
- [x] API ставит задачи через `app/services/task_queue.py` (ленивый ARQ-пул; сбой enqueue не критичен —
|
||||
cron `poll_receipts` подхватит зависший `pending`).
|
||||
|
||||
## Этап 4.5 — API
|
||||
|
||||
- [x] `app/api/v1/cash_registers.py` (AdminUser): list / create / update; секреты только на запись,
|
||||
в ответе — `mask(...)` из `core/crypto.py`. Проверка «подключиться» = пробный `signinPinCode`.
|
||||
- [x] `app/api/v1/receipts.py` (CashierUser):
|
||||
- `POST /receipts` — `{items: [{order_id, prepayment_kopecks}]}` → 202 + список чеков/ошибок валидации по заказам.
|
||||
- `GET /receipts?order_id=` / `GET /receipts/{id}`.
|
||||
- `POST /receipts/{id}/cancel`.
|
||||
- [x] `schemas/orders.py`: в `OrderRowOut` добавить `receipt_status`, `receipt_error`, `prepayment`.
|
||||
- [x] Подключить роутеры в `app/api/v1/router.py`; тесты по образцу `tests/test_orders_router.py`
|
||||
(`dependency_overrides`, стаб-клиенты, monkeypatch сервисов).
|
||||
|
||||
## Этап 4.6 — Frontend
|
||||
|
||||
- [x] `features/receipts/` — `api.ts`, `types.ts` (union-типы статусов, без `enum`), хуки react-query.
|
||||
- [x] `DashboardPage.tsx`:
|
||||
- поле «Предоплата» — контролируемое, в ₴, по умолчанию `сумма − наложка`; подсветка, если
|
||||
`сумма − предоплата ≠ наложка`;
|
||||
- кнопка «Чек» и «Создать чеки по выбранным» — включить, вызвать `POST /receipts`,
|
||||
показать ошибки по заказам;
|
||||
- вкладка «Выписаны чеки» — статус ЕТТН-чека (создан / фискализирован / возврат / ошибка),
|
||||
кнопка «Отменить» для `created`.
|
||||
- [x] `useOrders`: `refetchInterval` пока есть чеки в `pending`.
|
||||
- [x] `OrderDetailModal.tsx` — блок «Чек»: статус, Checkbox ID, ошибка, отмена.
|
||||
- [x] Админ-страница «Кассы» (name, license key, PIN, коды налогов, по умолчанию) — маршрут в `app/routes.tsx`, только admin.
|
||||
|
||||
## Этап 4.7 — Доработки и эксплуатация
|
||||
|
||||
- [ ] На первом боевом чеке (реальная касса): нужна ли открытая смена для ЕТТН; как ведёт себя `RECEIPT_ERROR`.
|
||||
- [ ] Сверить реальное значение `np_payment_status` (`Payed` vs `Paid` — фронт и стаб расходятся).
|
||||
- [x] Обновить `CLAUDE.md`/`README.md`/`.env.example` (статус, новые env-переменные).
|
||||
- [ ] Проверить на первом боевом чеке, что `value` скидок (`DISCOUNT`/`PRE_PAYMENT`, mode `VALUE`) — в копейках.
|
||||
- [ ] `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`. Отмену проверять на этом же чеке
|
||||
до выдачи посылки (или на ТТН, которую всё равно отменяем) — лишних фискальных чеков не создавать.
|
||||
@@ -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()`.
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
| 2 | Аутентификация, роли, журнал аудита | ✅ готово |
|
||||
| 3 | Заказы + заглушка CRM | ✅ готово |
|
||||
| 4 | Нова Пошта: статусы и сумма послеоплаты | ✅ готово |
|
||||
| 5 | Checkbox: смены, чеки, PDF | ⏳ |
|
||||
| 5 | Checkbox: ЕТТН-чеки по ТТН с контролем оплаты (план: `.plans/checkbox-ettn-receipts.md`) | 🚧 код готов, ждёт проверки на боевой кассе |
|
||||
| 6 | Очередь и редактор чека (фронтенд) | ⏳ |
|
||||
| 7 | Админка | ⏳ |
|
||||
| 8 | Реальный API CRM | ⏳ |
|
||||
|
||||
@@ -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)
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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'ов из строки через запятую."""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"),
|
||||
),
|
||||
)
|
||||
@@ -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')"),
|
||||
),
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -22,6 +22,9 @@ _REDACTED_KEYS = {
|
||||
"secret",
|
||||
"license_key",
|
||||
"api_key",
|
||||
"pin",
|
||||
"pin_code",
|
||||
"cashier_pin",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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()
|
||||
+27
-2
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"],))]
|
||||
@@ -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]
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
import { apiFetch } from '@/api/client'
|
||||
import type {
|
||||
CashRegister,
|
||||
CashRegisterCreate,
|
||||
CashRegisterUpdate,
|
||||
} from '@/features/cashRegisters/types'
|
||||
|
||||
export function getCashRegisters(): Promise<CashRegister[]> {
|
||||
return apiFetch<CashRegister[]>('/cash-registers')
|
||||
}
|
||||
|
||||
export function createCashRegister(payload: CashRegisterCreate): Promise<CashRegister> {
|
||||
return apiFetch<CashRegister>('/cash-registers', { method: 'POST', body: payload })
|
||||
}
|
||||
|
||||
export function updateCashRegister(id: string, payload: CashRegisterUpdate): Promise<CashRegister> {
|
||||
return apiFetch<CashRegister>(`/cash-registers/${id}`, { method: 'PATCH', body: payload })
|
||||
}
|
||||
|
||||
export function checkCashRegister(id: string): Promise<void> {
|
||||
return apiFetch<void>(`/cash-registers/${id}/check`, { method: 'POST' })
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { apiFetch } from '@/api/client'
|
||||
import type { Receipt, ReceiptCreateResponse, ReceiptRequestItem } from '@/features/receipts/types'
|
||||
|
||||
export function createReceipts(items: ReceiptRequestItem[]): Promise<ReceiptCreateResponse> {
|
||||
return apiFetch<ReceiptCreateResponse>('/receipts', { method: 'POST', body: { items } })
|
||||
}
|
||||
|
||||
export function cancelReceipt(receiptId: string): Promise<Receipt> {
|
||||
return apiFetch<Receipt>(`/receipts/${receiptId}/cancel`, { method: 'POST' })
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/cash-registers" element={<CashRegistersPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -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<CashRegisterCreate> & { is_active?: boolean }
|
||||
@@ -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) {
|
||||
<span>{order.total_amount} ₴</span>
|
||||
</div>
|
||||
|
||||
{order.receipt_status && (
|
||||
<div className="order-modal-meta">
|
||||
<div>
|
||||
<span className="order-modal-label">ЕТТН-чек Checkbox</span>
|
||||
<span className={`orders-status orders-status--${RECEIPT_STATUS[order.receipt_status].tone}`}>
|
||||
{RECEIPT_STATUS[order.receipt_status].label}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="order-modal-label">Предоплата в чеке</span>
|
||||
<span>{order.receipt_prepayment ? `${order.receipt_prepayment} ₴` : '—'}</span>
|
||||
</div>
|
||||
{order.receipt_error && (
|
||||
<div>
|
||||
<span className="order-modal-label">Ошибка</span>
|
||||
<span>{order.receipt_error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="order-modal-actions">
|
||||
<button type="button" className="order-modal-close-btn" onClick={onClose}>
|
||||
Закрыть
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* Меняются синхронно с ними вручную — см. пояснение в @/api/types.ts.
|
||||
*/
|
||||
|
||||
import type { ReceiptStatus } from '@/features/receipts/types'
|
||||
|
||||
export interface OrderGood {
|
||||
id: string
|
||||
sku: string
|
||||
@@ -29,4 +31,9 @@ export interface Order {
|
||||
np_status_code: string | null
|
||||
np_cod_amount: string | null
|
||||
np_payment_status: string | null
|
||||
// Последний ЕТТН-чек по заказу (в т.ч. отменённый/неудачный — для показа причины).
|
||||
receipt_id: string | null
|
||||
receipt_status: ReceiptStatus | null
|
||||
receipt_error: string | null
|
||||
receipt_prepayment: string | null
|
||||
}
|
||||
|
||||
@@ -2,6 +2,17 @@ import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { getOrders } from '@/api/orders'
|
||||
|
||||
/** Пока чек отправляется в Checkbox — опрашиваем часто, иначе статусы чеков обновляет worker раз в минуту. */
|
||||
const PENDING_POLL_MS = 3_000
|
||||
const RECEIPTS_POLL_MS = 30_000
|
||||
|
||||
export function useOrders(hasReceipt: boolean) {
|
||||
return useQuery({ queryKey: ['orders', hasReceipt], queryFn: () => getOrders(hasReceipt) })
|
||||
return useQuery({
|
||||
queryKey: ['orders', hasReceipt],
|
||||
queryFn: () => getOrders(hasReceipt),
|
||||
refetchInterval: (query) => {
|
||||
if (query.state.data?.some((order) => order.receipt_status === 'pending')) return PENDING_POLL_MS
|
||||
return hasReceipt ? RECEIPTS_POLL_MS : false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/** Деньги на фронте приходят строками "1200.00"; в API чеков уходят integer-копейками. */
|
||||
|
||||
export function toKopecks(value: string): number | null {
|
||||
const normalized = value.trim().replace(/\s/g, '').replace(',', '.')
|
||||
if (normalized === '') return 0
|
||||
if (!/^\d+(\.\d{1,2})?$/.test(normalized)) return null
|
||||
return Math.round(Number(normalized) * 100)
|
||||
}
|
||||
|
||||
export function formatKopecks(kopecks: number): string {
|
||||
return (kopecks / 100).toFixed(2)
|
||||
}
|
||||
|
||||
/** Предоплата по умолчанию: сумма заказа − наложка НП (как считает бэкенд). */
|
||||
export function defaultPrepayment(total: string, cod: string | null): string {
|
||||
if (!cod) return ''
|
||||
const diff = (toKopecks(total) ?? 0) - (toKopecks(cod) ?? 0)
|
||||
return diff > 0 ? formatKopecks(diff) : '0.00'
|
||||
}
|
||||
|
||||
/** Проверка инварианта ЕТТН-чека: сумма − предоплата = наложка. */
|
||||
export function prepaymentMatches(total: string, cod: string | null, prepayment: string): boolean {
|
||||
if (!cod) return false
|
||||
const prep = toKopecks(prepayment)
|
||||
if (prep === null) return false
|
||||
return (toKopecks(total) ?? 0) - prep === toKopecks(cod)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Типы, зеркалящие backend/app/schemas/receipts.py и `ReceiptStatus`
|
||||
* (backend/app/db/models/receipt.py). Меняются синхронно с ними вручную.
|
||||
*/
|
||||
|
||||
export type ReceiptStatus =
|
||||
| 'pending'
|
||||
| 'created'
|
||||
| 'done'
|
||||
| 'returned'
|
||||
| 'receipt_error'
|
||||
| 'cancelled'
|
||||
| 'failed'
|
||||
|
||||
export interface Receipt {
|
||||
id: string
|
||||
order_id: string
|
||||
waybill_number: string
|
||||
status: ReceiptStatus
|
||||
total_amount: string
|
||||
prepayment_amount: string
|
||||
cod_amount: string
|
||||
checkbox_ettn_id: string | null
|
||||
checkbox_status: string | null
|
||||
checkbox_receipt_id: string | null
|
||||
error: string | null
|
||||
created_at: string
|
||||
last_checked_at: string | null
|
||||
}
|
||||
|
||||
export interface ReceiptRequestItem {
|
||||
order_id: string
|
||||
/** null — предоплата = сумма заказа − наложка (считает бэкенд). */
|
||||
prepayment_kopecks: number | null
|
||||
}
|
||||
|
||||
export interface ReceiptCreateResponse {
|
||||
created: Receipt[]
|
||||
/** order_id → причина, по которой чек не создан. */
|
||||
errors: Record<string, string>
|
||||
}
|
||||
|
||||
export type Tone = 'delivered' | 'processing' | 'danger' | 'new'
|
||||
|
||||
export const RECEIPT_STATUS: Record<ReceiptStatus, { label: string; tone: Tone }> = {
|
||||
pending: { label: 'Отправляется', tone: 'new' },
|
||||
created: { label: 'Ждёт оплаты', tone: 'processing' },
|
||||
done: { label: 'Фискализирован', tone: 'delivered' },
|
||||
returned: { label: 'Возврат посылки', tone: 'danger' },
|
||||
receipt_error: { label: 'Ошибка фискализации', tone: 'danger' },
|
||||
cancelled: { label: 'Отменён', tone: 'new' },
|
||||
failed: { label: 'Не создан', tone: 'danger' },
|
||||
}
|
||||
|
||||
/** Статусы, из которых чек можно отменить (см. services/receipts.cancel_receipt). */
|
||||
export const CANCELLABLE: ReadonlySet<ReceiptStatus> = new Set(['created', 'receipt_error'])
|
||||
@@ -0,0 +1,46 @@
|
||||
.cr-default {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.cr-form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
max-width: 480px;
|
||||
margin-top: 24px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.cr-form h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cr-form label {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.cr-form input:not([type='checkbox']) {
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cr-form .cr-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.cr-form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { Link, Navigate } from 'react-router-dom'
|
||||
|
||||
import {
|
||||
checkCashRegister,
|
||||
createCashRegister,
|
||||
getCashRegisters,
|
||||
updateCashRegister,
|
||||
} from '@/api/cashRegisters'
|
||||
import '@/pages/DashboardPage.css'
|
||||
import '@/pages/CashRegistersPage.css'
|
||||
import { useAuth } from '@/features/auth/useAuth'
|
||||
import type { CashRegister, CashRegisterUpdate } from '@/features/cashRegisters/types'
|
||||
|
||||
interface FormState {
|
||||
name: string
|
||||
fiscal_number: string
|
||||
license_key: string
|
||||
pin_code: string
|
||||
tax_codes: string
|
||||
is_default: boolean
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormState = {
|
||||
name: '',
|
||||
fiscal_number: '',
|
||||
license_key: '',
|
||||
pin_code: '',
|
||||
tax_codes: '',
|
||||
is_default: true,
|
||||
}
|
||||
|
||||
function parseTaxCodes(value: string): (number | string)[] {
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.map((item) => (/^\d+$/.test(item) ? Number(item) : item))
|
||||
}
|
||||
|
||||
export function CashRegistersPage() {
|
||||
const { user } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: registers, isLoading, isError } = useQuery({
|
||||
queryKey: ['cash-registers'],
|
||||
queryFn: getCashRegisters,
|
||||
enabled: user?.role === 'admin',
|
||||
})
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState<{ tone: 'ok' | 'error'; text: string } | null>(null)
|
||||
|
||||
if (user && user.role !== 'admin') return <Navigate to="/" replace />
|
||||
|
||||
async function run(action: () => Promise<unknown>, okText: string) {
|
||||
setBusy(true)
|
||||
try {
|
||||
await action()
|
||||
setMessage({ tone: 'ok', text: okText })
|
||||
await queryClient.invalidateQueries({ queryKey: ['cash-registers'] })
|
||||
} catch (err) {
|
||||
setMessage({ tone: 'error', text: err instanceof Error ? err.message : 'Ошибка запроса' })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(register: CashRegister) {
|
||||
setEditingId(register.id)
|
||||
setForm({
|
||||
name: register.name,
|
||||
fiscal_number: register.fiscal_number ?? '',
|
||||
license_key: '',
|
||||
pin_code: '',
|
||||
tax_codes: register.tax_codes.join(', '),
|
||||
is_default: register.is_default,
|
||||
})
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setEditingId(null)
|
||||
setForm(EMPTY_FORM)
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
if (editingId) {
|
||||
// Пустые ключ/PIN при редактировании — «не менять».
|
||||
const payload: CashRegisterUpdate = {
|
||||
name: form.name,
|
||||
fiscal_number: form.fiscal_number || null,
|
||||
tax_codes: parseTaxCodes(form.tax_codes),
|
||||
is_default: form.is_default,
|
||||
}
|
||||
if (form.license_key) payload.license_key = form.license_key
|
||||
if (form.pin_code) payload.pin_code = form.pin_code
|
||||
await run(() => updateCashRegister(editingId, payload), 'Касса обновлена')
|
||||
} else {
|
||||
await run(
|
||||
() =>
|
||||
createCashRegister({
|
||||
name: form.name,
|
||||
fiscal_number: form.fiscal_number || null,
|
||||
license_key: form.license_key,
|
||||
pin_code: form.pin_code,
|
||||
tax_codes: parseTaxCodes(form.tax_codes),
|
||||
is_default: form.is_default,
|
||||
}),
|
||||
'Касса добавлена',
|
||||
)
|
||||
}
|
||||
resetForm()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard-shell">
|
||||
<header className="dashboard-topbar">
|
||||
<span className="dashboard-brand">lux_fiscal</span>
|
||||
<Link to="/" className="orders-link-btn">
|
||||
← К заказам
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<main className="orders-body">
|
||||
<div className="orders-toolbar">
|
||||
<h2>Кассы Checkbox</h2>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`orders-notice orders-notice--${message.tone}`}>
|
||||
<span>{message.text}</span>
|
||||
<button type="button" className="orders-notice-close" onClick={() => setMessage(null)} aria-label="Скрыть">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="orders-table-wrap">
|
||||
<table className="orders-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Название</th>
|
||||
<th>Фиск. номер</th>
|
||||
<th>Ключ лицензии</th>
|
||||
<th>Налоги</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td colSpan={6} className="orders-empty">
|
||||
Загрузка…
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{isError && (
|
||||
<tr>
|
||||
<td colSpan={6} className="orders-empty">
|
||||
Не удалось загрузить кассы
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{registers?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="orders-empty">
|
||||
Кассы не настроены — чеки создавать нельзя
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{registers?.map((register) => (
|
||||
<tr key={register.id}>
|
||||
<td>
|
||||
{register.name}
|
||||
{register.is_default && <span className="orders-status orders-status--delivered cr-default">основная</span>}
|
||||
</td>
|
||||
<td>{register.fiscal_number || '—'}</td>
|
||||
<td>{register.license_key_masked}</td>
|
||||
<td>{register.tax_codes.length ? register.tax_codes.join(', ') : 'без налога'}</td>
|
||||
<td>
|
||||
<span className={`orders-status orders-status--${register.is_active ? 'delivered' : 'new'}`}>
|
||||
{register.is_active ? 'Активна' : 'Отключена'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="orders-actions-cell">
|
||||
<button
|
||||
type="button"
|
||||
className="orders-view-btn"
|
||||
disabled={busy}
|
||||
onClick={() => void run(() => checkCashRegister(register.id), `Вход в «${register.name}» успешен`)}
|
||||
>
|
||||
Проверить
|
||||
</button>
|
||||
<button type="button" className="orders-view-btn" disabled={busy} onClick={() => startEdit(register)}>
|
||||
Изменить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="orders-delete-btn"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void run(
|
||||
() => updateCashRegister(register.id, { is_active: !register.is_active }),
|
||||
register.is_active ? 'Касса отключена' : 'Касса включена',
|
||||
)
|
||||
}
|
||||
>
|
||||
{register.is_active ? 'Отключить' : 'Включить'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<form className="cr-form" onSubmit={(e) => void handleSubmit(e)}>
|
||||
<h3>{editingId ? 'Изменить кассу' : 'Добавить кассу'}</h3>
|
||||
<label>
|
||||
Название
|
||||
<input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Фискальный номер
|
||||
<input value={form.fiscal_number} onChange={(e) => setForm({ ...form, fiscal_number: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Ключ лицензии кассы
|
||||
<input
|
||||
required={!editingId}
|
||||
autoComplete="off"
|
||||
placeholder={editingId ? 'не менять' : ''}
|
||||
value={form.license_key}
|
||||
onChange={(e) => setForm({ ...form, license_key: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
PIN-код кассира
|
||||
<input
|
||||
required={!editingId}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={editingId ? 'не менять' : ''}
|
||||
value={form.pin_code}
|
||||
onChange={(e) => setForm({ ...form, pin_code: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Коды налогов (через запятую, пусто — без налога)
|
||||
<input value={form.tax_codes} onChange={(e) => setForm({ ...form, tax_codes: e.target.value })} />
|
||||
</label>
|
||||
<label className="cr-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.is_default}
|
||||
onChange={(e) => setForm({ ...form, is_default: e.target.checked })}
|
||||
/>
|
||||
Основная касса (по ней создаются чеки)
|
||||
</label>
|
||||
<div className="cr-form-actions">
|
||||
<button type="submit" className="orders-create-btn" disabled={busy}>
|
||||
{editingId ? 'Сохранить' : 'Добавить'}
|
||||
</button>
|
||||
{editingId && (
|
||||
<button type="button" className="orders-view-btn" onClick={resetForm}>
|
||||
Отмена
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<Order | null>(null)
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
// Введённая кассиром предоплата (₴) по заказу; нет ключа — значение по умолчанию.
|
||||
const [prepayments, setPrepayments] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [cancellingId, setCancellingId] = useState<string | null>(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() {
|
||||
<main className="orders-body">
|
||||
<div className="orders-toolbar">
|
||||
<h2>Заказы</h2>
|
||||
<button type="button" className="orders-create-btn" disabled={selected.size === 0}>
|
||||
Создать чеки по выбранным ({selected.size})
|
||||
</button>
|
||||
<div className="orders-toolbar-actions">
|
||||
{user?.role === 'admin' && (
|
||||
<Link to="/cash-registers" className="orders-link-btn">
|
||||
Кассы
|
||||
</Link>
|
||||
)}
|
||||
{tab === 'no_receipt' && canFiscalize && (
|
||||
<button
|
||||
type="button"
|
||||
className="orders-create-btn"
|
||||
disabled={selected.size === 0 || submitting}
|
||||
onClick={() => void submitReceipts(filtered.filter((order) => selected.has(order.id)))}
|
||||
>
|
||||
Создать чеки по выбранным ({selected.size})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notice && (
|
||||
<div className={`orders-notice orders-notice--${notice.tone}`}>
|
||||
<ul>
|
||||
{notice.lines.map((line) => (
|
||||
<li key={line}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
<button type="button" className="orders-notice-close" onClick={() => setNotice(null)} aria-label="Скрыть">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="orders-tabs">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
className={`orders-tab${tab === t.key ? ' orders-tab--active' : ''}`}
|
||||
onClick={() => setTab(t.key)}
|
||||
onClick={() => {
|
||||
setTab(t.key)
|
||||
setSelected(new Set())
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
@@ -150,7 +249,7 @@ export function DashboardPage() {
|
||||
<th>Сумма</th>
|
||||
<th>Наложка</th>
|
||||
<th>Оплачено</th>
|
||||
<th>Предоплата</th>
|
||||
<th>Предоплата, ₴</th>
|
||||
<th>Статус ТТН</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
@@ -197,7 +296,25 @@ export function DashboardPage() {
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" className="orders-prepayment-input" placeholder="0%" />
|
||||
{tab === 'no_receipt' ? (
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className={`orders-prepayment-input${
|
||||
canCreateReceipt(order) &&
|
||||
!prepaymentMatches(order.total_amount, order.np_cod_amount, prepaymentOf(order))
|
||||
? ' orders-prepayment-input--invalid'
|
||||
: ''
|
||||
}`}
|
||||
placeholder="0.00"
|
||||
value={prepaymentOf(order)}
|
||||
disabled={!canFiscalize}
|
||||
title="Сумма заказа − предоплата должна равняться наложке"
|
||||
onChange={(e) => setPrepayments((prev) => ({ ...prev, [order.id]: e.target.value }))}
|
||||
/>
|
||||
) : (
|
||||
(order.receipt_prepayment ?? '—')
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`orders-status orders-status--${npStatusTone(order)}`}>
|
||||
@@ -208,10 +325,36 @@ export function DashboardPage() {
|
||||
<button type="button" className="orders-view-btn" onClick={() => setViewingOrder(order)}>
|
||||
Просмотр
|
||||
</button>
|
||||
<button type="button" className="orders-receipt-btn" disabled>
|
||||
Чек
|
||||
</button>
|
||||
{canDelete && (
|
||||
{tab === 'no_receipt' && canFiscalize && (
|
||||
<button
|
||||
type="button"
|
||||
className="orders-receipt-btn"
|
||||
disabled={!canCreateReceipt(order) || submitting}
|
||||
title={canCreateReceipt(order) ? 'Создать ЕТТН-чек в Checkbox' : 'Нет ТТН или наложки'}
|
||||
onClick={() => void submitReceipts([order])}
|
||||
>
|
||||
Чек
|
||||
</button>
|
||||
)}
|
||||
{order.receipt_status && (tab === 'has_receipt' || !CANCELLABLE.has(order.receipt_status)) && (
|
||||
<span
|
||||
className={`orders-status orders-status--${RECEIPT_STATUS[order.receipt_status].tone}`}
|
||||
title={order.receipt_error ?? undefined}
|
||||
>
|
||||
{RECEIPT_STATUS[order.receipt_status].label}
|
||||
</span>
|
||||
)}
|
||||
{tab === 'has_receipt' && canFiscalize && order.receipt_status && CANCELLABLE.has(order.receipt_status) && (
|
||||
<button
|
||||
type="button"
|
||||
className="orders-delete-btn"
|
||||
disabled={cancellingId === order.id}
|
||||
onClick={() => void handleCancel(order)}
|
||||
>
|
||||
Отменить
|
||||
</button>
|
||||
)}
|
||||
{canDelete && tab === 'no_receipt' && (
|
||||
<button
|
||||
type="button"
|
||||
className="orders-delete-btn"
|
||||
@@ -237,7 +380,12 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{viewingOrder && <OrderDetailModal order={viewingOrder} onClose={() => setViewingOrder(null)} />}
|
||||
{viewingOrder && (
|
||||
<OrderDetailModal
|
||||
order={orders?.find((order) => order.id === viewingOrder.id) ?? viewingOrder}
|
||||
onClose={() => setViewingOrder(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user