Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b7a92645a | ||
|
|
418f6b0352 | ||
|
|
44fe648ebc | ||
|
|
d92c715c11 | ||
|
|
8fffdc3ce6 | ||
|
|
0ba2ca01c4 | ||
|
|
b8f2fe6b6e | ||
|
|
47df3a78dc | ||
|
|
f6971a612f | ||
|
|
8b3c2b6d63 | ||
|
|
2b92d82693 | ||
|
|
a7cba7616a | ||
|
|
ed635cab94 | ||
|
|
a6072800b2 | ||
|
|
528a869657 | ||
|
|
f88b5cccc0 | ||
|
|
56ac0fc370 | ||
|
|
cbf9832e5b | ||
|
|
3a6ad85d11 | ||
|
|
912a37c00c | ||
|
|
089e0a4011 | ||
|
|
cedcd3caff | ||
|
|
518a99197f |
+17
-1
@@ -56,7 +56,23 @@ CRM_API_KEY=change-me-crm-apikey
|
|||||||
CRM_SECRET_KEY=change-me-crm-secretkey
|
CRM_SECRET_KEY=change-me-crm-secretkey
|
||||||
CRM_SHOP_KEY=change-me-crm-shopkey
|
CRM_SHOP_KEY=change-me-crm-shopkey
|
||||||
CRM_SID=1
|
CRM_SID=1
|
||||||
|
# Стаб вместо реальной CRM (локально — обязательно вместе с CHECKBOX_USE_STUB):
|
||||||
|
# иначе после стаб-чека боевой заказ уйдёт в PACKED. В production запрещено.
|
||||||
|
CRM_USE_STUB=true
|
||||||
|
|
||||||
# --- Nova Poshta ----------------------------------------------------------
|
# --- Nova Poshta ----------------------------------------------------------
|
||||||
# Ключ API кабинета Nova Poshta (my.novaposhta.ua → Настройки → API).
|
# Ключи API Nova Poshta задаются у касс (страница «Кассы»). Эта переменная нужна
|
||||||
|
# только миграции 0008: переносит старый общий ключ в кассу по умолчанию.
|
||||||
NOVA_POSHTA_API_KEY=change-me-novaposhta-apikey
|
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
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
@@ -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
|
||||||
@@ -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`. Отмену проверять на этом же чеке
|
||||||
|
до выдачи посылки (или на ТТН, которую всё равно отменяем) — лишних фискальных чеков не создавать.
|
||||||
@@ -19,6 +19,10 @@ backend/ FastAPI + SQLAlchemy (async) + Alembic + Postgres — see backend/ap
|
|||||||
frontend/ React 19 + TypeScript + Vite — see frontend/src/
|
frontend/ React 19 + TypeScript + Vite — see frontend/src/
|
||||||
docker/ nginx.frontend.conf (SPA + /api reverse proxy to the api container)
|
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.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.
|
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.
|
`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
|
## 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)
|
## 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()`.
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
| 2 | Аутентификация, роли, журнал аудита | ✅ готово |
|
| 2 | Аутентификация, роли, журнал аудита | ✅ готово |
|
||||||
| 3 | Заказы + заглушка CRM | ✅ готово |
|
| 3 | Заказы + заглушка CRM | ✅ готово |
|
||||||
| 4 | Нова Пошта: статусы и сумма послеоплаты | ✅ готово |
|
| 4 | Нова Пошта: статусы и сумма послеоплаты | ✅ готово |
|
||||||
| 5 | Checkbox: смены, чеки, PDF | ⏳ |
|
| 5 | Checkbox: ЕТТН-чеки по ТТН с контролем оплаты (план: `.plans/checkbox-ettn-receipts.md`) | 🚧 код готов, ждёт проверки на боевой кассе |
|
||||||
| 6 | Очередь и редактор чека (фронтенд) | ⏳ |
|
| 6 | Очередь и редактор чека (фронтенд) | ⏳ |
|
||||||
| 7 | Админка | ⏳ |
|
| 7 | Админка | ⏳ |
|
||||||
| 8 | Реальный API CRM | ⏳ |
|
| 8 | Реальный API CRM | ⏳ |
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Кассы Checkbox и ЕТТН-чеки
|
||||||
|
|
||||||
|
Revision ID: 0005
|
||||||
|
Revises: 0004
|
||||||
|
Create Date: 2026-09-23
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "0005"
|
||||||
|
down_revision: str | None = "0004"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
receipt_status = postgresql.ENUM(
|
||||||
|
"pending",
|
||||||
|
"created",
|
||||||
|
"done",
|
||||||
|
"returned",
|
||||||
|
"receipt_error",
|
||||||
|
"cancelled",
|
||||||
|
"failed",
|
||||||
|
name="receipt_status",
|
||||||
|
create_type=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
receipt_status.create(op.get_bind(), checkfirst=True)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"cash_registers",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("fiscal_number", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("license_key_enc", sa.String(length=512), nullable=False),
|
||||||
|
sa.Column("cashier_pin_enc", sa.String(length=512), nullable=False),
|
||||||
|
sa.Column("tax_codes", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("is_default", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_cash_registers")),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"uq_cash_registers_default",
|
||||||
|
"cash_registers",
|
||||||
|
["is_default"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text("is_default"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"receipts",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("order_id", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("cash_register_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("created_by_id", sa.Uuid(), nullable=True),
|
||||||
|
sa.Column("waybill_number", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("total_kopecks", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("prepayment_kopecks", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("cod_kopecks", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("status", receipt_status, nullable=False),
|
||||||
|
sa.Column("checkbox_ettn_id", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("checkbox_status", sa.String(length=32), nullable=True),
|
||||||
|
sa.Column("checkbox_receipt_id", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("request_body", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column("last_checked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["order_id"],
|
||||||
|
["orders.id"],
|
||||||
|
name=op.f("fk_receipts_order_id_orders"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["cash_register_id"],
|
||||||
|
["cash_registers.id"],
|
||||||
|
name=op.f("fk_receipts_cash_register_id_cash_registers"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["created_by_id"],
|
||||||
|
["users.id"],
|
||||||
|
name=op.f("fk_receipts_created_by_id_users"),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_receipts")),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_receipts_order_id"), "receipts", ["order_id"])
|
||||||
|
op.create_index(op.f("ix_receipts_cash_register_id"), "receipts", ["cash_register_id"])
|
||||||
|
op.create_index(op.f("ix_receipts_status"), "receipts", ["status"])
|
||||||
|
op.create_index(op.f("ix_receipts_checkbox_ettn_id"), "receipts", ["checkbox_ettn_id"])
|
||||||
|
op.create_index(
|
||||||
|
"uq_receipts_active_order",
|
||||||
|
"receipts",
|
||||||
|
["order_id"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text("status NOT IN ('cancelled', 'failed')"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("receipts")
|
||||||
|
op.drop_table("cash_registers")
|
||||||
|
receipt_status.drop(op.get_bind(), checkfirst=True)
|
||||||
@@ -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")
|
||||||
@@ -10,12 +10,12 @@ from fastapi import Depends, HTTPException, status
|
|||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.security import TokenError, decode_access_token
|
from app.core.security import TokenError, decode_access_token
|
||||||
from app.db.models.user import User, UserRole
|
from app.db.models.user import User, UserRole
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.services.crm.client import CrmClient
|
from app.services.checkbox.client import CheckboxClient, get_checkbox_client
|
||||||
from app.services.crm.exo_client import ExoCrmClient
|
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)
|
bearer_scheme = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
|||||||
|
|
||||||
_UNAUTHORIZED = HTTPException(
|
_UNAUTHORIZED = HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Требуется аутентификация",
|
detail="Потрібна автентифікація",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ def require_roles(*roles: UserRole) -> Callable[..., Coroutine[Any, Any, User]]:
|
|||||||
if user.role not in allowed:
|
if user.role not in allowed:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Недостаточно прав для этого действия",
|
detail="Недостатньо прав для цієї дії",
|
||||||
)
|
)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@@ -90,8 +90,8 @@ AdminUser = Annotated[User, Depends(require_admin)]
|
|||||||
CashierUser = Annotated[User, Depends(require_cashier)]
|
CashierUser = Annotated[User, Depends(require_cashier)]
|
||||||
|
|
||||||
|
|
||||||
def get_crm_client() -> CrmClient:
|
|
||||||
return ExoCrmClient(settings)
|
|
||||||
|
|
||||||
|
|
||||||
CrmClientDep = Annotated[CrmClient, Depends(get_crm_client)]
|
CrmClientDep = Annotated[CrmClient, Depends(get_crm_client)]
|
||||||
|
|
||||||
|
|
||||||
|
CheckboxClientDep = Annotated[CheckboxClient, Depends(get_checkbox_client)]
|
||||||
|
TaskQueueDep = Annotated[TaskQueue, Depends(get_task_queue)]
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ async def change_password(
|
|||||||
) -> Response:
|
) -> Response:
|
||||||
if not verify_password(payload.current_password, user.password_hash):
|
if not verify_password(payload.current_password, user.password_hash):
|
||||||
raise HTTPException(
|
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)
|
user.password_hash = hash_password(payload.new_password)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Локальная очередь заказов (синхронизируется из CRM) — список, вкладки, удаление."""
|
"""Локальная очередь заказов (синхронизируется из CRM) — список, вкладки, правка, удаление."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -6,22 +6,67 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|||||||
|
|
||||||
from app.api.deps import CashierUser, CrmClientDep, SessionDep, require_any
|
from app.api.deps import CashierUser, CrmClientDep, SessionDep, require_any
|
||||||
from app.db.models.audit import AuditAction
|
from app.db.models.audit import AuditAction
|
||||||
from app.schemas.orders import OrderRowOut
|
from app.schemas.orders import OrderRowOut, OrdersSummaryOut, OrderUpdateIn
|
||||||
from app.services import audit
|
from app.services import audit
|
||||||
from app.services import orders as orders_service
|
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 = APIRouter(prefix="/orders", tags=["orders"], dependencies=[Depends(require_any)])
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[OrderRowOut], response_model_by_alias=False)
|
@router.get("", response_model=list[OrderRowOut], response_model_by_alias=False)
|
||||||
async def list_orders(
|
async def list_orders(
|
||||||
session: SessionDep, crm: CrmClientDep, has_receipt: bool = False
|
session: SessionDep, crm: CrmClientDep, tab: OrderTab = OrderTab.NO_RECEIPT
|
||||||
) -> list[OrderRowOut]:
|
) -> list[OrderRowOut]:
|
||||||
# by_alias=False: без этого FastAPI сериализует вложенные OrderGoodOut через
|
# by_alias=False: без этого FastAPI сериализует вложенные OrderGoodOut через
|
||||||
# CRM-алиасы (SKU/Name/...) вместо snake_case полей, которые ждёт фронтенд.
|
# CRM-алиасы (SKU/Name/...) вместо snake_case полей, которые ждёт фронтенд.
|
||||||
await orders_service.sync_orders_from_crm(session, crm)
|
await orders_service.sync_orders_from_crm(session, crm)
|
||||||
orders = await orders_service.list_orders(session, has_receipt=has_receipt)
|
orders = await orders_service.list_orders(session, tab=tab)
|
||||||
return [OrderRowOut.from_order(order) for order in orders]
|
receipts = await receipts_service.latest_receipts_by_order(
|
||||||
|
session, [order.id for order in orders]
|
||||||
|
)
|
||||||
|
return [OrderRowOut.from_order(order, receipts.get(order.id)) for order in orders]
|
||||||
|
|
||||||
|
|
||||||
|
@router.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)
|
@router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -30,7 +75,7 @@ async def delete_order(
|
|||||||
) -> None:
|
) -> None:
|
||||||
order = await orders_service.delete_order(session, order_id)
|
order = await orders_service.delete_order(session, order_id)
|
||||||
if order is None:
|
if order is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Заказ не найден")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Замовлення не знайдено")
|
||||||
|
|
||||||
await audit.record(
|
await audit.record(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""ЕТТН-чеки Checkbox: запрос создания (массово), просмотр, отмена."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.deps import (
|
||||||
|
CashierUser,
|
||||||
|
CheckboxClientDep,
|
||||||
|
SessionDep,
|
||||||
|
TaskQueueDep,
|
||||||
|
require_any,
|
||||||
|
)
|
||||||
|
from app.db.models.receipt import Receipt
|
||||||
|
from app.schemas.receipts import ReceiptCreateRequest, ReceiptCreateResponse, ReceiptOut
|
||||||
|
from app.services import receipts as receipts_service
|
||||||
|
from app.services.checkbox.client import CheckboxError
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/receipts", tags=["receipts"], dependencies=[Depends(require_any)])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ReceiptCreateResponse, status_code=status.HTTP_202_ACCEPTED)
|
||||||
|
async def create_receipts(
|
||||||
|
payload: ReceiptCreateRequest,
|
||||||
|
request: Request,
|
||||||
|
session: SessionDep,
|
||||||
|
queue: TaskQueueDep,
|
||||||
|
actor: CashierUser,
|
||||||
|
) -> ReceiptCreateResponse:
|
||||||
|
result = await receipts_service.request_receipts(
|
||||||
|
session,
|
||||||
|
[(item.order_id, item.prepayment_kopecks) for item in payload.items],
|
||||||
|
user=actor,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# После commit: worker должен увидеть строку `pending`.
|
||||||
|
for receipt in result.created:
|
||||||
|
await queue.enqueue("create_ettn_receipt", str(receipt.id))
|
||||||
|
|
||||||
|
return ReceiptCreateResponse(
|
||||||
|
created=[ReceiptOut.from_receipt(receipt) for receipt in result.created],
|
||||||
|
errors=result.errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[ReceiptOut])
|
||||||
|
async def list_receipts(session: SessionDep, order_id: str | None = None) -> list[ReceiptOut]:
|
||||||
|
query = select(Receipt).order_by(Receipt.created_at.desc()).limit(500)
|
||||||
|
if order_id is not None:
|
||||||
|
query = query.where(Receipt.order_id == order_id)
|
||||||
|
return [ReceiptOut.from_receipt(receipt) for receipt in await session.scalars(query)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{receipt_id}", response_model=ReceiptOut)
|
||||||
|
async def get_receipt(receipt_id: uuid.UUID, session: SessionDep) -> ReceiptOut:
|
||||||
|
receipt = await session.get(Receipt, receipt_id)
|
||||||
|
if receipt is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Чек не знайдено")
|
||||||
|
return ReceiptOut.from_receipt(receipt)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{receipt_id}/cancel", response_model=ReceiptOut)
|
||||||
|
async def cancel_receipt(
|
||||||
|
receipt_id: uuid.UUID,
|
||||||
|
request: Request,
|
||||||
|
session: SessionDep,
|
||||||
|
checkbox: CheckboxClientDep,
|
||||||
|
actor: CashierUser,
|
||||||
|
) -> ReceiptOut:
|
||||||
|
try:
|
||||||
|
receipt = await receipts_service.cancel_receipt(
|
||||||
|
session, checkbox, receipt_id, user=actor, request=request
|
||||||
|
)
|
||||||
|
except receipts_service.ReceiptStateError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||||
|
except CheckboxError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Checkbox: {exc}"
|
||||||
|
) from exc
|
||||||
|
if receipt is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Чек не знайдено")
|
||||||
|
return ReceiptOut.from_receipt(receipt)
|
||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.v1 import auth, health, orders, users
|
from app.api.v1 import auth, cash_registers, health, orders, receipts, users
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
api_router.include_router(health.router)
|
api_router.include_router(health.router)
|
||||||
api_router.include_router(auth.router)
|
api_router.include_router(auth.router)
|
||||||
api_router.include_router(users.router)
|
api_router.include_router(users.router)
|
||||||
api_router.include_router(orders.router)
|
api_router.include_router(orders.router)
|
||||||
|
api_router.include_router(receipts.router)
|
||||||
|
api_router.include_router(cash_registers.router)
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ async def create_user(
|
|||||||
await session.rollback()
|
await session.rollback()
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
detail="Пользователь с таким email уже существует",
|
detail="Користувач із таким email уже існує",
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
return UserOut.model_validate(user)
|
return UserOut.model_validate(user)
|
||||||
@@ -87,7 +87,7 @@ async def update_user(
|
|||||||
) -> UserOut:
|
) -> UserOut:
|
||||||
user = await session.get(User, user_id)
|
user = await session.get(User, user_id)
|
||||||
if user is None:
|
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)
|
changes = payload.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
@@ -97,12 +97,12 @@ async def update_user(
|
|||||||
if changes.get("is_active") is False:
|
if changes.get("is_active") is False:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Нельзя отключить собственную учётную запись",
|
detail="Не можна вимкнути власний обліковий запис",
|
||||||
)
|
)
|
||||||
if "role" in changes and changes["role"] != UserRole.ADMIN:
|
if "role" in changes and changes["role"] != UserRole.ADMIN:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Нельзя снять с себя роль администратора",
|
detail="Не можна зняти із себе роль адміністратора",
|
||||||
)
|
)
|
||||||
|
|
||||||
if (new_password := changes.pop("password", None)) is not None:
|
if (new_password := changes.pop("password", None)) is not None:
|
||||||
|
|||||||
+5
-5
@@ -25,7 +25,7 @@ async def bootstrap() -> int:
|
|||||||
Идемпотентна: повторный запуск ничего не меняет и пароль не сбрасывает.
|
Идемпотентна: повторный запуск ничего не меняет и пароль не сбрасывает.
|
||||||
"""
|
"""
|
||||||
if not settings.first_admin_password:
|
if not settings.first_admin_password:
|
||||||
print("FIRST_ADMIN_PASSWORD не задан в .env", file=sys.stderr)
|
print("FIRST_ADMIN_PASSWORD не задано в .env", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
email = settings.first_admin_email.strip().lower()
|
email = settings.first_admin_email.strip().lower()
|
||||||
@@ -33,7 +33,7 @@ async def bootstrap() -> int:
|
|||||||
async with session_scope() as session:
|
async with session_scope() as session:
|
||||||
existing = await session.scalar(select(User).where(User.email == email))
|
existing = await session.scalar(select(User).where(User.email == email))
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
print(f"Пользователь {email} уже существует — ничего не изменено.")
|
print(f"Користувач {email} уже існує — нічого не змінено.")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
session.add(
|
session.add(
|
||||||
@@ -45,8 +45,8 @@ async def bootstrap() -> int:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
print(f"Администратор {email} создан.")
|
print(f"Адміністратора {email} створено.")
|
||||||
print("Смените пароль после первого входа и уберите FIRST_ADMIN_PASSWORD из .env.")
|
print("Змініть пароль після першого входу та приберіть FIRST_ADMIN_PASSWORD з .env.")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ def gen_keys() -> int:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> 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"])
|
parser.add_argument("command", choices=["bootstrap", "gen-keys"])
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ class Settings(BaseSettings):
|
|||||||
# --- Первый администратор (только для команды bootstrap) ---
|
# --- Первый администратор (только для команды bootstrap) ---
|
||||||
first_admin_email: str = "admin@example.com"
|
first_admin_email: str = "admin@example.com"
|
||||||
first_admin_password: str = ""
|
first_admin_password: str = ""
|
||||||
first_admin_name: str = "Администратор"
|
first_admin_name: str = "Адміністратор"
|
||||||
|
|
||||||
# --- CRM ---
|
# --- CRM ---
|
||||||
crm_base_url: str = "https://optstore.exocrm.com/api/1.1/"
|
crm_base_url: str = "https://optstore.exocrm.com/api/1.1/"
|
||||||
@@ -61,10 +61,26 @@ class Settings(BaseSettings):
|
|||||||
crm_secret_key: str = ""
|
crm_secret_key: str = ""
|
||||||
crm_shop_key: str = ""
|
crm_shop_key: str = ""
|
||||||
crm_sid: int = 1
|
crm_sid: int = 1
|
||||||
|
# Стаб вместо реальной CRM: иначе локальный worker переводил бы боевые заказы
|
||||||
|
# в PACKED после стаб-чеков Checkbox. В проде запрещено.
|
||||||
|
crm_use_stub: bool = False
|
||||||
|
|
||||||
# --- Nova Poshta ---
|
# --- Nova Poshta ---
|
||||||
|
# Ключи НП хранятся у касс (`cash_registers.np_api_key_enc`). Эта переменная
|
||||||
|
# читается только миграцией 0008: переносит старый общий ключ в кассу по умолчанию.
|
||||||
nova_poshta_api_key: str = ""
|
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
|
@property
|
||||||
def cors_origins(self) -> list[str]:
|
def cors_origins(self) -> list[str]:
|
||||||
"""Список разрешённых origin'ов из строки через запятую."""
|
"""Список разрешённых origin'ов из строки через запятую."""
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ def _fernet() -> Fernet:
|
|||||||
return Fernet(settings.encryption_key.encode())
|
return Fernet(settings.encryption_key.encode())
|
||||||
except (ValueError, TypeError) as exc:
|
except (ValueError, TypeError) as exc:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"ENCRYPTION_KEY некорректен. Сгенерируйте валидный ключ: "
|
"ENCRYPTION_KEY некоректний. Згенеруйте валідний ключ: "
|
||||||
"python -c \"from cryptography.fernet import Fernet; "
|
"python -c \"from cryptography.fernet import Fernet; "
|
||||||
'print(Fernet.generate_key().decode())"'
|
'print(Fernet.generate_key().decode())"'
|
||||||
) from exc
|
) from exc
|
||||||
@@ -41,8 +41,8 @@ def decrypt(value: str) -> str:
|
|||||||
return _fernet().decrypt(value.encode()).decode()
|
return _fernet().decrypt(value.encode()).decode()
|
||||||
except InvalidToken as exc:
|
except InvalidToken as exc:
|
||||||
raise DecryptionError(
|
raise DecryptionError(
|
||||||
"Не удалось расшифровать значение. Вероятная причина — ENCRYPTION_KEY "
|
"Не вдалося розшифрувати значення. Імовірна причина — ENCRYPTION_KEY "
|
||||||
"изменился с момента сохранения. Секрет нужно ввести заново."
|
"змінився з моменту збереження. Секрет потрібно ввести заново."
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -67,13 +67,13 @@ def decode_access_token(token: str) -> dict[str, Any]:
|
|||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
|
payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
|
||||||
except jwt.ExpiredSignatureError as exc:
|
except jwt.ExpiredSignatureError as exc:
|
||||||
raise TokenError("Срок действия токена истёк") from exc
|
raise TokenError("Термін дії токена минув") from exc
|
||||||
except jwt.InvalidTokenError as exc:
|
except jwt.InvalidTokenError as exc:
|
||||||
raise TokenError("Некорректный токен") from exc
|
raise TokenError("Некоректний токен") from exc
|
||||||
|
|
||||||
# Без этой проверки refresh-токен можно было бы предъявить как access.
|
# Без этой проверки refresh-токен можно было бы предъявить как access.
|
||||||
if payload.get("type") != "access":
|
if payload.get("type") != "access":
|
||||||
raise TokenError("Ожидался access-токен")
|
raise TokenError("Очікувався access-токен")
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,19 @@ Alembic автогенерирует миграции по `Base.metadata`, по
|
|||||||
|
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
from app.db.models.audit import AuditAction, AuditLog
|
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.order import Order
|
||||||
|
from app.db.models.receipt import Receipt, ReceiptStatus
|
||||||
from app.db.models.user import RefreshToken, User, UserRole
|
from app.db.models.user import RefreshToken, User, UserRole
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AuditAction",
|
"AuditAction",
|
||||||
"AuditLog",
|
"AuditLog",
|
||||||
"Base",
|
"Base",
|
||||||
|
"CashRegister",
|
||||||
"Order",
|
"Order",
|
||||||
|
"Receipt",
|
||||||
|
"ReceiptStatus",
|
||||||
"RefreshToken",
|
"RefreshToken",
|
||||||
"User",
|
"User",
|
||||||
"UserRole",
|
"UserRole",
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ class AuditAction(str):
|
|||||||
USER_UPDATED = "user.updated"
|
USER_UPDATED = "user.updated"
|
||||||
USER_DEACTIVATED = "user.deactivated"
|
USER_DEACTIVATED = "user.deactivated"
|
||||||
ORDER_DELETED = "order.deleted"
|
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):
|
class AuditLog(UUIDPrimaryKeyMixin, Base):
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -7,10 +7,11 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import BigInteger, Boolean, DateTime, String, Text
|
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, String, Text
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
@@ -41,6 +42,10 @@ class Order(TimestampMixin, Base):
|
|||||||
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
||||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
# Заказ правили вручную в карточке — синхронизация из CRM его больше не
|
||||||
|
# перезаписывает, иначе правки кассира затирались бы при следующем открытии.
|
||||||
|
edited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
# Заполняется будущей интеграцией с Checkbox — сейчас всегда NULL.
|
# Заполняется будущей интеграцией с Checkbox — сейчас всегда NULL.
|
||||||
receipt_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
receipt_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
|
||||||
@@ -50,3 +55,9 @@ class Order(TimestampMixin, Base):
|
|||||||
np_status_code: Mapped[str | None] = mapped_column(String(16))
|
np_status_code: Mapped[str | None] = mapped_column(String(16))
|
||||||
np_cod_amount_kopecks: Mapped[int | None] = mapped_column(BigInteger)
|
np_cod_amount_kopecks: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
np_payment_status: Mapped[str | None] = mapped_column(String(32))
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
@@ -16,6 +16,7 @@ from app.api.v1.router import api_router
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.logging import configure_logging, get_logger
|
from app.core.logging import configure_logging, get_logger
|
||||||
from app.db.session import engine
|
from app.db.session import engine
|
||||||
|
from app.services.task_queue import close_task_queue
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
configure_logging()
|
configure_logging()
|
||||||
log.info("app_starting", environment=settings.environment, version=app.version)
|
log.info("app_starting", environment=settings.environment, version=app.version)
|
||||||
yield
|
yield
|
||||||
|
await close_task_queue()
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
log.info("app_stopped")
|
log.info("app_stopped")
|
||||||
|
|
||||||
@@ -76,7 +78,7 @@ def create_app() -> FastAPI:
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title=settings.project_name,
|
title=settings.project_name,
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
description="Фискализация заказов через Checkbox",
|
description="Фіскалізація замовлень через Checkbox",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
# В проде интерактивная документация закрыта: схема API — лишняя
|
# В проде интерактивная документация закрыта: схема API — лишняя
|
||||||
# подсказка для того, кто ищет незащищённый эндпоинт.
|
# подсказка для того, кто ищет незащищённый эндпоинт.
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class TokenPair(BaseModel):
|
|||||||
access_token: str
|
access_token: str
|
||||||
refresh_token: str
|
refresh_token: str
|
||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
expires_in: int = Field(description="Время жизни access-токена в секундах")
|
expires_in: int = Field(description="Час життя access-токена в секундах")
|
||||||
|
|
||||||
|
|
||||||
class UserOut(BaseModel):
|
class UserOut(BaseModel):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -10,12 +10,14 @@ integer-kopecks из CLAUDE.md относится к будущим персис
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.db.models.order import Order
|
from app.db.models.order import Order
|
||||||
|
from app.db.models.receipt import Receipt
|
||||||
|
|
||||||
|
|
||||||
class OrderGoodOut(BaseModel):
|
class OrderGoodOut(BaseModel):
|
||||||
@@ -82,9 +84,15 @@ class OrderRowOut(BaseModel):
|
|||||||
np_status_code: str | None
|
np_status_code: str | None
|
||||||
np_cod_amount: str | None
|
np_cod_amount: str | None
|
||||||
np_payment_status: 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
|
@classmethod
|
||||||
def from_order(cls, order: Order) -> OrderRowOut:
|
def from_order(cls, order: Order, receipt: Receipt | None = None) -> OrderRowOut:
|
||||||
return cls(
|
return cls(
|
||||||
id=order.id,
|
id=order.id,
|
||||||
create_date_time=order.create_date_time,
|
create_date_time=order.create_date_time,
|
||||||
@@ -104,4 +112,56 @@ class OrderRowOut(BaseModel):
|
|||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
np_payment_status=order.np_payment_status,
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -32,9 +32,19 @@ class TrackingStatusOut(BaseModel):
|
|||||||
# какого-то типа накладной не возвращает `AfterpaymentOnGoodsCost`.
|
# какого-то типа накладной не возвращает `AfterpaymentOnGoodsCost`.
|
||||||
cod_amount: str | None = Field(default=None)
|
cod_amount: str | None = Field(default=None)
|
||||||
|
|
||||||
|
# Телефон отправителя NP отдаёт только кабинету отправителя (или получателя):
|
||||||
|
# по чужому ключу без телефона ответ урезан — нет ни отправителя, ни
|
||||||
|
# `AfterpaymentOnGoodsCost`. По этому полю определяется, чья это посылка.
|
||||||
|
phone_sender: str | None = Field(default=None, alias="PhoneSender")
|
||||||
|
|
||||||
# Статус оплаты аналогично раздвоен на `PaymentStatus`/`ExpressWaybillPaymentStatus`.
|
# Статус оплаты аналогично раздвоен на `PaymentStatus`/`ExpressWaybillPaymentStatus`.
|
||||||
payment_status: str | None = Field(default=None)
|
payment_status: str | None = Field(default=None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_own(self) -> bool:
|
||||||
|
"""ТТН видна ключу как своя — отправлена из кабинета этого ключа."""
|
||||||
|
return bool(self.phone_sender)
|
||||||
|
|
||||||
@model_validator(mode="before")
|
@model_validator(mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _fold_express_fields(cls, data: Any) -> Any:
|
def _fold_express_fields(cls, data: Any) -> Any:
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ _REDACTED_KEYS = {
|
|||||||
"secret",
|
"secret",
|
||||||
"license_key",
|
"license_key",
|
||||||
"api_key",
|
"api_key",
|
||||||
|
"np_api_key",
|
||||||
|
"pin",
|
||||||
|
"pin_code",
|
||||||
|
"cashier_pin",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ async def authenticate(
|
|||||||
request=request,
|
request=request,
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
raise AuthError("Неверный email или пароль")
|
raise AuthError("Невірний email або пароль")
|
||||||
|
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
await audit.record(
|
await audit.record(
|
||||||
@@ -64,7 +64,7 @@ async def authenticate(
|
|||||||
request=request,
|
request=request,
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
raise AuthError("Учётная запись отключена")
|
raise AuthError("Обліковий запис вимкнено")
|
||||||
|
|
||||||
# Параметры argon2 со временем ужесточаются — обновляем хеш на живом пароле.
|
# Параметры argon2 со временем ужесточаются — обновляем хеш на живом пароле.
|
||||||
if password_needs_rehash(user.password_hash):
|
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))
|
stored = await session.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash))
|
||||||
|
|
||||||
if stored is None:
|
if stored is None:
|
||||||
raise AuthError("Некорректный refresh-токен")
|
raise AuthError("Некоректний refresh-токен")
|
||||||
|
|
||||||
if stored.revoked_at is not None:
|
if stored.revoked_at is not None:
|
||||||
await revoke_all_for_user(session, stored.user_id)
|
await revoke_all_for_user(session, stored.user_id)
|
||||||
@@ -131,14 +131,14 @@ async def rotate_refresh_token(
|
|||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
log.warning("refresh_token_reuse_detected", user_id=str(stored.user_id))
|
log.warning("refresh_token_reuse_detected", user_id=str(stored.user_id))
|
||||||
raise AuthError("Сессия отозвана, требуется повторный вход")
|
raise AuthError("Сесію відкликано, потрібен повторний вхід")
|
||||||
|
|
||||||
if stored.expires_at <= datetime.now(UTC):
|
if stored.expires_at <= datetime.now(UTC):
|
||||||
raise AuthError("Срок действия refresh-токена истёк")
|
raise AuthError("Термін дії refresh-токена минув")
|
||||||
|
|
||||||
user = await session.get(User, stored.user_id)
|
user = await session.get(User, stored.user_id)
|
||||||
if user is None or not user.is_active:
|
if user is None or not user.is_active:
|
||||||
raise AuthError("Учётная запись недоступна")
|
raise AuthError("Обліковий запис недоступний")
|
||||||
|
|
||||||
pair = await issue_token_pair(session, user, request=request, replaces=stored)
|
pair = await issue_token_pair(session, user, request=request, replaces=stored)
|
||||||
await audit.record(
|
await audit.record(
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
from app.schemas.orders import OrderOut
|
from app.schemas.orders import OrderOut
|
||||||
|
|
||||||
|
|
||||||
@@ -13,3 +15,20 @@ class CrmError(Exception):
|
|||||||
|
|
||||||
class CrmClient(Protocol):
|
class CrmClient(Protocol):
|
||||||
async def get_orders(self, *, status: str) -> list[OrderOut]: ...
|
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)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"""Реальный клиент exoCRM (`GetOrders`)."""
|
"""Реальный клиент exoCRM (`GetOrders`, `SetStatus`)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.core.config import Settings
|
from app.core.config import Settings
|
||||||
@@ -10,6 +12,14 @@ from app.services.crm.checksum import compute_md5sum
|
|||||||
from app.services.crm.client import CrmError
|
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:
|
class ExoCrmClient:
|
||||||
def __init__(self, settings: Settings) -> None:
|
def __init__(self, settings: Settings) -> None:
|
||||||
self._base_url = settings.crm_base_url
|
self._base_url = settings.crm_base_url
|
||||||
@@ -18,29 +28,51 @@ class ExoCrmClient:
|
|||||||
self._shop_key = settings.crm_shop_key
|
self._shop_key = settings.crm_shop_key
|
||||||
self._sid = settings.crm_sid
|
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]:
|
async def get_orders(self, *, status: str) -> list[OrderOut]:
|
||||||
body = {
|
data = await self._post(
|
||||||
"apikey": self._api_key,
|
"GetOrders",
|
||||||
"object": "Orders",
|
{
|
||||||
"method": "GetOrders",
|
|
||||||
"params": {
|
|
||||||
"sid": self._sid,
|
"sid": self._sid,
|
||||||
"key": self._shop_key,
|
"key": self._shop_key,
|
||||||
"Status": status,
|
"Status": status,
|
||||||
"ReturnGoods": True,
|
"ReturnGoods": True,
|
||||||
"ReturnTotals": True,
|
"ReturnTotals": True,
|
||||||
},
|
},
|
||||||
}
|
)
|
||||||
body["md5sum"] = compute_md5sum(body, self._secret_key)
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
|
||||||
response = await client.post(self._base_url, json=body)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
if data.get("status") != "OK":
|
if data.get("status") != "OK":
|
||||||
errors = data.get("errors") or {}
|
raise CrmError(f"CRM повернула помилку: {_errors_text(data) or 'невідома помилка'}")
|
||||||
message = "; ".join(f"{code}: {text}" for code, text in errors.items())
|
return [OrderOut.model_validate(order) for order in data.get("result") or []]
|
||||||
raise CrmError(f"CRM вернула ошибку: {message or 'неизвестная ошибка'}")
|
|
||||||
|
|
||||||
return [OrderOut.model_validate(order) for order in data.get("result", [])]
|
async def set_status(self, *, order_id: str, status: str) -> None:
|
||||||
|
"""`SetStatus`. Формат проверен на боевой CRM и расходится с документацией.
|
||||||
|
|
||||||
|
Документация показывает `params: {"ID": ..., "Status": ...}`, но CRM на это
|
||||||
|
отвечает «Undefined order list.». Рабочий вариант — список ID в `Orders`,
|
||||||
|
а ответ — результат по каждому заказу без общего `status: OK`:
|
||||||
|
{"123901": {"Status": "Success", "ChangeStatus": "Success"}}
|
||||||
|
"""
|
||||||
|
data = await self._post("SetStatus", {"Orders": [order_id], "Status": status})
|
||||||
|
result = data.get(order_id)
|
||||||
|
if isinstance(result, dict) and result.get("Status") == "Success":
|
||||||
|
return
|
||||||
|
details = _errors_text(data) or (
|
||||||
|
str(result) if result is not None else "немає відповіді щодо замовлення"
|
||||||
|
)
|
||||||
|
raise CrmError(f"CRM не змінила статус замовлення {order_id} на {status}: {details}")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Фикстурный CRM-клиент для тестов — не ходит в сеть."""
|
"""Фикстурный CRM-клиент: тесты и локальный запуск (`CRM_USE_STUB=true`) — не ходит в сеть."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ _FIXTURE_ORDERS: list[dict] = [
|
|||||||
"RecipientPhone": "+380501112233",
|
"RecipientPhone": "+380501112233",
|
||||||
"RecipientEmail": None,
|
"RecipientEmail": None,
|
||||||
"Waybill_Number": "20450123456789",
|
"Waybill_Number": "20450123456789",
|
||||||
"Notes": "Тестовий заказ",
|
"Notes": "Тестове замовлення",
|
||||||
"Total": {
|
"Total": {
|
||||||
"Cost": "0.00",
|
"Cost": "0.00",
|
||||||
"Quantity": "2",
|
"Quantity": "2",
|
||||||
@@ -40,6 +40,11 @@ _FIXTURE_ORDERS: list[dict] = [
|
|||||||
class StubCrmClient:
|
class StubCrmClient:
|
||||||
def __init__(self, orders: list[dict] | None = None) -> None:
|
def __init__(self, orders: list[dict] | None = None) -> None:
|
||||||
self._orders = orders if orders is not None else _FIXTURE_ORDERS
|
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]:
|
async def get_orders(self, *, status: str) -> list[OrderOut]:
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -15,4 +15,8 @@ class NovaPoshtaError(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class NovaPoshtaClient(Protocol):
|
class NovaPoshtaClient(Protocol):
|
||||||
async def get_statuses(self, *, waybill_numbers: list[str]) -> list[TrackingStatusOut]: ...
|
"""Ключ API передаётся в каждый вызов: у каждой кассы свой кабинет отправителя."""
|
||||||
|
|
||||||
|
async def get_statuses(
|
||||||
|
self, *, api_key: str, waybill_numbers: list[str]
|
||||||
|
) -> list[TrackingStatusOut]: ...
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.core.config import Settings
|
|
||||||
from app.schemas.tracking import TrackingStatusOut
|
from app.schemas.tracking import TrackingStatusOut
|
||||||
from app.services.nova_poshta.client import NovaPoshtaError
|
from app.services.nova_poshta.client import NovaPoshtaError
|
||||||
|
|
||||||
@@ -15,20 +14,19 @@ _MAX_DOCUMENTS_PER_REQUEST = 100
|
|||||||
|
|
||||||
|
|
||||||
class NpTrackingClient:
|
class NpTrackingClient:
|
||||||
def __init__(self, settings: Settings) -> None:
|
async def get_statuses(
|
||||||
self._api_key = settings.nova_poshta_api_key
|
self, *, api_key: str, waybill_numbers: list[str]
|
||||||
|
) -> list[TrackingStatusOut]:
|
||||||
async def get_statuses(self, *, waybill_numbers: list[str]) -> list[TrackingStatusOut]:
|
|
||||||
if not waybill_numbers:
|
if not waybill_numbers:
|
||||||
return []
|
return []
|
||||||
if len(waybill_numbers) > _MAX_DOCUMENTS_PER_REQUEST:
|
if len(waybill_numbers) > _MAX_DOCUMENTS_PER_REQUEST:
|
||||||
raise NovaPoshtaError(
|
raise NovaPoshtaError(
|
||||||
f"Слишком много ТТН за один запрос: {len(waybill_numbers)} "
|
f"Забагато ТТН за один запит: {len(waybill_numbers)} "
|
||||||
f"(максимум {_MAX_DOCUMENTS_PER_REQUEST})"
|
f"(максимум {_MAX_DOCUMENTS_PER_REQUEST})"
|
||||||
)
|
)
|
||||||
|
|
||||||
body = {
|
body = {
|
||||||
"apiKey": self._api_key,
|
"apiKey": api_key,
|
||||||
"modelName": "TrackingDocument",
|
"modelName": "TrackingDocument",
|
||||||
"calledMethod": "getStatusDocuments",
|
"calledMethod": "getStatusDocuments",
|
||||||
"methodProperties": {
|
"methodProperties": {
|
||||||
@@ -44,6 +42,6 @@ class NpTrackingClient:
|
|||||||
if not data.get("success"):
|
if not data.get("success"):
|
||||||
errors = data.get("errors") or []
|
errors = data.get("errors") or []
|
||||||
message = "; ".join(str(error) for error in errors)
|
message = "; ".join(str(error) for error in errors)
|
||||||
raise NovaPoshtaError(f"NP вернул ошибку: {message or 'неизвестная ошибка'}")
|
raise NovaPoshtaError(f"NP повернув помилку: {message or 'невідома помилка'}")
|
||||||
|
|
||||||
return [TrackingStatusOut.model_validate(item) for item in data.get("data", [])]
|
return [TrackingStatusOut.model_validate(item) for item in data.get("data", [])]
|
||||||
|
|||||||
@@ -15,17 +15,32 @@ _FIXTURE_STATUSES: dict[str, dict] = {
|
|||||||
"AmountToPay": "1200.00",
|
"AmountToPay": "1200.00",
|
||||||
"AfterpaymentOnGoodsCost": 1200,
|
"AfterpaymentOnGoodsCost": 1200,
|
||||||
"PaymentStatus": "Paid",
|
"PaymentStatus": "Paid",
|
||||||
|
"PhoneSender": "380501112233",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class StubNovaPoshtaClient:
|
class StubNovaPoshtaClient:
|
||||||
def __init__(self, statuses: dict[str, dict] | None = None) -> None:
|
"""`statuses` — ответы NP по ТТН; `by_key` — отдельные ответы для конкретных
|
||||||
self._statuses = statuses if statuses is not None else _FIXTURE_STATUSES
|
ключей API (кабинет отправителя видит свои ТТН полностью, чужие — урезанно)."""
|
||||||
|
|
||||||
async def get_statuses(self, *, waybill_numbers: list[str]) -> list[TrackingStatusOut]:
|
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 [
|
return [
|
||||||
TrackingStatusOut.model_validate(self._statuses[number])
|
TrackingStatusOut.model_validate(statuses[number])
|
||||||
for number in waybill_numbers
|
for number in waybill_numbers
|
||||||
if number in self._statuses
|
if number in statuses
|
||||||
]
|
]
|
||||||
|
|||||||
+301
-27
@@ -2,21 +2,67 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from decimal import Decimal
|
from decimal import ROUND_HALF_UP, Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import select
|
import httpx
|
||||||
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.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.crm.client import CrmClient
|
||||||
from app.services.nova_poshta.client import NovaPoshtaClient
|
from app.services.nova_poshta.client import NovaPoshtaClient, NovaPoshtaError
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
_CRM_STATUS = "APPROVED"
|
_CRM_STATUS = "APPROVED"
|
||||||
|
|
||||||
# NP отклоняет запросы с более чем 100 накладными за раз (см. np_client.py).
|
# NP отклоняет запросы с более чем 100 накладными за раз (см. np_client.py).
|
||||||
_NP_BATCH_SIZE = 100
|
_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:
|
def _to_kopecks(amount: str) -> int:
|
||||||
return int((Decimal(amount) * 100).to_integral_value())
|
return int((Decimal(amount) * 100).to_integral_value())
|
||||||
@@ -31,7 +77,8 @@ async def sync_orders_from_crm(session: AsyncSession, crm: CrmClient) -> None:
|
|||||||
|
|
||||||
Уже скрытые (`is_deleted`) заказы не восстанавливаются и не перезаписываются
|
Уже скрытые (`is_deleted`) заказы не восстанавливаются и не перезаписываются
|
||||||
— иначе кнопка «Удалить» переставала бы работать при следующем открытии
|
— иначе кнопка «Удалить» переставала бы работать при следующем открытии
|
||||||
дашборда, т.к. CRM продолжает возвращать эти заказы как есть.
|
дашборда, т.к. CRM продолжает возвращать эти заказы как есть. По той же
|
||||||
|
причине не трогаются заказы, отредактированные вручную (`edited_at`).
|
||||||
"""
|
"""
|
||||||
crm_orders = await crm.get_orders(status=_CRM_STATUS)
|
crm_orders = await crm.get_orders(status=_CRM_STATUS)
|
||||||
if not crm_orders:
|
if not crm_orders:
|
||||||
@@ -45,7 +92,7 @@ async def sync_orders_from_crm(session: AsyncSession, crm: CrmClient) -> None:
|
|||||||
for crm_order in crm_orders:
|
for crm_order in crm_orders:
|
||||||
local = existing_by_id.get(crm_order.id)
|
local = existing_by_id.get(crm_order.id)
|
||||||
if local is not None:
|
if local is not None:
|
||||||
if local.is_deleted:
|
if local.is_deleted or local.edited_at is not None:
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
local = Order(id=crm_order.id)
|
local = Order(id=crm_order.id)
|
||||||
@@ -63,44 +110,84 @@ async def sync_orders_from_crm(session: AsyncSession, crm: CrmClient) -> None:
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
async def list_orders(session: AsyncSession, *, has_receipt: bool) -> list[Order]:
|
async def list_orders(session: AsyncSession, *, tab: OrderTab) -> list[Order]:
|
||||||
receipt_filter = (
|
"""Заказы вкладки. Статус посылки важнее наличия чека: полученный заказ
|
||||||
Order.receipt_created_at.is_not(None) if has_receipt else Order.receipt_created_at.is_(None)
|
показывается только в «Полученных», отказ — только в «Отказах», даже если по
|
||||||
|
нему уже создан ЕТТН-чек."""
|
||||||
|
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(
|
result = await session.scalars(
|
||||||
select(Order)
|
select(Order)
|
||||||
.where(Order.is_deleted.is_(False))
|
.where(Order.is_deleted.is_(False))
|
||||||
.where(receipt_filter)
|
.where(tab_filter)
|
||||||
.order_by(Order.create_date_time.desc())
|
.order_by(Order.create_date_time.desc())
|
||||||
)
|
)
|
||||||
return list(result)
|
return list(result)
|
||||||
|
|
||||||
|
|
||||||
async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
|
async def cod_in_transit(session: AsyncSession) -> tuple[int, int]:
|
||||||
"""Обновляет статус ТТН и сумму наложенного платежа для заказов без чека.
|
"""Наложка в пути: (кол-во посылок, сумма в копейках).
|
||||||
|
|
||||||
Вызывается ARQ worker'ом раз в минуту (см. `app/worker.py`), а не из
|
Учитываются только посылки, которые ещё не забрали и по которым нет отказа:
|
||||||
HTTP-запроса: опрос статусов не должен зависеть от того, открыт ли сейчас
|
финальный статус NP ("отримано"/отказ) или оплата наложки их исключают.
|
||||||
дашборд.
|
|
||||||
"""
|
"""
|
||||||
orders = await session.scalars(
|
result = await session.execute(
|
||||||
select(Order)
|
select(func.count(), func.coalesce(func.sum(Order.np_cod_amount_kopecks), 0))
|
||||||
.where(Order.is_deleted.is_(False))
|
.where(Order.is_deleted.is_(False))
|
||||||
.where(Order.receipt_created_at.is_(None))
|
.where(Order.np_cod_amount_kopecks.is_not(None))
|
||||||
.where(Order.waybill_number.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"))
|
||||||
)
|
)
|
||||||
orders_by_waybill: dict[str, Order] = {order.waybill_number: order for order in orders}
|
count, total = result.one()
|
||||||
if not orders_by_waybill:
|
return count, total
|
||||||
return
|
|
||||||
|
|
||||||
waybill_numbers = list(orders_by_waybill)
|
|
||||||
|
@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):
|
for i in range(0, len(waybill_numbers), _NP_BATCH_SIZE):
|
||||||
batch = waybill_numbers[i : i + _NP_BATCH_SIZE]
|
batch = waybill_numbers[i : i + _NP_BATCH_SIZE]
|
||||||
statuses = await np.get_statuses(waybill_numbers=batch)
|
for tracking_status in await np.get_statuses(api_key=api_key, waybill_numbers=batch):
|
||||||
for tracking_status in statuses:
|
statuses[tracking_status.number] = tracking_status
|
||||||
order = orders_by_waybill.get(tracking_status.number)
|
return statuses
|
||||||
if order is None:
|
|
||||||
continue
|
|
||||||
|
def _apply_status(order: Order, tracking_status: TrackingStatusOut) -> None:
|
||||||
order.np_status = tracking_status.status
|
order.np_status = tracking_status.status
|
||||||
order.np_status_code = tracking_status.status_code
|
order.np_status_code = tracking_status.status_code
|
||||||
order.np_cod_amount_kopecks = (
|
order.np_cod_amount_kopecks = (
|
||||||
@@ -108,6 +195,103 @@ async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
|
|||||||
)
|
)
|
||||||
order.np_payment_status = tracking_status.payment_status
|
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()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -119,3 +303,93 @@ async def delete_order(session: AsyncSession, order_id: str) -> Order | None:
|
|||||||
order.is_deleted = True
|
order.is_deleted = True
|
||||||
order.deleted_at = datetime.now(UTC)
|
order.deleted_at = datetime.now(UTC)
|
||||||
return order
|
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
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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()
|
||||||
+44
-4
@@ -1,27 +1,41 @@
|
|||||||
"""ARQ worker: раз в минуту опрашивает Nova Poshta по заказам без чека.
|
"""ARQ worker: опрос Nova Poshta и ЕТТН-чеки Checkbox.
|
||||||
|
|
||||||
Запускается отдельным процессом: `arq app.worker.WorkerSettings`.
|
Запускается отдельным процессом: `arq app.worker.WorkerSettings`.
|
||||||
|
- `create_ettn_receipt` — задача, которую ставит API после запроса кассира;
|
||||||
|
- `poll_np_statuses` — раз в минуту статусы ТТН по ключам НП касс и привязка заказов к кассам;
|
||||||
|
- `poll_receipts` — раз в минуту повтор зависших `pending`, статусы `created`-чеков
|
||||||
|
и повтор смены статуса заказа в CRM (PACKED), если CRM была недоступна.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from arq import cron
|
from arq import Retry, cron, func
|
||||||
from arq.connections import RedisSettings
|
from arq.connections import RedisSettings
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.logging import configure_logging, get_logger
|
from app.core.logging import configure_logging, get_logger
|
||||||
from app.db.session import SessionFactory
|
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.nova_poshta.np_client import NpTrackingClient
|
||||||
from app.services.orders import sync_np_statuses
|
from app.services.orders import sync_np_statuses
|
||||||
|
|
||||||
log = get_logger(__name__)
|
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:
|
async def startup(ctx: dict[str, Any]) -> None:
|
||||||
configure_logging()
|
configure_logging()
|
||||||
ctx["np_client"] = NpTrackingClient(settings)
|
ctx["np_client"] = NpTrackingClient()
|
||||||
|
ctx["checkbox_client"] = get_checkbox_client()
|
||||||
|
ctx["crm_client"] = get_crm_client()
|
||||||
log.info("worker_starting", environment=settings.environment)
|
log.info("worker_starting", environment=settings.environment)
|
||||||
|
|
||||||
|
|
||||||
@@ -31,7 +45,33 @@ async def poll_np_statuses(ctx: dict[str, Any]) -> None:
|
|||||||
log.info("np_statuses_polled")
|
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:
|
class WorkerSettings:
|
||||||
redis_settings = RedisSettings.from_dsn(settings.redis_url)
|
redis_settings = RedisSettings.from_dsn(settings.redis_url)
|
||||||
on_startup = startup
|
on_startup = startup
|
||||||
cron_jobs = [cron(poll_np_statuses, minute=set(range(60)), run_at_startup=True)]
|
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),
|
||||||
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -106,3 +106,43 @@ class TestExoCrmClientGetOrders:
|
|||||||
|
|
||||||
with pytest.raises(CrmError, match="Checksum Error"):
|
with pytest.raises(CrmError, match="Checksum Error"):
|
||||||
await client.get_orders(status="APPROVED")
|
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")
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -24,14 +24,18 @@ MODEL_TABLES = set(Base.metadata.tables)
|
|||||||
|
|
||||||
|
|
||||||
def _migration_block(table: str) -> str:
|
def _migration_block(table: str) -> str:
|
||||||
"""Тело вызова op.create_table для указанной таблицы.
|
"""Тело вызова op.create_table для указанной таблицы плюс её поздние op.add_column.
|
||||||
|
|
||||||
Границей служит следующий op.create_table; для последней таблицы — конец файла.
|
Границей create_table служит следующий op.create_table или конец файла миграции.
|
||||||
"""
|
"""
|
||||||
start = COMBINED_SOURCE.index(f'op.create_table(\n "{table}",')
|
marker = f'op.create_table(\n "{table}",'
|
||||||
next_table = COMBINED_SOURCE.find("op.create_table(", start + 1)
|
source = next(text for text in SOURCES.values() if marker in text)
|
||||||
end = next_table if next_table != -1 else len(COMBINED_SOURCE)
|
start = source.index(marker)
|
||||||
return COMBINED_SOURCE[start:end]
|
next_table = source.find("op.create_table(", start + 1)
|
||||||
|
end = next_table if next_table != -1 else len(source)
|
||||||
|
# Колонки, добавленные позже через op.add_column, — в любой миграции.
|
||||||
|
added = re.findall(rf'op\.add_column\(\s*"{table}",\s*(sa\.Column\(.*?\n)', COMBINED_SOURCE)
|
||||||
|
return source[start:end] + "\n" + "\n".join(added)
|
||||||
|
|
||||||
|
|
||||||
def test_migrations_create_every_model_table() -> None:
|
def test_migrations_create_every_model_table() -> None:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import pytest
|
|||||||
import respx
|
import respx
|
||||||
from httpx import Response
|
from httpx import Response
|
||||||
|
|
||||||
from app.core.config import Settings
|
from app.schemas.tracking import TrackingStatusOut
|
||||||
from app.services.nova_poshta.client import NovaPoshtaError
|
from app.services.nova_poshta.client import NovaPoshtaError
|
||||||
from app.services.nova_poshta.np_client import _API_URL, NpTrackingClient
|
from app.services.nova_poshta.np_client import _API_URL, NpTrackingClient
|
||||||
|
|
||||||
@@ -27,12 +27,7 @@ STATUS_PAYLOAD = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _settings() -> Settings:
|
API_KEY = "np-apikey-123"
|
||||||
return Settings(
|
|
||||||
secret_key="test-secret-key",
|
|
||||||
encryption_key="dGVzdC1lbmNyeXB0aW9uLWtleS0zMi1ieXRlcyEh",
|
|
||||||
nova_poshta_api_key="np-apikey-123",
|
|
||||||
) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
|
|
||||||
class TestNpTrackingClientGetStatuses:
|
class TestNpTrackingClientGetStatuses:
|
||||||
@@ -41,13 +36,13 @@ class TestNpTrackingClientGetStatuses:
|
|||||||
route = respx.post(_API_URL).mock(
|
route = respx.post(_API_URL).mock(
|
||||||
return_value=Response(200, json={"success": True, "data": [], "errors": []})
|
return_value=Response(200, json={"success": True, "data": [], "errors": []})
|
||||||
)
|
)
|
||||||
client = NpTrackingClient(_settings())
|
client = NpTrackingClient()
|
||||||
|
|
||||||
await client.get_statuses(waybill_numbers=["20451540916703"])
|
await client.get_statuses(api_key=API_KEY, waybill_numbers=["20451540916703"])
|
||||||
|
|
||||||
sent = route.calls.last.request
|
sent = route.calls.last.request
|
||||||
body = json.loads(sent.content)
|
body = json.loads(sent.content)
|
||||||
assert body["apiKey"] == "np-apikey-123"
|
assert body["apiKey"] == API_KEY
|
||||||
assert body["modelName"] == "TrackingDocument"
|
assert body["modelName"] == "TrackingDocument"
|
||||||
assert body["calledMethod"] == "getStatusDocuments"
|
assert body["calledMethod"] == "getStatusDocuments"
|
||||||
assert body["methodProperties"]["Documents"] == [
|
assert body["methodProperties"]["Documents"] == [
|
||||||
@@ -63,9 +58,9 @@ class TestNpTrackingClientGetStatuses:
|
|||||||
200, json={"success": True, "data": [STATUS_PAYLOAD], "errors": []}
|
200, json={"success": True, "data": [STATUS_PAYLOAD], "errors": []}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
client = NpTrackingClient(_settings())
|
client = NpTrackingClient()
|
||||||
|
|
||||||
statuses = await client.get_statuses(waybill_numbers=["20451540916703"])
|
statuses = await client.get_statuses(api_key=API_KEY, waybill_numbers=["20451540916703"])
|
||||||
|
|
||||||
assert len(statuses) == 1
|
assert len(statuses) == 1
|
||||||
status = statuses[0]
|
status = statuses[0]
|
||||||
@@ -75,6 +70,13 @@ class TestNpTrackingClientGetStatuses:
|
|||||||
# не "сколько заплатить сейчас" (`ExpressWaybillAmountToPay` = 827.48).
|
# не "сколько заплатить сейчас" (`ExpressWaybillAmountToPay` = 827.48).
|
||||||
assert status.cod_amount == "699"
|
assert status.cod_amount == "699"
|
||||||
assert status.payment_status == "NeedPayment"
|
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
|
@respx.mock
|
||||||
async def test_falls_back_to_amount_to_pay_when_afterpayment_missing(self) -> None:
|
async def test_falls_back_to_amount_to_pay_when_afterpayment_missing(self) -> None:
|
||||||
@@ -82,9 +84,9 @@ class TestNpTrackingClientGetStatuses:
|
|||||||
respx.post(_API_URL).mock(
|
respx.post(_API_URL).mock(
|
||||||
return_value=Response(200, json={"success": True, "data": [payload], "errors": []})
|
return_value=Response(200, json={"success": True, "data": [payload], "errors": []})
|
||||||
)
|
)
|
||||||
client = NpTrackingClient(_settings())
|
client = NpTrackingClient()
|
||||||
|
|
||||||
statuses = await client.get_statuses(waybill_numbers=["20451540916703"])
|
statuses = await client.get_statuses(api_key=API_KEY, waybill_numbers=["20451540916703"])
|
||||||
|
|
||||||
assert statuses[0].cod_amount == "827.48"
|
assert statuses[0].cod_amount == "827.48"
|
||||||
|
|
||||||
@@ -95,18 +97,20 @@ class TestNpTrackingClientGetStatuses:
|
|||||||
200, json={"success": False, "data": [], "errors": ["Invalid apiKey"]}
|
200, json={"success": False, "data": [], "errors": ["Invalid apiKey"]}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
client = NpTrackingClient(_settings())
|
client = NpTrackingClient()
|
||||||
|
|
||||||
with pytest.raises(NovaPoshtaError, match="Invalid apiKey"):
|
with pytest.raises(NovaPoshtaError, match="Invalid apiKey"):
|
||||||
await client.get_statuses(waybill_numbers=["20451540916703"])
|
await client.get_statuses(api_key=API_KEY, waybill_numbers=["20451540916703"])
|
||||||
|
|
||||||
async def test_returns_empty_list_for_no_documents(self) -> None:
|
async def test_returns_empty_list_for_no_documents(self) -> None:
|
||||||
client = NpTrackingClient(_settings())
|
client = NpTrackingClient()
|
||||||
|
|
||||||
assert await client.get_statuses(waybill_numbers=[]) == []
|
assert await client.get_statuses(api_key=API_KEY, waybill_numbers=[]) == []
|
||||||
|
|
||||||
async def test_rejects_too_many_documents(self) -> None:
|
async def test_rejects_too_many_documents(self) -> None:
|
||||||
client = NpTrackingClient(_settings())
|
client = NpTrackingClient()
|
||||||
|
|
||||||
with pytest.raises(NovaPoshtaError, match="Слишком много"):
|
with pytest.raises(NovaPoshtaError, match="Забагато"):
|
||||||
await client.get_statuses(waybill_numbers=[str(i) for i in range(101)])
|
await client.get_statuses(
|
||||||
|
api_key=API_KEY, waybill_numbers=[str(i) for i in range(101)]
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -69,8 +69,8 @@ def _patch_orders_service(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
async def fake_sync(session: object, crm: object) -> None:
|
async def fake_sync(session: object, crm: object) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def fake_list(session: object, *, has_receipt: bool) -> list[Order]:
|
async def fake_list(session: object, *, tab: str) -> list[Order]:
|
||||||
return [] if has_receipt else [_order()]
|
return [_order()] if tab == "no_receipt" else []
|
||||||
|
|
||||||
async def fake_delete(session: object, order_id: str) -> Order | None:
|
async def fake_delete(session: object, order_id: str) -> Order | None:
|
||||||
return _order(order_id) if order_id == "1" else None
|
return _order(order_id) if order_id == "1" else None
|
||||||
@@ -79,6 +79,31 @@ def _patch_orders_service(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
monkeypatch.setattr(orders_router.orders_service, "list_orders", fake_list)
|
monkeypatch.setattr(orders_router.orders_service, "list_orders", fake_list)
|
||||||
monkeypatch.setattr(orders_router.orders_service, "delete_order", fake_delete)
|
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:
|
async def fake_record(session: object, **kwargs: object) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -112,7 +137,7 @@ class TestListOrders:
|
|||||||
) -> None:
|
) -> None:
|
||||||
app.dependency_overrides[get_current_user] = lambda: _user(role)
|
app.dependency_overrides[get_current_user] = lambda: _user(role)
|
||||||
|
|
||||||
response = client.get("/api/v1/orders", params={"has_receipt": "false"})
|
response = client.get("/api/v1/orders", params={"tab": "no_receipt"})
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
body = response.json()
|
body = response.json()
|
||||||
@@ -122,14 +147,22 @@ class TestListOrders:
|
|||||||
assert body[0]["has_receipt"] is False
|
assert body[0]["has_receipt"] is False
|
||||||
assert body[0]["goods"][0]["sku"] == "SKU-1"
|
assert body[0]["goods"][0]["sku"] == "SKU-1"
|
||||||
|
|
||||||
def test_has_receipt_tab_is_empty_for_now(self, client: TestClient) -> None:
|
@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)
|
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER)
|
||||||
|
|
||||||
response = client.get("/api/v1/orders", params={"has_receipt": "true"})
|
response = client.get("/api/v1/orders", params={"tab": tab})
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == []
|
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:
|
def test_requires_authentication(self, client: TestClient) -> None:
|
||||||
response = client.get("/api/v1/orders")
|
response = client.get("/api/v1/orders")
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
@@ -157,3 +190,70 @@ class TestDeleteOrder:
|
|||||||
response = client.delete("/api/v1/orders/does-not-exist")
|
response = client.delete("/api/v1/orders/does-not-exist")
|
||||||
|
|
||||||
assert response.status_code == 404
|
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",
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
"""Юнит-тесты чистых хелперов сервиса заказов. Без БД и без сети."""
|
"""Юнит-тесты сервиса заказов. Без БД и без сети — сессия подменяется заглушкой."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
import asyncio
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from app.services.orders import _parse_crm_datetime, _to_kopecks
|
import pytest
|
||||||
|
from 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:
|
class TestToKopecks:
|
||||||
@@ -24,3 +39,195 @@ class TestToKopecks:
|
|||||||
class TestParseCrmDatetime:
|
class TestParseCrmDatetime:
|
||||||
def test_parses_crm_format(self) -> None:
|
def test_parses_crm_format(self) -> None:
|
||||||
assert _parse_crm_datetime("2026-09-20 10:00:00") == datetime(2026, 9, 20, 10, 0, 0)
|
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
|
||||||
|
|||||||
@@ -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"],))]
|
||||||
@@ -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"}
|
||||||
@@ -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
|
||||||
@@ -35,6 +35,8 @@ services:
|
|||||||
# Миграции выполняются отдельным одноразовым контейнером, а не при старте api.
|
# Миграции выполняются отдельным одноразовым контейнером, а не при старте api.
|
||||||
# Иначе при нескольких репликах api они пошли бы параллельно.
|
# Иначе при нескольких репликах api они пошли бы параллельно.
|
||||||
migrate:
|
migrate:
|
||||||
|
# Один образ на migrate/api/worker, а не три одинаковых копии на диске.
|
||||||
|
image: lux-fiscal-backend:latest
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
env_file: .env
|
env_file: .env
|
||||||
@@ -48,6 +50,7 @@ services:
|
|||||||
restart: "no"
|
restart: "no"
|
||||||
|
|
||||||
api:
|
api:
|
||||||
|
image: lux-fiscal-backend:latest
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -68,6 +71,7 @@ services:
|
|||||||
- receipt_storage:/app/storage
|
- receipt_storage:/app/storage
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
|
image: lux-fiscal-backend:latest
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -10,8 +10,15 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.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/ {
|
location /api/ {
|
||||||
proxy_pass http://api:8000/api/;
|
# С переменной proxy_pass без URI передаёт исходный путь /api/... как есть.
|
||||||
|
proxy_pass $api_upstream;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|||||||
+8
-2
@@ -1,10 +1,16 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ru">
|
<html lang="uk">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
}
|
||||||
@@ -27,10 +27,10 @@ async function readErrorMessage(response: Response): Promise<string> {
|
|||||||
// Тело не JSON или пустое — используем сообщение по статусу ниже.
|
// Тело не JSON или пустое — используем сообщение по статусу ниже.
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 401) return 'Требуется вход в систему'
|
if (response.status === 401) return 'Потрібно увійти в систему'
|
||||||
if (response.status === 403) return 'Недостаточно прав для этого действия'
|
if (response.status === 403) return 'Недостатньо прав для цієї дії'
|
||||||
if (response.status >= 500) return 'Сервер временно недоступен, попробуйте позже'
|
if (response.status >= 500) return 'Сервер тимчасово недоступний, спробуйте пізніше'
|
||||||
return `Ошибка запроса (${response.status})`
|
return `Помилка запиту (${response.status})`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Параллельные 401 не должны порождать несколько запросов на refresh —
|
// Параллельные 401 не должны порождать несколько запросов на refresh —
|
||||||
@@ -42,7 +42,7 @@ async function refreshAccessToken(): Promise<string> {
|
|||||||
|
|
||||||
const refreshToken = getRefreshToken()
|
const refreshToken = getRefreshToken()
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
throw new ApiError(401, 'Сессия истекла, войдите снова')
|
throw new ApiError(401, 'Сесія закінчилася, увійдіть знову')
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshPromise = (async () => {
|
refreshPromise = (async () => {
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { apiFetch } from '@/api/client'
|
import { apiFetch } from '@/api/client'
|
||||||
import type { Order } from '@/features/orders/types'
|
import type { Order, OrdersSummary, OrderTab, OrderUpdate } from '@/features/orders/types'
|
||||||
|
|
||||||
export function getOrders(hasReceipt: boolean): Promise<Order[]> {
|
export function getOrders(tab: OrderTab): Promise<Order[]> {
|
||||||
return apiFetch<Order[]>(`/orders?has_receipt=${hasReceipt}`)
|
return apiFetch<Order[]>(`/orders?tab=${tab}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrdersSummary(): Promise<OrdersSummary> {
|
||||||
|
return apiFetch<OrdersSummary>('/orders/summary')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteOrder(orderId: string): Promise<void> {
|
export function deleteOrder(orderId: string): Promise<void> {
|
||||||
return apiFetch<void>(`/orders/${orderId}`, { method: 'DELETE' })
|
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 })
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { apiFetch } from '@/api/client'
|
||||||
|
import type { Receipt, ReceiptCreateResponse, ReceiptRequestItem } from '@/features/receipts/types'
|
||||||
|
|
||||||
|
export function createReceipts(items: ReceiptRequestItem[]): Promise<ReceiptCreateResponse> {
|
||||||
|
return apiFetch<ReceiptCreateResponse>('/receipts', { method: 'POST', body: { items } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelReceipt(receiptId: string): Promise<Receipt> {
|
||||||
|
return apiFetch<Receipt>(`/receipts/${receiptId}/cancel`, { method: 'POST' })
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@ import { Navigate, Route, Routes } from 'react-router-dom'
|
|||||||
|
|
||||||
import { LoginPage } from '@/features/auth/LoginPage'
|
import { LoginPage } from '@/features/auth/LoginPage'
|
||||||
import { ProtectedRoute } from '@/features/auth/ProtectedRoute'
|
import { ProtectedRoute } from '@/features/auth/ProtectedRoute'
|
||||||
|
import { ModuleRoute } from '@/features/menu/ModuleRoute'
|
||||||
|
import { CashRegistersPage } from '@/pages/CashRegistersPage'
|
||||||
import { DashboardPage } from '@/pages/DashboardPage'
|
import { DashboardPage } from '@/pages/DashboardPage'
|
||||||
|
import { MainMenuPage } from '@/pages/MainMenuPage'
|
||||||
|
|
||||||
export function AppRoutes() {
|
export function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
@@ -10,7 +13,13 @@ export function AppRoutes() {
|
|||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
|
||||||
<Route element={<ProtectedRoute />}>
|
<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>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -19,3 +19,7 @@
|
|||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-content--wide {
|
||||||
|
max-width: 920px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import '@/components/Modal.css'
|
|||||||
interface ModalProps {
|
interface ModalProps {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
|
wide?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Modal({ onClose, children }: ModalProps) {
|
export function Modal({ onClose, children, wide = false }: ModalProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKeyDown(event: KeyboardEvent) {
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
if (event.key === 'Escape') onClose()
|
if (event.key === 'Escape') onClose()
|
||||||
@@ -19,7 +20,7 @@ export function Modal({ onClose, children }: ModalProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
<div className="modal-content" onClick={(event) => event.stopPropagation()}>
|
<div className={`modal-content${wide ? ' modal-content--wide' : ''}`} onClick={(event) => event.stopPropagation()}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setUser(result.user)
|
setUser(result.user)
|
||||||
setStatus('authenticated')
|
setStatus('authenticated')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof ApiError ? err.message : 'Не удалось подключиться к серверу'
|
const message = err instanceof ApiError ? err.message : 'Не вдалося підключитися до сервера'
|
||||||
setError(message)
|
setError(message)
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,68 +1,230 @@
|
|||||||
.login-page {
|
.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;
|
min-height: 100svh;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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 {
|
.login-card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 380px;
|
max-width: 400px;
|
||||||
background: var(--color-surface);
|
background: var(--login-glass);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--login-glass-border);
|
||||||
border-radius: 12px;
|
border-radius: 20px;
|
||||||
box-shadow: var(--shadow-card);
|
box-shadow:
|
||||||
padding: 32px;
|
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 {
|
.login-header {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 28px;
|
||||||
text-align: center;
|
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 {
|
.login-header h1 {
|
||||||
font-size: 22px;
|
font-size: 26px;
|
||||||
margin-bottom: 4px;
|
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 {
|
.login-header p {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-field {
|
.login-field {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-field label {
|
.login-field label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 8px;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-field input {
|
.login-field input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 12px;
|
height: 46px;
|
||||||
|
padding: 0 14px;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
background: var(--color-bg);
|
background: var(--login-input-bg);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
outline: none;
|
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 {
|
.login-field input:focus {
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-primary) 18%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-field input:disabled {
|
.login-field input:disabled {
|
||||||
opacity: 0.6;
|
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 {
|
.login-error {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -70,23 +232,29 @@
|
|||||||
background: var(--color-danger-bg);
|
background: var(--color-danger-bg);
|
||||||
border: 1px solid var(--color-danger-border);
|
border: 1px solid var(--color-danger-border);
|
||||||
color: var(--color-danger);
|
color: var(--color-danger);
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
padding: 10px 12px;
|
padding: 10px 14px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-submit {
|
.login-submit {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 11px 16px;
|
height: 48px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 0 16px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
background: var(--color-primary);
|
background: linear-gradient(135deg, var(--color-primary), var(--login-accent));
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -94,7 +262,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.login-submit:hover:not(:disabled) {
|
.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 {
|
.login-submit:disabled {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export function LoginPage() {
|
|||||||
|
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [formError, setFormError] = useState<string | null>(null)
|
const [formError, setFormError] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ export function LoginPage() {
|
|||||||
try {
|
try {
|
||||||
await login(email, password)
|
await login(email, password)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof ApiError ? err.message : 'Не удалось подключиться к серверу'
|
const message = err instanceof ApiError ? err.message : 'Не вдалося підключитися до сервера'
|
||||||
setFormError(message)
|
setFormError(message)
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
@@ -45,10 +46,22 @@ export function LoginPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="login-page">
|
<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-card">
|
||||||
<div className="login-header">
|
<div className="login-header">
|
||||||
<h1>lux_fiscal</h1>
|
<div className="login-logo" aria-hidden="true">
|
||||||
<p>Вход в систему фискализации заказов</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} noValidate>
|
<form onSubmit={handleSubmit} noValidate>
|
||||||
@@ -74,9 +87,10 @@ export function LoginPage() {
|
|||||||
|
|
||||||
<div className="login-field">
|
<div className="login-field">
|
||||||
<label htmlFor={passwordId}>Пароль</label>
|
<label htmlFor={passwordId}>Пароль</label>
|
||||||
|
<div className="login-password">
|
||||||
<input
|
<input
|
||||||
id={passwordId}
|
id={passwordId}
|
||||||
type="password"
|
type={showPassword ? 'text' : 'password'}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
required
|
required
|
||||||
value={password}
|
value={password}
|
||||||
@@ -84,11 +98,25 @@ export function LoginPage() {
|
|||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="••••••••"
|
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>
|
</div>
|
||||||
|
|
||||||
<button type="submit" className="login-submit" disabled={submitting}>
|
<button type="submit" className="login-submit" disabled={submitting}>
|
||||||
{submitting && <span className="spinner" aria-hidden="true" />}
|
{submitting && <span className="spinner" aria-hidden="true" />}
|
||||||
{submitting ? 'Входим…' : 'Войти'}
|
{submitting ? 'Входимо…' : 'Увійти'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function ProtectedRoute() {
|
|||||||
if (status === 'loading') {
|
if (status === 'loading') {
|
||||||
return (
|
return (
|
||||||
<div className="page-center">
|
<div className="page-center">
|
||||||
<span className="spinner" aria-label="Загрузка" />
|
<span className="spinner" aria-label="Завантаження" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { UserRole } from '@/api/types'
|
||||||
|
|
||||||
|
export const ROLE_LABEL: Record<UserRole, string> = {
|
||||||
|
admin: 'Адміністратор',
|
||||||
|
cashier: 'Касир',
|
||||||
|
viewer: 'Спостерігач',
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { AuthContext } from '@/features/auth/AuthContext'
|
|||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
const ctx = useContext(AuthContext)
|
const ctx = useContext(AuthContext)
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
throw new Error('useAuth должен вызываться внутри <AuthProvider>')
|
throw new Error('useAuth має викликатися всередині <AuthProvider>')
|
||||||
}
|
}
|
||||||
return ctx
|
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 />
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -37,7 +37,8 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-modal-meta div {
|
.order-modal-meta div,
|
||||||
|
.order-modal-meta label {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
@@ -111,3 +112,154 @@
|
|||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,226 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import { updateOrder } from '@/api/orders'
|
||||||
import { Modal } from '@/components/Modal'
|
import { Modal } from '@/components/Modal'
|
||||||
import '@/features/orders/OrderDetailModal.css'
|
import '@/features/orders/OrderDetailModal.css'
|
||||||
import type { Order } from '@/features/orders/types'
|
import type { Order, OrderUpdate } from '@/features/orders/types'
|
||||||
|
import { formatKopecks, toKopecks } from '@/features/receipts/money'
|
||||||
|
import { RECEIPT_STATUS } from '@/features/receipts/types'
|
||||||
|
|
||||||
interface OrderDetailModalProps {
|
interface OrderDetailModalProps {
|
||||||
order: Order
|
order: Order
|
||||||
|
/** Кассир/админ и по заказу ещё нет чека — карточку можно править. */
|
||||||
|
canEdit: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) {
|
interface GoodDraft {
|
||||||
|
key: string
|
||||||
|
id: string | null
|
||||||
|
sku: string
|
||||||
|
name: string
|
||||||
|
price: string
|
||||||
|
quantity: string
|
||||||
|
discount: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Draft {
|
||||||
|
recipient_name: string
|
||||||
|
recipient_phone: string
|
||||||
|
recipient_email: string
|
||||||
|
waybill_number: string
|
||||||
|
notes: string
|
||||||
|
total: string
|
||||||
|
goods: GoodDraft[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type TextField = Exclude<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 (
|
return (
|
||||||
<Modal onClose={onClose}>
|
<Modal onClose={requestClose} wide={editable}>
|
||||||
<div className="order-modal">
|
<div className="order-modal">
|
||||||
<div className="order-modal-header">
|
<div className="order-modal-header">
|
||||||
<h3>Просмотр заказа {order.id}</h3>
|
<h3>
|
||||||
<button type="button" className="order-modal-close" onClick={onClose} aria-label="Закрыть">
|
Замовлення {order.id}
|
||||||
|
{order.edited && <span className="order-modal-edited">змінено вручну</span>}
|
||||||
|
</h3>
|
||||||
|
<button type="button" className="order-modal-close" onClick={requestClose} aria-label="Закрити">
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -23,91 +230,206 @@ export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) {
|
|||||||
<span className="order-modal-label">Дата</span>
|
<span className="order-modal-label">Дата</span>
|
||||||
<span>{order.create_date_time}</span>
|
<span>{order.create_date_time}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<span className="order-modal-label">Номер ТТН</span>
|
|
||||||
<span>{order.waybill_number || '—'}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<span className="order-modal-label">Статус ТТН</span>
|
<span className="order-modal-label">Статус ТТН</span>
|
||||||
<span>{order.np_status || 'Нет данных'}</span>
|
<span>{order.np_status || 'Немає даних'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="order-modal-label">Наложенный платёж</span>
|
<span className="order-modal-label">Накладений платіж</span>
|
||||||
<span>{order.np_cod_amount ? `${order.np_cod_amount} ₴` : '—'}</span>
|
<span>{order.np_cod_amount ? `${order.np_cod_amount} ₴` : '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="order-modal-label">Оплачено</span>
|
<span className="order-modal-label">Оплачено</span>
|
||||||
<span>
|
<span>
|
||||||
{order.np_payment_status === 'Payed'
|
{order.np_payment_status === 'Payed'
|
||||||
? 'Да'
|
? 'Так'
|
||||||
: order.np_payment_status === 'NeedPayment'
|
: order.np_payment_status === 'NeedPayment'
|
||||||
? 'Нет'
|
? 'Ні'
|
||||||
: '—'}
|
: '—'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{TEXT_FIELDS.map(({ field, label, type }) =>
|
||||||
<span className="order-modal-label">Клиент</span>
|
editable ? (
|
||||||
<span>{order.recipient_name || '—'}</span>
|
<label key={field}>
|
||||||
</div>
|
<span className="order-modal-label">{label}</span>
|
||||||
<div>
|
<input
|
||||||
<span className="order-modal-label">Телефон</span>
|
type={type ?? 'text'}
|
||||||
<span>{order.recipient_phone || '—'}</span>
|
className="order-modal-input"
|
||||||
</div>
|
value={draft[field]}
|
||||||
{order.recipient_email && (
|
onChange={(e) => setField(field, e.target.value)}
|
||||||
<div>
|
/>
|
||||||
<span className="order-modal-label">Email</span>
|
</label>
|
||||||
<span>{order.recipient_email}</span>
|
) : (
|
||||||
|
(field !== 'recipient_email' || draft[field]) && (
|
||||||
|
<div key={field}>
|
||||||
|
<span className="order-modal-label">{label}</span>
|
||||||
|
<span>{draft[field] || '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{order.notes && (
|
{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">
|
<div className="order-modal-notes">
|
||||||
<span className="order-modal-label">Заметки</span>
|
<span className="order-modal-label">Нотатки</span>
|
||||||
<p>{order.notes}</p>
|
<p>{draft.notes}</p>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<table className="order-modal-goods">
|
<table className="order-modal-goods">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Наименование</th>
|
<th>Найменування</th>
|
||||||
<th>SKU</th>
|
<th>SKU</th>
|
||||||
<th>Цена</th>
|
<th>Ціна</th>
|
||||||
<th>Кол-во</th>
|
<th>К-сть</th>
|
||||||
<th>Скидка</th>
|
<th>Знижка</th>
|
||||||
<th>Сумма</th>
|
<th>Сума</th>
|
||||||
|
{editable && <th aria-label="Видалити" />}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{order.goods.map((good) => (
|
{draft.goods.map((good) => {
|
||||||
<tr key={good.id}>
|
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.name}</td>
|
||||||
<td>{good.sku}</td>
|
<td>{good.sku}</td>
|
||||||
<td>{good.price}</td>
|
<td>{good.price}</td>
|
||||||
<td>{good.quantity}</td>
|
<td>{good.quantity}</td>
|
||||||
<td>{good.discount_amount && good.discount_amount !== '0.00' ? good.discount_amount : '—'}</td>
|
<td>{good.discount !== '0.00' ? good.discount : '—'}</td>
|
||||||
<td>{good.amount}</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>
|
</tr>
|
||||||
))}
|
)
|
||||||
{order.goods.length === 0 && (
|
})}
|
||||||
|
{draft.goods.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="order-modal-goods-empty">
|
<td colSpan={editable ? 7 : 6} className="order-modal-goods-empty">
|
||||||
Товары не указаны
|
Товари не вказані
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
{editable && (
|
||||||
|
<button type="button" className="order-modal-add" onClick={addGood}>
|
||||||
|
+ Додати товар
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="order-modal-total">
|
<div className="order-modal-total">
|
||||||
<span>Итого</span>
|
<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>
|
<span>{order.total_amount} ₴</span>
|
||||||
|
)}
|
||||||
</div>
|
</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">
|
<div className="order-modal-actions">
|
||||||
<button type="button" className="order-modal-close-btn" onClick={onClose}>
|
{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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
* Меняются синхронно с ними вручную — см. пояснение в @/api/types.ts.
|
* Меняются синхронно с ними вручную — см. пояснение в @/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 {
|
export interface OrderGood {
|
||||||
id: string
|
id: string
|
||||||
sku: string
|
sku: string
|
||||||
@@ -29,4 +34,37 @@ export interface Order {
|
|||||||
np_status_code: string | null
|
np_status_code: string | null
|
||||||
np_cod_amount: string | null
|
np_cod_amount: string | null
|
||||||
np_payment_status: 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,30 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
import { getOrders } from '@/api/orders'
|
import { getOrders, getOrdersSummary } from '@/api/orders'
|
||||||
|
import type { OrderTab } from '@/features/orders/types'
|
||||||
|
|
||||||
export function useOrders(hasReceipt: boolean) {
|
/** Пока чек отправляется в Checkbox — опрашиваем часто, иначе статусы чеков обновляет worker раз в минуту. */
|
||||||
return useQuery({ queryKey: ['orders', hasReceipt], queryFn: () => getOrders(hasReceipt) })
|
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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/** Деньги на фронте приходят строками "1200.00"; в API чеков уходят integer-копейками. */
|
||||||
|
|
||||||
|
export function toKopecks(value: string): number | null {
|
||||||
|
const normalized = value.trim().replace(/\s/g, '').replace(',', '.')
|
||||||
|
if (normalized === '') return 0
|
||||||
|
if (!/^\d+(\.\d{1,2})?$/.test(normalized)) return null
|
||||||
|
return Math.round(Number(normalized) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatKopecks(kopecks: number): string {
|
||||||
|
return (kopecks / 100).toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Предоплата по умолчанию: сумма заказа − наложка НП (как считает бэкенд). */
|
||||||
|
export function defaultPrepayment(total: string, cod: string | null): string {
|
||||||
|
if (!cod) return ''
|
||||||
|
const diff = (toKopecks(total) ?? 0) - (toKopecks(cod) ?? 0)
|
||||||
|
return diff > 0 ? formatKopecks(diff) : '0.00'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Проверка инварианта ЕТТН-чека: сумма − предоплата = наложка. */
|
||||||
|
export function prepaymentMatches(total: string, cod: string | null, prepayment: string): boolean {
|
||||||
|
if (!cod) return false
|
||||||
|
const prep = toKopecks(prepayment)
|
||||||
|
if (prep === null) return false
|
||||||
|
return (toKopecks(total) ?? 0) - prep === toKopecks(cod)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Типы, зеркалящие backend/app/schemas/receipts.py и `ReceiptStatus`
|
||||||
|
* (backend/app/db/models/receipt.py). Меняются синхронно с ними вручную.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ReceiptStatus =
|
||||||
|
| 'pending'
|
||||||
|
| 'created'
|
||||||
|
| 'done'
|
||||||
|
| 'returned'
|
||||||
|
| 'receipt_error'
|
||||||
|
| 'cancelled'
|
||||||
|
| 'failed'
|
||||||
|
|
||||||
|
export interface Receipt {
|
||||||
|
id: string
|
||||||
|
order_id: string
|
||||||
|
waybill_number: string
|
||||||
|
status: ReceiptStatus
|
||||||
|
total_amount: string
|
||||||
|
prepayment_amount: string
|
||||||
|
cod_amount: string
|
||||||
|
checkbox_ettn_id: string | null
|
||||||
|
checkbox_status: string | null
|
||||||
|
checkbox_receipt_id: string | null
|
||||||
|
error: string | null
|
||||||
|
created_at: string
|
||||||
|
last_checked_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReceiptRequestItem {
|
||||||
|
order_id: string
|
||||||
|
/** null — предоплата = сумма заказа − наложка (считает бэкенд). */
|
||||||
|
prepayment_kopecks: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReceiptCreateResponse {
|
||||||
|
created: Receipt[]
|
||||||
|
/** order_id → причина, по которой чек не создан. */
|
||||||
|
errors: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Tone = 'delivered' | 'processing' | 'danger' | 'new'
|
||||||
|
|
||||||
|
export const RECEIPT_STATUS: Record<ReceiptStatus, { label: string; tone: Tone }> = {
|
||||||
|
pending: { label: 'Надсилається', tone: 'new' },
|
||||||
|
created: { label: 'Очікує оплати', tone: 'processing' },
|
||||||
|
done: { label: 'Фіскалізовано', tone: 'delivered' },
|
||||||
|
returned: { label: 'Повернення посилки', tone: 'danger' },
|
||||||
|
receipt_error: { label: 'Помилка фіскалізації', tone: 'danger' },
|
||||||
|
cancelled: { label: 'Скасовано', tone: 'new' },
|
||||||
|
failed: { label: 'Не створено', tone: 'danger' },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Статусы, из которых чек можно отменить (см. services/receipts.cancel_receipt). */
|
||||||
|
export const CANCELLABLE: ReadonlySet<ReceiptStatus> = new Set(['created', 'receipt_error'])
|
||||||
@@ -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 {
|
.page-center {
|
||||||
min-height: 100svh;
|
min-height: 100svh;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import '@/index.css'
|
|||||||
|
|
||||||
const rootElement = document.getElementById('root')
|
const rootElement = document.getElementById('root')
|
||||||
if (!rootElement) {
|
if (!rootElement) {
|
||||||
throw new Error('Элемент #root не найден в index.html')
|
throw new Error('Елемент #root не знайдено в index.html')
|
||||||
}
|
}
|
||||||
|
|
||||||
createRoot(rootElement).render(
|
createRoot(rootElement).render(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,6 +13,12 @@
|
|||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-topbar-start {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-brand {
|
.dashboard-brand {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -65,6 +71,39 @@
|
|||||||
justify-content: space-between;
|
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 {
|
.orders-create-btn {
|
||||||
border: none;
|
border: none;
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
@@ -325,3 +364,68 @@
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
padding: 32px 0;
|
padding: 32px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.orders-toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders-link-btn {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders-prepayment-input--invalid {
|
||||||
|
border-color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders-notice {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders-notice ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders-notice--ok {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders-notice--error {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders-notice-close {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.orders-notice--ok {
|
||||||
|
background: rgba(22, 163, 74, 0.18);
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders-notice--error {
|
||||||
|
background: rgba(220, 38, 38, 0.18);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
import { deleteOrder } from '@/api/orders'
|
import { deleteOrder } from '@/api/orders'
|
||||||
|
import { cancelReceipt, createReceipts } from '@/api/receipts'
|
||||||
import '@/pages/DashboardPage.css'
|
import '@/pages/DashboardPage.css'
|
||||||
|
import { ROLE_LABEL } from '@/features/auth/roles'
|
||||||
import { useAuth } from '@/features/auth/useAuth'
|
import { useAuth } from '@/features/auth/useAuth'
|
||||||
import { OrderDetailModal } from '@/features/orders/OrderDetailModal'
|
import { OrderDetailModal } from '@/features/orders/OrderDetailModal'
|
||||||
import type { Order } from '@/features/orders/types'
|
import type { Order, OrderTab } from '@/features/orders/types'
|
||||||
import { useOrders } from '@/features/orders/useOrders'
|
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> = {
|
const TABS: { key: OrderTab; label: string }[] = [
|
||||||
admin: 'Администратор',
|
|
||||||
cashier: 'Кассир',
|
|
||||||
viewer: 'Наблюдатель',
|
|
||||||
}
|
|
||||||
|
|
||||||
type Tab = 'no_receipt' | 'has_receipt'
|
|
||||||
|
|
||||||
const TABS: { key: Tab; label: string }[] = [
|
|
||||||
{ key: 'no_receipt', label: 'Без чека' },
|
{ key: 'no_receipt', label: 'Без чека' },
|
||||||
{ key: 'has_receipt', label: 'Выписаны чеки' },
|
{ key: 'has_receipt', label: 'Виписані чеки' },
|
||||||
|
{ key: 'received', label: 'Отримані' },
|
||||||
|
{ key: 'refused', label: 'Відмови' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function npStatusTone(order: Order): 'delivered' | 'processing' | 'danger' | 'new' {
|
function npStatusTone(order: Order): 'delivered' | 'processing' | 'danger' | 'new' {
|
||||||
@@ -29,23 +29,100 @@ function npStatusTone(order: Order): 'delivered' | 'processing' | 'danger' | 'ne
|
|||||||
return 'new'
|
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 {
|
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 === 'Payed') return { label: 'Оплачено', tone: 'delivered' }
|
||||||
if (order.np_payment_status === 'NeedPayment') return { label: 'Не оплачено', tone: 'processing' }
|
if (order.np_payment_status === 'NeedPayment') return { label: 'Не оплачено', tone: 'processing' }
|
||||||
return null
|
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() {
|
export function DashboardPage() {
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
const [tab, setTab] = useState<Tab>('no_receipt')
|
const [tab, setTab] = useState<OrderTab>('no_receipt')
|
||||||
const { data: orders, isLoading, isError } = useOrders(tab === 'has_receipt')
|
const { data: orders, isLoading, isError } = useOrders(tab)
|
||||||
|
const { data: summary } = useOrdersSummary()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [viewingOrder, setViewingOrder] = useState<Order | null>(null)
|
const [viewingOrder, setViewingOrder] = useState<Order | null>(null)
|
||||||
const [deletingId, setDeletingId] = useState<string | 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 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 filtered = useMemo(() => {
|
||||||
const query = search.trim().toLowerCase()
|
const query = search.trim().toLowerCase()
|
||||||
@@ -92,23 +169,62 @@ export function DashboardPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="dashboard-shell">
|
<div className="dashboard-shell">
|
||||||
<header className="dashboard-topbar">
|
<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">
|
<div className="dashboard-user">
|
||||||
<span>{user?.full_name}</span>
|
<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 type="button" className="dashboard-logout" onClick={() => void logout()}>
|
||||||
Выйти
|
Вийти
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="orders-body">
|
<main className="orders-body">
|
||||||
<div className="orders-toolbar">
|
<div className="orders-toolbar">
|
||||||
<h2>Заказы</h2>
|
<h2>Замовлення</h2>
|
||||||
<button type="button" className="orders-create-btn" disabled={selected.size === 0}>
|
<div className="orders-toolbar-actions">
|
||||||
Создать чеки по выбранным ({selected.size})
|
{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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="orders-tabs">
|
<div className="orders-tabs">
|
||||||
{TABS.map((t) => (
|
{TABS.map((t) => (
|
||||||
@@ -116,7 +232,10 @@ export function DashboardPage() {
|
|||||||
key={t.key}
|
key={t.key}
|
||||||
type="button"
|
type="button"
|
||||||
className={`orders-tab${tab === t.key ? ' orders-tab--active' : ''}`}
|
className={`orders-tab${tab === t.key ? ' orders-tab--active' : ''}`}
|
||||||
onClick={() => setTab(t.key)}
|
onClick={() => {
|
||||||
|
setTab(t.key)
|
||||||
|
setSelected(new Set())
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t.label}
|
{t.label}
|
||||||
</button>
|
</button>
|
||||||
@@ -124,16 +243,16 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="orders-filterbar">
|
<div className="orders-filterbar">
|
||||||
<span className="orders-selected-count">Выбрано: {selected.size} заказов</span>
|
<span className="orders-selected-count">Вибрано: {selected.size} замовлень</span>
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
className="orders-search"
|
className="orders-search"
|
||||||
placeholder="Поиск по ID, ТТН, клиенту"
|
placeholder="Пошук за ID, ТТН, клієнтом"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="orders-filter-btn">
|
<button type="button" className="orders-filter-btn">
|
||||||
Фильтр
|
Фільтр
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -142,31 +261,31 @@ export function DashboardPage() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="orders-checkbox-col">
|
<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>
|
||||||
<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>
|
<th>Статус ТТН</th>
|
||||||
<th>Действия</th>
|
<th>Дії</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={10} className="orders-empty">
|
<td colSpan={10} className="orders-empty">
|
||||||
Загрузка…
|
Завантаження…
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{isError && (
|
{isError && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={10} className="orders-empty">
|
<td colSpan={10} className="orders-empty">
|
||||||
Не удалось загрузить заказы из CRM
|
Не вдалося завантажити замовлення з CRM
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
@@ -181,7 +300,7 @@ export function DashboardPage() {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selected.has(order.id)}
|
checked={selected.has(order.id)}
|
||||||
onChange={() => toggleOne(order.id)}
|
onChange={() => toggleOne(order.id)}
|
||||||
aria-label={`Выбрать заказ ${order.id}`}
|
aria-label={`Вибрати замовлення ${order.id}`}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>{order.id}</td>
|
<td>{order.id}</td>
|
||||||
@@ -197,28 +316,72 @@ export function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input type="text" className="orders-prepayment-input" placeholder="0%" />
|
{tab === 'no_receipt' ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
className={`orders-prepayment-input${
|
||||||
|
canCreateReceipt(order) &&
|
||||||
|
!prepaymentMatches(order.total_amount, order.np_cod_amount, prepaymentOf(order))
|
||||||
|
? ' orders-prepayment-input--invalid'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
placeholder="0.00"
|
||||||
|
value={prepaymentOf(order)}
|
||||||
|
disabled={!canFiscalize}
|
||||||
|
title="Сума замовлення − передоплата має дорівнювати післяплаті"
|
||||||
|
onChange={(e) => setPrepayments((prev) => ({ ...prev, [order.id]: e.target.value }))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
(order.receipt_prepayment ?? '—')
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`orders-status orders-status--${npStatusTone(order)}`}>
|
<span className={`orders-status orders-status--${npStatusTone(order)}`}>
|
||||||
{order.np_status || 'Нет данных'}
|
{order.np_status || 'Немає даних'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="orders-actions-cell">
|
<td className="orders-actions-cell">
|
||||||
<button type="button" className="orders-view-btn" onClick={() => setViewingOrder(order)}>
|
<button type="button" className="orders-view-btn" onClick={() => setViewingOrder(order)}>
|
||||||
Просмотр
|
Перегляд
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="orders-receipt-btn" disabled>
|
{tab === 'no_receipt' && canFiscalize && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="orders-receipt-btn"
|
||||||
|
disabled={!canCreateReceipt(order) || submitting}
|
||||||
|
title={canCreateReceipt(order) ? 'Створити ЕТТН-чек у Checkbox' : 'Немає ТТН або післяплати'}
|
||||||
|
onClick={() => void submitReceipts([order])}
|
||||||
|
>
|
||||||
Чек
|
Чек
|
||||||
</button>
|
</button>
|
||||||
{canDelete && (
|
)}
|
||||||
|
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="orders-delete-btn"
|
className="orders-delete-btn"
|
||||||
disabled={deletingId === order.id}
|
disabled={deletingId === order.id}
|
||||||
onClick={() => void handleDelete(order)}
|
onClick={() => void handleDelete(order)}
|
||||||
>
|
>
|
||||||
Удалить
|
Видалити
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
@@ -228,7 +391,7 @@ export function DashboardPage() {
|
|||||||
{!isLoading && !isError && filtered.length === 0 && (
|
{!isLoading && !isError && filtered.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={10} className="orders-empty">
|
<td colSpan={10} className="orders-empty">
|
||||||
Ничего не найдено
|
Нічого не знайдено
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
@@ -237,7 +400,13 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{viewingOrder && <OrderDetailModal order={viewingOrder} onClose={() => setViewingOrder(null)} />}
|
{viewingOrder && (
|
||||||
|
<OrderDetailModal
|
||||||
|
order={orders?.find((order) => order.id === viewingOrder.id) ?? viewingOrder}
|
||||||
|
canEdit={canFiscalize && tab === 'no_receipt'}
|
||||||
|
onClose={() => setViewingOrder(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Бэкап прод-БД lux_fiscal: cron пользователя deploy (ежедневно) и scripts/deploy.sh (перед выкладкой).
|
||||||
|
# Дампы содержат зашифрованные ключи касс: для восстановления нужен тот же ENCRYPTION_KEY из .env.
|
||||||
|
# Последняя строка вывода — "ok <путь к дампу> <размер>", её читает deploy.sh.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-$HOME/backups/lux_fiscal}"
|
||||||
|
KEEP_DAYS=14
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
C="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
|
||||||
|
FILE="$BACKUP_DIR/lux_fiscal_$(date +%F_%H%M%S).dump"
|
||||||
|
$C exec -T postgres sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$FILE.tmp"
|
||||||
|
|
||||||
|
# Битый/пустой дамп не должен вытеснить ротацией хорошие.
|
||||||
|
$C exec -T postgres pg_restore --list < "$FILE.tmp" > /dev/null
|
||||||
|
mv "$FILE.tmp" "$FILE"
|
||||||
|
|
||||||
|
find "$BACKUP_DIR" -name "lux_fiscal_*.dump" -mtime +"$KEEP_DAYS" -delete
|
||||||
|
find "$BACKUP_DIR" -name "*.tmp" -mtime +1 -delete
|
||||||
|
echo "$(date -Is) ok $FILE $(du -h "$FILE" | cut -f1)"
|
||||||
Executable
+56
@@ -0,0 +1,56 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Выкладка main на прод: бэкап БД → git pull → сборка и запуск → проверка health.
|
||||||
|
# Если новая версия не поднялась — откат кода на предыдущий коммит (БД не откатывается,
|
||||||
|
# путь к свежему бэкапу печатается). Запуск на сервере: ~/lux_fiscal/scripts/deploy.sh [--force]
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
C="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
|
||||||
|
|
||||||
|
health() {
|
||||||
|
# Цепочка целиком: nginx фронтенда → api → БД/Redis.
|
||||||
|
$C exec -T frontend wget -qO- http://127.0.0.1/api/v1/health | grep -q '"status":"ok"'
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
|
||||||
|
echo "На сервере есть незакоммиченные правки в отслеживаемых файлах — разберитесь вручную:" >&2
|
||||||
|
git status --short --untracked-files=no >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# PREV — то, что крутится сейчас (после неудачной выкладки это detached-коммит отката, а не main).
|
||||||
|
PREV=$(git rev-parse HEAD)
|
||||||
|
git checkout -q main
|
||||||
|
git fetch -q origin main
|
||||||
|
git merge -q --ff-only origin/main
|
||||||
|
NEW=$(git rev-parse HEAD)
|
||||||
|
|
||||||
|
if [ "$PREV" = "$NEW" ] && [ "${1:-}" != "--force" ]; then
|
||||||
|
echo "Нечего выкладывать: уже на $(git log --oneline -1). Пересобрать всё равно: --force"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== Бэкап БД"
|
||||||
|
BACKUP=$(scripts/backup.sh | tail -1 | awk '{print $3}')
|
||||||
|
echo " $BACKUP"
|
||||||
|
|
||||||
|
echo "== Выкладка $(git log --oneline -1 "$PREV") -> $(git log --oneline -1 "$NEW")"
|
||||||
|
if $C up -d --build --wait --remove-orphans && health; then
|
||||||
|
echo "== Готово: $(git log --oneline -1)"
|
||||||
|
docker image prune -f --filter "label=com.docker.compose.project=lux-fiscal" > /dev/null || true
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "!! Новая версия не поднялась — откат кода на $PREV" >&2
|
||||||
|
$C logs --tail 50 migrate api worker >&2 || true
|
||||||
|
git checkout -q --detach "$PREV"
|
||||||
|
$C up -d --build --wait --remove-orphans || true
|
||||||
|
if health; then
|
||||||
|
echo "!! Откат выполнен, работает $(git log --oneline -1). Репозиторий в detached HEAD —" >&2
|
||||||
|
echo "!! следующий deploy.sh сам вернётся на main." >&2
|
||||||
|
else
|
||||||
|
echo "!! Откат не помог: сервис не отвечает." >&2
|
||||||
|
fi
|
||||||
|
echo "!! Если новая версия успела применить миграции, а старый код с ними несовместим —" >&2
|
||||||
|
echo "!! восстановите БД из бэкапа, снятого перед выкладкой: $BACKUP (см. DEPLOY.md)." >&2
|
||||||
|
exit 1
|
||||||
Reference in New Issue
Block a user