Author SHA1 Message Date
lauadminandClaude Opus 5.5 2b7a92645a Add CRM stub flag, deploy/backup scripts, CI and prod runbook
CI / backend (pull_request) Successful in 3m7s
CI / frontend (pull_request) Failing after 15m55s
- CRM_USE_STUB: local runs no longer reach the live CRM. With only the
  Checkbox stub, a stub receipt would still move the real order to PACKED.
  Refused in production, same as CHECKBOX_USE_STUB.
- scripts/deploy.sh: backup, fast-forward main, build, health check and
  code rollback on failure. scripts/backup.sh: pg_dump with verification
  and 14-day rotation (used by cron and deploy.sh).
- Gitea Actions CI: ruff + pytest, oxlint + build.
- DEPLOY.md runbook; CLAUDE.md rules for safe local development.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 20:52:20 +03:00
lauadmin 418f6b0352 Merge pull request 'Add production compose overlay for deploy behind Nginx Proxy Manager' (#9) from feature/prod-deploy into main
Reviewed-on: #9
2026-09-25 17:24:30 +00:00
lauadminandClaude Opus 5.5 44fe648ebc Add production compose overlay for deploy behind Nginx Proxy Manager
The prod overlay drops host ports for api/frontend and attaches frontend
to the server's external `web` network (alias lux-fiscal-frontend), where
NPM terminates TLS for asist.ystyle.com.ua. migrate/api/worker now share
a single lux-fiscal-backend image instead of three identical copies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 20:23:12 +03:00
lauadmin d92c715c11 Merge pull request 'Главное меню с модулями по ролям (#7)' (#8) from feature/main-menu into main 2026-09-25 16:41:18 +00:00
lauadminandClaude Opus 5.5 8fffdc3ce6 Add main menu page with role-based modules (#7)
- `/` is now the main menu; orders/receipts page moved to `/receipts`
- Module registry (features/menu/modules.ts) drives both the menu tiles
  and route guards (ModuleRoute); admin sees every module
- Menu: module search with Ctrl+K, profile with logout, responsive grid,
  palette matching the login page
- «← Головне меню» link on receipts and cash registers pages;
  «Каси» moved from the receipts toolbar into the menu

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 17:11:57 +03:00
lauadmin 0ba2ca01c4 Merge pull request 'Редизайн страницы входа и перевод интерфейса на украинский (#5)' (#6) from feature/login-page-redesign into main 2026-09-25 13:46:25 +00:00
lauadminandClaude Opus 5.5 b8f2fe6b6e Translate UI and user-facing messages to Ukrainian (#5)
- All frontend pages, labels, notices and errors; html lang=uk, uk-UA money format
- Brand "Assistant System" in the top bar and page title
- Backend error details returned to the UI (auth, orders, receipts,
  cash registers, Checkbox/CRM/NP errors) and CLI output
- Tests updated for the new messages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:27:39 +03:00
lauadminandClaude Opus 5.5 47df3a78dc Redesign login page (#5)
- Rename header to "Assistant System", subtitle to "Вхід у систему"
- Glassmorphism card over animated gradient backdrop, logo mark
- Password visibility toggle, larger inputs, focus rings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:21:11 +03:00
lauadmin f6971a612f Merge pull request 'Привязка ключа Новой Почты к кассе (#3)' (#4) from feature/np-key-per-register into main 2026-09-25 10:17:10 +00:00
lauadminandClaude Opus 5.5 8b3c2b6d63 Bind Nova Poshta API key to cash register (#3)
Each cash register stores its own encrypted NP API key. Status polling uses
register keys and binds an order to the register whose key sees the TTN as
its own (PhoneSender present); ETTN receipts are created from that register.
Migration 0008 moves the old NOVA_POSHTA_API_KEY into the default register.

Closes #3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:02:40 +03:00
lauadmin 2b92d82693 Merge pull request 'Poll ETTN statuses in batches and commit per receipt' (#2) from feature/checkbox-ettn into main 2026-09-25 09:27:06 +00:00
lauadminandClaude Opus 5.5 a7cba7616a Poll ETTN statuses in batches and commit per receipt
The cron poll committed only at the end; with ~100 created receipts and a 1s
pause between Checkbox requests it hit arq's 300s timeout every run, so all
status updates were rolled back and fiscalized receipts stayed "created".
Now each run polls at most 30 least-recently-checked receipts and commits
after each one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:25:12 +03:00
lauadmin ed635cab94 Merge pull request 'Checkbox ETTN receipts, order editing, dashboard tabs and COD summary' (#1) from feature/checkbox-ettn into main 2026-09-24 22:36:51 +00:00
lauadminandClaude Opus 5.5 a6072800b2 Add COD-in-transit summary and received tab to the dashboard
- GET /orders/summary: count and sum of cash-on-delivery for parcels not
  yet picked up (excludes received, refused, deleted/unknown TTNs, paid COD);
  shown as a card above the tabs.
- New «Полученные» tab: orders with NP received codes (9, 10, 11, 106) move
  there automatically, regardless of receipt; 106 is now a final status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 01:23:39 +03:00
lauadminandClaude Opus 5.5 528a869657 Retry ETTN creation on Nova Poshta rate limit instead of failing
Checkbox relays Nova Poshta's "To many requests" (20000401501) as a 4xx
third_party.generic error, not a 429, so bursts of receipt requests ended
up as failed receipts. Such responses are now CheckboxRateLimitedError:
the receipt stays pending and the worker job is retried with arq.Retry
after the "Try again after N seconds" delay plus backoff. NP timeouts
relayed the same way are treated as unavailable (unknown outcome).

The HTTP client also sends Checkbox requests one at a time with a
CHECKBOX_MIN_REQUEST_INTERVAL_MS pause and signs the cashier in once for
concurrent jobs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 21:25:59 +03:00
lauadminandClaude Opus 5.5 f88b5cccc0 Add refusals tab to the order dashboard
Orders whose Nova Poshta status is a refusal (102/103/105/108) now show
only under «Отказы», whether or not they have a receipt. GET /orders takes
tab=no_receipt|has_receipt|refused instead of has_receipt. NP statuses are
also polled for orders that already have a receipt, until NP reports a
final status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 21:25:59 +03:00
lauadminandClaude Opus 5.5 56ac0fc370 Allow editing orders in the order card
Cashiers can edit recipient, TTN, notes, goods and total in the order
modal and save via PATCH /orders/{id}. Edited orders get edited_at and
are no longer overwritten by CRM sync. Editing is blocked once a receipt
exists; changing the TTN resets Nova Poshta tracking fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:23:41 +03:00
lauadminandClaude Opus 5.5 cbf9832e5b Set CRM order status to PACKED after Checkbox accepts the receipt
- ExoCrmClient.set_status: live SetStatus needs {Orders: [id], Status} and
  replies per order, unlike the documented {ID, Status}
- receipts.crm_status_set_at (migration 0006); set right after creation,
  retried by cron, row-locked to avoid a repeat PACKED overwriting a newer status
- CRM errors under capitalized 'Errors' and non-JSON replies are reported

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 14:04:26 +03:00
lauadminandClaude Opus 5.5 3a6ad85d11 Fix ETTN creation on live Checkbox
- prepayment as DISCOUNT: live API rejects PRE_PAYMENT with 400 third_party.generic
- normalize lowercase ETTN statuses returned by the live API
- show Checkbox error code instead of bare 'Internal Server Error'
- page size 50 for ETTN list, 429 rate limit is retryable, pause polling on it
- phone in 380XXXXXXXXX format like portal receipts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 13:49:50 +03:00
lauadminandClaude Opus 5.5 912a37c00c Resolve api upstream per request in frontend nginx
nginx resolved api:8000 once at startup, so recreating the api container
(new IP) left the proxy pointing at a dead address and every /api call
returned 502.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 13:36:15 +03:00
lauadminandClaude Opus 5.5 089e0a4011 Send ETTN payment type and label explicitly, verify receipt total
Payments now carry type=ETTN / label=Експрес-накладна instead of relying on
Checkbox defaults; body build fails fast if goods - discounts - prepayment
does not equal the COD amount.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 13:33:29 +03:00
lauadminandClaude Opus 5.5 cedcd3caff Mark migration 0005 as applied in plan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 00:15:01 +03:00
lauadminandClaude Opus 5.5 518a99197f 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>
2026-09-23 23:39:00 +03:00
lauadminandClaude Sonnet 5 d13e7ce2b3 Add Nova Poshta tracking: TTN status, COD amount, payment status
Adds NpTrackingClient (Protocol + real/stub impls) and an ARQ worker that
polls Nova Poshta every minute for orders without a receipt, writing
status, net COD amount (Контроль оплати), and payment status onto the
order. Surfaced in the orders table and detail modal. Marks plan stages
3-4 done in README.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 22:33:58 +03:00
lauadminandClaude Sonnet 5 f4072be451 Add CRM order queue: live sync, view modal, receipt tabs, delete
Wires the CRM (exoCRM GetOrders) into the dashboard as a locally
persisted order queue instead of the previous static mockup:

- CrmClient Protocol + ExoCrmClient/StubCrmClient for the CRM's
  signed JSON-RPC API
- Order model + migration, synced from CRM on each queue view;
  soft-deleted orders stay hidden across re-syncs
- GET/DELETE /api/v1/orders with "no receipt"/"receipt issued" tabs
  (the latter is empty until Checkbox fiscalization lands)
- Dashboard: real order list, item-detail modal, tab switcher,
  one-click delete

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:39:03 +03:00
102 changed files with 8295 additions and 202 deletions
+28
View File
@@ -48,3 +48,31 @@ REFRESH_TOKEN_EXPIRE_DAYS=7
FIRST_ADMIN_EMAIL=admin@example.com
FIRST_ADMIN_PASSWORD=change-me-admin-password
FIRST_ADMIN_NAME=Администратор
# --- CRM (exoCRM) ---------------------------------------------------------
# Ключи доступа к магазину — запросить у администратора CRM.
CRM_BASE_URL=https://optstore.exocrm.com/api/1.1/
CRM_API_KEY=change-me-crm-apikey
CRM_SECRET_KEY=change-me-crm-secretkey
CRM_SHOP_KEY=change-me-crm-shopkey
CRM_SID=1
# Стаб вместо реальной CRM (локально — обязательно вместе с CHECKBOX_USE_STUB):
# иначе после стаб-чека боевой заказ уйдёт в PACKED. В production запрещено.
CRM_USE_STUB=true
# --- Nova Poshta ----------------------------------------------------------
# Ключи API Nova Poshta задаются у касс (страница «Кассы»). Эта переменная нужна
# только миграции 0008: переносит старый общий ключ в кассу по умолчанию.
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 (мс): Новая Почта за Checkbox ограничивает частоту.
CHECKBOX_MIN_REQUEST_INTERVAL_MS=1000
# ЕТТН-чеки на тестовой кассе Checkbox не работают: локально весь цикл
# прогоняется через стаб (чек «фискализируется» на втором опросе). В production запрещено.
CHECKBOX_USE_STUB=true
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+37
View File
@@ -0,0 +1,37 @@
# Проверки на каждый PR и push в main. Нужен зарегистрированный act_runner (см. DEPLOY.md).
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
backend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -e ".[dev]"
- run: ruff check app tests
# SECRET_KEY/ENCRYPTION_KEY подставляет tests/conftest.py.
- run: pytest -q
frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- run: npm ci
- run: npm run lint
- run: npm run build
+158
View File
@@ -0,0 +1,158 @@
# Этап 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-переменные).
- [x] Первый боевой чек (ТТН 20451543715206, 2026-09-24): `value` скидок — в копейках (подтверждено).
`PRE_PAYMENT` → 400 `third_party.generic`; предоплата теперь идёт обычной скидкой `DISCOUNT` «Знижка»,
как в чеках из портала. Боевой API отдаёт статусы строчными (`created`/`done`) — нормализуются.
Лимит списка — 50 за страницу; 429 «Занадто часто» — повторяемая ошибка.
- [x] `alembic upgrade head` на живом Postgres (0004 → 0005 применена в Docker).
- [x] После создания чека — статус заказа в CRM `PACKED` (`SetStatus`, миграция 0006 `receipts.crm_status_set_at`,
повтор cron'ом при сбое CRM). Проверено на боевой CRM: заказы 123901, 123793.
- [ ] Позже (не в этом этапе): `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`. Отмену проверять на этом же чеке
до выдачи посылки (или на ТТН, которую всё равно отменяем) — лишних фискальных чеков не создавать.
+27 -2
View File
@@ -19,6 +19,10 @@ backend/ FastAPI + SQLAlchemy (async) + Alembic + Postgres — see backend/ap
frontend/ React 19 + TypeScript + Vite — see frontend/src/
docker/ nginx.frontend.conf (SPA + /api reverse proxy to the api container)
docker-compose.yml postgres, redis, migrate (one-shot), api, worker, frontend
docker-compose.prod.yml prod overlay: no host ports, frontend joins the external `web` network (Nginx Proxy Manager)
scripts/ deploy.sh (runs on the prod server), backup.sh (pg_dump + rotation)
.gitea/workflows/ci.yml ruff + pytest, oxlint + build on every PR
DEPLOY.md production runbook — read before touching the server
```
Backend and frontend are independent projects with their own dependency files (`backend/pyproject.toml`, `frontend/package.json`) — always `cd` into the right one before running tooling.
@@ -70,7 +74,18 @@ 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.
## Production and safe local development
Prod runs on `websrv` (`ssh lux-prod`, user `deploy`), `https://asist.ystyle.com.ua`, behind the server's Nginx Proxy Manager. The full runbook (deploy, rollback, backups, restore, CI runner) is in `DEPLOY.md`.
- **The local DB is a copy of prod data.** Local must never reach live systems: `.env` always has `ENVIRONMENT=local`, `CHECKBOX_USE_STUB=true` **and** `CRM_USE_STUB=true`. The Checkbox stub alone is not enough — a stub receipt counts as accepted and `sync_crm_statuses` would move the real CRM order to `PACKED`. Both factories (`get_checkbox_client`, `get_crm_client`) refuse stubs in production. After restoring a prod dump locally, overwrite `cash_registers` keys before starting `api`/`worker` (snippet in `DEPLOY.md`).
- **Workflow:** `feature/*` branch → PR in Gitea (CI green) → merge to `main` → on the server `~/lux_fiscal/scripts/deploy.sh`. `main` is always what prod runs. Never edit tracked files on the server or commit/push to `main` directly.
- **Migrations must be backward-compatible with existing data:** new columns nullable or with `server_default`; drop/rename a column only in a later release after code stopped using it (code rollback does not roll back the schema). Test a new migration locally on a fresh (sanitized) prod backup.
- **Never change prod `ENCRYPTION_KEY`** — cash register secrets in the DB are encrypted with it.
- New integrations with side effects on real systems (CRM, Checkbox, NP, anything that writes) get their own `*_USE_STUB` flag with the same production guard, selected in one factory function.
- Claude has SSH access as `deploy`, but reading/decrypting prod secrets and writing to prod `.env` is left to the user.
## Backend architecture
@@ -118,4 +133,14 @@ 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. The dashboard has four tabs (`GET /orders?tab=no_receipt|has_receipt|received|refused`, `services/orders.OrderTab`); NP status beats receipt presence: an order whose NP status code is «received» (`NP_RECEIVED_STATUS_CODES`) shows only under «Полученные», a refusal (`NP_REFUSAL_STATUS_CODES`) only under «Отказы», whether or not it has a receipt. 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`. Prepayment goes into the receipt as a plain `DISCOUNT` («Знижка»), **not** `PRE_PAYMENT` — the live API rejects `PRE_PAYMENT` on ETTN with 400 `third_party.generic`. The live API also returns statuses lowercase (`EttnOut` upper-cases them).
- Two-phase create: `POST /receipts` writes `Receipt(pending)` + audit and commits, then enqueues `create_ettn_receipt`; the worker calls Checkbox. A timeout leaves the row `pending` with `error` set — the retry first looks the TTN up via `find_ettn` instead of blindly re-posting (would create a second receipt). Keep this.
- State machine and the "one live receipt per order" partial unique index live in `app/db/models/receipt.py`. `orders.receipt_created_at` is set on request and reset to NULL when a receipt ends `failed`/`cancelled` (order goes back to the queue).
- Once Checkbox accepts the receipt, the order is moved to `PACKED` in the CRM (`services/receipts.sync_crm_statuses`, marked by `receipts.crm_status_set_at`; runs right after creation and is retried by cron). The live exoCRM `SetStatus` differs from its docs: params must be `{"Orders": [id], "Status": ...}` (the documented `{"ID": ...}` returns "Undefined order list."), and the reply has no `status: OK` — success is `{"<id>": {"Status": "Success"}}`.
- Nova Poshta's rate limit comes back through Checkbox as a 4xx with `code=third_party.generic` and «To many requests» / `20000401501`, not as a 429. `http_client._transient_error` maps it to `CheckboxRateLimitedError`: the receipt stays `pending` and the worker retries with `arq.Retry`. The client also sends requests one at a time with a `CHECKBOX_MIN_REQUEST_INTERVAL_MS` pause, so don't parallelize Checkbox calls in the worker.
- Each cash register has its own Nova Poshta API key (`cash_registers.np_api_key_enc`, Fernet). `sync_np_statuses` polls TTNs with register keys and binds the order to the register whose key sees the TTN as its own (`orders.cash_register_id`; ownership = response contains `PhoneSender` — a foreign key gets a truncated reply without sender/`AfterpaymentOnGoodsCost`). Receipts are created from the order's register, not the default one; an unbound order is rejected. `NOVA_POSHTA_API_KEY` env is only read by migration 0008.
- ETTN does **not** work on a Checkbox test cash register. Locally use `CHECKBOX_USE_STUB=true` together with `CRM_USE_STUB=true`; client selection is only in `services/checkbox/client.get_checkbox_client()` and `services/crm/client.get_crm_client()`.
+143
View File
@@ -0,0 +1,143 @@
# Выкладка в прод
## Где что
| Что | Где |
|---|---|
| Сервер | `websrv`, `192.168.88.100`, Ubuntu; SSH: пользователь `deploy`, порт 22 (алиас `lux-prod` в `~/.ssh/config` разработчика) |
| Приложение | `https://asist.ystyle.com.ua` |
| Код | `/home/deploy/lux_fiscal` — клон `main` из Gitea (deploy key только на чтение, `~/.ssh/config` → `Host gitea` = `127.0.0.1:2222`) |
| Секреты | `/home/deploy/lux_fiscal/.env` (права 600, в git не попадает) |
| Бэкапы | `/home/deploy/backups/lux_fiscal/*.dump`, журнал — `backup.log` там же |
| TLS / домен | Nginx Proxy Manager на том же сервере (`http://192.168.88.100:81`), Proxy Host `asist.ystyle.com.ua` → `lux-fiscal-frontend:80` |
Сервисы поднимаются из двух файлов: `docker-compose.yml` + `docker-compose.prod.yml` (прод-оверлей убирает порты на хосте
и подключает `frontend` к внешней сети `web`, где живёт NPM). Во всех командах ниже:
```bash
C="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
```
## Обычная выкладка
1. Изменения попадают в `main` только через PR в Gitea; CI (`.gitea/workflows/ci.yml`) должен быть зелёным.
2. На сервере:
```bash
ssh lux-prod
~/lux_fiscal/scripts/deploy.sh
```
`deploy.sh` делает: бэкап БД (`scripts/backup.sh`) → `git fetch` + fast-forward `main` → `up -d --build --wait`
(миграции применяет одноразовый контейнер `migrate`) → проверка `/api/v1/health` через nginx фронтенда.
Если новая версия не поднялась — откатывает **код** на предыдущий коммит и печатает путь к бэкапу.
**БД автоматически не откатывается.** `--force` — пересобрать и перезапустить без новых коммитов.
После выкладки:
```bash
$C ps
$C logs --since 10m api worker | grep -iE "error|exception" | tail -20
```
## Правила, чтобы не сломать прод
- **Миграции — только совместимые с данными.** Новые колонки — `nullable` или с `server_default`. Удаление/переименование
колонки — отдельным релизом, после того как код перестал её использовать (иначе откат кода не спасёт).
- Перед мержем миграцию прогоняют локально на копии свежего прод-бэкапа (см. «Прод-данные локально»).
- `ENCRYPTION_KEY` в прод-`.env` **никогда не меняется**: им зашифрованы ключи касс в БД. Копия `.env` хранится отдельно от
сервера (менеджер паролей).
- В проде запрещены `CHECKBOX_USE_STUB=true` и `CRM_USE_STUB=true` — приложение упадёт на старте клиента.
- Руками на сервере файлы репозитория не правятся: `deploy.sh` откажется работать с грязным деревом.
- Выкладка — не в часы пик работы кассиров: во время `up` API недоступен несколько секунд, а воркер перезапускается.
## Откат
Код:
```bash
cd ~/lux_fiscal
git log --oneline -10
git checkout --detach <коммит>
$C up -d --build --wait
```
Следующий `deploy.sh` сам вернётся на `main`. Правильный путь исправления — revert-коммит через PR.
БД из бэкапа (всё, что было после бэкапа, потеряется):
```bash
cd ~/lux_fiscal
$C stop api worker
$C exec -T postgres sh -c 'pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists --no-owner' \
< ~/backups/lux_fiscal/<файл>.dump
$C start api worker
```
Код при этом должен соответствовать версии схемы в дампе (`alembic_version`).
## Бэкапы
- `scripts/backup.sh`: `pg_dump -Fc` → проверка `pg_restore --list` → ротация 14 дней.
- Cron пользователя `deploy` (`crontab -l`): каждый день в 03:30 UTC.
- Бэкапы лежат на том же диске, что и БД, — периодически копируйте их за пределы сервера:
```bash
scp "lux-prod:backups/lux_fiscal/*.dump" D:/Backups/lux_fiscal/
```
## Прод-данные локально
Прод-дамп содержит зашифрованные ключи касс, а локальный `.env` — ключи CRM. Чтобы локальный воркер не создавал боевые
чеки и не менял статусы заказов в CRM, локально **всегда**:
```ini
ENVIRONMENT=local
CHECKBOX_USE_STUB=true
CRM_USE_STUB=true
```
После восстановления прод-дампа в локальную БД сразу обнулите ключи касс (лицензия/PIN — фиктивные, ключ НП — `NULL`):
```bash
docker compose run --rm --no-deps -T api python - <<'EOF'
import asyncio
from sqlalchemy import text
from app.core import crypto
from app.core.config import settings
from app.db.session import engine
assert not settings.is_production
async def main():
async with engine.begin() as c:
await c.execute(text("update cash_registers set license_key_enc=:l, cashier_pin_enc=:p, np_api_key_enc=null"),
{"l": crypto.encrypt("local-stub-license"), "p": crypto.encrypt("0000")})
asyncio.run(main())
EOF
```
Зашифрованы только ключи касс, и скрипт их перезаписывает — поэтому локальный `ENCRYPTION_KEY` может быть любым.
Делайте это до первого запуска локальных `api`/`worker`.
Варнинги воркера `ettn_poll_failed … не знайдено` на прод-копии — норма: стаб Checkbox не знает ID настоящих чеков.
## CI (Gitea Actions)
`.gitea/workflows/ci.yml`: backend — `ruff check` + `pytest`; frontend — `npm run lint` + `npm run build`.
Workflow выполняется только при зарегистрированном `act_runner`:
1. Gitea → Site Administration → Actions → Runners → Create new Runner — скопировать registration token.
2. Запустить раннер контейнером рядом с Gitea (образ `gitea/act_runner`, переменные `GITEA_INSTANCE_URL`,
`GITEA_RUNNER_REGISTRATION_TOKEN`, проброс `/var/run/docker.sock`).
Сервер слабый (2.5 ГБ RAM): при выкладке во время прогона CI сборка фронтенда может упереться в память.
## Первичная установка (для справки)
1. Пользователь `deploy` в группе `docker`, SSH-ключ разработчика в `~deploy/.ssh/authorized_keys` (700/600, владелец `deploy`).
2. Deploy key `~deploy/.ssh/gitea_deploy` добавлен в Gitea (repo → Settings → Deploy Keys, только чтение);
`git clone gitea:lauadmin/lux_fiscal.git ~/lux_fiscal`.
3. `.env` из `.env.example`: `ENVIRONMENT=production`, `DEBUG=false`, `BASE_URL`/`CORS_ORIGINS` = `https://asist.ystyle.com.ua`,
сгенерированные `SECRET_KEY`/`POSTGRES_PASSWORD`, `ENCRYPTION_KEY` — тот, которым зашифрованы ключи касс в переносимой БД.
4. `$C up -d --build --wait`; на пустой БД — `$C run --rm api python -m app.cli bootstrap`.
5. NPM: Proxy Host → `lux-fiscal-frontend:80`, Let's Encrypt, Force SSL.
6. Cron: `30 3 * * * $HOME/lux_fiscal/scripts/backup.sh >> $HOME/backups/lux_fiscal/backup.log 2>&1`.
+3 -3
View File
@@ -10,9 +10,9 @@
|---|---|---|
| 1 | Каркас: FastAPI, Postgres, Alembic, Docker, логи, health-check | ✅ готово |
| 2 | Аутентификация, роли, журнал аудита | ✅ готово |
| 3 | Заказы + заглушка CRM | ⏳ |
| 4 | Нова Пошта: статусы и сумма послеоплаты | ⏳ |
| 5 | Checkbox: смены, чеки, PDF | ⏳ |
| 3 | Заказы + заглушка CRM | ✅ готово |
| 4 | Нова Пошта: статусы и сумма послеоплаты | ✅ готово |
| 5 | Checkbox: ЕТТН-чеки по ТТН с контролем оплаты (план: `.plans/checkbox-ettn-receipts.md`) | 🚧 код готов, ждёт проверки на боевой кассе |
| 6 | Очередь и редактор чека (фронтенд) | ⏳ |
| 7 | Админка | ⏳ |
| 8 | Реальный API CRM | ⏳ |
+46
View File
@@ -0,0 +1,46 @@
"""Локальная копия заказов, синхронизированная из CRM
Revision ID: 0002
Revises: 0001
Create Date: 2026-09-22
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0002"
down_revision: str | None = "0001"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"orders",
sa.Column("id", sa.String(length=32), nullable=False),
sa.Column("create_date_time", sa.DateTime(timezone=True), nullable=False),
sa.Column("recipient_name", sa.String(length=255), nullable=True),
sa.Column("recipient_phone", sa.String(length=32), nullable=True),
sa.Column("recipient_email", sa.String(length=320), nullable=True),
sa.Column("waybill_number", sa.String(length=64), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("total_amount_kopecks", sa.BigInteger(), nullable=False),
sa.Column("goods", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("is_deleted", sa.Boolean(), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("receipt_created_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.PrimaryKeyConstraint("id", name=op.f("pk_orders")),
)
op.create_index(op.f("ix_orders_is_deleted"), "orders", ["is_deleted"])
op.create_index(op.f("ix_orders_receipt_created_at"), "orders", ["receipt_created_at"])
def downgrade() -> None:
op.drop_table("orders")
@@ -0,0 +1,30 @@
"""Статус ТТН Nova Poshta на заказе
Revision ID: 0003
Revises: 0002
Create Date: 2026-09-23
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0003"
down_revision: str | None = "0002"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("orders", sa.Column("np_status", sa.String(length=255), nullable=True))
op.add_column("orders", sa.Column("np_status_code", sa.String(length=16), nullable=True))
op.add_column("orders", sa.Column("np_cod_amount_kopecks", sa.BigInteger(), nullable=True))
def downgrade() -> None:
op.drop_column("orders", "np_cod_amount_kopecks")
op.drop_column("orders", "np_status_code")
op.drop_column("orders", "np_status")
@@ -0,0 +1,26 @@
"""Статус оплаты ТТН Nova Poshta на заказе
Revision ID: 0004
Revises: 0003
Create Date: 2026-09-23
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0004"
down_revision: str | None = "0003"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("orders", sa.Column("np_payment_status", sa.String(length=32), nullable=True))
def downgrade() -> None:
op.drop_column("orders", "np_payment_status")
@@ -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)
@@ -0,0 +1,28 @@
"""Отметка о смене статуса заказа в CRM после создания чека
Revision ID: 0006
Revises: 0005
Create Date: 2026-09-24
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0006"
down_revision: str | None = "0005"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"receipts", sa.Column("crm_status_set_at", sa.DateTime(timezone=True), nullable=True)
)
def downgrade() -> None:
op.drop_column("receipts", "crm_status_set_at")
@@ -0,0 +1,26 @@
"""Отметка о ручном редактировании заказа
Revision ID: 0007
Revises: 0006
Create Date: 2026-09-24
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0007"
down_revision: str | None = "0006"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("orders", sa.Column("edited_at", sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
op.drop_column("orders", "edited_at")
@@ -0,0 +1,58 @@
"""Ключ API Новой Почты у кассы и привязка заказа к кассе
Revision ID: 0008
Revises: 0007
Create Date: 2026-09-25
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from app.core import crypto
from app.core.config import settings
revision: str = "0008"
down_revision: str | None = "0007"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"cash_registers", sa.Column("np_api_key_enc", sa.String(length=512), nullable=True)
)
op.add_column("orders", sa.Column("cash_register_id", sa.Uuid(), nullable=True))
op.create_foreign_key(
op.f("fk_orders_cash_register_id_cash_registers"),
"orders",
"cash_registers",
["cash_register_id"],
["id"],
ondelete="SET NULL",
)
op.create_index(op.f("ix_orders_cash_register_id"), "orders", ["cash_register_id"])
# Раньше ключ НП был один на всё приложение (NOVA_POSHTA_API_KEY) — переносим
# его в кассу по умолчанию, чтобы опрос статусов не остановился после обновления.
if settings.nova_poshta_api_key:
op.get_bind().execute(
sa.text(
"UPDATE cash_registers SET np_api_key_enc = :key "
"WHERE id = (SELECT id FROM cash_registers "
"ORDER BY is_default DESC, created_at LIMIT 1)"
),
{"key": crypto.encrypt(settings.nova_poshta_api_key)},
)
def downgrade() -> None:
op.drop_index(op.f("ix_orders_cash_register_id"), table_name="orders")
op.drop_constraint(
op.f("fk_orders_cash_register_id_cash_registers"), "orders", type_="foreignkey"
)
op.drop_column("orders", "cash_register_id")
op.drop_column("cash_registers", "np_api_key_enc")
+12 -2
View File
@@ -13,6 +13,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
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, get_crm_client
from app.services.task_queue import TaskQueue, get_task_queue
bearer_scheme = HTTPBearer(auto_error=False)
@@ -20,7 +23,7 @@ SessionDep = Annotated[AsyncSession, Depends(get_session)]
_UNAUTHORIZED = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Требуется аутентификация",
detail="Потрібна автентифікація",
headers={"WWW-Authenticate": "Bearer"},
)
@@ -71,7 +74,7 @@ def require_roles(*roles: UserRole) -> Callable[..., Coroutine[Any, Any, User]]:
if user.role not in allowed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Недостаточно прав для этого действия",
detail="Недостатньо прав для цієї дії",
)
return user
@@ -85,3 +88,10 @@ require_any = require_roles(UserRole.ADMIN, UserRole.CASHIER, UserRole.VIEWER)
AdminUser = Annotated[User, Depends(require_admin)]
CashierUser = Annotated[User, Depends(require_cashier)]
CrmClientDep = Annotated[CrmClient, Depends(get_crm_client)]
CheckboxClientDep = Annotated[CheckboxClient, Depends(get_checkbox_client)]
TaskQueueDep = Annotated[TaskQueue, Depends(get_task_queue)]
+1 -1
View File
@@ -70,7 +70,7 @@ async def change_password(
) -> Response:
if not verify_password(payload.current_password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Текущий пароль указан неверно"
status_code=status.HTTP_400_BAD_REQUEST, detail="Поточний пароль вказано неправильно"
)
user.password_hash = hash_password(payload.new_password)
+148
View File
@@ -0,0 +1,148 @@
"""Кассы 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)
)
async def _ensure_np_key_unique(
session: SessionDep, api_key: str, exclude_id: uuid.UUID | None = None
) -> None:
"""Один кабинет НП — одна касса, иначе неясно, от какой кассы пробивать его посылки."""
registers = await session.scalars(
select(CashRegister).where(CashRegister.np_api_key_enc.is_not(None))
)
for register in registers:
if register.id == exclude_id:
continue
try:
same = crypto.decrypt(register.np_api_key_enc) == api_key
except crypto.DecryptionError:
continue
if same:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Цей ключ Нової Пошти вже прив'язаний до каси «{register.name}»",
)
@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:
if payload.np_api_key:
await _ensure_np_key_unique(session, payload.np_api_key)
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),
np_api_key_enc=crypto.encrypt(payload.np_api_key) if payload.np_api_key else None,
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,
"np_api_key_set": register.np_api_key_enc is not None,
},
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 changes.get("np_api_key"):
await _ensure_np_key_unique(session, changes["np_api_key"], exclude_id=register.id)
register.np_api_key_enc = crypto.encrypt(changes["np_api_key"])
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 / np_api_key редактируются в 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
+88
View File
@@ -0,0 +1,88 @@
"""Локальная очередь заказов (синхронизируется из CRM) — список, вкладки, правка, удаление."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request, status
from app.api.deps import CashierUser, CrmClientDep, SessionDep, require_any
from app.db.models.audit import AuditAction
from app.schemas.orders import OrderRowOut, OrdersSummaryOut, OrderUpdateIn
from app.services import audit
from app.services import orders as orders_service
from app.services import receipts as receipts_service
from app.services.orders import OrderTab
router = APIRouter(prefix="/orders", tags=["orders"], dependencies=[Depends(require_any)])
@router.get("", response_model=list[OrderRowOut], response_model_by_alias=False)
async def list_orders(
session: SessionDep, crm: CrmClientDep, tab: OrderTab = OrderTab.NO_RECEIPT
) -> list[OrderRowOut]:
# by_alias=False: без этого FastAPI сериализует вложенные OrderGoodOut через
# CRM-алиасы (SKU/Name/...) вместо snake_case полей, которые ждёт фронтенд.
await orders_service.sync_orders_from_crm(session, crm)
orders = await orders_service.list_orders(session, tab=tab)
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.get("/summary", response_model=OrdersSummaryOut)
async def orders_summary(session: SessionDep) -> OrdersSummaryOut:
count, kopecks = await orders_service.cod_in_transit(session)
return OrdersSummaryOut(
cod_in_transit_count=count, cod_in_transit_amount=f"{kopecks / 100:.2f}"
)
@router.patch("/{order_id}", response_model=OrderRowOut, response_model_by_alias=False)
async def update_order(
order_id: str,
payload: OrderUpdateIn,
request: Request,
session: SessionDep,
actor: CashierUser,
) -> OrderRowOut:
try:
result = await orders_service.update_order(session, order_id, payload)
except orders_service.OrderEditError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
if result is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Замовлення не знайдено")
order, changed = result
if changed:
await audit.record(
session,
action=AuditAction.ORDER_UPDATED,
user=actor,
entity_type="order",
entity_id=order.id,
payload={"fields": changed},
request=request,
)
await session.commit()
receipts = await receipts_service.latest_receipts_by_order(session, [order.id])
return OrderRowOut.from_order(order, receipts.get(order.id))
@router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_order(
order_id: str, request: Request, session: SessionDep, actor: CashierUser
) -> None:
order = await orders_service.delete_order(session, order_id)
if order is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Замовлення не знайдено")
await audit.record(
session,
action=AuditAction.ORDER_DELETED,
user=actor,
entity_type="order",
entity_id=order.id,
request=request,
)
await session.commit()
+87
View File
@@ -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)
+4 -1
View File
@@ -2,9 +2,12 @@
from fastapi import APIRouter
from app.api.v1 import auth, health, 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)
+4 -4
View File
@@ -71,7 +71,7 @@ async def create_user(
await session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Пользователь с таким email уже существует",
detail="Користувач із таким email уже існує",
) from exc
return UserOut.model_validate(user)
@@ -87,7 +87,7 @@ async def update_user(
) -> UserOut:
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Користувача не знайдено")
changes = payload.model_dump(exclude_unset=True)
@@ -97,12 +97,12 @@ async def update_user(
if changes.get("is_active") is False:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Нельзя отключить собственную учётную запись",
detail="Не можна вимкнути власний обліковий запис",
)
if "role" in changes and changes["role"] != UserRole.ADMIN:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Нельзя снять с себя роль администратора",
detail="Не можна зняти із себе роль адміністратора",
)
if (new_password := changes.pop("password", None)) is not None:
+5 -5
View File
@@ -25,7 +25,7 @@ async def bootstrap() -> int:
Идемпотентна: повторный запуск ничего не меняет и пароль не сбрасывает.
"""
if not settings.first_admin_password:
print("FIRST_ADMIN_PASSWORD не задан в .env", file=sys.stderr)
print("FIRST_ADMIN_PASSWORD не задано в .env", file=sys.stderr)
return 1
email = settings.first_admin_email.strip().lower()
@@ -33,7 +33,7 @@ async def bootstrap() -> int:
async with session_scope() as session:
existing = await session.scalar(select(User).where(User.email == email))
if existing is not None:
print(f"Пользователь {email} уже существует — ничего не изменено.")
print(f"Користувач {email} уже існує — нічого не змінено.")
return 0
session.add(
@@ -45,8 +45,8 @@ async def bootstrap() -> int:
)
)
print(f"Администратор {email} создан.")
print("Смените пароль после первого входа и уберите FIRST_ADMIN_PASSWORD из .env.")
print(f"Адміністратора {email} створено.")
print("Змініть пароль після першого входу та приберіть FIRST_ADMIN_PASSWORD з .env.")
return 0
@@ -59,7 +59,7 @@ def gen_keys() -> int:
def main() -> int:
parser = argparse.ArgumentParser(prog="app.cli", description="Служебные команды lux_fiscal")
parser = argparse.ArgumentParser(prog="app.cli", description="Службові команди lux_fiscal")
parser.add_argument("command", choices=["bootstrap", "gen-keys"])
args = parser.parse_args()
+27 -1
View File
@@ -53,7 +53,33 @@ class Settings(BaseSettings):
# --- Первый администратор (только для команды bootstrap) ---
first_admin_email: str = "admin@example.com"
first_admin_password: str = ""
first_admin_name: str = "Администратор"
first_admin_name: str = "Адміністратор"
# --- CRM ---
crm_base_url: str = "https://optstore.exocrm.com/api/1.1/"
crm_api_key: str = ""
crm_secret_key: str = ""
crm_shop_key: str = ""
crm_sid: int = 1
# Стаб вместо реальной CRM: иначе локальный worker переводил бы боевые заказы
# в PACKED после стаб-чеков Checkbox. В проде запрещено.
crm_use_stub: bool = False
# --- Nova Poshta ---
# Ключи НП хранятся у касс (`cash_registers.np_api_key_enc`). Эта переменная
# читается только миграцией 0008: переносит старый общий ключ в кассу по умолчанию.
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 ходит в API Новой Почты, а та отвечает «To many requests» на частые вызовы.
checkbox_min_request_interval_ms: int = 1000
# Стаб вместо реального Checkbox: ЕТТН работает только на боевой кассе,
# поэтому локально весь цикл прогоняется через стаб. В проде запрещено.
checkbox_use_stub: bool = False
@property
def cors_origins(self) -> list[str]:
+3 -3
View File
@@ -26,7 +26,7 @@ def _fernet() -> Fernet:
return Fernet(settings.encryption_key.encode())
except (ValueError, TypeError) as exc:
raise RuntimeError(
"ENCRYPTION_KEY некорректен. Сгенерируйте валидный ключ: "
"ENCRYPTION_KEY некоректний. Згенеруйте валідний ключ: "
"python -c \"from cryptography.fernet import Fernet; "
'print(Fernet.generate_key().decode())"'
) from exc
@@ -41,8 +41,8 @@ def decrypt(value: str) -> str:
return _fernet().decrypt(value.encode()).decode()
except InvalidToken as exc:
raise DecryptionError(
"Не удалось расшифровать значение. Вероятная причина — ENCRYPTION_KEY "
"изменился с момента сохранения. Секрет нужно ввести заново."
"Не вдалося розшифрувати значення. Імовірна причина — ENCRYPTION_KEY "
"змінився з моменту збереження. Секрет потрібно ввести заново."
) from exc
+3 -3
View File
@@ -67,13 +67,13 @@ def decode_access_token(token: str) -> dict[str, Any]:
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
except jwt.ExpiredSignatureError as exc:
raise TokenError("Срок действия токена истёк") from exc
raise TokenError("Термін дії токена минув") from exc
except jwt.InvalidTokenError as exc:
raise TokenError("Некорректный токен") from exc
raise TokenError("Некоректний токен") from exc
# Без этой проверки refresh-токен можно было бы предъявить как access.
if payload.get("type") != "access":
raise TokenError("Ожидался access-токен")
raise TokenError("Очікувався access-токен")
return payload
+7
View File
@@ -6,12 +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",
+7
View File
@@ -31,6 +31,13 @@ class AuditAction(str):
USER_CREATED = "user.created"
USER_UPDATED = "user.updated"
USER_DEACTIVATED = "user.deactivated"
ORDER_DELETED = "order.deleted"
ORDER_UPDATED = "order.updated"
CASH_REGISTER_CREATED = "cash_register.created"
CASH_REGISTER_UPDATED = "cash_register.updated"
RECEIPT_CREATE_REQUESTED = "receipt.create_requested"
RECEIPT_CANCELLED = "receipt.cancelled"
ORDER_CRM_STATUS_SET = "order.crm_status_set"
class AuditLog(UUIDPrimaryKeyMixin, Base):
+44
View File
@@ -0,0 +1,44 @@
"""Кассы (ПРРО) Checkbox.
Ключ лицензии, PIN кассира и ключ API Новой Почты хранятся только в
зашифрованном виде (`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)
# Ключ API кабинета отправителя НП. Посылки, которые этот ключ видит как
# свои, привязываются к кассе (`orders.cash_register_id`), и ЕТТН-чеки по
# ним создаются от неё. Без ключа касса статусы ТТН не опрашивает.
np_api_key_enc: Mapped[str | None] = mapped_column(String(512))
# Коды налоговых ставок 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"),
),
)
+63
View File
@@ -0,0 +1,63 @@
"""Локальная копия заказов, синхронизированная из CRM.
Живёт отдельно от live-проксирования (`app/services/crm/`), чтобы кассир мог
скрыть ненужный заказ (soft-delete) и он не появился снова при следующей
синхронизации, и чтобы отличать заказы без чека от уже пробитых.
"""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, TimestampMixin
class Order(TimestampMixin, Base):
__tablename__ = "orders"
# PK — собственный ID заказа в CRM, а не сгенерированный UUID: синхронизация
# делает upsert по нему.
id: Mapped[str] = mapped_column(String(32), primary_key=True)
create_date_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
recipient_name: Mapped[str | None] = mapped_column(String(255))
recipient_phone: Mapped[str | None] = mapped_column(String(32))
recipient_email: Mapped[str | None] = mapped_column(String(320))
waybill_number: Mapped[str | None] = mapped_column(String(64))
notes: Mapped[str | None] = mapped_column(Text)
# Деньги — integer копейки (конвенция проекта), а не строка/float.
total_amount_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False)
# Товарные позиции — снимок из CRM для отображения в модалке. Построчно в
# копейки не переводится: не участвует в денежных расчётах, только в показе.
goods: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, default=list)
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Заказ правили вручную в карточке — синхронизация из CRM его больше не
# перезаписывает, иначе правки кассира затирались бы при следующем открытии.
edited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Заполняется будущей интеграцией с Checkbox — сейчас всегда NULL.
receipt_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
# Статус ТТН Nova Poshta — опрашивается ARQ worker'ом раз в минуту для
# заказов без чека, у которых есть номер ТТН (см. app/worker.py).
np_status: Mapped[str | None] = mapped_column(String(255))
np_status_code: Mapped[str | None] = mapped_column(String(16))
np_cod_amount_kopecks: Mapped[int | None] = mapped_column(BigInteger)
np_payment_status: Mapped[str | None] = mapped_column(String(32))
# Касса, чей ключ НП видит ТТН как свою (определяется при опросе статусов,
# см. `services/orders.sync_np_statuses`). От неё создаётся ЕТТН-чек.
cash_register_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("cash_registers.id", ondelete="SET NULL"), index=True
)
+86
View File
@@ -0,0 +1,86 @@
"""ЕТТН-чеки 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))
# Когда заказу в CRM выставлен статус после создания чека (PACKED). NULL при
# живом чеке — ещё не выставлен (CRM была недоступна), cron повторит.
crm_status_set_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')"),
),
)
+3 -1
View File
@@ -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")
@@ -76,7 +78,7 @@ def create_app() -> FastAPI:
app = FastAPI(
title=settings.project_name,
version="0.1.0",
description="Фискализация заказов через Checkbox",
description="Фіскалізація замовлень через Checkbox",
lifespan=lifespan,
# В проде интерактивная документация закрыта: схема API — лишняя
# подсказка для того, кто ищет незащищённый эндпоинт.
+1 -1
View File
@@ -22,7 +22,7 @@ class TokenPair(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int = Field(description="Время жизни access-токена в секундах")
expires_in: int = Field(description="Час життя access-токена в секундах")
class UserOut(BaseModel):
+39
View File
@@ -0,0 +1,39 @@
"""Схемы Checkbox API для ЕТТН-чеков (`/api/v1/ettn`).
Тело запроса собирается как обычный dict в `app/services/receipts.py` —
его снимок сохраняется в `receipts.request_body` без преобразований.
Здесь — только разбор ответа (`BaseEttnResponse`).
"""
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field, field_validator
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")
# В OpenAPI статусы заглавные, а боевой API отдаёт строчные (`"done"`,
# `"created"`) — приводим к одному регистру, иначе статусы не сопоставятся.
@field_validator("status", mode="before")
@classmethod
def _upper_status(cls, value: object) -> object:
return value.upper() if isinstance(value, str) else value
+167
View File
@@ -0,0 +1,167 @@
"""Схемы заказов CRM.
Поля валидируются напрямую из "сырых" PascalCase-ключей ответа CRM через
`Field(alias=...)`, а наружу (в JSON фронту) отдаются как чистые snake_case
имена. Денежные значения остаются строками "как есть" от CRM — конвенция
integer-kopecks из CLAUDE.md относится к будущим персистентным моделям
`Order`/`Receipt`, а не к этому read-only проксирующему эндпоинту.
"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import TYPE_CHECKING, Any
from pydantic import BaseModel, ConfigDict, Field, field_validator
if TYPE_CHECKING:
from app.db.models.order import Order
from app.db.models.receipt import Receipt
class OrderGoodOut(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: str = Field(alias="ID")
sku: str = Field(alias="SKU")
name: str = Field(alias="Name")
price: str = Field(alias="Price")
quantity: str = Field(alias="Quantity")
discount_amount: str | None = Field(default=None, alias="DiscountAmount")
discount_percent: str | None = Field(default=None, alias="DiscountPercent")
amount: str = Field(alias="Amount")
class OrderTotalOut(BaseModel):
model_config = ConfigDict(populate_by_name=True)
cost: str = Field(alias="Cost")
quantity: str = Field(alias="Quantity")
weight: str = Field(alias="Weight")
discount_amount: str = Field(alias="DiscountAmount")
discount_percent: str = Field(alias="DiscountPercent")
amount: str = Field(alias="Amount")
# CRM возвращает Quantity/Weight то числом, то строкой в зависимости от
# заказа — приводим к строке единообразно для фронта.
@field_validator("quantity", "weight", mode="before")
@classmethod
def _stringify(cls, value: Any) -> Any:
return str(value) if value is not None else value
class OrderOut(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: str = Field(alias="ID")
create_date_time: str = Field(alias="CreateDateTime")
recipient_name: str | None = Field(default=None, alias="RecipientDName")
recipient_phone: str | None = Field(default=None, alias="RecipientPhone")
recipient_email: str | None = Field(default=None, alias="RecipientEmail")
waybill_number: str | None = Field(default=None, alias="Waybill_Number")
notes: str | None = Field(default=None, alias="Notes")
total: OrderTotalOut = Field(alias="Total")
goods: list[OrderGoodOut] = Field(default_factory=list, alias="Goods")
class OrderRowOut(BaseModel):
"""Ответ `GET /orders` — строится из локальной таблицы `orders`, не из CRM напрямую."""
model_config = ConfigDict(from_attributes=True)
id: str
create_date_time: datetime
recipient_name: str | None
recipient_phone: str | None
recipient_email: str | None
waybill_number: str | None
notes: str | None
total_amount: str
goods: list[OrderGoodOut]
has_receipt: bool
np_status: str | None
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
edited: bool = False
@classmethod
def from_order(cls, order: Order, receipt: Receipt | None = None) -> OrderRowOut:
return cls(
id=order.id,
create_date_time=order.create_date_time,
recipient_name=order.recipient_name,
recipient_phone=order.recipient_phone,
recipient_email=order.recipient_email,
waybill_number=order.waybill_number,
notes=order.notes,
total_amount=f"{order.total_amount_kopecks / 100:.2f}",
goods=[OrderGoodOut.model_validate(good) for good in order.goods],
has_receipt=order.receipt_created_at is not None,
np_status=order.np_status,
np_status_code=order.np_status_code,
np_cod_amount=(
f"{order.np_cod_amount_kopecks / 100:.2f}"
if order.np_cod_amount_kopecks is not None
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
),
edited=order.edited_at is not None,
)
class OrdersSummaryOut(BaseModel):
"""`GET /orders/summary` — сводка над таблицей заказов."""
cod_in_transit_count: int
cod_in_transit_amount: str
class OrderGoodIn(BaseModel):
"""Позиция заказа из формы редактирования. `amount` считает сервер."""
id: str | None = Field(default=None, max_length=64)
sku: str = Field(default="", max_length=128)
name: str = Field(min_length=1, max_length=512)
price: Decimal = Field(ge=0, max_digits=12, decimal_places=2)
quantity: Decimal = Field(gt=0, max_digits=12, decimal_places=3)
discount_amount: Decimal = Field(default=Decimal(0), ge=0, max_digits=12, decimal_places=2)
@field_validator("sku", "name", mode="after")
@classmethod
def _strip(cls, value: str) -> str:
return value.strip()
class OrderUpdateIn(BaseModel):
"""`PATCH /orders/{id}` — полная замена редактируемых полей заказа."""
recipient_name: str | None = Field(default=None, max_length=255)
recipient_phone: str | None = Field(default=None, max_length=32)
recipient_email: str | None = Field(default=None, max_length=320)
waybill_number: str | None = Field(default=None, max_length=64)
notes: str | None = None
total_amount: Decimal = Field(ge=0, max_digits=12, decimal_places=2)
goods: list[OrderGoodIn] = Field(min_length=1)
# Пустая строка из формы = «не указано».
@field_validator(
"recipient_name", "recipient_phone", "recipient_email", "waybill_number", "notes"
)
@classmethod
def _blank_to_none(cls, value: str | None) -> str | None:
if value is None:
return None
return value.strip() or None
+127
View File
@@ -0,0 +1,127 @@
"""Схемы 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]
# --- Кассы -------------------------------------------------------------------
def _masked(encrypted: str) -> str:
try:
return crypto.mask(crypto.decrypt(encrypted))
except crypto.DecryptionError:
return "не розшифровується — введіть заново"
class CashRegisterOut(BaseModel):
id: uuid.UUID
name: str
fiscal_number: str | None
license_key_masked: str
# None — ключ НП не задан: касса не опрашивает посылки и не получает заказы.
np_api_key_masked: str | None
tax_codes: list[Any]
is_active: bool
is_default: bool
@classmethod
def from_register(cls, register: CashRegister) -> CashRegisterOut:
return cls(
id=register.id,
name=register.name,
fiscal_number=register.fiscal_number,
license_key_masked=_masked(register.license_key_enc),
np_api_key_masked=(
_masked(register.np_api_key_enc) if register.np_api_key_enc else None
),
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)
np_api_key: str | None = Field(default=None, min_length=1, max_length=255)
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)
np_api_key: str | None = Field(default=None, min_length=1, max_length=255)
tax_codes: list[int | str] | None = Field(default=None, max_length=2)
is_active: bool | None = None
is_default: bool | None = None
+61
View File
@@ -0,0 +1,61 @@
"""Схема статуса ТТН Nova Poshta (`TrackingDocument.getStatusDocuments`).
Поля валидируются из "сырых" PascalCase-ключей ответа NP через `Field(alias=...)`,
как и `OrderOut` для CRM. Суммы остаются строками "как есть" от NP — в
integer-копейки переводится только на границе с персистентной моделью.
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, model_validator
class TrackingStatusOut(BaseModel):
model_config = ConfigDict(populate_by_name=True)
number: str = Field(alias="Number")
status: str = Field(alias="Status")
status_code: str = Field(alias="StatusCode")
payment_method: str | None = Field(default=None, alias="PaymentMethod")
scheduled_delivery_date: str | None = Field(default=None, alias="ScheduledDeliveryDate")
actual_delivery_date: str | None = Field(default=None, alias="ActualDeliveryDate")
# Чистая сумма к перечислению продавцу за товар — поле `AfterpaymentOnGoodsCost`
# ("Контроль оплати" в кабинете NP). В отличие от `AmountToPay`/
# `ExpressWaybillAmountToPay` (сколько получатель должен заплатить НП прямо
# сейчас — включает стоимость доставки и комиссию НП и обнуляется после
# оплаты), это поле — зафиксированная при создании ТТН сумма за товар без
# доставки и комиссии, и не меняется по ходу доставки. `AmountToPay`/
# `ExpressWaybillAmountToPay` — запасной вариант на случай, если NP для
# какого-то типа накладной не возвращает `AfterpaymentOnGoodsCost`.
cod_amount: str | None = Field(default=None)
# Телефон отправителя NP отдаёт только кабинету отправителя (или получателя):
# по чужому ключу без телефона ответ урезан — нет ни отправителя, ни
# `AfterpaymentOnGoodsCost`. По этому полю определяется, чья это посылка.
phone_sender: str | None = Field(default=None, alias="PhoneSender")
# Статус оплаты аналогично раздвоен на `PaymentStatus`/`ExpressWaybillPaymentStatus`.
payment_status: str | None = Field(default=None)
@property
def is_own(self) -> bool:
"""ТТН видна ключу как своя — отправлена из кабинета этого ключа."""
return bool(self.phone_sender)
@model_validator(mode="before")
@classmethod
def _fold_express_fields(cls, data: Any) -> Any:
if not isinstance(data, dict):
return data
data = dict(data)
afterpayment = data.get("AfterpaymentOnGoodsCost")
fallback = data.get("AmountToPay") or data.get("ExpressWaybillAmountToPay") or None
data.setdefault("cod_amount", str(afterpayment) if afterpayment else fallback)
data.setdefault(
"payment_status",
data.get("PaymentStatus") or data.get("ExpressWaybillPaymentStatus") or None,
)
return data
+4
View File
@@ -22,6 +22,10 @@ _REDACTED_KEYS = {
"secret",
"license_key",
"api_key",
"np_api_key",
"pin",
"pin_code",
"cashier_pin",
}
+6 -6
View File
@@ -53,7 +53,7 @@ async def authenticate(
request=request,
)
await session.commit()
raise AuthError("Неверный email или пароль")
raise AuthError("Невірний email або пароль")
if not user.is_active:
await audit.record(
@@ -64,7 +64,7 @@ async def authenticate(
request=request,
)
await session.commit()
raise AuthError("Учётная запись отключена")
raise AuthError("Обліковий запис вимкнено")
# Параметры argon2 со временем ужесточаются — обновляем хеш на живом пароле.
if password_needs_rehash(user.password_hash):
@@ -117,7 +117,7 @@ async def rotate_refresh_token(
stored = await session.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash))
if stored is None:
raise AuthError("Некорректный refresh-токен")
raise AuthError("Некоректний refresh-токен")
if stored.revoked_at is not None:
await revoke_all_for_user(session, stored.user_id)
@@ -131,14 +131,14 @@ async def rotate_refresh_token(
)
await session.commit()
log.warning("refresh_token_reuse_detected", user_id=str(stored.user_id))
raise AuthError("Сессия отозвана, требуется повторный вход")
raise AuthError("Сесію відкликано, потрібен повторний вхід")
if stored.expires_at <= datetime.now(UTC):
raise AuthError("Срок действия refresh-токена истёк")
raise AuthError("Термін дії refresh-токена минув")
user = await session.get(User, stored.user_id)
if user is None or not user.is_active:
raise AuthError("Учётная запись недоступна")
raise AuthError("Обліковий запис недоступний")
pair = await issue_token_pair(session, user, request=request, replaces=stored)
await audit.record(
+73
View File
@@ -0,0 +1,73 @@
"""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 — исход запроса неизвестен, можно повторить."""
class CheckboxRateLimitedError(CheckboxUnavailableError):
"""Лимит частоты запросов (Checkbox 429 или «To many requests» от Новой Почты).
В отличие от прочих `CheckboxUnavailableError` исход известен — запрос отклонён,
повторять можно не раньше чем через `retry_after` секунд.
"""
def __init__(self, message: str, retry_after: float) -> None:
super().__init__(message)
self.retry_after = retry_after
@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,233 @@
"""Реальный клиент Checkbox (ЕТТН-чеки, `/api/v1/ettn`).
Авторизация — токен кассира по PIN-коду (`/api/v1/cashier/signinPinCode`).
Токен кэшируется в памяти процесса по кассе; на 401 — один повторный вход.
Запросы из одного процесса идут строго по одному с паузой
`CHECKBOX_MIN_REQUEST_INTERVAL_MS`: при создании ЕТТН Checkbox синхронно ходит
в API Новой Почты, а та на частые вызовы отвечает «To many requests».
"""
from __future__ import annotations
import asyncio
import re
import time
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,
CheckboxRateLimitedError,
CheckboxUnavailableError,
)
_PROVIDER = "novapost"
_TIMEOUT = httpx.Timeout(30, connect=10)
# Сколько страниц списка ЕТТН просматривать при сверке после таймаута.
# Список отдаётся от новых к старым; больше 50 за страницу Checkbox не принимает.
_FIND_PAGES = 3
_FIND_PAGE_SIZE = 50
# Ошибки Новой Почты, которые Checkbox отдаёт 4xx с `code=third_party.*`:
# {"message": "To many requests", "info": ["Try again after 1 seconds"],
# "errorCodes": ["20000401501"]} — лимит частоты НП;
# "cURL error 28: SSL connection timeout" — Checkbox не дождался НП.
_NP_RATE_LIMIT_RE = re.compile(r"20000401501|too? many requests", re.IGNORECASE)
_NP_RETRY_AFTER_RE = re.compile(r"try again after (\d+) second", re.IGNORECASE)
_NP_TIMEOUT_RE = re.compile(r"curl error|timed? ?out", re.IGNORECASE)
_MIN_RETRY_AFTER = 1.0
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")
# Ошибки со стороны Новой Почты Checkbox отдаёт как
# {"code": "third_party.*", "message": "Internal Server Error"} —
# без кода кассир видит только бесполезное «Internal Server Error».
if code := data.get("code"):
message = f"{message or 'Помилка'} ({code})"
if isinstance(detail, list) and detail:
parts = [
f"{'.'.join(str(p) for p in item.get('loc', []))}: {item.get('msg')}"
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}"
def _retry_after(response: httpx.Response) -> float:
seconds: float = 0
if match := _NP_RETRY_AFTER_RE.search(response.text):
seconds = float(match.group(1))
elif (header := response.headers.get("Retry-After", "")).isdigit():
seconds = float(header)
return max(seconds, _MIN_RETRY_AFTER)
def _transient_error(response: httpx.Response) -> CheckboxUnavailableError | None:
"""Ответ, после которого запрос можно повторить, или None, если ошибка окончательная."""
if response.status_code == 429:
return CheckboxRateLimitedError(_error_message(response), _retry_after(response))
if response.status_code >= 500:
return CheckboxUnavailableError(_error_message(response))
if response.status_code >= 400 and '"third_party.' in response.text:
if _NP_RATE_LIMIT_RE.search(response.text):
return CheckboxRateLimitedError(_error_message(response), _retry_after(response))
if _NP_TIMEOUT_RE.search(response.text):
return CheckboxUnavailableError(_error_message(response))
return None
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] = {}
self._min_interval = settings.checkbox_min_request_interval_ms / 1000
self._send_lock = asyncio.Lock()
self._sign_in_lock = asyncio.Lock()
self._last_sent_at = float("-inf")
async def _send(
self,
method: str,
path: str,
*,
headers: dict[str, str],
json: Any = None,
params: dict[str, Any] | None = None,
) -> httpx.Response:
# Строго по одному запросу с паузой — параллельные задачи worker'а
# иначе пачкой упираются в лимит Новой Почты.
async with self._send_lock:
delay = self._last_sent_at + self._min_interval - time.monotonic()
if delay > 0:
await asyncio.sleep(delay)
self._last_sent_at = time.monotonic()
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 error := _transient_error(response):
raise error
return response
async def _token(self, creds: CheckboxCredentials, *, stale: str | None = None) -> str:
"""Токен кассира; `stale` — отвергнутый Checkbox'ом (401), его не переиспользуем.
Под блокировкой: параллельные задачи без токена входят один раз,
остальные берут токен, полученный первой.
"""
async with self._sign_in_lock:
token = self._tokens.get(creds.cash_register_id)
if token is not None and token != stale:
return token
return await self._sign_in(creds)
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 = await self._token(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._token(creds, stale=token)
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)
+4
View File
@@ -0,0 +1,4 @@
"""Интеграция с CRM (exoCRM).
См. `client.py` за Protocol и `exo_client.py`/`stub_client.py` за реализациями.
"""
+38
View File
@@ -0,0 +1,38 @@
"""Контрольная сумма запросов к CRM API (см. документацию "My CRM API 1.1")."""
from __future__ import annotations
import hashlib
from typing import Any
def _collect_values_sorted(obj: Any) -> list[Any]:
"""Рекурсивно сортирует ключи на каждом уровне и собирает значения depth-first."""
values: list[Any] = []
if isinstance(obj, dict):
for key in sorted(obj.keys()):
values.extend(_collect_values_sorted(obj[key]))
elif isinstance(obj, list):
for item in obj:
values.extend(_collect_values_sorted(item))
else:
values.append(obj)
return values
def _to_str(value: Any) -> str:
# Булевы значения CRM ожидает в контрольной сумме как "1"/"" (PHP-style
# truthy-приведение) — это нигде не задокументировано и подобрано опытным
# путём: "True"/"False" и "true"/"false" оба дают "Checksum Error".
if isinstance(value, bool):
return "1" if value else ""
return str(value)
def compute_md5sum(payload: dict[str, Any], secret_key: str) -> str:
"""Считает md5sum по алгоритму из документации CRM: отсортировать все ключи
(включая вложенные), конкатенировать все значения, добавить приватный ключ, взять MD5.
"""
values = _collect_values_sorted(payload)
concatenated = "".join(_to_str(v) for v in values)
return hashlib.md5((concatenated + secret_key).encode("utf-8")).hexdigest()
+34
View File
@@ -0,0 +1,34 @@
"""Protocol клиента CRM — позволяет подменять реализацию в тестах (`StubCrmClient`)."""
from __future__ import annotations
from functools import lru_cache
from typing import Protocol
from app.core.config import settings
from app.schemas.orders import OrderOut
class CrmError(Exception):
"""CRM ответила `status: ERROR` (см. поле `errors` в ответе API)."""
class CrmClient(Protocol):
async def get_orders(self, *, status: str) -> list[OrderOut]: ...
async def set_status(self, *, order_id: str, status: str) -> None: ...
@lru_cache
def get_crm_client() -> CrmClient:
"""Один экземпляр на процесс: стаб копит выставленные статусы в памяти."""
if settings.crm_use_stub:
if settings.is_production:
raise RuntimeError("CRM_USE_STUB=true заборонено в production")
from app.services.crm.stub_client import StubCrmClient
return StubCrmClient()
from app.services.crm.exo_client import ExoCrmClient
return ExoCrmClient(settings)
+78
View File
@@ -0,0 +1,78 @@
"""Реальный клиент exoCRM (`GetOrders`, `SetStatus`)."""
from __future__ import annotations
from typing import Any
import httpx
from app.core.config import Settings
from app.schemas.orders import OrderOut
from app.services.crm.checksum import compute_md5sum
from app.services.crm.client import CrmError
def _errors_text(data: dict[str, Any]) -> str:
# Ключ бывает и `errors` (dict код → текст), и `Errors` (список строк).
errors = data.get("errors") or data.get("Errors") or {}
if isinstance(errors, dict):
return "; ".join(f"{code}: {text}" for code, text in errors.items())
return "; ".join(str(error) for error in errors)
class ExoCrmClient:
def __init__(self, settings: Settings) -> None:
self._base_url = settings.crm_base_url
self._api_key = settings.crm_api_key
self._secret_key = settings.crm_secret_key
self._shop_key = settings.crm_shop_key
self._sid = settings.crm_sid
async def _post(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
body = {"apikey": self._api_key, "object": "Orders", "method": method, "params": params}
body["md5sum"] = compute_md5sum(body, self._secret_key)
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(self._base_url, json=body)
response.raise_for_status()
try:
data = response.json()
except ValueError as exc:
# PHP-notice'ы CRM перед JSON — признак неверных параметров.
raise CrmError(f"CRM повернула не JSON: {response.text[:300]}") from exc
if not isinstance(data, dict):
raise CrmError(f"Неочікувана відповідь CRM: {str(data)[:300]}")
return data
async def get_orders(self, *, status: str) -> list[OrderOut]:
data = await self._post(
"GetOrders",
{
"sid": self._sid,
"key": self._shop_key,
"Status": status,
"ReturnGoods": True,
"ReturnTotals": True,
},
)
if data.get("status") != "OK":
raise CrmError(f"CRM повернула помилку: {_errors_text(data) or 'невідома помилка'}")
return [OrderOut.model_validate(order) for order in data.get("result") or []]
async def set_status(self, *, order_id: str, status: str) -> None:
"""`SetStatus`. Формат проверен на боевой CRM и расходится с документацией.
Документация показывает `params: {"ID": ..., "Status": ...}`, но CRM на это
отвечает «Undefined order list.». Рабочий вариант — список ID в `Orders`,
а ответ — результат по каждому заказу без общего `status: OK`:
{"123901": {"Status": "Success", "ChangeStatus": "Success"}}
"""
data = await self._post("SetStatus", {"Orders": [order_id], "Status": status})
result = data.get(order_id)
if isinstance(result, dict) and result.get("Status") == "Success":
return
details = _errors_text(data) or (
str(result) if result is not None else "немає відповіді щодо замовлення"
)
raise CrmError(f"CRM не змінила статус замовлення {order_id} на {status}: {details}")
+54
View File
@@ -0,0 +1,54 @@
"""Фикстурный CRM-клиент: тесты и локальный запуск (`CRM_USE_STUB=true`) — не ходит в сеть."""
from __future__ import annotations
from app.schemas.orders import OrderOut
_FIXTURE_ORDERS: list[dict] = [
{
"ID": "100001",
"CreateDateTime": "2026-09-20 10:00:00",
"RecipientDName": "Тестовий Покупець",
"RecipientPhone": "+380501112233",
"RecipientEmail": None,
"Waybill_Number": "20450123456789",
"Notes": "Тестове замовлення",
"Total": {
"Cost": "0.00",
"Quantity": "2",
"Weight": "0",
"DiscountAmount": "0.00",
"DiscountPercent": "0.00",
"Amount": "1200.00",
},
"Goods": [
{
"ID": "1",
"SKU": "SKU-1",
"Name": "Товар 1",
"Price": "600.00",
"Quantity": "2.000",
"DiscountAmount": "0.00",
"DiscountPercent": "0.00",
"Amount": "1200.00",
}
],
}
]
class StubCrmClient:
def __init__(self, orders: list[dict] | None = None) -> None:
self._orders = orders if orders is not None else _FIXTURE_ORDERS
# order_id → статус, выставленный через set_status (для проверок в тестах).
self.statuses: dict[str, str] = {}
async def set_status(self, *, order_id: str, status: str) -> None:
self.statuses[order_id] = status
async def get_orders(self, *, status: str) -> list[OrderOut]:
return [
OrderOut.model_validate(order)
for order in self._orders
if order.get("Status", status) == status
]
@@ -0,0 +1,4 @@
"""Интеграция с Nova Poshta (статусы ТТН, сумма наложенного платежа).
См. `client.py` за Protocol и `np_client.py`/`stub_client.py` за реализациями.
"""
@@ -0,0 +1,22 @@
"""Protocol клиента Nova Poshta — позволяет подменять реализацию в тестах.
См. `StubNovaPoshtaClient`.
"""
from __future__ import annotations
from typing import Protocol
from app.schemas.tracking import TrackingStatusOut
class NovaPoshtaError(Exception):
"""NP API ответил `success: false` (см. поле `errors` в ответе)."""
class NovaPoshtaClient(Protocol):
"""Ключ API передаётся в каждый вызов: у каждой кассы свой кабинет отправителя."""
async def get_statuses(
self, *, api_key: str, waybill_numbers: list[str]
) -> list[TrackingStatusOut]: ...
@@ -0,0 +1,47 @@
"""Реальный клиент Nova Poshta (`TrackingDocument.getStatusDocuments`)."""
from __future__ import annotations
import httpx
from app.schemas.tracking import TrackingStatusOut
from app.services.nova_poshta.client import NovaPoshtaError
_API_URL = "https://api.novaposhta.ua/v2.0/json/"
# NP отклоняет запросы с более чем 100 накладными за раз.
_MAX_DOCUMENTS_PER_REQUEST = 100
class NpTrackingClient:
async def get_statuses(
self, *, api_key: str, waybill_numbers: list[str]
) -> list[TrackingStatusOut]:
if not waybill_numbers:
return []
if len(waybill_numbers) > _MAX_DOCUMENTS_PER_REQUEST:
raise NovaPoshtaError(
f"Забагато ТТН за один запит: {len(waybill_numbers)} "
f"(максимум {_MAX_DOCUMENTS_PER_REQUEST})"
)
body = {
"apiKey": api_key,
"modelName": "TrackingDocument",
"calledMethod": "getStatusDocuments",
"methodProperties": {
"Documents": [{"DocumentNumber": number, "Phone": ""} for number in waybill_numbers]
},
}
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(_API_URL, json=body)
response.raise_for_status()
data = response.json()
if not data.get("success"):
errors = data.get("errors") or []
message = "; ".join(str(error) for error in errors)
raise NovaPoshtaError(f"NP повернув помилку: {message or 'невідома помилка'}")
return [TrackingStatusOut.model_validate(item) for item in data.get("data", [])]
@@ -0,0 +1,46 @@
"""Фикстурный клиент Nova Poshta для тестов — не ходит в сеть."""
from __future__ import annotations
from app.schemas.tracking import TrackingStatusOut
_FIXTURE_STATUSES: dict[str, dict] = {
"20450123456789": {
"Number": "20450123456789",
"Status": "Відправлення отримано",
"StatusCode": "9",
"PaymentMethod": "Cash",
"ScheduledDeliveryDate": "22-09-2026 18:00:00",
"ActualDeliveryDate": "22-09-2026 15:30:00",
"AmountToPay": "1200.00",
"AfterpaymentOnGoodsCost": 1200,
"PaymentStatus": "Paid",
"PhoneSender": "380501112233",
}
}
class StubNovaPoshtaClient:
"""`statuses` — ответы NP по ТТН; `by_key` — отдельные ответы для конкретных
ключей API (кабинет отправителя видит свои ТТН полностью, чужие — урезанно)."""
def __init__(
self,
statuses: dict[str, dict] | None = None,
*,
by_key: dict[str, dict[str, dict]] | None = None,
) -> None:
self._statuses = statuses if statuses is not None else _FIXTURE_STATUSES
self._by_key = by_key or {}
self.calls: list[tuple[str, list[str]]] = []
async def get_statuses(
self, *, api_key: str, waybill_numbers: list[str]
) -> list[TrackingStatusOut]:
self.calls.append((api_key, list(waybill_numbers)))
statuses = self._by_key.get(api_key, self._statuses)
return [
TrackingStatusOut.model_validate(statuses[number])
for number in waybill_numbers
if number in statuses
]
+395
View File
@@ -0,0 +1,395 @@
"""Синхронизация локальной очереди заказов с CRM и работа с ней."""
from __future__ import annotations
import enum
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from decimal import ROUND_HALF_UP, Decimal
from typing import Any
import httpx
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core import crypto
from app.core.logging import get_logger
from app.db.models.cash_register import CashRegister
from app.db.models.order import Order
from app.schemas.orders import OrderUpdateIn
from app.schemas.tracking import TrackingStatusOut
from app.services.crm.client import CrmClient
from app.services.nova_poshta.client import NovaPoshtaClient, NovaPoshtaError
log = get_logger(__name__)
_CRM_STATUS = "APPROVED"
# NP отклоняет запросы с более чем 100 накладными за раз (см. np_client.py).
_NP_BATCH_SIZE = 100
# Коды NP "відмова одержувача" / "припинено зберігання" — клиент не забрал
# посылку, заказ уходит во вкладку «Отказы».
NP_REFUSAL_STATUS_CODES = ("102", "103", "105", "108")
# Коды NP "відправлення отримано" (106 — отримано і створено ЄН зворотньої
# доставки) — заказ уходит во вкладку «Полученные».
NP_RECEIVED_STATUS_CODES = ("9", "10", "11", "106")
# Получено или отказ — после этого статус ТТН больше не опрашивается.
_NP_FINAL_STATUS_CODES = (*NP_RECEIVED_STATUS_CODES, *NP_REFUSAL_STATUS_CODES)
# "Видалено" / "Номер не знайдено" — денег по такой ТТН не будет.
_NP_DEAD_STATUS_CODES = ("2", "3")
# Поля, которые кассир может править в карточке заказа (кроме goods/total).
_EDITABLE_FIELDS = (
"recipient_name",
"recipient_phone",
"recipient_email",
"waybill_number",
"notes",
)
class OrderTab(enum.StrEnum):
NO_RECEIPT = "no_receipt"
HAS_RECEIPT = "has_receipt"
REFUSED = "refused"
RECEIVED = "received"
class OrderEditError(Exception):
"""Заказ нельзя сохранить — сообщение показывается кассиру."""
def _to_kopecks(amount: str) -> int:
return int((Decimal(amount) * 100).to_integral_value())
def _parse_crm_datetime(value: str) -> datetime:
return datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
async def sync_orders_from_crm(session: AsyncSession, crm: CrmClient) -> None:
"""Подтягивает заказы CRM (статус APPROVED) в локальную таблицу.
Уже скрытые (`is_deleted`) заказы не восстанавливаются и не перезаписываются
— иначе кнопка «Удалить» переставала бы работать при следующем открытии
дашборда, т.к. CRM продолжает возвращать эти заказы как есть. По той же
причине не трогаются заказы, отредактированные вручную (`edited_at`).
"""
crm_orders = await crm.get_orders(status=_CRM_STATUS)
if not crm_orders:
return
existing = await session.scalars(
select(Order).where(Order.id.in_(order.id for order in crm_orders))
)
existing_by_id = {order.id: order for order in existing}
for crm_order in crm_orders:
local = existing_by_id.get(crm_order.id)
if local is not None:
if local.is_deleted or local.edited_at is not None:
continue
else:
local = Order(id=crm_order.id)
session.add(local)
local.create_date_time = _parse_crm_datetime(crm_order.create_date_time)
local.recipient_name = crm_order.recipient_name
local.recipient_phone = crm_order.recipient_phone
local.recipient_email = crm_order.recipient_email
local.waybill_number = crm_order.waybill_number
local.notes = crm_order.notes
local.total_amount_kopecks = _to_kopecks(crm_order.total.amount)
local.goods = [good.model_dump(by_alias=False) for good in crm_order.goods]
await session.commit()
async def list_orders(session: AsyncSession, *, tab: OrderTab) -> list[Order]:
"""Заказы вкладки. Статус посылки важнее наличия чека: полученный заказ
показывается только в «Полученных», отказ — только в «Отказах», даже если по
нему уже создан ЕТТН-чек."""
refused = Order.np_status_code.in_(NP_REFUSAL_STATUS_CODES)
received = Order.np_status_code.in_(NP_RECEIVED_STATUS_CODES)
in_transit = Order.np_status_code.is_(None) | Order.np_status_code.not_in(
_NP_FINAL_STATUS_CODES
)
tab_filter = {
OrderTab.REFUSED: refused,
OrderTab.RECEIVED: received,
OrderTab.NO_RECEIPT: Order.receipt_created_at.is_(None) & in_transit,
OrderTab.HAS_RECEIPT: Order.receipt_created_at.is_not(None) & in_transit,
}[tab]
result = await session.scalars(
select(Order)
.where(Order.is_deleted.is_(False))
.where(tab_filter)
.order_by(Order.create_date_time.desc())
)
return list(result)
async def cod_in_transit(session: AsyncSession) -> tuple[int, int]:
"""Наложка в пути: (кол-во посылок, сумма в копейках).
Учитываются только посылки, которые ещё не забрали и по которым нет отказа:
финальный статус NP ("отримано"/отказ) или оплата наложки их исключают.
"""
result = await session.execute(
select(func.count(), func.coalesce(func.sum(Order.np_cod_amount_kopecks), 0))
.where(Order.is_deleted.is_(False))
.where(Order.np_cod_amount_kopecks.is_not(None))
.where(Order.np_status_code.not_in((*_NP_FINAL_STATUS_CODES, *_NP_DEAD_STATUS_CODES)))
.where(Order.np_payment_status.is_distinct_from("Payed"))
)
count, total = result.one()
return count, total
@dataclass(frozen=True)
class _NpAccount:
"""Кабинет отправителя НП, привязанный к кассе."""
register_id: uuid.UUID
api_key: str
async def _np_accounts(session: AsyncSession) -> list[_NpAccount]:
"""Активные кассы с ключом НП; касса по умолчанию — первой."""
registers = await session.scalars(
select(CashRegister)
.where(CashRegister.is_active.is_(True))
.where(CashRegister.np_api_key_enc.is_not(None))
.order_by(CashRegister.is_default.desc(), CashRegister.created_at)
)
accounts = []
for register in registers:
try:
accounts.append(_NpAccount(register.id, crypto.decrypt(register.np_api_key_enc)))
except crypto.DecryptionError:
log.warning("np_api_key_undecryptable", cash_register_id=str(register.id))
return accounts
async def _fetch_statuses(
np: NovaPoshtaClient, api_key: str, waybill_numbers: list[str]
) -> dict[str, TrackingStatusOut]:
statuses: dict[str, TrackingStatusOut] = {}
for i in range(0, len(waybill_numbers), _NP_BATCH_SIZE):
batch = waybill_numbers[i : i + _NP_BATCH_SIZE]
for tracking_status in await np.get_statuses(api_key=api_key, waybill_numbers=batch):
statuses[tracking_status.number] = tracking_status
return statuses
def _apply_status(order: Order, tracking_status: TrackingStatusOut) -> None:
order.np_status = tracking_status.status
order.np_status_code = tracking_status.status_code
order.np_cod_amount_kopecks = (
_to_kopecks(tracking_status.cod_amount) if tracking_status.cod_amount else None
)
order.np_payment_status = tracking_status.payment_status
async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
"""Обновляет статус ТТН, сумму и статус оплаты наложки по заказам в пути
и привязывает каждый заказ к кассе, чей ключ НП видит ТТН как свою.
Заказ, уже привязанный к кассе, опрашивается её ключом. Непривязанный (или
переставший быть «своим» для своей кассы) — ключами всех касс по очереди:
первая, для которой ТТН своя (`TrackingStatusOut.is_own`), его забирает.
Если ТТН не своя ни для одной кассы, статус доставки всё равно сохраняется,
но без наложки и без кассы — чек по такой посылке не создать.
Опрашиваются и заказы с уже созданным ЕТТН-чеком: именно после создания
чека посылка доезжает и оплачивается, и этот переход кассир должен видеть.
Опрос прекращается, когда NP вернул финальный статус "отримано" или отказ.
Вызывается ARQ worker'ом раз в минуту (см. `app/worker.py`), а не из
HTTP-запроса: опрос статусов не должен зависеть от того, открыт ли сейчас
дашборд.
"""
accounts = await _np_accounts(session)
if not accounts:
log.warning("np_no_api_keys", hint="Вкажіть ключ API Нової Пошти в касі")
return
orders = await session.scalars(
select(Order)
.where(Order.is_deleted.is_(False))
.where(Order.waybill_number.is_not(None))
.where(
Order.np_status_code.is_(None)
| Order.np_status_code.not_in(_NP_FINAL_STATUS_CODES)
)
)
orders_by_waybill: dict[str, Order] = {order.waybill_number: order for order in orders}
if not orders_by_waybill:
return
account_ids = {account.register_id for account in accounts}
unresolved = {
waybill: order
for waybill, order in orders_by_waybill.items()
if order.cash_register_id not in account_ids
}
# Урезанный ответ по чужому ключу — на случай, если ТТН не своя ни для кого.
foreign: dict[str, TrackingStatusOut] = {}
complete = True
for account in accounts:
bound = {
waybill: order
for waybill, order in orders_by_waybill.items()
if order.cash_register_id == account.register_id
}
if not bound:
continue
try:
statuses = await _fetch_statuses(np, account.api_key, list(bound))
except (NovaPoshtaError, httpx.HTTPError) as exc:
# Заказы кассы остаются как есть до следующего опроса.
log.warning("np_poll_failed", cash_register_id=str(account.register_id), error=str(exc))
continue
for waybill, order in bound.items():
tracking_status = statuses.get(waybill)
if tracking_status is not None and tracking_status.is_own:
_apply_status(order, tracking_status)
else:
unresolved[waybill] = order
if tracking_status is not None:
foreign[waybill] = tracking_status
for account in accounts:
if not unresolved:
break
try:
statuses = await _fetch_statuses(np, account.api_key, list(unresolved))
except (NovaPoshtaError, httpx.HTTPError) as exc:
log.warning("np_poll_failed", cash_register_id=str(account.register_id), error=str(exc))
complete = False
continue
for waybill, tracking_status in statuses.items():
order = unresolved.get(waybill)
if order is None:
continue
if tracking_status.is_own:
order.cash_register_id = account.register_id
_apply_status(order, tracking_status)
del unresolved[waybill]
else:
foreign.setdefault(waybill, tracking_status)
# Если какой-то ключ не ответил, не отвязываем: ТТН могла быть его.
if complete:
for waybill, order in unresolved.items():
order.cash_register_id = None
if waybill in foreign:
_apply_status(order, foreign[waybill])
await session.commit()
async def delete_order(session: AsyncSession, order_id: str) -> Order | None:
order = await session.get(Order, order_id)
if order is None or order.is_deleted:
return None
order.is_deleted = True
order.deleted_at = datetime.now(UTC)
return order
def _good_key(good: dict[str, Any]) -> tuple[Any, ...]:
"""Позиция без учёта формата строк: CRM пишет "1", форма — "1.000"."""
return (
good["id"],
good.get("sku") or "",
good["name"],
_to_kopecks(good["price"]),
Decimal(good["quantity"]),
_to_kopecks(good["amount"]) if good.get("amount") else None,
)
def _build_goods(data: OrderUpdateIn) -> tuple[list[dict[str, Any]], int]:
"""Позиции формы → снимок `orders.goods` в формате CRM и их сумма в копейках."""
goods: list[dict[str, Any]] = []
total = 0
for good in data.goods:
price = int(good.price * 100)
discount = int(good.discount_amount * 100)
gross = int((price * good.quantity).to_integral_value(ROUND_HALF_UP))
net = gross - discount
if net < 0:
raise OrderEditError(f"Товар «{good.name}»: знижка більша за суму рядка")
goods.append(
{
"id": good.id or f"new-{uuid.uuid4().hex[:8]}",
"sku": good.sku,
"name": good.name,
"price": f"{good.price:.2f}",
"quantity": f"{good.quantity:.3f}",
"discount_amount": f"{good.discount_amount:.2f}",
"discount_percent": None,
"amount": f"{net / 100:.2f}",
}
)
total += net
return goods, total
async def update_order(
session: AsyncSession, order_id: str, data: OrderUpdateIn
) -> tuple[Order, list[str]] | None:
"""Сохраняет ручные правки заказа. Возвращает заказ и список изменённых полей.
Править можно только заказ без чека: у заказа с чеком (в т.ч. `pending`)
данные уже ушли или уходят в Checkbox. Строка блокируется, чтобы
параллельный запрос на создание чека не прочитал заказ посреди правки.
"""
order = await session.scalar(select(Order).where(Order.id == order_id).with_for_update())
if order is None or order.is_deleted:
return None
if order.receipt_created_at is not None:
raise OrderEditError("За замовленням уже створено чек — редагування недоступне")
goods, goods_total = _build_goods(data)
total = _to_kopecks(str(data.total_amount))
if total > goods_total:
raise OrderEditError(
f"Сума замовлення {total / 100:.2f} ₴ більша за суму товарів {goods_total / 100:.2f} ₴"
)
changed = [
field for field in _EDITABLE_FIELDS if getattr(order, field) != getattr(data, field)
]
goods_changed = [_good_key(good) for good in order.goods] != [_good_key(good) for good in goods]
if goods_changed:
changed.append("goods")
if order.total_amount_kopecks != total:
changed.append("total_amount")
if not changed:
return order, changed
if "waybill_number" in changed:
# Статус и касса старой ТТН к новой не относятся — worker опросит новую за минуту.
order.cash_register_id = None
order.np_status = None
order.np_status_code = None
order.np_cod_amount_kopecks = None
order.np_payment_status = None
for field in _EDITABLE_FIELDS:
setattr(order, field, getattr(data, field))
if goods_changed:
# Иначе оставляем снимок CRM как есть — форматы строк у CRM свои ("1" vs "1.000").
order.goods = goods
order.total_amount_kopecks = total
order.edited_at = datetime.now(UTC)
return order, changed
+609
View File
@@ -0,0 +1,609 @@
"""ЕТТН-чеки 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
import httpx
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,
CheckboxRateLimitedError,
CheckboxUnavailableError,
)
from app.services.crm.client import CrmClient, CrmError
log = get_logger(__name__)
# Коды статусов НП, при которых ЕТТН-чек создавать поздно или бессмысленно:
# посылка уже получена, возвращается/возвращена или ТТН удалена.
NP_FINAL_STATUS_CODES = frozenset(
{"2", "9", "10", "11", "102", "103", "105", "106", "108"}
)
# Способ оплаты в ЕТТН-чеке (`ETTNPaymentSchema`).
ETTN_PAYMENT_TYPE = "ETTN"
ETTN_PAYMENT_LABEL = "Експрес-накладна"
# Статус Checkbox → наш статус. CREATED не меняет ничего.
_CHECKBOX_TO_STATUS = {
EttnStatus.DONE: ReceiptStatus.DONE,
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)
# Сколько `created`-чеков опрашивать за один прогон cron'а (раз в минуту):
# при паузе ~1 с между запросами к Checkbox прогон укладывается в минуту и
# не наслаивается на следующий.
_ETTN_POLL_BATCH = 30
# Статус заказа в CRM, когда Checkbox принял ЕТТН-чек: заказ можно собирать.
CRM_STATUS_AFTER_RECEIPT = "PACKED"
# Чеки, при которых заказу нужен этот статус в CRM (Checkbox чек принял).
_CRM_STATUS_RECEIPT_STATUSES = (
ReceiptStatus.CREATED,
ReceiptStatus.DONE,
ReceiptStatus.RECEIPT_ERROR,
ReceiptStatus.RETURNED,
)
class ReceiptValidationError(Exception):
"""Заказ нельзя отправить в Checkbox — сообщение показывается кассиру."""
# --- Деньги и количества -----------------------------------------------------
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:
"""Телефон для отправки чека в формате `380XXXXXXXXX`, как в чеках из портала Checkbox."""
if not phone:
return None
digits = re.sub(r"\D", "", phone)
if len(digits) == 10 and digits.startswith("0"):
digits = "38" + digits
return 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)
# Скидка на весь заказ в CRM (не распределённая по строкам).
order_discount = goods_total - amounts.total_kopecks
if order_discount < 0:
raise ReceiptValidationError(
"Сума товарів менша за суму замовлення — перевірте замовлення в CRM"
)
# Предоплата и скидка заказа — одной обычной скидкой «Знижка», как в чеках из
# портала Checkbox. Тип `PRE_PAYMENT` для ЕТТН не годится: Checkbox отвечает
# 400 `third_party.generic` (проверено на боевой кассе).
discount_total = order_discount + amounts.prepayment_kopecks
discounts: list[dict[str, Any]] = []
if discount_total:
discounts.append(
{"type": "DISCOUNT", "mode": "VALUE", "value": discount_total, "name": "Знижка"}
)
# Последняя страховка перед отправкой: сумма чека (товары − скидки − предоплата)
# обязана совпасть с оплатой по ЕТТН, иначе Checkbox не сможет фискализировать.
receipt_total = goods_total - order_discount - amounts.prepayment_kopecks
if receipt_total != amounts.cod_kopecks:
raise ReceiptValidationError(
f"Сума чека {receipt_total / 100:.2f} ₴ ≠ післяплата {amounts.cod_kopecks / 100:.2f} ₴"
)
receipt_body: dict[str, Any] = {
"goods": goods,
# Оплата «Експрес-накладна»: деньги примет НП при выдаче посылки,
# тогда Checkbox и фискализирует чек. Тип и подпись — явно, не полагаясь
# на значения по умолчанию Checkbox.
"payments": [
{
"type": ETTN_PAYMENT_TYPE,
"label": ETTN_PAYMENT_LABEL,
"value": amounts.cod_kopecks,
"ettn": order.waybill_number,
}
],
}
if discounts:
receipt_body["discounts"] = discounts
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 _active_registers(session: AsyncSession) -> dict[uuid.UUID, CashRegister]:
registers = await session.scalars(
select(CashRegister).where(CashRegister.is_active.is_(True))
)
return {register.id: register for register in registers}
# --- Создание ----------------------------------------------------------------
@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`.
Ошибки по отдельным заказам не мешают остальным — массовое действие.
Чек создаётся от кассы, чей ключ НП видит ТТН заказа как свою
(`orders.cash_register_id`, проставляет `orders.sync_np_statuses`).
"""
result = RequestResult(created=[], errors={})
registers = await _active_registers(session)
if not registers:
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
register = registers.get(order.cash_register_id) if order.cash_register_id else None
if register is None:
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,
"cash_register_id": str(register.id),
"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. Идемпотентна — безопасно вызывать повторно.
На лимит частоты пробрасывает `CheckboxRateLimitedError`, чек остаётся `pending`.
"""
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 CheckboxRateLimitedError as exc:
# Запрос отклонён, чек точно не создан: остаётся `pending` без пометки
# «исход неизвестен». Повтор с задержкой — забота вызывающего (worker).
await session.commit() # снять блокировку строки
log.info("ettn_rate_limited", receipt_id=str(receipt_id), retry_after=exc.retry_after)
raise
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:
try:
await create_ettn_for_receipt(session, client, receipt_id)
except CheckboxRateLimitedError:
break # остальные — в следующем проходе cron'а
# --- Отмена и опрос ----------------------------------------------------------
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`).
За прогон — не больше `_ETTN_POLL_BATCH` давно не проверенных чеков, с
коммитом после каждого: запросы к Checkbox идут с паузой, и опрос всех
чеков разом не укладывался в таймаут cron-задачи, а откат по таймауту
терял весь прогон — статусы не обновлялись вовсе.
"""
receipts = list(
await session.scalars(
select(Receipt)
.where(Receipt.status == ReceiptStatus.CREATED)
.order_by(Receipt.last_checked_at.asc().nulls_first())
.limit(_ETTN_POLL_BATCH)
)
)
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 CheckboxUnavailableError as exc:
# Недоступен или лимит частоты запросов — остальные чеки опросим
# в следующий раз, а не будем добивать Checkbox прямо сейчас.
log.warning("ettn_poll_paused", receipt_id=str(receipt.id), error=str(exc))
break
except CheckboxError as exc:
log.warning("ettn_poll_failed", receipt_id=str(receipt.id), error=str(exc))
continue
_apply_ettn(receipt, ettn)
if receipt.status in CLOSED_STATUSES:
await _release_order(session, receipt.order_id)
await session.commit()
async def sync_crm_statuses(session: AsyncSession, crm: CrmClient) -> None:
"""Переводит в CRM заказы с принятым Checkbox чеком в статус PACKED.
Отдельно от создания чека и идемпотентно по `crm_status_set_at`: сбой CRM
не должен ни откатывать уже созданный в Checkbox чек, ни теряться —
вызывается сразу после создания и повторяется cron'ом до успеха.
"""
receipt_ids = list(
await session.scalars(
select(Receipt.id)
.where(Receipt.status.in_(_CRM_STATUS_RECEIPT_STATUSES))
.where(Receipt.crm_status_set_at.is_(None))
)
)
for receipt_id in receipt_ids:
# Строка блокируется на время вызова CRM: задача создания и cron могут
# сработать одновременно, а повторный PACKED откатил бы статус, который
# менеджер уже успел сменить дальше. Занято или уже выставлено — пропуск.
receipt = await session.scalar(
select(Receipt)
.where(Receipt.id == receipt_id)
.where(Receipt.crm_status_set_at.is_(None))
.with_for_update(skip_locked=True)
)
if receipt is None:
continue
order_id = receipt.order_id # после rollback атрибуты истекают
try:
await crm.set_status(order_id=order_id, status=CRM_STATUS_AFTER_RECEIPT)
except (CrmError, httpx.HTTPError) as exc:
await session.rollback() # снять блокировку строки
log.warning("crm_status_set_failed", order_id=order_id, error=repr(exc))
continue
receipt.crm_status_set_at = datetime.now(UTC)
await audit.record(
session,
action=AuditAction.ORDER_CRM_STATUS_SET,
actor_label="worker",
entity_type="order",
entity_id=receipt.order_id,
payload={"status": CRM_STATUS_AFTER_RECEIPT, "receipt_id": str(receipt.id)},
)
# Коммит на каждый заказ: статус в CRM уже сменён, отметку нельзя терять
# из-за сбоя на следующем заказе.
await session.commit()
log.info("crm_status_set", order_id=receipt.order_id, status=CRM_STATUS_AFTER_RECEIPT)
async def latest_receipts_by_order(
session: AsyncSession, order_ids: list[str]
) -> dict[str, Receipt]:
"""Последний чек по каждому заказу — для колонок очереди."""
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}
+53
View File
@@ -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()
+77
View File
@@ -0,0 +1,77 @@
"""ARQ worker: опрос Nova Poshta и ЕТТН-чеки Checkbox.
Запускается отдельным процессом: `arq app.worker.WorkerSettings`.
- `create_ettn_receipt` — задача, которую ставит API после запроса кассира;
- `poll_np_statuses` — раз в минуту статусы ТТН по ключам НП касс и привязка заказов к кассам;
- `poll_receipts` — раз в минуту повтор зависших `pending`, статусы `created`-чеков
и повтор смены статуса заказа в CRM (PACKED), если CRM была недоступна.
"""
from __future__ import annotations
import uuid
from typing import Any
from arq import Retry, cron, func
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 CheckboxRateLimitedError, get_checkbox_client
from app.services.crm.client import get_crm_client
from app.services.nova_poshta.np_client import NpTrackingClient
from app.services.orders import sync_np_statuses
log = get_logger(__name__)
# Повторы `create_ettn_receipt` при лимите частоты: пауза retry_after + 5 с × номер попытки.
_RATE_LIMIT_BACKOFF = 5
_CREATE_ETTN_MAX_TRIES = 6
async def startup(ctx: dict[str, Any]) -> None:
configure_logging()
ctx["np_client"] = NpTrackingClient()
ctx["checkbox_client"] = get_checkbox_client()
ctx["crm_client"] = get_crm_client()
log.info("worker_starting", environment=settings.environment)
async def poll_np_statuses(ctx: dict[str, Any]) -> None:
async with SessionFactory() as session:
await sync_np_statuses(session, ctx["np_client"])
log.info("np_statuses_polled")
async def create_ettn_receipt(ctx: dict[str, Any], receipt_id: str) -> None:
async with SessionFactory() as session:
try:
await receipts_service.create_ettn_for_receipt(
session, ctx["checkbox_client"], uuid.UUID(receipt_id)
)
except CheckboxRateLimitedError as exc:
# Лимит частоты Новой Почты: повтор с нарастающей паузой. Когда попытки
# кончатся, `pending`-чек подберёт cron `poll_receipts`.
raise Retry(defer=exc.retry_after + _RATE_LIMIT_BACKOFF * ctx["job_try"]) from exc
# Чек принят Checkbox — сразу переводим заказ в CRM в PACKED.
await receipts_service.sync_crm_statuses(session, ctx["crm_client"])
async def poll_receipts(ctx: dict[str, Any]) -> None:
async with SessionFactory() as session:
await receipts_service.retry_pending_receipts(session, ctx["checkbox_client"])
await receipts_service.sync_ettn_statuses(session, ctx["checkbox_client"])
await receipts_service.sync_crm_statuses(session, ctx["crm_client"])
log.info("receipts_polled")
class WorkerSettings:
redis_settings = RedisSettings.from_dsn(settings.redis_url)
on_startup = startup
functions = [func(create_ettn_receipt, max_tries=_CREATE_ETTN_MAX_TRIES)]
cron_jobs = [
cron(poll_np_statuses, minute=set(range(60)), run_at_startup=True),
cron(poll_receipts, minute=set(range(60)), second=30),
]
+259
View File
@@ -0,0 +1,259 @@
"""Тесты HttpCheckboxClient. Сеть замокана через respx — реальных запросов не делает."""
from __future__ import annotations
import asyncio
import json
import time
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,
CheckboxRateLimitedError,
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(interval_ms: int = 0) -> HttpCheckboxClient:
settings = Settings(
secret_key="test-secret-key",
encryption_key="dGVzdC1lbmNyeXB0aW9uLWtleS0zMi1ieXRlcyEh",
checkbox_base_url=BASE,
checkbox_min_request_interval_ms=interval_ms,
) # 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)
@respx.mock
async def test_lowercase_status_from_live_api_is_normalized() -> None:
_signin()
respx.get(f"{BASE}/api/v1/ettn/e-1").mock(
return_value=Response(200, json={**ETTN, "status": "done"})
)
ettn = await _client().get_ettn(CREDS, "e-1")
assert ettn.status == "DONE"
@respx.mock
async def test_third_party_error_code_is_shown() -> None:
_signin()
respx.post(f"{BASE}/api/v1/ettn").mock(
return_value=Response(
400, json={"code": "third_party.generic", "message": "Internal Server Error"}
)
)
with pytest.raises(CheckboxError, match=r"third_party\.generic"):
await _client().create_ettn(CREDS, {})
@respx.mock
async def test_rate_limit_is_retryable() -> None:
_signin()
respx.post(f"{BASE}/api/v1/ettn").mock(
return_value=Response(429, json={"message": "Занадто часто виконуються запити"})
)
with pytest.raises(CheckboxRateLimitedError):
await _client().create_ettn(CREDS, {})
def _np_error(message: str, info: list[str], codes: list[str]) -> Response:
"""Ошибка Новой Почты в том виде, в каком её отдаёт боевой Checkbox."""
inner = {
"type": "ettn",
"message": message,
"code": 0,
"context": {"success": False, "errors": [message], "info": info, "errorCodes": codes},
}
return Response(400, json={"code": "third_party.generic", "message": json.dumps(inner)})
@respx.mock
async def test_np_too_many_requests_is_rate_limited() -> None:
_signin()
respx.post(f"{BASE}/api/v1/ettn").mock(
return_value=_np_error("To many requests", ["Try again after 3 seconds"], ["20000401501"])
)
with pytest.raises(CheckboxRateLimitedError) as exc_info:
await _client().create_ettn(CREDS, {})
assert exc_info.value.retry_after == 3
@respx.mock
async def test_np_zero_retry_after_waits_at_least_a_second() -> None:
_signin()
respx.post(f"{BASE}/api/v1/ettn").mock(
return_value=_np_error("To many requests", ["Try again after 0 seconds"], ["20000401501"])
)
with pytest.raises(CheckboxRateLimitedError) as exc_info:
await _client().create_ettn(CREDS, {})
assert exc_info.value.retry_after == 1
@respx.mock
async def test_np_timeout_is_unavailable_not_rate_limited() -> None:
_signin()
respx.post(f"{BASE}/api/v1/ettn").mock(
return_value=_np_error("cURL error 28: SSL connection timeout", [], [])
)
with pytest.raises(CheckboxUnavailableError) as exc_info:
await _client().create_ettn(CREDS, {})
assert not isinstance(exc_info.value, CheckboxRateLimitedError)
@respx.mock
async def test_concurrent_calls_sign_in_once() -> None:
signin = _signin()
respx.get(f"{BASE}/api/v1/ettn/e-1").mock(return_value=Response(200, json=ETTN))
client = _client()
await asyncio.gather(*(client.get_ettn(CREDS, "e-1") for _ in range(5)))
assert signin.call_count == 1
@respx.mock
async def test_requests_are_spaced_by_min_interval() -> None:
_signin()
sent_at: list[float] = []
def record(_: httpx.Request) -> Response:
sent_at.append(time.monotonic())
return Response(200, json=ETTN)
respx.get(f"{BASE}/api/v1/ettn/e-1").mock(side_effect=record)
client = _client(interval_ms=50)
await asyncio.gather(*(client.get_ettn(CREDS, "e-1") for _ in range(3)))
gaps = [b - a for a, b in zip(sent_at, sent_at[1:], strict=False)]
assert len(gaps) == 2 and all(gap >= 0.045 for gap in gaps)
+28
View File
@@ -0,0 +1,28 @@
"""Тесты контрольной суммы CRM-запросов. Без сети."""
from __future__ import annotations
import hashlib
from app.services.crm.checksum import compute_md5sum
class TestComputeMd5sum:
def test_matches_manual_calculation_for_flat_payload(self) -> None:
payload = {"b": "2", "a": "1"}
expected = hashlib.md5(b"12secret").hexdigest()
assert compute_md5sum(payload, "secret") == expected
def test_sorts_nested_keys_before_concatenating(self) -> None:
payload = {"params": {"z": "1", "a": "2"}, "apikey": "k"}
# sorted top-level: apikey, params -> "k" then sorted nested (a,z) -> "2","1"
expected = hashlib.md5(b"k21secret").hexdigest()
assert compute_md5sum(payload, "secret") == expected
def test_true_becomes_one_and_false_becomes_empty_string(self) -> None:
payload = {"a": True, "b": False}
expected = hashlib.md5(b"1secret").hexdigest()
assert compute_md5sum(payload, "secret") == expected
def test_different_payloads_give_different_sums(self) -> None:
assert compute_md5sum({"a": "1"}, "secret") != compute_md5sum({"a": "2"}, "secret")
+148
View File
@@ -0,0 +1,148 @@
"""Тесты ExoCrmClient. Сеть замокана через respx — реальных запросов не делает."""
from __future__ import annotations
import pytest
import respx
from httpx import Response
from app.core.config import Settings
from app.services.crm.client import CrmError
from app.services.crm.exo_client import ExoCrmClient
BASE_URL = "https://crm.example.test/api/1.1/"
def _settings() -> Settings:
return Settings(
secret_key="test-secret-key",
encryption_key="dGVzdC1lbmNyeXB0aW9uLWtleS0zMi1ieXRlcyEh",
crm_base_url=BASE_URL,
crm_api_key="apikey123",
crm_secret_key="secret123",
crm_shop_key="shopkey123",
crm_sid=1,
) # type: ignore[arg-type]
ORDER_PAYLOAD = {
"ID": "1",
"CreateDateTime": "2026-09-20 10:00:00",
"RecipientDName": "Іван Іванов",
"RecipientPhone": "+380501112233",
"RecipientEmail": None,
"Waybill_Number": "",
"Notes": None,
"Total": {
"Cost": "0.00",
"Quantity": 1,
"Weight": 0,
"DiscountAmount": "0.00",
"DiscountPercent": "0.00",
"Amount": "100.00",
},
"Goods": [
{
"ID": "10",
"SKU": "SKU-1",
"Name": "Товар",
"Price": "100.00",
"Quantity": "1.000",
"DiscountAmount": "0.00",
"DiscountPercent": "0.00",
"Amount": "100.00",
}
],
}
class TestExoCrmClientGetOrders:
@respx.mock
async def test_sends_expected_request_body(self) -> None:
route = respx.post(BASE_URL).mock(
return_value=Response(200, json={"status": "OK", "result": []})
)
client = ExoCrmClient(_settings())
await client.get_orders(status="APPROVED")
sent = route.calls.last.request
body = sent.content
import json
parsed = json.loads(body)
assert parsed["apikey"] == "apikey123"
assert parsed["object"] == "Orders"
assert parsed["method"] == "GetOrders"
assert parsed["params"]["sid"] == 1
assert parsed["params"]["key"] == "shopkey123"
assert parsed["params"]["Status"] == "APPROVED"
assert "md5sum" in parsed
@respx.mock
async def test_parses_successful_response_into_order_out(self) -> None:
respx.post(BASE_URL).mock(
return_value=Response(200, json={"status": "OK", "result": [ORDER_PAYLOAD]})
)
client = ExoCrmClient(_settings())
orders = await client.get_orders(status="APPROVED")
assert len(orders) == 1
order = orders[0]
assert order.id == "1"
assert order.recipient_name == "Іван Іванов"
assert order.total.quantity == "1"
assert order.goods[0].sku == "SKU-1"
@respx.mock
async def test_raises_crm_error_on_error_status(self) -> None:
respx.post(BASE_URL).mock(
return_value=Response(
200, json={"status": "ERROR", "errors": {"1005": "Checksum Error"}}
)
)
client = ExoCrmClient(_settings())
with pytest.raises(CrmError, match="Checksum Error"):
await client.get_orders(status="APPROVED")
class TestExoCrmClientSetStatus:
@respx.mock
async def test_sends_order_list_and_parses_per_order_result(self) -> None:
import json
route = respx.post(BASE_URL).mock(
return_value=Response(
200,
json={
"<": "<",
"123901": {"Status": "Success", "ChangeStatus": "Success"},
">": ">",
},
)
)
await ExoCrmClient(_settings()).set_status(order_id="123901", status="PACKED")
body = json.loads(route.calls.last.request.content)
assert body["object"] == "Orders"
assert body["method"] == "SetStatus"
# Не {"ID": ...} из документации — боевая CRM принимает только список в Orders.
assert body["params"] == {"Orders": ["123901"], "Status": "PACKED"}
assert "md5sum" in body
@respx.mock
async def test_raises_on_capitalized_errors(self) -> None:
respx.post(BASE_URL).mock(
return_value=Response(200, json={"<": "<", "Errors": ["Undefined order list."]})
)
with pytest.raises(CrmError, match="Undefined order list"):
await ExoCrmClient(_settings()).set_status(order_id="1", status="PACKED")
@respx.mock
async def test_raises_on_non_json_reply(self) -> None:
respx.post(BASE_URL).mock(return_value=Response(200, text="<b>Notice</b>: ..."))
with pytest.raises(CrmError, match="не JSON"):
await ExoCrmClient(_settings()).set_status(order_id="1", status="PACKED")
+37
View File
@@ -0,0 +1,37 @@
"""Выбор реализации CRM-клиента по `CRM_USE_STUB`."""
from __future__ import annotations
from collections.abc import Iterator
import pytest
from app.core.config import settings
from app.services.crm.client import get_crm_client
from app.services.crm.exo_client import ExoCrmClient
from app.services.crm.stub_client import StubCrmClient
@pytest.fixture(autouse=True)
def _fresh_factory() -> Iterator[None]:
get_crm_client.cache_clear()
yield
get_crm_client.cache_clear()
def test_real_client_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "crm_use_stub", False)
assert isinstance(get_crm_client(), ExoCrmClient)
def test_stub_when_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "crm_use_stub", True)
monkeypatch.setattr(settings, "environment", "local")
assert isinstance(get_crm_client(), StubCrmClient)
def test_stub_forbidden_in_production(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "crm_use_stub", True)
monkeypatch.setattr(settings, "environment", "production")
with pytest.raises(RuntimeError, match="CRM_USE_STUB"):
get_crm_client()
+32 -21
View File
@@ -1,8 +1,8 @@
"""Миграция 0001 обязана точно соответствовать моделям.
"""Миграции обязаны точно соответствовать моделям.
Расхождение между `Base.metadata` и миграцией обнаруживается только на живой
Расхождение между `Base.metadata` и миграциями обнаруживается только на живой
БД и обычно уже в проде. Проверка статическая: сравниваем таблицы, колонки,
nullability и индексы, которые создаёт миграция, с тем, что описано моделями.
nullability и индексы, которые создают миграции, с тем, что описано моделями.
"""
from __future__ import annotations
@@ -14,36 +14,45 @@ import pytest
from app.db.models import Base
MIGRATION = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0001_users_and_audit.py"
SOURCE = MIGRATION.read_text(encoding="utf-8")
VERSIONS_DIR = Path(__file__).resolve().parents[1] / "alembic" / "versions"
MIGRATION_FILES = sorted(VERSIONS_DIR.glob("*.py"))
SOURCES = {path.name: path.read_text(encoding="utf-8") for path in MIGRATION_FILES}
# Склеенный текст всех миграций — таблица может быть создана в любой из них.
COMBINED_SOURCE = "\n".join(SOURCES.values())
# Миграция 0001 создаёт схему с нуля, поэтому в ней обязаны быть все таблицы.
MODEL_TABLES = set(Base.metadata.tables)
def _migration_block(table: str) -> str:
"""Тело вызова op.create_table для указанной таблицы.
"""Тело вызова op.create_table для указанной таблицы плюс её поздние op.add_column.
Границей служит следующий op.create_table; для последней таблицы — конец файла.
Границей create_table служит следующий op.create_table или конец файла миграции.
"""
start = SOURCE.index(f'op.create_table(\n "{table}",')
next_table = SOURCE.find("op.create_table(", start + 1)
end = next_table if next_table != -1 else len(SOURCE)
return SOURCE[start:end]
marker = f'op.create_table(\n "{table}",'
source = next(text for text in SOURCES.values() if marker in text)
start = source.index(marker)
next_table = source.find("op.create_table(", start + 1)
end = next_table if next_table != -1 else len(source)
# Колонки, добавленные позже через op.add_column, — в любой миграции.
added = re.findall(rf'op\.add_column\(\s*"{table}",\s*(sa\.Column\(.*?\n)', COMBINED_SOURCE)
return source[start:end] + "\n" + "\n".join(added)
def test_migration_creates_every_model_table() -> None:
created = set(re.findall(r'op\.create_table\(\n\s+"(\w+)"', SOURCE))
def test_migrations_create_every_model_table() -> None:
created = set(re.findall(r'op\.create_table\(\n\s+"(\w+)"', COMBINED_SOURCE))
assert created == MODEL_TABLES, (
f"Миграция и модели разошлись. Только в моделях: {MODEL_TABLES - created}; "
f"только в миграции: {created - MODEL_TABLES}"
f"Миграции и модели разошлись. Только в моделях: {MODEL_TABLES - created}; "
f"только в миграциях: {created - MODEL_TABLES}"
)
def test_downgrade_drops_every_created_table() -> None:
downgrade = SOURCE[SOURCE.index("def downgrade()") :]
dropped = set(re.findall(r'op\.drop_table\("(\w+)"\)', downgrade))
assert dropped == MODEL_TABLES, f"downgrade не удаляет: {MODEL_TABLES - dropped}"
def test_each_migration_downgrade_drops_its_own_tables() -> None:
"""Каждая миграция откатывает ровно те таблицы, которые сама создаёт."""
for name, source in SOURCES.items():
created = set(re.findall(r'op\.create_table\(\n\s+"(\w+)"', source))
downgrade = source[source.index("def downgrade()") :]
dropped = set(re.findall(r'op\.drop_table\("(\w+)"\)', downgrade))
assert dropped == created, f"{name}: downgrade не удаляет {created - dropped}"
@pytest.mark.parametrize("table_name", sorted(MODEL_TABLES))
@@ -77,7 +86,9 @@ def test_nullability_matches(table_name: str) -> None:
@pytest.mark.parametrize("table_name", sorted(MODEL_TABLES))
def test_indexes_match(table_name: str) -> None:
model_indexes = {index.name for index in Base.metadata.tables[table_name].indexes}
migration_indexes = set(re.findall(r'op\.create_index\(\s*(?:op\.f\()?"(\w+)"', SOURCE))
migration_indexes = set(
re.findall(r'op\.create_index\(\s*(?:op\.f\()?"(\w+)"', COMBINED_SOURCE)
)
missing = model_indexes - migration_indexes
assert not missing, f"Таблица {table_name}: миграция не создаёт индексы {missing}"
+116
View File
@@ -0,0 +1,116 @@
"""Тесты NpTrackingClient. Сеть замокана через respx — реальных запросов не делает."""
from __future__ import annotations
import json
import pytest
import respx
from httpx import Response
from app.schemas.tracking import TrackingStatusOut
from app.services.nova_poshta.client import NovaPoshtaError
from app.services.nova_poshta.np_client import _API_URL, NpTrackingClient
STATUS_PAYLOAD = {
"Number": "20451540916703",
"Status": "Відправник самостійно вказав цю накладну, але ще не надав до відправки",
"StatusCode": "1",
"PaymentMethod": "Cash",
"ScheduledDeliveryDate": "22-09-2026 18:00:00",
"ActualDeliveryDate": "",
"AmountToPay": "",
"ExpressWaybillAmountToPay": "827.48",
"AfterpaymentOnGoodsCost": 699,
"PaymentStatus": "",
"ExpressWaybillPaymentStatus": "NeedPayment",
}
API_KEY = "np-apikey-123"
class TestNpTrackingClientGetStatuses:
@respx.mock
async def test_sends_expected_request_body(self) -> None:
route = respx.post(_API_URL).mock(
return_value=Response(200, json={"success": True, "data": [], "errors": []})
)
client = NpTrackingClient()
await client.get_statuses(api_key=API_KEY, waybill_numbers=["20451540916703"])
sent = route.calls.last.request
body = json.loads(sent.content)
assert body["apiKey"] == API_KEY
assert body["modelName"] == "TrackingDocument"
assert body["calledMethod"] == "getStatusDocuments"
assert body["methodProperties"]["Documents"] == [
{"DocumentNumber": "20451540916703", "Phone": ""}
]
@respx.mock
async def test_parses_successful_response_and_prefers_afterpayment_on_goods_cost(
self,
) -> None:
respx.post(_API_URL).mock(
return_value=Response(
200, json={"success": True, "data": [STATUS_PAYLOAD], "errors": []}
)
)
client = NpTrackingClient()
statuses = await client.get_statuses(api_key=API_KEY, waybill_numbers=["20451540916703"])
assert len(statuses) == 1
status = statuses[0]
assert status.number == "20451540916703"
assert status.status_code == "1"
# "Контроль оплати" — сумма за товар без стоимости доставки и комиссии НП,
# не "сколько заплатить сейчас" (`ExpressWaybillAmountToPay` = 827.48).
assert status.cod_amount == "699"
assert status.payment_status == "NeedPayment"
assert not status.is_own # в STATUS_PAYLOAD нет PhoneSender
def test_is_own_by_sender_phone(self) -> None:
own = TrackingStatusOut.model_validate({**STATUS_PAYLOAD, "PhoneSender": "380961112233"})
foreign = TrackingStatusOut.model_validate({**STATUS_PAYLOAD, "PhoneSender": ""})
assert own.is_own
assert not foreign.is_own
@respx.mock
async def test_falls_back_to_amount_to_pay_when_afterpayment_missing(self) -> None:
payload = {**STATUS_PAYLOAD, "AfterpaymentOnGoodsCost": 0}
respx.post(_API_URL).mock(
return_value=Response(200, json={"success": True, "data": [payload], "errors": []})
)
client = NpTrackingClient()
statuses = await client.get_statuses(api_key=API_KEY, waybill_numbers=["20451540916703"])
assert statuses[0].cod_amount == "827.48"
@respx.mock
async def test_raises_nova_poshta_error_on_failure(self) -> None:
respx.post(_API_URL).mock(
return_value=Response(
200, json={"success": False, "data": [], "errors": ["Invalid apiKey"]}
)
)
client = NpTrackingClient()
with pytest.raises(NovaPoshtaError, match="Invalid apiKey"):
await client.get_statuses(api_key=API_KEY, waybill_numbers=["20451540916703"])
async def test_returns_empty_list_for_no_documents(self) -> None:
client = NpTrackingClient()
assert await client.get_statuses(api_key=API_KEY, waybill_numbers=[]) == []
async def test_rejects_too_many_documents(self) -> None:
client = NpTrackingClient()
with pytest.raises(NovaPoshtaError, match="Забагато"):
await client.get_statuses(
api_key=API_KEY, waybill_numbers=[str(i) for i in range(101)]
)
+120
View File
@@ -0,0 +1,120 @@
"""Опрос статусов НП ключами касс и привязка заказов к кассам (`sync_np_statuses`)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
from app.core import crypto
from app.db.models.cash_register import CashRegister
from app.db.models.order import Order
from app.services.nova_poshta.stub_client import StubNovaPoshtaClient
from app.services.orders import sync_np_statuses
def _register(name: str, np_key: str | None, *, is_default: bool = False) -> CashRegister:
return CashRegister(
id=uuid.uuid4(),
name=name,
license_key_enc=crypto.encrypt("lic"),
cashier_pin_enc=crypto.encrypt("1111"),
np_api_key_enc=crypto.encrypt(np_key) if np_key else None,
tax_codes=[],
is_active=True,
is_default=is_default,
created_at=datetime(2026, 9, 1, tzinfo=UTC),
)
def _order(waybill: str, register_id: uuid.UUID | None = None) -> Order:
return Order(
id=waybill[-4:],
create_date_time=datetime(2026, 9, 20, tzinfo=UTC),
waybill_number=waybill,
total_amount_kopecks=120000,
goods=[],
is_deleted=False,
cash_register_id=register_id,
)
def _status(waybill: str, *, own: bool) -> dict[str, Any]:
status = {"Number": waybill, "Status": "В дорозі", "StatusCode": "5"}
if own:
status |= {"PhoneSender": "380961112233", "AfterpaymentOnGoodsCost": 1200}
return status
class FakeSession:
"""`scalars` отдаёт кассы (с ключом НП, активные) или заказы — по сущности запроса."""
def __init__(self, registers: list[CashRegister], orders: list[Order]) -> None:
self.registers = registers
self.orders = orders
self.commits = 0
async def scalars(self, statement: Any) -> list[Any]:
entity = statement.column_descriptions[0]["entity"]
if entity is CashRegister:
return [r for r in self.registers if r.np_api_key_enc and r.is_active]
return self.orders
async def commit(self) -> None:
self.commits += 1
async def test_unbound_order_is_claimed_by_register_that_sees_it_as_own() -> None:
first, second = _register("A", "key-a", is_default=True), _register("B", "key-b")
order = _order("20450000000001")
np = StubNovaPoshtaClient(
by_key={
"key-a": {order.waybill_number: _status(order.waybill_number, own=False)},
"key-b": {order.waybill_number: _status(order.waybill_number, own=True)},
}
)
session = FakeSession([first, second], [order])
await sync_np_statuses(session, np) # type: ignore[arg-type]
assert order.cash_register_id == second.id
assert order.np_cod_amount_kopecks == 120000
assert session.commits == 1
async def test_bound_order_is_polled_only_with_its_register_key() -> None:
first, second = _register("A", "key-a", is_default=True), _register("B", "key-b")
order = _order("20450000000002", register_id=second.id)
np = StubNovaPoshtaClient(
by_key={"key-b": {order.waybill_number: _status(order.waybill_number, own=True)}}
)
await sync_np_statuses(FakeSession([first, second], [order]), np) # type: ignore[arg-type]
assert [key for key, _ in np.calls] == ["key-b"]
assert order.cash_register_id == second.id
async def test_foreign_waybill_keeps_status_but_no_register_or_cod() -> None:
register = _register("A", "key-a", is_default=True)
order = _order("20450000000003", register_id=register.id)
np = StubNovaPoshtaClient(
by_key={"key-a": {order.waybill_number: _status(order.waybill_number, own=False)}}
)
await sync_np_statuses(FakeSession([register], [order]), np) # type: ignore[arg-type]
assert order.cash_register_id is None
assert order.np_status_code == "5"
assert order.np_cod_amount_kopecks is None
async def test_no_registers_with_np_key_does_nothing() -> None:
order = _order("20450000000004")
np = StubNovaPoshtaClient()
session = FakeSession([_register("A", None)], [order])
await sync_np_statuses(session, np) # type: ignore[arg-type]
assert np.calls == []
assert session.commits == 0
+259
View File
@@ -0,0 +1,259 @@
"""Тесты роутера заказов.
Auth подменена через dependency_overrides, сервисный слой (`app.services.orders`)
— через monkeypatch. `SessionDep` создаёт `AsyncSession`, но соединение с БД
открывается лениво только при первом запросе — раз сервисные функции его не
трогают, реальная БД не нужна.
"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
import pytest
from fastapi.testclient import TestClient
from app.api.deps import get_crm_client, get_current_user
from app.db.models.order import Order
from app.db.models.user import User, UserRole
from app.main import app
from app.services.crm.stub_client import StubCrmClient
pytestmark = pytest.mark.usefixtures("_patch_orders_service")
def _user(role: UserRole) -> User:
return User(
id=uuid.uuid4(),
email="test@example.com",
password_hash="x",
full_name="Тест",
role=role,
is_active=True,
)
def _order(order_id: str = "1") -> Order:
return Order(
id=order_id,
create_date_time=datetime(2026, 9, 20, 10, 0, 0, tzinfo=UTC),
recipient_name="Тест Тестов",
recipient_phone="+380501112233",
recipient_email=None,
waybill_number="20450123456789",
notes=None,
total_amount_kopecks=120000,
goods=[
{
"id": "1",
"sku": "SKU-1",
"name": "Товар 1",
"price": "1200.00",
"quantity": "1.000",
"discount_amount": "0.00",
"discount_percent": "0.00",
"amount": "1200.00",
}
],
is_deleted=False,
deleted_at=None,
receipt_created_at=None,
)
@pytest.fixture
def _patch_orders_service(monkeypatch: pytest.MonkeyPatch) -> None:
import app.api.v1.orders as orders_router
async def fake_sync(session: object, crm: object) -> None:
return None
async def fake_list(session: object, *, tab: str) -> list[Order]:
return [_order()] if tab == "no_receipt" else []
async def fake_delete(session: object, order_id: str) -> Order | None:
return _order(order_id) if order_id == "1" else None
monkeypatch.setattr(orders_router.orders_service, "sync_orders_from_crm", fake_sync)
monkeypatch.setattr(orders_router.orders_service, "list_orders", fake_list)
monkeypatch.setattr(orders_router.orders_service, "delete_order", fake_delete)
async def fake_cod_in_transit(session: object) -> tuple[int, int]:
return 3, 245050
monkeypatch.setattr(orders_router.orders_service, "cod_in_transit", fake_cod_in_transit)
async def fake_update(
session: object, order_id: str, data: object
) -> tuple[Order, list[str]] | None:
if order_id == "locked":
raise orders_router.orders_service.OrderEditError("За замовленням уже створено чек")
if order_id != "1":
return None
order = _order(order_id)
order.edited_at = datetime(2026, 9, 24, tzinfo=UTC)
return order, ["notes"]
monkeypatch.setattr(orders_router.orders_service, "update_order", fake_update)
async def fake_latest(session: object, order_ids: list[str]) -> dict:
return {}
monkeypatch.setattr(
orders_router.receipts_service, "latest_receipts_by_order", fake_latest
)
async def fake_record(session: object, **kwargs: object) -> None:
return None
monkeypatch.setattr(orders_router.audit, "record", fake_record)
# session.commit() вызывается напрямую роутером после audit.record — сама
# сессия не трогает БД до первого execute/commit, поэтому патчим только
# commit, а не всю SessionDep.
from sqlalchemy.ext.asyncio import AsyncSession
async def fake_commit(self: AsyncSession) -> None:
return None
monkeypatch.setattr(AsyncSession, "commit", fake_commit)
@pytest.fixture
def client() -> TestClient:
app.dependency_overrides[get_crm_client] = lambda: StubCrmClient()
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_crm_client, None)
app.dependency_overrides.pop(get_current_user, None)
class TestListOrders:
@pytest.mark.parametrize("role", [UserRole.ADMIN, UserRole.CASHIER, UserRole.VIEWER])
def test_any_authenticated_role_can_list_no_receipt_orders(
self, client: TestClient, role: UserRole
) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(role)
response = client.get("/api/v1/orders", params={"tab": "no_receipt"})
assert response.status_code == 200
body = response.json()
assert len(body) == 1
assert body[0]["id"] == "1"
assert body[0]["total_amount"] == "1200.00"
assert body[0]["has_receipt"] is False
assert body[0]["goods"][0]["sku"] == "SKU-1"
@pytest.mark.parametrize("tab", ["has_receipt", "refused"])
def test_other_tabs_are_passed_to_service(self, client: TestClient, tab: str) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER)
response = client.get("/api/v1/orders", params={"tab": tab})
assert response.status_code == 200
assert response.json() == []
def test_unknown_tab_is_rejected(self, client: TestClient) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER)
response = client.get("/api/v1/orders", params={"tab": "bogus"})
assert response.status_code == 422
def test_requires_authentication(self, client: TestClient) -> None:
response = client.get("/api/v1/orders")
assert response.status_code == 401
class TestDeleteOrder:
@pytest.mark.parametrize("role", [UserRole.ADMIN, UserRole.CASHIER])
def test_admin_and_cashier_can_delete(self, client: TestClient, role: UserRole) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(role)
response = client.delete("/api/v1/orders/1")
assert response.status_code == 204
def test_viewer_cannot_delete(self, client: TestClient) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.VIEWER)
response = client.delete("/api/v1/orders/1")
assert response.status_code == 403
def test_deleting_unknown_order_is_404(self, client: TestClient) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER)
response = client.delete("/api/v1/orders/does-not-exist")
assert response.status_code == 404
_UPDATE_BODY = {
"recipient_name": "Тест Тестов",
"recipient_phone": "+380501112233",
"waybill_number": "20450123456789",
"notes": "правка",
"total_amount": "1200.00",
"goods": [
{"id": "1", "sku": "SKU-1", "name": "Товар 1", "price": "1200.00", "quantity": "1"}
],
}
class TestUpdateOrder:
@pytest.mark.parametrize("role", [UserRole.ADMIN, UserRole.CASHIER])
def test_admin_and_cashier_can_update(self, client: TestClient, role: UserRole) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(role)
response = client.patch("/api/v1/orders/1", json=_UPDATE_BODY)
assert response.status_code == 200
body = response.json()
assert body["edited"] is True
assert body["goods"][0]["sku"] == "SKU-1"
def test_viewer_cannot_update(self, client: TestClient) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.VIEWER)
response = client.patch("/api/v1/orders/1", json=_UPDATE_BODY)
assert response.status_code == 403
def test_unknown_order_is_404(self, client: TestClient) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER)
response = client.patch("/api/v1/orders/nope", json=_UPDATE_BODY)
assert response.status_code == 404
def test_order_with_receipt_is_409(self, client: TestClient) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER)
response = client.patch("/api/v1/orders/locked", json=_UPDATE_BODY)
assert response.status_code == 409
def test_empty_goods_is_rejected(self, client: TestClient) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER)
response = client.patch("/api/v1/orders/1", json={**_UPDATE_BODY, "goods": []})
assert response.status_code == 422
class TestSummary:
@pytest.mark.parametrize("role", [UserRole.ADMIN, UserRole.CASHIER, UserRole.VIEWER])
def test_returns_cod_in_transit(self, client: TestClient, role: UserRole) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(role)
response = client.get("/api/v1/orders/summary")
assert response.status_code == 200
assert response.json() == {
"cod_in_transit_count": 3,
"cod_in_transit_amount": "2450.50",
}
+233
View File
@@ -0,0 +1,233 @@
"""Юнит-тесты сервиса заказов. Без БД и без сети — сессия подменяется заглушкой."""
from __future__ import annotations
import asyncio
from datetime import UTC, datetime
from typing import Any
import pytest
from sqlalchemy.dialects import postgresql
from app.db.models.order import Order
from app.schemas.orders import OrderUpdateIn
from app.services.orders import (
OrderEditError,
OrderTab,
_parse_crm_datetime,
_to_kopecks,
cod_in_transit,
list_orders,
update_order,
)
class TestToKopecks:
def test_converts_decimal_string_to_kopecks(self) -> None:
assert _to_kopecks("599.00") == 59900
def test_handles_fractional_kopecks(self) -> None:
assert _to_kopecks("12.34") == 1234
def test_handles_integer_without_fraction(self) -> None:
assert _to_kopecks("100") == 10000
def test_handles_zero(self) -> None:
assert _to_kopecks("0.00") == 0
class TestParseCrmDatetime:
def test_parses_crm_format(self) -> None:
assert _parse_crm_datetime("2026-09-20 10:00:00") == datetime(2026, 9, 20, 10, 0, 0)
class _FakeSession:
def __init__(self, order: Order | None) -> None:
self.order = order
async def scalar(self, query: Any) -> Order | None:
return self.order
def _order(**overrides: Any) -> Order:
fields: dict[str, Any] = {
"id": "1",
"recipient_name": "Тест",
"recipient_phone": "0501112233",
"recipient_email": None,
"waybill_number": "20450000000001",
"notes": None,
"total_amount_kopecks": 120000,
"goods": [],
"is_deleted": False,
"receipt_created_at": None,
"edited_at": None,
"np_status": "Прибув",
"np_status_code": "7",
"np_cod_amount_kopecks": 120000,
"np_payment_status": "NeedPayment",
}
fields.update(overrides)
return Order(**fields)
_GOOD = {
"id": "1",
"sku": "A",
"name": "Товар",
"price": "600.00",
"quantity": "2",
"discount_amount": "50.00",
}
def _payload(**overrides: Any) -> OrderUpdateIn:
data: dict[str, Any] = {
"recipient_name": "Тест",
"recipient_phone": "0501112233",
"recipient_email": "",
"waybill_number": "20450000000001",
"notes": "",
"total_amount": "1100.00",
"goods": [_GOOD],
}
data.update(overrides)
return OrderUpdateIn.model_validate(data)
def _update(order: Order | None, payload: OrderUpdateIn) -> tuple[Order, list[str]]:
result = asyncio.run(update_order(_FakeSession(order), "1", payload)) # type: ignore[arg-type]
assert result is not None
return result
class TestUpdateOrder:
def test_recomputes_line_amount_and_marks_edited(self) -> None:
order, changed = _update(_order(), _payload())
assert order.goods[0]["amount"] == "1150.00"
assert order.goods[0]["quantity"] == "2.000"
assert order.total_amount_kopecks == 110000
assert order.edited_at is not None
assert set(changed) == {"goods", "total_amount"}
def test_blank_strings_become_null(self) -> None:
order, _ = _update(_order(), _payload(notes=" "))
assert order.notes is None
assert order.recipient_email is None
def test_new_line_gets_generated_id(self) -> None:
good = {"sku": "", "name": "Новый", "price": "10.00", "quantity": "1"}
order, _ = _update(_order(), _payload(goods=[good], total_amount="10.00"))
assert order.goods[0]["id"].startswith("new-")
def test_waybill_change_resets_np_tracking(self) -> None:
order, changed = _update(_order(), _payload(waybill_number="20450000000002"))
assert "waybill_number" in changed
assert order.np_status is None
assert order.np_cod_amount_kopecks is None
def test_no_changes_does_not_mark_edited(self) -> None:
# Снимок в формате CRM ("2", а не "2.000") — формат строк не считается правкой.
stored = {**_GOOD, "discount_percent": "4.17", "amount": "1150.00"}
order, changed = _update(_order(goods=[stored], total_amount_kopecks=110000), _payload())
assert changed == []
assert order.edited_at is None
assert order.goods == [stored]
def test_total_above_goods_sum_is_rejected(self) -> None:
with pytest.raises(OrderEditError, match="більша за суму товарів"):
_update(_order(), _payload(total_amount="1200.00"))
def test_discount_above_line_sum_is_rejected(self) -> None:
good = {"name": "Товар", "price": "10.00", "quantity": "1", "discount_amount": "11"}
with pytest.raises(OrderEditError, match="знижка більша"):
_update(_order(), _payload(goods=[good], total_amount="0"))
def test_order_with_receipt_is_locked(self) -> None:
with pytest.raises(OrderEditError, match="уже створено чек"):
_update(_order(receipt_created_at=datetime.now(UTC)), _payload())
def test_missing_or_deleted_order_returns_none(self) -> None:
payload = _payload()
assert asyncio.run(update_order(_FakeSession(None), "1", payload)) is None # type: ignore[arg-type]
deleted = _FakeSession(_order(is_deleted=True))
assert asyncio.run(update_order(deleted, "1", payload)) is None # type: ignore[arg-type]
class _CapturingSession:
def __init__(self) -> None:
self.statement: Any = None
async def scalars(self, statement: Any) -> list[Order]:
self.statement = statement
return []
def _list_where(tab: OrderTab) -> str:
"""WHERE-часть запроса вкладки (SELECT перечисляет все колонки, в т.ч. receipt_created_at)."""
session = _CapturingSession()
asyncio.run(list_orders(session, tab=tab)) # type: ignore[arg-type]
sql = str(
session.statement.compile(
dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}
)
)
return sql.split("WHERE", 1)[1]
class TestListOrders:
def test_refused_tab_filters_by_refusal_codes_only(self) -> None:
sql = _list_where(OrderTab.REFUSED)
assert "orders.np_status_code IN ('102', '103', '105', '108')" in sql
assert "receipt_created_at" not in sql
def test_received_tab_filters_by_received_codes_only(self) -> None:
sql = _list_where(OrderTab.RECEIVED)
assert "orders.np_status_code IN ('9', '10', '11', '106')" in sql
assert "receipt_created_at" not in sql
@pytest.mark.parametrize("tab", [OrderTab.NO_RECEIPT, OrderTab.HAS_RECEIPT])
def test_other_tabs_exclude_received_and_refusals(self, tab: OrderTab) -> None:
sql = _list_where(tab)
assert "NOT IN ('9', '10', '11', '106', '102', '103', '105', '108')" in sql
assert "receipt_created_at" in sql
class _CapturingExecuteSession:
def __init__(self) -> None:
self.statement: Any = None
async def execute(self, statement: Any) -> Any:
self.statement = statement
class _Result:
def one(self) -> tuple[int, int]:
return 2, 150000
return _Result()
class TestCodInTransit:
def test_excludes_received_refused_and_paid(self) -> None:
session = _CapturingExecuteSession()
result = asyncio.run(cod_in_transit(session)) # type: ignore[arg-type]
sql = str(
session.statement.compile(
dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}
)
)
assert result == (2, 150000)
assert "sum(orders.np_cod_amount_kopecks)" in sql
assert "orders.is_deleted IS false" in sql
assert "orders.np_cod_amount_kopecks IS NOT NULL" in sql
assert (
"orders.np_status_code NOT IN "
"('9', '10', '11', '106', '102', '103', '105', '108', '2', '3')" in sql
)
assert "orders.np_payment_status IS DISTINCT FROM 'Payed'" in sql
+80
View File
@@ -0,0 +1,80 @@
"""Тесты роутера чеков: роли, постановка задач после 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"],))]
+457
View File
@@ -0,0 +1,457 @@
"""Тесты сервиса ЕТТН-чеков: суммы, валидация, машина состояний.
БД заменена минимальной фейковой сессией (`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.db.models.user import User
from app.schemas.checkbox import EttnStatus
from app.services import receipts as svc
from app.services.checkbox.client import CheckboxRateLimitedError, 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"] == [
{
"type": "ETTN",
"label": "Експрес-накладна",
"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}
]
# Скидка заказа 50 ₴ + предоплата 200 ₴ — одной «Знижкой», без PRE_PAYMENT.
assert [(d["type"], d["value"]) for d in rb["discounts"]] == [("DISCOUNT", 25000)]
assert rb["payments"][0]["value"] == 85000
def test_all_goods_with_prices_quantities_and_sums(self) -> None:
order = _order(
goods=[
_good(id="1", sku="A", name="Сукня", price="600.00", amount="1200.00"),
_good(id="2", sku="", name="Пояс", price="150.50", quantity="1.000",
amount="150.50"),
_good(id="3", sku="C", name="Тканина", price="99.99", quantity="1.500",
amount="149.99"),
],
total_amount_kopecks=150049,
np_cod_amount_kopecks=130049, # 200 ₴ предоплаты
)
body = svc.build_ettn_body(order, _register(), svc.resolve_amounts(order, 20000))
rb = body["receipt_body"]
assert [
(g["good"]["code"], g["good"]["name"], g["good"]["price"], g["quantity"])
for g in rb["goods"]
] == [
("A", "Сукня", 60000, 2000),
("2", "Пояс", 15050, 1000), # пустой SKU → ID товара
("C", "Тканина", 9999, 1500),
]
# 99.99 × 1.5 = 149.985 → CRM округлила до 149.99, скидки по строке нет.
assert all("discounts" not in g for g in rb["goods"])
goods_total = sum(svc._line_sum(g["good"]["price"], g["quantity"]) for g in rb["goods"])
assert goods_total - 20000 == rb["payments"][0]["value"] == 130049
assert rb["discounts"] == [
{"type": "DISCOUNT", "mode": "VALUE", "value": 20000, "name": "Знижка"}
]
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 TestRequestReceipts:
"""Чек создаётся от кассы, к которой опрос НП привязал заказ."""
class Session:
def __init__(self, registers: list[CashRegister], orders: list[Order]) -> None:
self.registers, self.orders, self.added = registers, orders, []
async def scalars(self, statement: Any) -> list[Any]:
entity = statement.column_descriptions[0]["entity"]
if entity is CashRegister:
return self.registers
if entity is Order:
return self.orders
return [] # активные чеки заказов
def add(self, obj: Any) -> None:
self.added.append(obj)
@staticmethod
def _user() -> User:
return User(id=uuid.uuid4(), email="c@x.ua")
async def test_uses_order_register(self) -> None:
default, other = _register(), _register()
order = _order(cash_register_id=other.id)
session = self.Session([default, other], [order])
result = await svc.request_receipts(
session, [(order.id, None)], user=self._user() # type: ignore[arg-type]
)
assert result.errors == {}
assert result.created[0].cash_register_id == other.id
async def test_unbound_order_is_rejected(self) -> None:
order = _order(cash_register_id=None)
session = self.Session([_register()], [order])
result = await svc.request_receipts(
session, [(order.id, None)], user=self._user() # type: ignore[arg-type]
)
assert result.created == []
assert "Касу не визначено" in result.errors[order.id]
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 # второго чека на ту же ТТН нет
async def test_rate_limit_keeps_pending_without_unknown_outcome(self) -> None:
order, register = _order(receipt_created_at=datetime.now(UTC)), _register()
receipt = _pending(order, register)
client = StubCheckboxClient()
session = FakeSession(order, register, receipt)
async def rate_limited(creds: Any, body: dict[str, Any]) -> Any:
raise CheckboxRateLimitedError("To many requests", retry_after=1)
client.create_ettn = rate_limited # type: ignore[method-assign]
with pytest.raises(CheckboxRateLimitedError):
await svc.create_ettn_for_receipt(session, client, receipt.id)
assert receipt.status == ReceiptStatus.PENDING
assert receipt.error is None # повтор не пойдёт через find_ettn
assert order.receipt_created_at is not None # заказ не вернулся в очередь
assert session.commits == 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]
)
class CrmSession:
"""Фейковая сессия для sync_crm_statuses: scalars → id, scalar → строка с «блокировкой»."""
def __init__(self, *receipts: Receipt) -> None:
self.receipts = {r.id: r for r in receipts}
self.added: list[Any] = []
self.commits = 0
self.rollbacks = 0
async def scalars(self, _: Any) -> list[uuid.UUID]:
return [r.id for r in self.receipts.values() if r.crm_status_set_at is None]
async def scalar(self, query: Any) -> Receipt | None:
receipt_id = query.whereclause.clauses[0].right.value
receipt = self.receipts[receipt_id]
return receipt if receipt.crm_status_set_at is None else None
def add(self, obj: Any) -> None:
self.added.append(obj)
async def commit(self) -> None:
self.commits += 1
async def rollback(self) -> None:
self.rollbacks += 1
class TestSyncCrmStatuses:
async def test_sets_packed_once(self) -> None:
from app.services.crm.stub_client import StubCrmClient
order, register = _order(), _register()
receipt = _pending(order, register)
receipt.status = ReceiptStatus.CREATED
session = CrmSession(receipt)
crm = StubCrmClient()
await svc.sync_crm_statuses(session, crm) # type: ignore[arg-type]
await svc.sync_crm_statuses(session, crm) # type: ignore[arg-type]
assert crm.statuses == {"100": "PACKED"}
assert receipt.crm_status_set_at is not None
assert session.commits == 1 # второй проход ничего не делает
async def test_crm_failure_is_retried_later(self) -> None:
from app.services.crm.client import CrmError
from app.services.crm.stub_client import StubCrmClient
order, register = _order(), _register()
receipt = _pending(order, register)
receipt.status = ReceiptStatus.CREATED
session = CrmSession(receipt)
crm = StubCrmClient()
async def broken(**_: Any) -> None:
raise CrmError("CRM вернула ошибку")
real_set_status = crm.set_status
crm.set_status = broken # type: ignore[method-assign]
await svc.sync_crm_statuses(session, crm) # type: ignore[arg-type]
assert receipt.crm_status_set_at is None and session.rollbacks == 1
crm.set_status = real_set_status # type: ignore[method-assign]
await svc.sync_crm_statuses(session, crm) # type: ignore[arg-type]
assert crm.statuses == {"100": "PACKED"}
+21
View File
@@ -0,0 +1,21 @@
# Прод-оверлей: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
#
# TLS и публичный домен держит Nginx Proxy Manager сервера (внешняя сеть `web`).
# Наружу торчит только frontend — и то лишь в сеть `web`, без портов на хосте;
# api/postgres/redis доступны исключительно внутри сети compose.
services:
api:
ports: !reset []
frontend:
ports: !reset []
networks:
default:
web:
# Имя, по которому NPM проксирует: Forward Hostname = lux-fiscal-frontend, порт 80.
aliases: [lux-fiscal-frontend]
networks:
web:
external: true
+7 -5
View File
@@ -35,6 +35,8 @@ services:
# Миграции выполняются отдельным одноразовым контейнером, а не при старте api.
# Иначе при нескольких репликах api они пошли бы параллельно.
migrate:
# Один образ на migrate/api/worker, а не три одинаковых копии на диске.
image: lux-fiscal-backend:latest
build:
context: ./backend
env_file: .env
@@ -48,6 +50,7 @@ services:
restart: "no"
api:
image: lux-fiscal-backend:latest
build:
context: ./backend
restart: unless-stopped
@@ -68,6 +71,7 @@ services:
- receipt_storage:/app/storage
worker:
image: lux-fiscal-backend:latest
build:
context: ./backend
restart: unless-stopped
@@ -75,11 +79,9 @@ services:
environment:
POSTGRES_HOST: postgres
REDIS_HOST: redis
# Подключается на этапе 4 (Нова Пошта) — сейчас заглушка, чтобы структура
# compose не менялась задним числом.
command: ["python", "-c", "print('worker placeholder: см. этап 4 плана'); import time; time.sleep(3600)"]
# Заглушка ничего не слушает на 8000, а HEALTHCHECK бэкенда унаследован
# из общего образа — без отключения контейнер вечно висел бы "unhealthy".
command: ["arq", "app.worker.WorkerSettings"]
# ARQ ничего не слушает на 8000, а HEALTHCHECK бэкенда унаследован из
# общего образа — без отключения контейнер вечно висел бы "unhealthy".
healthcheck:
disable: true
depends_on:
+8 -1
View File
@@ -10,8 +10,15 @@ server {
root /usr/share/nginx/html;
index index.html;
# Имя `api` резолвится через DNS docker на каждый запрос (resolver + переменная),
# а не один раз при старте nginx: иначе после пересоздания контейнера api
# (новый IP) nginx продолжает стучаться на старый адрес и отдаёт 502.
resolver 127.0.0.11 valid=10s ipv6=off;
set $api_upstream http://api:8000;
location /api/ {
proxy_pass http://api:8000/api/;
# С переменной proxy_pass без URI передаёт исходный путь /api/... как есть.
proxy_pass $api_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+8 -2
View File
@@ -1,10 +1,16 @@
<!doctype html>
<html lang="ru">
<html lang="uk">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>lux_fiscal</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Unbounded:wght@500;600&display=swap"
/>
<title>Assistant System</title>
</head>
<body>
<div id="root"></div>
+22
View File
@@ -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' })
}
+5 -5
View File
@@ -27,10 +27,10 @@ async function readErrorMessage(response: Response): Promise<string> {
// Тело не JSON или пустое — используем сообщение по статусу ниже.
}
if (response.status === 401) return 'Требуется вход в систему'
if (response.status === 403) return 'Недостаточно прав для этого действия'
if (response.status >= 500) return 'Сервер временно недоступен, попробуйте позже'
return `Ошибка запроса (${response.status})`
if (response.status === 401) return 'Потрібно увійти в систему'
if (response.status === 403) return 'Недостатньо прав для цієї дії'
if (response.status >= 500) return 'Сервер тимчасово недоступний, спробуйте пізніше'
return `Помилка запиту (${response.status})`
}
// Параллельные 401 не должны порождать несколько запросов на refresh —
@@ -42,7 +42,7 @@ async function refreshAccessToken(): Promise<string> {
const refreshToken = getRefreshToken()
if (!refreshToken) {
throw new ApiError(401, 'Сессия истекла, войдите снова')
throw new ApiError(401, 'Сесія закінчилася, увійдіть знову')
}
refreshPromise = (async () => {
+18
View File
@@ -0,0 +1,18 @@
import { apiFetch } from '@/api/client'
import type { Order, OrdersSummary, OrderTab, OrderUpdate } from '@/features/orders/types'
export function getOrders(tab: OrderTab): Promise<Order[]> {
return apiFetch<Order[]>(`/orders?tab=${tab}`)
}
export function getOrdersSummary(): Promise<OrdersSummary> {
return apiFetch<OrdersSummary>('/orders/summary')
}
export function deleteOrder(orderId: string): Promise<void> {
return apiFetch<void>(`/orders/${orderId}`, { method: 'DELETE' })
}
export function updateOrder(orderId: string, data: OrderUpdate): Promise<Order> {
return apiFetch<Order>(`/orders/${orderId}`, { method: 'PATCH', body: data })
}
+10
View File
@@ -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' })
}
+10 -1
View File
@@ -2,7 +2,10 @@ import { Navigate, Route, Routes } from 'react-router-dom'
import { LoginPage } from '@/features/auth/LoginPage'
import { ProtectedRoute } from '@/features/auth/ProtectedRoute'
import { ModuleRoute } from '@/features/menu/ModuleRoute'
import { CashRegistersPage } from '@/pages/CashRegistersPage'
import { DashboardPage } from '@/pages/DashboardPage'
import { MainMenuPage } from '@/pages/MainMenuPage'
export function AppRoutes() {
return (
@@ -10,7 +13,13 @@ export function AppRoutes() {
<Route path="/login" element={<LoginPage />} />
<Route element={<ProtectedRoute />}>
<Route path="/" element={<DashboardPage />} />
<Route path="/" element={<MainMenuPage />} />
<Route element={<ModuleRoute moduleId="receipts" />}>
<Route path="/receipts" element={<DashboardPage />} />
</Route>
<Route element={<ModuleRoute moduleId="cash-registers" />}>
<Route path="/cash-registers" element={<CashRegistersPage />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
+25
View File
@@ -0,0 +1,25 @@
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 100;
}
.modal-content {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
box-shadow: var(--shadow-card);
max-width: 640px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
}
.modal-content--wide {
max-width: 920px;
}
+28
View File
@@ -0,0 +1,28 @@
import { useEffect } from 'react'
import type { ReactNode } from 'react'
import '@/components/Modal.css'
interface ModalProps {
onClose: () => void
children: ReactNode
wide?: boolean
}
export function Modal({ onClose, children, wide = false }: ModalProps) {
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [onClose])
return (
<div className="modal-overlay" onClick={onClose}>
<div className={`modal-content${wide ? ' modal-content--wide' : ''}`} onClick={(event) => event.stopPropagation()}>
{children}
</div>
</div>
)
}
+1 -1
View File
@@ -65,7 +65,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(result.user)
setStatus('authenticated')
} catch (err) {
const message = err instanceof ApiError ? err.message : 'Не удалось подключиться к серверу'
const message = err instanceof ApiError ? err.message : 'Не вдалося підключитися до сервера'
setError(message)
throw err
}
+207 -28
View File
@@ -1,68 +1,230 @@
.login-page {
--login-accent: #7c3aed;
--login-glass: rgba(255, 255, 255, 0.72);
--login-glass-border: rgba(255, 255, 255, 0.6);
--login-input-bg: rgba(255, 255, 255, 0.85);
position: relative;
min-height: 100svh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
padding: 24px 16px;
overflow: hidden;
isolation: isolate;
background:
radial-gradient(1200px 600px at 10% -10%, rgba(37, 99, 235, 0.14), transparent 60%),
radial-gradient(900px 500px at 110% 110%, rgba(124, 58, 237, 0.14), transparent 60%),
var(--color-bg);
}
@media (prefers-color-scheme: dark) {
.login-page {
--login-accent: #a78bfa;
--login-glass: rgba(23, 26, 33, 0.7);
--login-glass-border: rgba(255, 255, 255, 0.08);
--login-input-bg: rgba(15, 17, 21, 0.7);
}
}
/* Декоративные размытые пятна на фоне */
.login-backdrop {
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
}
.login-blob {
position: absolute;
border-radius: 50%;
filter: blur(70px);
opacity: 0.55;
animation: login-float 18s ease-in-out infinite alternate;
}
.login-blob--a {
width: 380px;
height: 380px;
top: -80px;
left: -60px;
background: var(--color-primary);
}
.login-blob--b {
width: 320px;
height: 320px;
bottom: -90px;
right: -40px;
background: var(--login-accent);
animation-delay: -6s;
}
.login-blob--c {
width: 220px;
height: 220px;
top: 55%;
left: 60%;
background: #06b6d4;
opacity: 0.3;
animation-delay: -12s;
}
@keyframes login-float {
from {
transform: translate3d(0, 0, 0) scale(1);
}
to {
transform: translate3d(40px, 30px, 0) scale(1.1);
}
}
@media (prefers-reduced-motion: reduce) {
.login-blob {
animation: none;
}
}
.login-card {
width: 100%;
max-width: 380px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
box-shadow: var(--shadow-card);
padding: 32px;
max-width: 400px;
background: var(--login-glass);
border: 1px solid var(--login-glass-border);
border-radius: 20px;
box-shadow:
0 20px 50px -12px rgba(16, 24, 40, 0.25),
0 1px 2px rgba(16, 24, 40, 0.06);
backdrop-filter: blur(18px) saturate(140%);
-webkit-backdrop-filter: blur(18px) saturate(140%);
padding: 40px 32px 32px;
animation: login-enter 0.45s cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
@keyframes login-enter {
from {
opacity: 0;
transform: translateY(12px) scale(0.98);
}
}
@media (max-width: 420px) {
.login-card {
padding: 32px 20px 24px;
border-radius: 16px;
}
}
.login-header {
margin-bottom: 24px;
margin-bottom: 28px;
text-align: center;
}
.login-logo {
width: 56px;
height: 56px;
margin: 0 auto 16px;
display: grid;
place-items: center;
border-radius: 16px;
color: #fff;
background: linear-gradient(135deg, var(--color-primary), var(--login-accent));
box-shadow: 0 10px 24px -8px var(--color-primary);
}
.login-header h1 {
font-size: 22px;
margin-bottom: 4px;
font-size: 26px;
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 6px;
background: linear-gradient(135deg, var(--color-text) 30%, var(--color-primary));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.login-header p {
color: var(--color-text-muted);
font-size: 14px;
font-size: 15px;
}
.login-field {
margin-bottom: 16px;
margin-bottom: 18px;
}
.login-field label {
display: block;
font-size: 13px;
font-weight: 500;
margin-bottom: 6px;
font-weight: 600;
margin-bottom: 8px;
color: var(--color-text);
}
.login-field input {
width: 100%;
padding: 10px 12px;
height: 46px;
padding: 0 14px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-bg);
border-radius: 12px;
background: var(--login-input-bg);
color: var(--color-text);
font-size: 14px;
font-size: 15px;
outline: none;
transition: border-color 0.15s ease;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.login-field input::placeholder {
color: var(--color-text-muted);
opacity: 0.7;
}
.login-field input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-primary) 18%, transparent);
}
.login-field input:disabled {
opacity: 0.6;
}
.login-password {
position: relative;
}
.login-password input {
padding-right: 46px;
}
.login-toggle {
position: absolute;
top: 50%;
right: 6px;
transform: translateY(-50%);
width: 34px;
height: 34px;
display: grid;
place-items: center;
border: none;
border-radius: 8px;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
transition:
color 0.15s ease,
background-color 0.15s ease;
}
.login-toggle:hover {
color: var(--color-text);
background: color-mix(in srgb, var(--color-text) 8%, transparent);
}
.login-toggle:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
.login-error {
display: flex;
gap: 8px;
@@ -70,23 +232,29 @@
background: var(--color-danger-bg);
border: 1px solid var(--color-danger-border);
color: var(--color-danger);
border-radius: 8px;
padding: 10px 12px;
border-radius: 12px;
padding: 10px 14px;
font-size: 13px;
margin-bottom: 16px;
margin-bottom: 18px;
}
.login-submit {
width: 100%;
padding: 11px 16px;
height: 48px;
margin-top: 8px;
padding: 0 16px;
border: none;
border-radius: 8px;
background: var(--color-primary);
border-radius: 12px;
background: linear-gradient(135deg, var(--color-primary), var(--login-accent));
color: #fff;
font-size: 14px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.15s ease;
box-shadow: 0 10px 24px -10px var(--color-primary);
transition:
transform 0.15s ease,
box-shadow 0.15s ease,
filter 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
@@ -94,7 +262,18 @@
}
.login-submit:hover:not(:disabled) {
background: var(--color-primary-hover);
transform: translateY(-1px);
filter: brightness(1.06);
box-shadow: 0 14px 28px -10px var(--color-primary);
}
.login-submit:active:not(:disabled) {
transform: translateY(0);
}
.login-submit:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.login-submit:disabled {
+42 -14
View File
@@ -19,6 +19,7 @@ export function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [formError, setFormError] = useState<string | null>(null)
@@ -36,7 +37,7 @@ export function LoginPage() {
try {
await login(email, password)
} catch (err) {
const message = err instanceof ApiError ? err.message : 'Не удалось подключиться к серверу'
const message = err instanceof ApiError ? err.message : 'Не вдалося підключитися до сервера'
setFormError(message)
} finally {
setSubmitting(false)
@@ -45,10 +46,22 @@ export function LoginPage() {
return (
<div className="login-page">
<div className="login-backdrop" aria-hidden="true">
<span className="login-blob login-blob--a" />
<span className="login-blob login-blob--b" />
<span className="login-blob login-blob--c" />
</div>
<div className="login-card">
<div className="login-header">
<h1>lux_fiscal</h1>
<p>Вход в систему фискализации заказов</p>
<div className="login-logo" aria-hidden="true">
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3l7 4v5c0 4.5-3 8-7 9-4-1-7-4.5-7-9V7l7-4z" />
<path d="M9 12l2 2 4-4" />
</svg>
</div>
<h1>Assistant System</h1>
<p>Вхід у систему</p>
</div>
<form onSubmit={handleSubmit} noValidate>
@@ -74,21 +87,36 @@ export function LoginPage() {
<div className="login-field">
<label htmlFor={passwordId}>Пароль</label>
<input
id={passwordId}
type="password"
autoComplete="current-password"
required
value={password}
disabled={submitting}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
/>
<div className="login-password">
<input
id={passwordId}
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
required
value={password}
disabled={submitting}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
/>
<button
type="button"
className="login-toggle"
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? 'Приховати пароль' : 'Показати пароль'}
aria-pressed={showPassword}
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z" />
<circle cx="12" cy="12" r="3" />
{showPassword && <path d="M3 3l18 18" />}
</svg>
</button>
</div>
</div>
<button type="submit" className="login-submit" disabled={submitting}>
{submitting && <span className="spinner" aria-hidden="true" />}
{submitting ? 'Входим…' : 'Войти'}
{submitting ? 'Входимо…' : 'Увійти'}
</button>
</form>
</div>
@@ -10,7 +10,7 @@ export function ProtectedRoute() {
if (status === 'loading') {
return (
<div className="page-center">
<span className="spinner" aria-label="Загрузка" />
<span className="spinner" aria-label="Завантаження" />
</div>
)
}
+7
View File
@@ -0,0 +1,7 @@
import type { UserRole } from '@/api/types'
export const ROLE_LABEL: Record<UserRole, string> = {
admin: 'Адміністратор',
cashier: 'Касир',
viewer: 'Спостерігач',
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { AuthContext } from '@/features/auth/AuthContext'
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) {
throw new Error('useAuth должен вызываться внутри <AuthProvider>')
throw new Error('useAuth має викликатися всередині <AuthProvider>')
}
return ctx
}
@@ -0,0 +1,25 @@
/** Зеркало `CashRegisterOut`/`CashRegisterCreate`/`CashRegisterUpdate` из backend/app/schemas/receipts.py. */
export interface CashRegister {
id: string
name: string
fiscal_number: string | null
license_key_masked: string
/** null — ключ Новой Почты не задан: касса не получает посылки. */
np_api_key_masked: string | null
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
np_api_key: string | null
tax_codes: (number | string)[]
is_default: boolean
}
export type CashRegisterUpdate = Partial<CashRegisterCreate> & { is_active?: boolean }
@@ -0,0 +1,11 @@
import { Navigate, Outlet } from 'react-router-dom'
import { useAuth } from '@/features/auth/useAuth'
import { canAccess, getModule } from '@/features/menu/modules'
/** Пускает в модуль только роли из реестра; остальных возвращает в главное меню. */
export function ModuleRoute({ moduleId }: { moduleId: string }) {
const { user } = useAuth()
if (!canAccess(user, getModule(moduleId).roles)) return <Navigate to="/" replace />
return <Outlet />
}
+46
View File
@@ -0,0 +1,46 @@
import type { User, UserRole } from '@/api/types'
/**
* Реестр модулей главного меню. Единственное место, где модуль связывается
* с путём и ролями: по нему рисуется меню и защищается роут модуля.
*/
export interface AppModule {
id: string
title: string
description: string
path: string
/** Роли, которым модуль доступен; администратор видит всё и в списке не нужен. */
roles: readonly UserRole[]
/** `d` для stroke-иконки 24×24. */
icon: string
}
export const MODULES: readonly AppModule[] = [
{
id: 'receipts',
title: 'Чеки',
description: 'Замовлення з CRM, ЕТТН-чеки Checkbox і статуси Нової Пошти',
path: '/receipts',
roles: ['cashier', 'viewer'],
icon: 'M6 3h12v18l-3-2-3 2-3-2-3 2z M9 8h6 M9 12h6 M9 16h3',
},
{
id: 'cash-registers',
title: 'Каси',
description: 'Каси Checkbox, ліцензійні ключі та ключі Нової Пошти',
path: '/cash-registers',
roles: [],
icon: 'M4 10h16v10H4z M7 10V4h10v6 M8 14h2 M14 14h2 M8 17h2 M14 17h2',
},
]
export function canAccess(user: User | null | undefined, roles: readonly UserRole[]): boolean {
if (!user) return false
return user.role === 'admin' || roles.includes(user.role)
}
export function getModule(id: string): AppModule {
const module = MODULES.find((m) => m.id === id)
if (!module) throw new Error(`Невідомий модуль: ${id}`)
return module
}
@@ -0,0 +1,265 @@
.order-modal {
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.order-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.order-modal-header h3 {
margin: 0;
font-size: 17px;
}
.order-modal-close {
border: none;
background: none;
color: var(--color-text-muted);
font-size: 22px;
line-height: 1;
cursor: pointer;
padding: 4px;
}
.order-modal-close:hover {
color: var(--color-text);
}
.order-modal-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 20px;
font-size: 14px;
}
.order-modal-meta div,
.order-modal-meta label {
display: flex;
flex-direction: column;
gap: 2px;
}
.order-modal-label {
font-size: 11px;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.order-modal-notes p {
margin: 4px 0 0;
font-size: 13px;
white-space: pre-wrap;
color: var(--color-text-muted);
}
.order-modal-goods {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.order-modal-goods th,
.order-modal-goods td {
padding: 8px 10px;
text-align: left;
border-bottom: 1px solid var(--color-border);
}
.order-modal-goods th {
color: var(--color-text-muted);
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.order-modal-goods-empty {
text-align: center;
color: var(--color-text-muted);
padding: 16px 0;
}
.order-modal-total {
display: flex;
justify-content: flex-end;
gap: 12px;
font-size: 16px;
font-weight: 700;
}
.order-modal-actions {
display: flex;
justify-content: flex-end;
}
.order-modal-close-btn {
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
border-radius: 8px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
}
.order-modal-close-btn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.order-modal-edited {
margin-left: 10px;
padding: 2px 8px;
border-radius: 999px;
background: var(--color-bg);
border: 1px solid var(--color-border);
color: var(--color-text-muted);
font-size: 11px;
font-weight: 500;
vertical-align: middle;
}
.order-modal-notes {
display: flex;
flex-direction: column;
gap: 2px;
}
.order-modal-input {
width: 100%;
box-sizing: border-box;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
font: inherit;
font-size: 13px;
}
.order-modal-input:focus {
outline: none;
border-color: var(--color-primary);
}
textarea.order-modal-input {
resize: vertical;
}
.order-modal-input--sku {
min-width: 80px;
}
.order-modal-input--num {
width: 84px;
text-align: right;
}
.order-modal-goods td:has(.order-modal-input) {
padding: 4px 4px;
}
.order-modal-invalid {
color: var(--color-danger);
}
.order-modal-remove {
border: none;
background: none;
color: var(--color-text-muted);
font-size: 18px;
line-height: 1;
cursor: pointer;
padding: 2px 6px;
}
.order-modal-remove:hover {
color: var(--color-danger);
}
.order-modal-add {
align-self: flex-start;
border: 1px dashed var(--color-border);
background: none;
color: var(--color-primary);
border-radius: 8px;
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
}
.order-modal-add:hover {
border-color: var(--color-primary);
}
.order-modal-total {
align-items: center;
}
.order-modal-message {
padding: 8px 12px;
border-radius: 8px;
font-size: 13px;
}
.order-modal-message--ok {
background: #dcfce7;
color: #166534;
}
.order-modal-message--error {
background: var(--color-danger-bg);
color: var(--color-danger);
border: 1px solid var(--color-danger-border);
}
.order-modal-actions {
gap: 8px;
}
.order-modal-save-btn {
border: 1px solid var(--color-primary);
background: var(--color-primary);
color: #fff;
border-radius: 8px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
}
.order-modal-save-btn:hover:not(:disabled) {
background: var(--color-primary-hover);
}
.order-modal-save-btn:disabled {
opacity: 0.5;
cursor: default;
}
@media (prefers-color-scheme: dark) {
.order-modal-message--ok {
background: rgba(22, 163, 74, 0.18);
color: #86efac;
}
}
@media (max-width: 640px) {
.order-modal {
padding: 16px;
}
.order-modal-meta {
grid-template-columns: minmax(0, 1fr);
}
.order-modal-goods {
display: block;
overflow-x: auto;
}
}
@@ -0,0 +1,438 @@
import { useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { updateOrder } from '@/api/orders'
import { Modal } from '@/components/Modal'
import '@/features/orders/OrderDetailModal.css'
import type { Order, OrderUpdate } from '@/features/orders/types'
import { formatKopecks, toKopecks } from '@/features/receipts/money'
import { RECEIPT_STATUS } from '@/features/receipts/types'
interface OrderDetailModalProps {
order: Order
/** Кассир/админ и по заказу ещё нет чека — карточку можно править. */
canEdit: boolean
onClose: () => void
}
interface GoodDraft {
key: string
id: string | null
sku: string
name: string
price: string
quantity: string
discount: string
}
interface Draft {
recipient_name: string
recipient_phone: string
recipient_email: string
waybill_number: string
notes: string
total: string
goods: GoodDraft[]
}
type TextField = Exclude<keyof Draft, 'goods' | 'total'>
/** Количество в тысячных (конвенция Checkbox: 1 шт = 1000). */
function toThousandths(value: string): number | null {
const normalized = value.trim().replace(',', '.')
if (!/^\d+(\.\d{1,3})?$/.test(normalized)) return null
return Math.round(Number(normalized) * 1000)
}
/** Сумма строки после скидки в копейках; null — в строке некорректные числа. */
function lineAmount(good: GoodDraft): number | null {
const price = toKopecks(good.price)
const quantity = toThousandths(good.quantity)
const discount = toKopecks(good.discount)
if (price === null || quantity === null || discount === null) return null
return Math.round((price * quantity) / 1000) - discount
}
function goodsSum(goods: GoodDraft[]): number | null {
let sum = 0
for (const good of goods) {
const amount = lineAmount(good)
if (amount === null) return null
sum += amount
}
return sum
}
let newGoodSeq = 0
function toDraft(order: Order): Draft {
return {
recipient_name: order.recipient_name ?? '',
recipient_phone: order.recipient_phone ?? '',
recipient_email: order.recipient_email ?? '',
waybill_number: order.waybill_number ?? '',
notes: order.notes ?? '',
total: order.total_amount,
goods: order.goods.map((good) => {
// Скидку строки выводим из amount, как бэкенд при сборке чека, — не из discount_amount/percent.
const gross = Math.round(((toKopecks(good.price) ?? 0) * (toThousandths(good.quantity) ?? 0)) / 1000)
const discount = Math.max(gross - (toKopecks(good.amount) ?? gross), 0)
return {
key: good.id,
id: good.id,
sku: good.sku,
name: good.name,
price: good.price,
quantity: good.quantity,
discount: formatKopecks(discount),
}
}),
}
}
function orderDiscountOf(draft: Draft): number {
return (goodsSum(draft.goods) ?? 0) - (toKopecks(draft.total) ?? 0)
}
/** Проверяет черновик и собирает тело PATCH; строка — текст ошибки для кассира. */
function toPayload(draft: Draft): OrderUpdate | string {
if (draft.goods.length === 0) return 'Додайте хоча б один товар'
const goods = []
for (const good of draft.goods) {
const label = good.name.trim() || 'без назви'
if (!good.name.trim()) return 'У товару не вказано найменування'
const price = toKopecks(good.price)
const quantity = toThousandths(good.quantity)
const discount = toKopecks(good.discount)
if (price === null) return `Товар «${label}»: некоректна ціна`
if (quantity === null || quantity <= 0) return `Товар «${label}»: некоректна кількість`
if (discount === null) return `Товар «${label}»: некоректна знижка`
if ((lineAmount(good) ?? 0) < 0) return `Товар «${label}»: знижка більша за суму рядка`
goods.push({
id: good.id,
sku: good.sku.trim(),
name: good.name.trim(),
price: formatKopecks(price),
quantity: (quantity / 1000).toFixed(3),
discount_amount: formatKopecks(discount),
})
}
const total = toKopecks(draft.total)
if (total === null) return 'Некоректна сума замовлення'
const sum = goodsSum(draft.goods) ?? 0
if (total > sum) return `Сума замовлення більша за суму товарів (${formatKopecks(sum)} ₴)`
return {
recipient_name: draft.recipient_name,
recipient_phone: draft.recipient_phone,
recipient_email: draft.recipient_email,
waybill_number: draft.waybill_number,
notes: draft.notes,
total_amount: formatKopecks(total),
goods,
}
}
const TEXT_FIELDS: { field: TextField; label: string; type?: string }[] = [
{ field: 'recipient_name', label: 'Клієнт' },
{ field: 'recipient_phone', label: 'Телефон', type: 'tel' },
{ field: 'recipient_email', label: 'Email', type: 'email' },
{ field: 'waybill_number', label: 'Номер ТТН' },
]
export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalProps) {
const queryClient = useQueryClient()
const [draft, setDraft] = useState<Draft>(() => toDraft(order))
const [saved, setSaved] = useState<Draft>(draft)
// Скидка на весь заказ (итог товаров − сумма заказа): держим её при правке товаров.
const [orderDiscount, setOrderDiscount] = useState(() => orderDiscountOf(draft))
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<{ tone: 'ok' | 'error'; text: string } | null>(null)
const dirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(saved), [draft, saved])
const editable = canEdit && !order.has_receipt
function requestClose() {
if (dirty && !window.confirm('Є незбережені зміни. Закрити без збереження?')) return
onClose()
}
function setField(field: TextField, value: string) {
setDraft((prev) => ({ ...prev, [field]: value }))
setMessage(null)
}
/** Меняет товары и пересчитывает итог с сохранением скидки на заказ. */
function setGoods(update: (goods: GoodDraft[]) => GoodDraft[]) {
const goods = update(draft.goods)
const sum = goodsSum(goods)
setDraft({ ...draft, goods, total: sum === null ? draft.total : formatKopecks(Math.max(sum - orderDiscount, 0)) })
setMessage(null)
}
function setTotal(value: string) {
setDraft({ ...draft, total: value })
const sum = goodsSum(draft.goods)
const total = toKopecks(value)
if (sum !== null && total !== null) setOrderDiscount(sum - total)
setMessage(null)
}
function setGood(key: string, field: keyof GoodDraft, value: string) {
setGoods((goods) => goods.map((good) => (good.key === key ? { ...good, [field]: value } : good)))
}
function addGood() {
newGoodSeq += 1
setGoods((goods) => [
...goods,
{ key: `new-${newGoodSeq}`, id: null, sku: '', name: '', price: '0.00', quantity: '1', discount: '0.00' },
])
}
async function handleSave() {
const payload = toPayload(draft)
if (typeof payload === 'string') {
setMessage({ tone: 'error', text: payload })
return
}
setSaving(true)
try {
const updated = await updateOrder(order.id, payload)
const next = toDraft(updated)
setDraft(next)
setSaved(next)
setOrderDiscount(orderDiscountOf(next))
setMessage({ tone: 'ok', text: 'Зміни збережено' })
await queryClient.invalidateQueries({ queryKey: ['orders'] })
} catch (err) {
setMessage({ tone: 'error', text: err instanceof Error ? err.message : 'Не вдалося зберегти замовлення' })
} finally {
setSaving(false)
}
}
return (
<Modal onClose={requestClose} wide={editable}>
<div className="order-modal">
<div className="order-modal-header">
<h3>
Замовлення {order.id}
{order.edited && <span className="order-modal-edited">змінено вручну</span>}
</h3>
<button type="button" className="order-modal-close" onClick={requestClose} aria-label="Закрити">
×
</button>
</div>
<div className="order-modal-meta">
<div>
<span className="order-modal-label">Дата</span>
<span>{order.create_date_time}</span>
</div>
<div>
<span className="order-modal-label">Статус ТТН</span>
<span>{order.np_status || 'Немає даних'}</span>
</div>
<div>
<span className="order-modal-label">Накладений платіж</span>
<span>{order.np_cod_amount ? `${order.np_cod_amount} ₴` : '—'}</span>
</div>
<div>
<span className="order-modal-label">Оплачено</span>
<span>
{order.np_payment_status === 'Payed'
? 'Так'
: order.np_payment_status === 'NeedPayment'
? 'Ні'
: '—'}
</span>
</div>
{TEXT_FIELDS.map(({ field, label, type }) =>
editable ? (
<label key={field}>
<span className="order-modal-label">{label}</span>
<input
type={type ?? 'text'}
className="order-modal-input"
value={draft[field]}
onChange={(e) => setField(field, e.target.value)}
/>
</label>
) : (
(field !== 'recipient_email' || draft[field]) && (
<div key={field}>
<span className="order-modal-label">{label}</span>
<span>{draft[field] || '—'}</span>
</div>
)
),
)}
</div>
{editable ? (
<label className="order-modal-notes">
<span className="order-modal-label">Нотатки</span>
<textarea
className="order-modal-input"
rows={2}
value={draft.notes}
onChange={(e) => setField('notes', e.target.value)}
/>
</label>
) : (
draft.notes && (
<div className="order-modal-notes">
<span className="order-modal-label">Нотатки</span>
<p>{draft.notes}</p>
</div>
)
)}
<table className="order-modal-goods">
<thead>
<tr>
<th>Найменування</th>
<th>SKU</th>
<th>Ціна</th>
<th>К-сть</th>
<th>Знижка</th>
<th>Сума</th>
{editable && <th aria-label="Видалити" />}
</tr>
</thead>
<tbody>
{draft.goods.map((good) => {
const amount = lineAmount(good)
return (
<tr key={good.key}>
{editable ? (
<>
<td>
<input
className="order-modal-input"
value={good.name}
onChange={(e) => setGood(good.key, 'name', e.target.value)}
/>
</td>
<td>
<input
className="order-modal-input order-modal-input--sku"
value={good.sku}
onChange={(e) => setGood(good.key, 'sku', e.target.value)}
/>
</td>
{(['price', 'quantity', 'discount'] as const).map((field) => (
<td key={field}>
<input
className="order-modal-input order-modal-input--num"
inputMode="decimal"
value={good[field]}
onChange={(e) => setGood(good.key, field, e.target.value)}
/>
</td>
))}
</>
) : (
<>
<td>{good.name}</td>
<td>{good.sku}</td>
<td>{good.price}</td>
<td>{good.quantity}</td>
<td>{good.discount !== '0.00' ? good.discount : '—'}</td>
</>
)}
<td className={amount === null || amount < 0 ? 'order-modal-invalid' : undefined}>
{amount === null ? '—' : formatKopecks(amount)}
</td>
{editable && (
<td>
<button
type="button"
className="order-modal-remove"
aria-label={`Видалити товар ${good.name}`}
onClick={() => setGoods((goods) => goods.filter((g) => g.key !== good.key))}
>
×
</button>
</td>
)}
</tr>
)
})}
{draft.goods.length === 0 && (
<tr>
<td colSpan={editable ? 7 : 6} className="order-modal-goods-empty">
Товари не вказані
</td>
</tr>
)}
</tbody>
</table>
{editable && (
<button type="button" className="order-modal-add" onClick={addGood}>
+ Додати товар
</button>
)}
<div className="order-modal-total">
<span>Разом</span>
{editable ? (
<span>
<input
className="order-modal-input order-modal-input--num"
inputMode="decimal"
value={draft.total}
title="Сума замовлення після всіх знижок"
onChange={(e) => setTotal(e.target.value)}
/>{' '}
₴
</span>
) : (
<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>
)}
{message && <div className={`order-modal-message order-modal-message--${message.tone}`}>{message.text}</div>}
<div className="order-modal-actions">
{editable && (
<button
type="button"
className="order-modal-save-btn"
disabled={!dirty || saving}
onClick={() => void handleSave()}
>
{saving ? 'Збереження…' : 'Зберегти'}
</button>
)}
<button type="button" className="order-modal-close-btn" onClick={requestClose}>
Закрити
</button>
</div>
</div>
</Modal>
)
}
+70
View File
@@ -0,0 +1,70 @@
/**
* Типы, зеркалящие backend/app/schemas/orders.py (`OrderRowOut`/`OrderGoodOut`).
* Меняются синхронно с ними вручную — см. пояснение в @/api/types.ts.
*/
import type { ReceiptStatus } from '@/features/receipts/types'
/** Вкладка дашборда (`OrderTab` в backend/app/services/orders.py). */
export type OrderTab = 'no_receipt' | 'has_receipt' | 'received' | 'refused'
export interface OrderGood {
id: string
sku: string
name: string
price: string
quantity: string
discount_amount: string | null
discount_percent: string | null
amount: string
}
export interface Order {
id: string
create_date_time: string
recipient_name: string | null
recipient_phone: string | null
recipient_email: string | null
waybill_number: string | null
notes: string | null
total_amount: string
goods: OrderGood[]
has_receipt: boolean
np_status: string | null
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
// Заказ правили вручную — синхронизация из CRM его больше не перезаписывает.
edited: boolean
}
/** Тело `PATCH /orders/{id}` (`OrderUpdateIn`). Деньги — строки "1200.00", `amount` считает сервер. */
export interface OrderGoodUpdate {
id: string | null
sku: string
name: string
price: string
quantity: string
discount_amount: string
}
export interface OrderUpdate {
recipient_name: string
recipient_phone: string
recipient_email: string
waybill_number: string
notes: string
total_amount: string
goods: OrderGoodUpdate[]
}
/** Ответ `GET /orders/summary` (`OrdersSummaryOut`). */
export interface OrdersSummary {
cod_in_transit_count: number
cod_in_transit_amount: string
}
+30
View File
@@ -0,0 +1,30 @@
import { useQuery } from '@tanstack/react-query'
import { getOrders, getOrdersSummary } from '@/api/orders'
import type { OrderTab } from '@/features/orders/types'
/** Пока чек отправляется в Checkbox — опрашиваем часто, иначе статусы чеков обновляет worker раз в минуту. */
const PENDING_POLL_MS = 3_000
const RECEIPTS_POLL_MS = 30_000
// Статусы NP обновляет worker раз в минуту — чаще сводку опрашивать незачем.
const SUMMARY_POLL_MS = 60_000
export function useOrders(tab: OrderTab) {
return useQuery({
queryKey: ['orders', tab],
queryFn: () => getOrders(tab),
refetchInterval: (query) => {
if (query.state.data?.some((order) => order.receipt_status === 'pending')) return PENDING_POLL_MS
return tab === 'has_receipt' ? RECEIPTS_POLL_MS : false
},
})
}
export function useOrdersSummary() {
return useQuery({
// Под ключом ['orders', ...] — чтобы invalidateQueries(['orders']) обновлял и сводку.
queryKey: ['orders', 'summary'],
queryFn: getOrdersSummary,
refetchInterval: SUMMARY_POLL_MS,
})
}
+27
View File
@@ -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)
}
+56
View File
@@ -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'])
+13
View File
@@ -74,6 +74,19 @@ input {
/* --- Общие утилиты, используются в нескольких экранах --- */
/* Скрыт визуально, но читается скринридером (подписи к полям без видимого label). */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.page-center {
min-height: 100svh;
display: flex;
+1 -1
View File
@@ -6,7 +6,7 @@ import '@/index.css'
const rootElement = document.getElementById('root')
if (!rootElement) {
throw new Error('Элемент #root не найден в index.html')
throw new Error('Елемент #root не знайдено в index.html')
}
createRoot(rootElement).render(
+50
View File
@@ -0,0 +1,50 @@
.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;
}
.cr-missing {
color: var(--color-danger);
}
+295
View File
@@ -0,0 +1,295 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import type { FormEvent } from 'react'
import { Link } 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
np_api_key: string
tax_codes: string
is_default: boolean
}
const EMPTY_FORM: FormState = {
name: '',
fiscal_number: '',
license_key: '',
pin_code: '',
np_api_key: '',
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)
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: '',
np_api_key: '',
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
if (form.np_api_key) payload.np_api_key = form.np_api_key
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,
np_api_key: form.np_api_key,
tax_codes: parseTaxCodes(form.tax_codes),
is_default: form.is_default,
}),
'Касу додано',
)
}
resetForm()
}
return (
<div className="dashboard-shell">
<header className="dashboard-topbar">
<div className="dashboard-topbar-start">
<Link to="/" className="orders-link-btn">
← Головне меню
</Link>
<span className="dashboard-brand">Assistant System</span>
</div>
</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>
<th>Дії</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={7} className="orders-empty">
Завантаження…
</td>
</tr>
)}
{isError && (
<tr>
<td colSpan={7} className="orders-empty">
Не вдалося завантажити каси
</td>
</tr>
)}
{registers?.length === 0 && (
<tr>
<td colSpan={7} 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.np_api_key_masked ?? <span className="cr-missing">не задано</span>}</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>
Ключ API Нової Пошти (посилки цього кабінету пробиваються через цю касу)
<input
required={!editingId}
autoComplete="off"
placeholder={editingId ? 'не змінювати' : ''}
value={form.np_api_key}
onChange={(e) => setForm({ ...form, np_api_key: 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>
)
}
+189
View File
@@ -13,6 +13,12 @@
border-bottom: 1px solid var(--color-border);
}
.dashboard-topbar-start {
display: flex;
align-items: center;
gap: 16px;
}
.dashboard-brand {
font-size: 16px;
font-weight: 700;
@@ -65,6 +71,39 @@
justify-content: space-between;
}
.orders-summary {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.orders-summary-card {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 220px;
padding: 14px 18px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 10px;
}
.orders-summary-label {
font-size: 13px;
color: var(--color-text-muted);
}
.orders-summary-value {
font-size: 22px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.orders-summary-hint {
font-size: 12px;
color: var(--color-text-muted);
}
.orders-create-btn {
border: none;
background: var(--color-primary);
@@ -85,6 +124,32 @@
cursor: not-allowed;
}
.orders-tabs {
display: flex;
gap: 8px;
}
.orders-tab {
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-muted);
border-radius: 999px;
padding: 6px 16px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.orders-tab:hover {
color: var(--color-text);
}
.orders-tab--active {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.orders-filterbar {
display: flex;
align-items: center;
@@ -198,6 +263,16 @@
color: #166534;
}
.orders-status--approved {
background: #dcfce7;
color: #166534;
}
.orders-status--danger {
background: #fee2e2;
color: #991b1b;
}
@media (prefers-color-scheme: dark) {
.orders-status--processing {
background: rgba(217, 119, 6, 0.18);
@@ -213,6 +288,55 @@
background: rgba(22, 163, 74, 0.18);
color: #86efac;
}
.orders-status--approved {
background: rgba(22, 163, 74, 0.18);
color: #86efac;
}
.orders-status--danger {
background: rgba(220, 38, 38, 0.18);
color: #fca5a5;
}
}
.orders-actions-cell {
display: flex;
gap: 8px;
}
.orders-view-btn {
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
border-radius: 6px;
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
}
.orders-view-btn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.orders-delete-btn {
border: 1px solid var(--color-border);
background: var(--color-surface);
color: #b91c1c;
border-radius: 6px;
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
}
.orders-delete-btn:hover:not(:disabled) {
border-color: #b91c1c;
}
.orders-delete-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.orders-receipt-btn {
@@ -240,3 +364,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;
}
}
+331 -88
View File
@@ -1,61 +1,140 @@
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 { ROLE_LABEL } from '@/features/auth/roles'
import { useAuth } from '@/features/auth/useAuth'
import { OrderDetailModal } from '@/features/orders/OrderDetailModal'
import type { Order, OrderTab } from '@/features/orders/types'
import { useOrders, useOrdersSummary } 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: 'Администратор',
cashier: 'Кассир',
viewer: 'Наблюдатель',
}
type OrderStatus = 'processing' | 'new' | 'delivered'
const STATUS_LABEL: Record<OrderStatus, string> = {
processing: 'Обработка',
new: 'Новый',
delivered: 'Доставлен',
}
interface OrderRow {
id: string
ttn: string
client: string
totalLabel: string
prepayment: string
status: OrderStatus
fiscalized: boolean
}
/**
* Вёрстка макета из ТЗ — статичные данные, реальная очередь «доставлено, чек
* не пробит» приедет на этапе 3+ вместе с CrmClient/StubCrmClient и заказами
* из БД. Пока не подключаем это к API, чтобы не гадать форму данных CRM.
*/
const MOCK_ORDERS: OrderRow[] = [
{ id: '#ORD-24810', ttn: '20400123456', client: 'Иван Смирнов', totalLabel: '45 990 ₴', prepayment: '', status: 'processing', fiscalized: false },
{ id: '#ORD-24809', ttn: '20400123457', client: 'Ольга Ткаченко', totalLabel: '12 450 ₴', prepayment: '0%', status: 'new', fiscalized: false },
{ id: '#ORD-24808', ttn: '20400123458', client: 'Иван Смирнов', totalLabel: '45 990 ₴', prepayment: '15%', status: 'new', fiscalized: false },
{ id: '#ORD-24807', ttn: '20400123459', client: 'Марія Бondar', totalLabel: '8 200 ₴', prepayment: '20%', status: 'delivered', fiscalized: false },
{ id: '#ORD-24806', ttn: '20400123460', client: 'Петро Коваль', totalLabel: '31 000 ₴', prepayment: '0%', status: 'delivered', fiscalized: false },
{ id: '#ORD-24805', ttn: '20400123461', client: 'Іван Смирнов', totalLabel: '45 990 ₴', prepayment: '0%', status: 'delivered', fiscalized: false },
const TABS: { key: OrderTab; label: string }[] = [
{ key: 'no_receipt', label: 'Без чека' },
{ key: 'has_receipt', label: 'Виписані чеки' },
{ key: 'received', label: 'Отримані' },
{ key: 'refused', label: 'Відмови' },
]
function npStatusTone(order: Order): 'delivered' | 'processing' | 'danger' | 'new' {
const text = (order.np_status ?? '').toLowerCase()
if (text.includes('видано') || text.includes('отримано')) return 'delivered'
if (text.includes('відмова') || text.includes('не забра') || text.includes('поверн')) return 'danger'
if (text) return 'processing'
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' }
return null
}
const MONEY_FORMAT = new Intl.NumberFormat('uk-UA', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
function formatMoney(amount: string): string {
return MONEY_FORMAT.format(Number(amount))
}
export function DashboardPage() {
const { user, logout } = useAuth()
const [tab, setTab] = useState<OrderTab>('no_receipt')
const { data: orders, isLoading, isError } = useOrders(tab)
const { data: summary } = useOrdersSummary()
const queryClient = useQueryClient()
const [selected, setSelected] = useState<Set<string>>(new Set())
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
const outcome = {
no_receipt: 'Замовлення повернеться в чергу.',
has_receipt: 'Замовлення повернеться в чергу.',
received: 'Замовлення залишиться в отриманих.',
refused: 'Замовлення залишиться у відмовах.',
}[tab]
if (!window.confirm(`Скасувати ЕТТН-чек за замовленням ${order.id}? ${outcome}`)) 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()
if (!query) return MOCK_ORDERS
return MOCK_ORDERS.filter(
const list = orders ?? []
if (!query) return list
return list.filter(
(order) =>
order.id.toLowerCase().includes(query) ||
order.ttn.includes(query) ||
order.client.toLowerCase().includes(query),
(order.waybill_number ?? '').includes(query) ||
(order.recipient_name ?? '').toLowerCase().includes(query),
)
}, [search])
}, [orders, search])
const allSelected = filtered.length > 0 && filtered.every((order) => selected.has(order.id))
@@ -72,38 +151,108 @@ export function DashboardPage() {
})
}
async function handleDelete(order: Order) {
setDeletingId(order.id)
try {
await deleteOrder(order.id)
setSelected((prev) => {
const next = new Set(prev)
next.delete(order.id)
return next
})
await queryClient.invalidateQueries({ queryKey: ['orders'] })
} finally {
setDeletingId(null)
}
}
return (
<div className="dashboard-shell">
<header className="dashboard-topbar">
<span className="dashboard-brand">lux_fiscal</span>
<div className="dashboard-topbar-start">
<Link to="/" className="orders-link-btn">
← Головне меню
</Link>
<span className="dashboard-brand">Assistant System</span>
</div>
<div className="dashboard-user">
<span>{user?.full_name}</span>
<span className="dashboard-role">{user ? (ROLE_LABEL[user.role] ?? user.role) : ''}</span>
<span className="dashboard-role">{user ? ROLE_LABEL[user.role] : ''}</span>
<button type="button" className="dashboard-logout" onClick={() => void logout()}>
Выйти
Вийти
</button>
</div>
</header>
<main className="orders-body">
<div className="orders-toolbar">
<h2>Заказы</h2>
<button type="button" className="orders-create-btn" disabled={selected.size === 0}>
Создать чеки по выбранным ({selected.size})
</button>
<h2>Замовлення</h2>
<div className="orders-toolbar-actions">
{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>
<div className="orders-summary">
<div className="orders-summary-card" title="Посилки з післяплатою, які ще не забрали і за якими немає відмови">
<span className="orders-summary-label">Післяплата в дорозі</span>
<span className="orders-summary-value">
{summary ? `${formatMoney(summary.cod_in_transit_amount)} ₴` : '—'}
</span>
<span className="orders-summary-hint">
{summary ? `${summary.cod_in_transit_count} посилок` : ' '}
</span>
</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)
setSelected(new Set())
}}
>
{t.label}
</button>
))}
</div>
<div className="orders-filterbar">
<span className="orders-selected-count">Выбрано: {selected.size} заказов</span>
<span className="orders-selected-count">Вибрано: {selected.size} замовлень</span>
<input
type="search"
className="orders-search"
placeholder="Поиск по ID, ТТН, клиенту"
placeholder="Пошук за ID, ТТН, клієнтом"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<button type="button" className="orders-filter-btn">
Фильтр
Фільтр
</button>
</div>
@@ -112,51 +261,137 @@ export function DashboardPage() {
<thead>
<tr>
<th className="orders-checkbox-col">
<input type="checkbox" checked={allSelected} onChange={toggleAll} aria-label="Выбрать всё" />
<input type="checkbox" checked={allSelected} onChange={toggleAll} aria-label="Вибрати все" />
</th>
<th>ID заказа</th>
<th>ID замовлення</th>
<th>Номер ТТН</th>
<th>Клиент</th>
<th>Сумма</th>
<th>Предоплата</th>
<th>Статус</th>
<th>Действия</th>
<th>Клієнт</th>
<th>Сума</th>
<th>Післяплата</th>
<th>Оплачено</th>
<th>Передоплата, ₴</th>
<th>Статус ТТН</th>
<th>Дії</th>
</tr>
</thead>
<tbody>
{filtered.map((order) => (
<tr key={order.id}>
<td>
<input
type="checkbox"
checked={selected.has(order.id)}
onChange={() => toggleOne(order.id)}
aria-label={`Выбрать заказ ${order.id}`}
/>
</td>
<td>{order.id}</td>
<td>{order.ttn}</td>
<td>{order.client}</td>
<td>{order.totalLabel}</td>
<td>
<input type="text" className="orders-prepayment-input" defaultValue={order.prepayment} placeholder="0%" />
</td>
<td>
<span className={`orders-status orders-status--${order.status}`}>
{STATUS_LABEL[order.status]}
</span>
</td>
<td>
<button type="button" className="orders-receipt-btn" disabled={order.status !== 'delivered'}>
Чек
</button>
{isLoading && (
<tr>
<td colSpan={10} className="orders-empty">
Завантаження…
</td>
</tr>
))}
{filtered.length === 0 && (
)}
{isError && (
<tr>
<td colSpan={8} className="orders-empty">
Ничего не найдено
<td colSpan={10} className="orders-empty">
Не вдалося завантажити замовлення з CRM
</td>
</tr>
)}
{!isLoading &&
!isError &&
filtered.map((order) => {
const badge = paymentBadge(order)
return (
<tr key={order.id}>
<td>
<input
type="checkbox"
checked={selected.has(order.id)}
onChange={() => toggleOne(order.id)}
aria-label={`Вибрати замовлення ${order.id}`}
/>
</td>
<td>{order.id}</td>
<td>{order.waybill_number || '—'}</td>
<td>{order.recipient_name || '—'}</td>
<td>{order.total_amount} ₴</td>
<td>{order.np_cod_amount ? `${order.np_cod_amount} ₴` : '—'}</td>
<td>
{badge ? (
<span className={`orders-status orders-status--${badge.tone}`}>{badge.label}</span>
) : (
'—'
)}
</td>
<td>
{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)}`}>
{order.np_status || 'Немає даних'}
</span>
</td>
<td className="orders-actions-cell">
<button type="button" className="orders-view-btn" onClick={() => setViewingOrder(order)}>
Перегляд
</button>
{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 !== 'no_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 !== 'no_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' || ((tab === 'refused' || tab === 'received') && !order.has_receipt)) && (
<button
type="button"
className="orders-delete-btn"
disabled={deletingId === order.id}
onClick={() => void handleDelete(order)}
>
Видалити
</button>
)}
</td>
</tr>
)
})}
{!isLoading && !isError && filtered.length === 0 && (
<tr>
<td colSpan={10} className="orders-empty">
Нічого не знайдено
</td>
</tr>
)}
@@ -164,6 +399,14 @@ export function DashboardPage() {
</table>
</div>
</main>
{viewingOrder && (
<OrderDetailModal
order={orders?.find((order) => order.id === viewingOrder.id) ?? viewingOrder}
canEdit={canFiscalize && tab === 'no_receipt'}
onClose={() => setViewingOrder(null)}
/>
)}
</div>
)
}
+458
View File
@@ -0,0 +1,458 @@
/* Главное меню: плитки модулей. Палитра и «стекло» — как на странице входа. */
.menu-page {
--menu-accent: #7c3aed;
--menu-gradient: linear-gradient(135deg, var(--color-primary), var(--menu-accent));
--menu-glass: rgba(255, 255, 255, 0.72);
--menu-glass-border: rgba(255, 255, 255, 0.6);
--menu-tint: color-mix(in srgb, var(--color-primary) 10%, transparent);
--menu-shadow: 0 1px 2px rgba(16, 24, 40, 0.06);
--menu-shadow-hover: 0 20px 40px -16px color-mix(in srgb, var(--color-primary) 45%, transparent);
--menu-font-display: 'Unbounded', var(--font-sans);
--menu-font-body: 'Manrope', var(--font-sans);
min-height: 100svh;
display: flex;
flex-direction: column;
gap: 44px;
padding: 32px 64px 48px;
/* Тот же фон, что на странице входа. */
background:
radial-gradient(1200px 600px at 10% -10%, rgba(37, 99, 235, 0.14), transparent 60%),
radial-gradient(900px 500px at 110% 110%, rgba(124, 58, 237, 0.14), transparent 60%),
var(--color-bg);
background-attachment: fixed;
color: var(--color-text);
font-family: var(--menu-font-body);
}
@media (prefers-color-scheme: dark) {
.menu-page {
--menu-accent: #a78bfa;
--menu-glass: rgba(23, 26, 33, 0.7);
--menu-glass-border: rgba(255, 255, 255, 0.08);
--menu-tint: color-mix(in srgb, var(--color-primary) 18%, transparent);
--menu-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
}
/* Стеклянная поверхность, как карточка входа. */
.menu-search,
.menu-profile,
.menu-tile {
background: var(--menu-glass);
border: 1px solid var(--menu-glass-border);
box-shadow: var(--menu-shadow);
backdrop-filter: blur(18px) saturate(140%);
-webkit-backdrop-filter: blur(18px) saturate(140%);
}
/* --- Шапка --- */
.menu-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
flex-wrap: wrap;
}
.menu-brand {
display: flex;
align-items: center;
gap: 14px;
}
.menu-logo {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background: var(--menu-gradient);
color: #fff;
box-shadow: 0 10px 24px -8px var(--color-primary);
}
.menu-brand-name {
font-family: var(--menu-font-display);
font-weight: 600;
font-size: 18px;
letter-spacing: -0.01em;
}
.menu-header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.menu-search {
width: 380px;
height: 48px;
padding: 0 16px;
display: flex;
align-items: center;
gap: 10px;
border-radius: 14px;
color: var(--color-text-muted);
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.menu-search:focus-within {
border-color: var(--color-primary);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-primary) 18%, transparent);
}
.menu-search input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
background: transparent;
color: var(--color-text);
font: inherit;
font-size: 15px;
}
.menu-kbd {
font-family: inherit;
font-size: 12px;
font-weight: 600;
padding: 3px 7px;
border-radius: 6px;
background: var(--menu-tint);
color: var(--color-text-muted);
white-space: nowrap;
}
.menu-profile {
height: 48px;
padding: 0 6px;
display: flex;
align-items: center;
gap: 10px;
border-radius: 14px;
}
.menu-avatar {
width: 36px;
height: 36px;
flex-shrink: 0;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: var(--menu-gradient);
color: #fff;
font-weight: 700;
font-size: 14px;
}
.menu-profile-text {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.menu-profile-name {
font-size: 15px;
font-weight: 600;
}
.menu-profile-role {
font-size: 12px;
color: var(--color-text-muted);
}
.menu-logout {
width: 36px;
height: 36px;
border: 0;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
}
.menu-logout:hover {
background: color-mix(in srgb, var(--color-text) 8%, transparent);
color: var(--color-text);
}
.menu-logout:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* --- Приветствие --- */
.menu-main {
display: flex;
flex-direction: column;
gap: 44px;
}
.menu-hero {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 40px;
}
.menu-hero-title {
display: flex;
flex-direction: column;
gap: 14px;
}
.menu-eyebrow {
font-size: 14px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-primary);
}
.menu-hero h1 {
font-family: var(--menu-font-display);
font-weight: 600;
font-size: 54px;
line-height: 1.05;
letter-spacing: -0.03em;
background: linear-gradient(135deg, var(--color-text) 30%, var(--color-primary));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.menu-hero p {
max-width: 360px;
font-size: 16px;
line-height: 1.55;
color: var(--color-text-muted);
}
/* --- Плитки модулей --- */
.menu-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 20px;
}
.menu-tile {
height: 100%;
min-height: 220px;
padding: 26px;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 16px;
border-radius: 22px;
color: var(--color-text);
text-decoration: none;
transition:
transform 0.18s ease,
box-shadow 0.18s ease,
border-color 0.18s ease;
}
.menu-tile:hover {
transform: translateY(-3px);
box-shadow: var(--menu-shadow-hover);
border-color: color-mix(in srgb, var(--color-primary) 55%, transparent);
}
.menu-tile:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 3px;
}
.menu-tile-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.menu-tile-icon {
width: 56px;
height: 56px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
background: var(--menu-tint);
color: var(--color-primary);
}
.menu-tile-num {
font-family: var(--menu-font-display);
font-size: 13px;
font-weight: 500;
color: var(--color-text-muted);
}
.menu-tile-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.menu-tile-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-family: var(--menu-font-display);
font-size: 20px;
font-weight: 600;
letter-spacing: -0.01em;
}
.menu-tile-arrow {
display: flex;
color: var(--color-primary);
opacity: 0.35;
transform: translateX(-4px);
transition:
opacity 0.18s ease,
transform 0.18s ease;
}
.menu-tile:hover .menu-tile-arrow,
.menu-tile:focus-visible .menu-tile-arrow {
opacity: 1;
transform: translateX(0);
}
.menu-tile-desc {
font-size: 14px;
line-height: 1.5;
color: var(--color-text-muted);
}
.menu-empty {
padding: 48px 0;
text-align: center;
color: var(--color-text-muted);
}
@media (prefers-reduced-motion: reduce) {
.menu-tile,
.menu-tile-arrow {
transition: none;
}
.menu-tile:hover {
transform: none;
}
}
/* --- Адаптив --- */
@media (max-width: 1200px) {
.menu-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 960px) {
.menu-page {
padding: 24px 32px 40px;
}
.menu-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.menu-hero {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.menu-hero h1 {
font-size: 40px;
}
}
@media (max-width: 640px) {
.menu-page {
gap: 22px;
padding: 20px 16px 28px;
}
.menu-main {
gap: 22px;
}
.menu-header-actions {
display: contents;
}
/* Поиск уходит на отдельную строку под шапкой, на всю ширину. */
.menu-search {
order: 1;
width: 100%;
}
.menu-kbd,
.menu-profile-text,
.menu-hero p {
display: none;
}
.menu-brand-name {
font-size: 15px;
}
.menu-eyebrow {
font-size: 12px;
}
.menu-hero-title {
gap: 8px;
}
.menu-hero h1 {
font-size: 30px;
line-height: 1.1;
}
.menu-grid {
gap: 12px;
}
.menu-tile {
min-height: 0;
padding: 16px;
gap: 12px;
border-radius: 18px;
}
.menu-tile-icon {
width: 44px;
height: 44px;
border-radius: 12px;
}
.menu-tile-num,
.menu-tile-arrow,
.menu-tile-desc {
display: none;
}
.menu-tile-title {
font-size: 15px;
}
}
+157
View File
@@ -0,0 +1,157 @@
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import '@/pages/MainMenuPage.css'
import { ROLE_LABEL } from '@/features/auth/roles'
import { useAuth } from '@/features/auth/useAuth'
import { canAccess, MODULES } from '@/features/menu/modules'
function initials(fullName: string): string {
const letters = fullName
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((word) => word[0])
.join('')
return letters.toUpperCase() || '?'
}
function Icon({ d, size }: { d: string; size: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d={d} />
</svg>
)
}
export function MainMenuPage() {
const { user, logout } = useAuth()
const [query, setQuery] = useState('')
const searchRef = useRef<HTMLInputElement>(null)
const searchId = useId()
const available = useMemo(() => MODULES.filter((module) => canAccess(user, module.roles)), [user])
const visible = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return available
return available.filter(
(module) => module.title.toLowerCase().includes(q) || module.description.toLowerCase().includes(q),
)
}, [available, query])
// Ctrl+K / ⌘K — фокус на поиске модулей.
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
event.preventDefault()
searchRef.current?.focus()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
return (
<div className="menu-page">
<header className="menu-header">
<div className="menu-brand">
<span className="menu-logo">
<Icon d="M4 4h7v7H4z M13 13h7v7h-7z M13 4h7v7h-7z" size={22} />
</span>
<span className="menu-brand-name">Assistant System</span>
</div>
<div className="menu-header-actions">
<div className="menu-search">
<label htmlFor={searchId} className="visually-hidden">
Пошук модулів
</label>
<Icon d="M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14z M20 20l-4-4" size={18} />
<input
ref={searchRef}
id={searchId}
type="search"
placeholder="Знайти модуль…"
autoComplete="off"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') setQuery('')
}}
/>
<kbd className="menu-kbd">Ctrl K</kbd>
</div>
{user && (
<div className="menu-profile">
<span className="menu-avatar" aria-hidden="true">
{initials(user.full_name)}
</span>
<span className="menu-profile-text">
<span className="menu-profile-name">{user.full_name}</span>
<span className="menu-profile-role">{ROLE_LABEL[user.role]}</span>
</span>
<button type="button" className="menu-logout" onClick={() => void logout()} aria-label="Вийти">
<Icon d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3 M10 17l5-5-5-5 M15 12H4" size={18} />
</button>
</div>
)}
</div>
</header>
<main className="menu-main">
<section className="menu-hero">
<div className="menu-hero-title">
<span className="menu-eyebrow">Головне меню</span>
<h1>З чого почнемо сьогодні?</h1>
</div>
<p>Оберіть модуль, щоб перейти до роботи.</p>
</section>
<nav aria-label="Модулі">
{visible.length > 0 ? (
<ul className="menu-grid">
{visible.map((module, index) => (
<li key={module.id}>
<Link to={module.path} className="menu-tile">
<span className="menu-tile-top">
<span className="menu-tile-icon">
<Icon d={module.icon} size={26} />
</span>
<span className="menu-tile-num" aria-hidden="true">
{String(index + 1).padStart(2, '0')}
</span>
</span>
<span className="menu-tile-body">
<span className="menu-tile-title">
{module.title}
<span className="menu-tile-arrow">
<Icon d="M5 12h14 M13 6l6 6-6 6" size={20} />
</span>
</span>
<span className="menu-tile-desc">{module.description}</span>
</span>
</Link>
</li>
))}
</ul>
) : (
<p className="menu-empty">
{available.length === 0 ? 'Для вашої ролі поки немає доступних модулів' : 'Нічого не знайдено'}
</p>
)}
</nav>
</main>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More