Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b7a92645a | ||
|
|
418f6b0352 | ||
|
|
44fe648ebc | ||
|
|
d92c715c11 | ||
|
|
8fffdc3ce6 | ||
|
|
0ba2ca01c4 | ||
|
|
b8f2fe6b6e | ||
|
|
47df3a78dc | ||
|
|
f6971a612f | ||
|
|
8b3c2b6d63 | ||
|
|
2b92d82693 | ||
|
|
a7cba7616a |
+6
-2
@@ -56,9 +56,13 @@ CRM_API_KEY=change-me-crm-apikey
|
||||
CRM_SECRET_KEY=change-me-crm-secretkey
|
||||
CRM_SHOP_KEY=change-me-crm-shopkey
|
||||
CRM_SID=1
|
||||
# Стаб вместо реальной CRM (локально — обязательно вместе с CHECKBOX_USE_STUB):
|
||||
# иначе после стаб-чека боевой заказ уйдёт в PACKED. В production запрещено.
|
||||
CRM_USE_STUB=true
|
||||
|
||||
# --- Nova Poshta ----------------------------------------------------------
|
||||
# Ключ API кабинета Nova Poshta (my.novaposhta.ua → Настройки → API).
|
||||
# Ключи API Nova Poshta задаются у касс (страница «Кассы»). Эта переменная нужна
|
||||
# только миграции 0008: переносит старый общий ключ в кассу по умолчанию.
|
||||
NOVA_POSHTA_API_KEY=change-me-novaposhta-apikey
|
||||
|
||||
# --- Checkbox -------------------------------------------------------------
|
||||
@@ -71,4 +75,4 @@ CHECKBOX_CLIENT_VERSION=0.1.0
|
||||
CHECKBOX_MIN_REQUEST_INTERVAL_MS=1000
|
||||
# ЕТТН-чеки на тестовой кассе Checkbox не работают: локально весь цикл
|
||||
# прогоняется через стаб (чек «фискализируется» на втором опросе). В production запрещено.
|
||||
CHECKBOX_USE_STUB=false
|
||||
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
|
||||
@@ -19,6 +19,10 @@ backend/ FastAPI + SQLAlchemy (async) + Alembic + Postgres — see backend/ap
|
||||
frontend/ React 19 + TypeScript + Vite — see frontend/src/
|
||||
docker/ nginx.frontend.conf (SPA + /api reverse proxy to the api container)
|
||||
docker-compose.yml postgres, redis, migrate (one-shot), api, worker, frontend
|
||||
docker-compose.prod.yml prod overlay: no host ports, frontend joins the external `web` network (Nginx Proxy Manager)
|
||||
scripts/ deploy.sh (runs on the prod server), backup.sh (pg_dump + rotation)
|
||||
.gitea/workflows/ci.yml ruff + pytest, oxlint + build on every PR
|
||||
DEPLOY.md production runbook — read before touching the server
|
||||
```
|
||||
|
||||
Backend and frontend are independent projects with their own dependency files (`backend/pyproject.toml`, `frontend/package.json`) — always `cd` into the right one before running tooling.
|
||||
@@ -72,6 +76,17 @@ docker compose ps # migrate should show Exited(0) — it's a one
|
||||
|
||||
`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
|
||||
|
||||
- **Async everywhere.** SQLAlchemy 2.0 async ORM + `asyncpg`, one DSN (`app.core.config.Settings.database_url`) used by both the app and Alembic — no separate sync driver.
|
||||
@@ -127,4 +142,5 @@ Stages 1–4 (scaffolding, auth/audit, CRM order queue, Nova Poshta tracking) ar
|
||||
- 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.
|
||||
- ETTN does **not** work on a Checkbox test cash register. Locally use `CHECKBOX_USE_STUB=true`; client selection is only in `services/checkbox/client.get_checkbox_client()`.
|
||||
- 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`.
|
||||
@@ -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,13 +10,11 @@ from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import TokenError, decode_access_token
|
||||
from app.db.models.user import User, UserRole
|
||||
from app.db.session import get_session
|
||||
from app.services.checkbox.client import CheckboxClient, get_checkbox_client
|
||||
from app.services.crm.client import CrmClient
|
||||
from app.services.crm.exo_client import ExoCrmClient
|
||||
from app.services.crm.client import CrmClient, get_crm_client
|
||||
from app.services.task_queue import TaskQueue, get_task_queue
|
||||
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
@@ -25,7 +23,7 @@ SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
_UNAUTHORIZED = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Требуется аутентификация",
|
||||
detail="Потрібна автентифікація",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
@@ -76,7 +74,7 @@ def require_roles(*roles: UserRole) -> Callable[..., Coroutine[Any, Any, User]]:
|
||||
if user.role not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Недостаточно прав для этого действия",
|
||||
detail="Недостатньо прав для цієї дії",
|
||||
)
|
||||
return user
|
||||
|
||||
@@ -92,10 +90,6 @@ AdminUser = Annotated[User, Depends(require_admin)]
|
||||
CashierUser = Annotated[User, Depends(require_cashier)]
|
||||
|
||||
|
||||
def get_crm_client() -> CrmClient:
|
||||
return ExoCrmClient(settings)
|
||||
|
||||
|
||||
CrmClientDep = Annotated[CrmClient, Depends(get_crm_client)]
|
||||
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ async def change_password(
|
||||
) -> Response:
|
||||
if not verify_password(payload.current_password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Текущий пароль указан неверно"
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Поточний пароль вказано неправильно"
|
||||
)
|
||||
|
||||
user.password_hash = hash_password(payload.new_password)
|
||||
|
||||
@@ -27,6 +27,27 @@ async def _clear_default(session: SessionDep, keep_id: uuid.UUID) -> None:
|
||||
)
|
||||
|
||||
|
||||
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))
|
||||
@@ -37,12 +58,15 @@ async def list_cash_registers(session: SessionDep) -> list[CashRegisterOut]:
|
||||
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,
|
||||
@@ -56,7 +80,11 @@ async def create_cash_register(
|
||||
user=actor,
|
||||
entity_type="cash_register",
|
||||
entity_id=register.id,
|
||||
payload={"name": register.name, "fiscal_number": register.fiscal_number},
|
||||
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()
|
||||
@@ -73,13 +101,16 @@ async def update_cash_register(
|
||||
) -> CashRegisterOut:
|
||||
register = await session.get(CashRegister, register_id)
|
||||
if register 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)
|
||||
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"):
|
||||
@@ -94,7 +125,7 @@ async def update_cash_register(
|
||||
user=actor,
|
||||
entity_type="cash_register",
|
||||
entity_id=register.id,
|
||||
payload=changes, # license_key / pin_code редактируются в audit._sanitize
|
||||
payload=changes, # license_key / pin_code / np_api_key редактируются в audit._sanitize
|
||||
request=request,
|
||||
)
|
||||
await session.commit()
|
||||
@@ -108,7 +139,7 @@ async def check_cash_register(
|
||||
"""Пробный вход кассира в Checkbox — проверка ключа лицензии и PIN."""
|
||||
register = await session.get(CashRegister, register_id)
|
||||
if register is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Касса не найдена")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Касу не знайдено")
|
||||
try:
|
||||
await checkbox.sign_in(credentials(register))
|
||||
except (CheckboxError, crypto.DecryptionError) as exc:
|
||||
|
||||
@@ -50,7 +50,7 @@ async def update_order(
|
||||
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="Заказ не найден")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Замовлення не знайдено")
|
||||
|
||||
order, changed = result
|
||||
if changed:
|
||||
@@ -75,7 +75,7 @@ async def delete_order(
|
||||
) -> None:
|
||||
order = await orders_service.delete_order(session, order_id)
|
||||
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(
|
||||
session,
|
||||
|
||||
@@ -60,7 +60,7 @@ async def list_receipts(session: SessionDep, order_id: str | None = None) -> lis
|
||||
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="Чек не найден")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Чек не знайдено")
|
||||
return ReceiptOut.from_receipt(receipt)
|
||||
|
||||
|
||||
@@ -83,5 +83,5 @@ async def cancel_receipt(
|
||||
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="Чек не найден")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Чек не знайдено")
|
||||
return ReceiptOut.from_receipt(receipt)
|
||||
|
||||
@@ -71,7 +71,7 @@ async def create_user(
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Пользователь с таким email уже существует",
|
||||
detail="Користувач із таким email уже існує",
|
||||
) from exc
|
||||
|
||||
return UserOut.model_validate(user)
|
||||
@@ -87,7 +87,7 @@ async def update_user(
|
||||
) -> UserOut:
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Користувача не знайдено")
|
||||
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
|
||||
@@ -97,12 +97,12 @@ async def update_user(
|
||||
if changes.get("is_active") is False:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Нельзя отключить собственную учётную запись",
|
||||
detail="Не можна вимкнути власний обліковий запис",
|
||||
)
|
||||
if "role" in changes and changes["role"] != UserRole.ADMIN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Нельзя снять с себя роль администратора",
|
||||
detail="Не можна зняти із себе роль адміністратора",
|
||||
)
|
||||
|
||||
if (new_password := changes.pop("password", None)) is not None:
|
||||
|
||||
+5
-5
@@ -25,7 +25,7 @@ async def bootstrap() -> int:
|
||||
Идемпотентна: повторный запуск ничего не меняет и пароль не сбрасывает.
|
||||
"""
|
||||
if not settings.first_admin_password:
|
||||
print("FIRST_ADMIN_PASSWORD не задан в .env", file=sys.stderr)
|
||||
print("FIRST_ADMIN_PASSWORD не задано в .env", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
email = settings.first_admin_email.strip().lower()
|
||||
@@ -33,7 +33,7 @@ async def bootstrap() -> int:
|
||||
async with session_scope() as session:
|
||||
existing = await session.scalar(select(User).where(User.email == email))
|
||||
if existing is not None:
|
||||
print(f"Пользователь {email} уже существует — ничего не изменено.")
|
||||
print(f"Користувач {email} уже існує — нічого не змінено.")
|
||||
return 0
|
||||
|
||||
session.add(
|
||||
@@ -45,8 +45,8 @@ async def bootstrap() -> int:
|
||||
)
|
||||
)
|
||||
|
||||
print(f"Администратор {email} создан.")
|
||||
print("Смените пароль после первого входа и уберите FIRST_ADMIN_PASSWORD из .env.")
|
||||
print(f"Адміністратора {email} створено.")
|
||||
print("Змініть пароль після першого входу та приберіть FIRST_ADMIN_PASSWORD з .env.")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ def gen_keys() -> int:
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(prog="app.cli", description="Служебные команды lux_fiscal")
|
||||
parser = argparse.ArgumentParser(prog="app.cli", description="Службові команди lux_fiscal")
|
||||
parser.add_argument("command", choices=["bootstrap", "gen-keys"])
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ class Settings(BaseSettings):
|
||||
# --- Первый администратор (только для команды bootstrap) ---
|
||||
first_admin_email: str = "admin@example.com"
|
||||
first_admin_password: str = ""
|
||||
first_admin_name: str = "Администратор"
|
||||
first_admin_name: str = "Адміністратор"
|
||||
|
||||
# --- CRM ---
|
||||
crm_base_url: str = "https://optstore.exocrm.com/api/1.1/"
|
||||
@@ -61,8 +61,13 @@ class Settings(BaseSettings):
|
||||
crm_secret_key: str = ""
|
||||
crm_shop_key: str = ""
|
||||
crm_sid: int = 1
|
||||
# Стаб вместо реальной CRM: иначе локальный worker переводил бы боевые заказы
|
||||
# в PACKED после стаб-чеков Checkbox. В проде запрещено.
|
||||
crm_use_stub: bool = False
|
||||
|
||||
# --- Nova Poshta ---
|
||||
# Ключи НП хранятся у касс (`cash_registers.np_api_key_enc`). Эта переменная
|
||||
# читается только миграцией 0008: переносит старый общий ключ в кассу по умолчанию.
|
||||
nova_poshta_api_key: str = ""
|
||||
|
||||
# --- Checkbox ---
|
||||
|
||||
@@ -26,7 +26,7 @@ def _fernet() -> Fernet:
|
||||
return Fernet(settings.encryption_key.encode())
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise RuntimeError(
|
||||
"ENCRYPTION_KEY некорректен. Сгенерируйте валидный ключ: "
|
||||
"ENCRYPTION_KEY некоректний. Згенеруйте валідний ключ: "
|
||||
"python -c \"from cryptography.fernet import Fernet; "
|
||||
'print(Fernet.generate_key().decode())"'
|
||||
) from exc
|
||||
@@ -41,8 +41,8 @@ def decrypt(value: str) -> str:
|
||||
return _fernet().decrypt(value.encode()).decode()
|
||||
except InvalidToken as exc:
|
||||
raise DecryptionError(
|
||||
"Не удалось расшифровать значение. Вероятная причина — ENCRYPTION_KEY "
|
||||
"изменился с момента сохранения. Секрет нужно ввести заново."
|
||||
"Не вдалося розшифрувати значення. Імовірна причина — ENCRYPTION_KEY "
|
||||
"змінився з моменту збереження. Секрет потрібно ввести заново."
|
||||
) from exc
|
||||
|
||||
|
||||
|
||||
@@ -67,13 +67,13 @@ def decode_access_token(token: str) -> dict[str, Any]:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
raise TokenError("Срок действия токена истёк") from exc
|
||||
raise TokenError("Термін дії токена минув") from exc
|
||||
except jwt.InvalidTokenError as exc:
|
||||
raise TokenError("Некорректный токен") from exc
|
||||
raise TokenError("Некоректний токен") from exc
|
||||
|
||||
# Без этой проверки refresh-токен можно было бы предъявить как access.
|
||||
if payload.get("type") != "access":
|
||||
raise TokenError("Ожидался access-токен")
|
||||
raise TokenError("Очікувався access-токен")
|
||||
return payload
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Кассы (ПРРО) Checkbox.
|
||||
|
||||
Ключ лицензии и PIN кассира хранятся только в зашифрованном виде
|
||||
(`app/core/crypto.py`): утечка дампа БД не должна давать доступ к кассе.
|
||||
Ключ лицензии, PIN кассира и ключ API Новой Почты хранятся только в
|
||||
зашифрованном виде (`app/core/crypto.py`): утечка дампа БД не должна давать
|
||||
доступ к кассе и кабинету отправителя.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,6 +23,10 @@ class CashRegister(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
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)
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
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.orm import Mapped, mapped_column
|
||||
|
||||
@@ -54,3 +55,9 @@ class Order(TimestampMixin, Base):
|
||||
np_status_code: Mapped[str | None] = mapped_column(String(16))
|
||||
np_cod_amount_kopecks: Mapped[int | None] = mapped_column(BigInteger)
|
||||
np_payment_status: Mapped[str | None] = mapped_column(String(32))
|
||||
|
||||
# Касса, чей ключ НП видит ТТН как свою (определяется при опросе статусов,
|
||||
# см. `services/orders.sync_np_statuses`). От неё создаётся ЕТТН-чек.
|
||||
cash_register_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("cash_registers.id", ondelete="SET NULL"), index=True
|
||||
)
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title=settings.project_name,
|
||||
version="0.1.0",
|
||||
description="Фискализация заказов через Checkbox",
|
||||
description="Фіскалізація замовлень через Checkbox",
|
||||
lifespan=lifespan,
|
||||
# В проде интерактивная документация закрыта: схема API — лишняя
|
||||
# подсказка для того, кто ищет незащищённый эндпоинт.
|
||||
|
||||
@@ -22,7 +22,7 @@ class TokenPair(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int = Field(description="Время жизни access-токена в секундах")
|
||||
expires_in: int = Field(description="Час життя access-токена в секундах")
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
|
||||
@@ -70,26 +70,34 @@ class ReceiptCreateResponse(BaseModel):
|
||||
# --- Кассы -------------------------------------------------------------------
|
||||
|
||||
|
||||
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:
|
||||
try:
|
||||
masked = crypto.mask(crypto.decrypt(register.license_key_enc))
|
||||
except crypto.DecryptionError:
|
||||
masked = "не расшифровывается — введите заново"
|
||||
return cls(
|
||||
id=register.id,
|
||||
name=register.name,
|
||||
fiscal_number=register.fiscal_number,
|
||||
license_key_masked=masked,
|
||||
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,
|
||||
@@ -101,6 +109,7 @@ class CashRegisterCreate(BaseModel):
|
||||
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
|
||||
|
||||
@@ -112,6 +121,7 @@ class CashRegisterUpdate(BaseModel):
|
||||
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`.
|
||||
cod_amount: str | None = Field(default=None)
|
||||
|
||||
# Телефон отправителя NP отдаёт только кабинету отправителя (или получателя):
|
||||
# по чужому ключу без телефона ответ урезан — нет ни отправителя, ни
|
||||
# `AfterpaymentOnGoodsCost`. По этому полю определяется, чья это посылка.
|
||||
phone_sender: str | None = Field(default=None, alias="PhoneSender")
|
||||
|
||||
# Статус оплаты аналогично раздвоен на `PaymentStatus`/`ExpressWaybillPaymentStatus`.
|
||||
payment_status: str | None = Field(default=None)
|
||||
|
||||
@property
|
||||
def is_own(self) -> bool:
|
||||
"""ТТН видна ключу как своя — отправлена из кабинета этого ключа."""
|
||||
return bool(self.phone_sender)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _fold_express_fields(cls, data: Any) -> Any:
|
||||
|
||||
@@ -22,6 +22,7 @@ _REDACTED_KEYS = {
|
||||
"secret",
|
||||
"license_key",
|
||||
"api_key",
|
||||
"np_api_key",
|
||||
"pin",
|
||||
"pin_code",
|
||||
"cashier_pin",
|
||||
|
||||
@@ -53,7 +53,7 @@ async def authenticate(
|
||||
request=request,
|
||||
)
|
||||
await session.commit()
|
||||
raise AuthError("Неверный email или пароль")
|
||||
raise AuthError("Невірний email або пароль")
|
||||
|
||||
if not user.is_active:
|
||||
await audit.record(
|
||||
@@ -64,7 +64,7 @@ async def authenticate(
|
||||
request=request,
|
||||
)
|
||||
await session.commit()
|
||||
raise AuthError("Учётная запись отключена")
|
||||
raise AuthError("Обліковий запис вимкнено")
|
||||
|
||||
# Параметры argon2 со временем ужесточаются — обновляем хеш на живом пароле.
|
||||
if password_needs_rehash(user.password_hash):
|
||||
@@ -117,7 +117,7 @@ async def rotate_refresh_token(
|
||||
stored = await session.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash))
|
||||
|
||||
if stored is None:
|
||||
raise AuthError("Некорректный refresh-токен")
|
||||
raise AuthError("Некоректний refresh-токен")
|
||||
|
||||
if stored.revoked_at is not None:
|
||||
await revoke_all_for_user(session, stored.user_id)
|
||||
@@ -131,14 +131,14 @@ async def rotate_refresh_token(
|
||||
)
|
||||
await session.commit()
|
||||
log.warning("refresh_token_reuse_detected", user_id=str(stored.user_id))
|
||||
raise AuthError("Сессия отозвана, требуется повторный вход")
|
||||
raise AuthError("Сесію відкликано, потрібен повторний вхід")
|
||||
|
||||
if stored.expires_at <= datetime.now(UTC):
|
||||
raise AuthError("Срок действия refresh-токена истёк")
|
||||
raise AuthError("Термін дії refresh-токена минув")
|
||||
|
||||
user = await session.get(User, stored.user_id)
|
||||
if user is None or not user.is_active:
|
||||
raise AuthError("Учётная запись недоступна")
|
||||
raise AuthError("Обліковий запис недоступний")
|
||||
|
||||
pair = await issue_token_pair(session, user, request=request, replaces=stored)
|
||||
await audit.record(
|
||||
|
||||
@@ -63,7 +63,7 @@ def get_checkbox_client() -> CheckboxClient:
|
||||
"""Один экземпляр на процесс: внутри — кэш токенов кассиров."""
|
||||
if settings.checkbox_use_stub:
|
||||
if settings.is_production:
|
||||
raise RuntimeError("CHECKBOX_USE_STUB=true запрещён в production")
|
||||
raise RuntimeError("CHECKBOX_USE_STUB=true заборонено в production")
|
||||
from app.services.checkbox.stub_client import StubCheckboxClient
|
||||
|
||||
return StubCheckboxClient(auto_complete_after=2)
|
||||
|
||||
@@ -57,7 +57,7 @@ def _error_message(response: httpx.Response) -> str:
|
||||
# {"code": "third_party.*", "message": "Internal Server Error"} —
|
||||
# без кода кассир видит только бесполезное «Internal Server Error».
|
||||
if code := data.get("code"):
|
||||
message = f"{message or 'Ошибка'} ({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')}"
|
||||
@@ -130,7 +130,7 @@ class HttpCheckboxClient:
|
||||
method, path, headers=headers, json=json, params=params
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise CheckboxUnavailableError(f"Checkbox недоступен: {exc!r}") from exc
|
||||
raise CheckboxUnavailableError(f"Checkbox недоступний: {exc!r}") from exc
|
||||
if error := _transient_error(response):
|
||||
raise error
|
||||
return response
|
||||
@@ -155,7 +155,7 @@ class HttpCheckboxClient:
|
||||
json={"pin_code": creds.pin_code},
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise CheckboxError(f"Вход кассира не удался: {_error_message(response)}")
|
||||
raise CheckboxError(f"Вхід касира не вдався: {_error_message(response)}")
|
||||
token = response.json()["access_token"]
|
||||
self._tokens[creds.cash_register_id] = token
|
||||
return token
|
||||
|
||||
@@ -32,12 +32,12 @@ class StubCheckboxClient:
|
||||
|
||||
async def sign_in(self, creds: CheckboxCredentials) -> None:
|
||||
if not creds.license_key or not creds.pin_code:
|
||||
raise CheckboxError("Вход кассира не удался: пустой ключ или PIN")
|
||||
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} уже привязана к чеку")
|
||||
raise CheckboxError(f"ЕТТН {waybill} вже прив'язана до чека")
|
||||
ettn = EttnOut(
|
||||
id=str(uuid.uuid4()),
|
||||
status=EttnStatus.CREATED,
|
||||
@@ -50,7 +50,7 @@ class StubCheckboxClient:
|
||||
|
||||
async def get_ettn(self, creds: CheckboxCredentials, ettn_id: str) -> EttnOut:
|
||||
if ettn_id not in self.orders:
|
||||
raise CheckboxError(f"ЕТТН-чек {ettn_id} не найден")
|
||||
raise CheckboxError(f"ЕТТН-чек {ettn_id} не знайдено")
|
||||
self._polls[ettn_id] = self._polls.get(ettn_id, 0) + 1
|
||||
if (
|
||||
self.auto_complete_after is not None
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Protocol
|
||||
|
||||
from app.core.config import settings
|
||||
from app.schemas.orders import OrderOut
|
||||
|
||||
|
||||
@@ -15,3 +17,18 @@ class CrmClient(Protocol):
|
||||
async def get_orders(self, *, status: str) -> list[OrderOut]: ...
|
||||
|
||||
async def set_status(self, *, order_id: str, status: str) -> None: ...
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_crm_client() -> CrmClient:
|
||||
"""Один экземпляр на процесс: стаб копит выставленные статусы в памяти."""
|
||||
if settings.crm_use_stub:
|
||||
if settings.is_production:
|
||||
raise RuntimeError("CRM_USE_STUB=true заборонено в production")
|
||||
from app.services.crm.stub_client import StubCrmClient
|
||||
|
||||
return StubCrmClient()
|
||||
|
||||
from app.services.crm.exo_client import ExoCrmClient
|
||||
|
||||
return ExoCrmClient(settings)
|
||||
|
||||
@@ -39,10 +39,10 @@ class ExoCrmClient:
|
||||
data = response.json()
|
||||
except ValueError as exc:
|
||||
# PHP-notice'ы CRM перед JSON — признак неверных параметров.
|
||||
raise CrmError(f"CRM вернула не JSON: {response.text[:300]}") from exc
|
||||
raise CrmError(f"CRM повернула не JSON: {response.text[:300]}") from exc
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise CrmError(f"Неожиданный ответ CRM: {str(data)[:300]}")
|
||||
raise CrmError(f"Неочікувана відповідь CRM: {str(data)[:300]}")
|
||||
return data
|
||||
|
||||
async def get_orders(self, *, status: str) -> list[OrderOut]:
|
||||
@@ -57,7 +57,7 @@ class ExoCrmClient:
|
||||
},
|
||||
)
|
||||
if data.get("status") != "OK":
|
||||
raise CrmError(f"CRM вернула ошибку: {_errors_text(data) or 'неизвестная ошибка'}")
|
||||
raise CrmError(f"CRM повернула помилку: {_errors_text(data) or 'невідома помилка'}")
|
||||
return [OrderOut.model_validate(order) for order in data.get("result") or []]
|
||||
|
||||
async def set_status(self, *, order_id: str, status: str) -> None:
|
||||
@@ -73,6 +73,6 @@ class ExoCrmClient:
|
||||
if isinstance(result, dict) and result.get("Status") == "Success":
|
||||
return
|
||||
details = _errors_text(data) or (
|
||||
str(result) if result is not None else "нет ответа по заказу"
|
||||
str(result) if result is not None else "немає відповіді щодо замовлення"
|
||||
)
|
||||
raise CrmError(f"CRM не сменила статус заказа {order_id} на {status}: {details}")
|
||||
raise CrmError(f"CRM не змінила статус замовлення {order_id} на {status}: {details}")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Фикстурный CRM-клиент для тестов — не ходит в сеть."""
|
||||
"""Фикстурный CRM-клиент: тесты и локальный запуск (`CRM_USE_STUB=true`) — не ходит в сеть."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,7 +12,7 @@ _FIXTURE_ORDERS: list[dict] = [
|
||||
"RecipientPhone": "+380501112233",
|
||||
"RecipientEmail": None,
|
||||
"Waybill_Number": "20450123456789",
|
||||
"Notes": "Тестовий заказ",
|
||||
"Notes": "Тестове замовлення",
|
||||
"Total": {
|
||||
"Cost": "0.00",
|
||||
"Quantity": "2",
|
||||
|
||||
@@ -15,4 +15,8 @@ class NovaPoshtaError(Exception):
|
||||
|
||||
|
||||
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
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.schemas.tracking import TrackingStatusOut
|
||||
from app.services.nova_poshta.client import NovaPoshtaError
|
||||
|
||||
@@ -15,20 +14,19 @@ _MAX_DOCUMENTS_PER_REQUEST = 100
|
||||
|
||||
|
||||
class NpTrackingClient:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._api_key = settings.nova_poshta_api_key
|
||||
|
||||
async def get_statuses(self, *, waybill_numbers: list[str]) -> list[TrackingStatusOut]:
|
||||
async def get_statuses(
|
||||
self, *, api_key: str, waybill_numbers: list[str]
|
||||
) -> list[TrackingStatusOut]:
|
||||
if not waybill_numbers:
|
||||
return []
|
||||
if len(waybill_numbers) > _MAX_DOCUMENTS_PER_REQUEST:
|
||||
raise NovaPoshtaError(
|
||||
f"Слишком много ТТН за один запрос: {len(waybill_numbers)} "
|
||||
f"Забагато ТТН за один запит: {len(waybill_numbers)} "
|
||||
f"(максимум {_MAX_DOCUMENTS_PER_REQUEST})"
|
||||
)
|
||||
|
||||
body = {
|
||||
"apiKey": self._api_key,
|
||||
"apiKey": api_key,
|
||||
"modelName": "TrackingDocument",
|
||||
"calledMethod": "getStatusDocuments",
|
||||
"methodProperties": {
|
||||
@@ -44,6 +42,6 @@ class NpTrackingClient:
|
||||
if not data.get("success"):
|
||||
errors = data.get("errors") or []
|
||||
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", [])]
|
||||
|
||||
@@ -15,17 +15,32 @@ _FIXTURE_STATUSES: dict[str, dict] = {
|
||||
"AmountToPay": "1200.00",
|
||||
"AfterpaymentOnGoodsCost": 1200,
|
||||
"PaymentStatus": "Paid",
|
||||
"PhoneSender": "380501112233",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class StubNovaPoshtaClient:
|
||||
def __init__(self, statuses: dict[str, dict] | None = None) -> None:
|
||||
self._statuses = statuses if statuses is not None else _FIXTURE_STATUSES
|
||||
"""`statuses` — ответы NP по ТТН; `by_key` — отдельные ответы для конкретных
|
||||
ключей 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 [
|
||||
TrackingStatusOut.model_validate(self._statuses[number])
|
||||
TrackingStatusOut.model_validate(statuses[number])
|
||||
for number in waybill_numbers
|
||||
if number in self._statuses
|
||||
if number in statuses
|
||||
]
|
||||
|
||||
+129
-18
@@ -4,17 +4,25 @@ from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core import crypto
|
||||
from app.core.logging import get_logger
|
||||
from app.db.models.cash_register import CashRegister
|
||||
from app.db.models.order import Order
|
||||
from app.schemas.orders import OrderUpdateIn
|
||||
from app.schemas.tracking import TrackingStatusOut
|
||||
from app.services.crm.client import CrmClient
|
||||
from app.services.nova_poshta.client import NovaPoshtaClient
|
||||
from app.services.nova_poshta.client import NovaPoshtaClient, NovaPoshtaError
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
_CRM_STATUS = "APPROVED"
|
||||
|
||||
@@ -143,8 +151,60 @@ async def cod_in_transit(session: AsyncSession) -> tuple[int, int]:
|
||||
return count, total
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _NpAccount:
|
||||
"""Кабинет отправителя НП, привязанный к кассе."""
|
||||
|
||||
register_id: uuid.UUID
|
||||
api_key: str
|
||||
|
||||
|
||||
async def _np_accounts(session: AsyncSession) -> list[_NpAccount]:
|
||||
"""Активные кассы с ключом НП; касса по умолчанию — первой."""
|
||||
registers = await session.scalars(
|
||||
select(CashRegister)
|
||||
.where(CashRegister.is_active.is_(True))
|
||||
.where(CashRegister.np_api_key_enc.is_not(None))
|
||||
.order_by(CashRegister.is_default.desc(), CashRegister.created_at)
|
||||
)
|
||||
accounts = []
|
||||
for register in registers:
|
||||
try:
|
||||
accounts.append(_NpAccount(register.id, crypto.decrypt(register.np_api_key_enc)))
|
||||
except crypto.DecryptionError:
|
||||
log.warning("np_api_key_undecryptable", cash_register_id=str(register.id))
|
||||
return accounts
|
||||
|
||||
|
||||
async def _fetch_statuses(
|
||||
np: NovaPoshtaClient, api_key: str, waybill_numbers: list[str]
|
||||
) -> dict[str, TrackingStatusOut]:
|
||||
statuses: dict[str, TrackingStatusOut] = {}
|
||||
for i in range(0, len(waybill_numbers), _NP_BATCH_SIZE):
|
||||
batch = waybill_numbers[i : i + _NP_BATCH_SIZE]
|
||||
for tracking_status in await np.get_statuses(api_key=api_key, waybill_numbers=batch):
|
||||
statuses[tracking_status.number] = tracking_status
|
||||
return statuses
|
||||
|
||||
|
||||
def _apply_status(order: Order, tracking_status: TrackingStatusOut) -> None:
|
||||
order.np_status = tracking_status.status
|
||||
order.np_status_code = tracking_status.status_code
|
||||
order.np_cod_amount_kopecks = (
|
||||
_to_kopecks(tracking_status.cod_amount) if tracking_status.cod_amount else None
|
||||
)
|
||||
order.np_payment_status = tracking_status.payment_status
|
||||
|
||||
|
||||
async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
|
||||
"""Обновляет статус ТТН, сумму и статус оплаты наложки по заказам в пути.
|
||||
"""Обновляет статус ТТН, сумму и статус оплаты наложки по заказам в пути
|
||||
и привязывает каждый заказ к кассе, чей ключ НП видит ТТН как свою.
|
||||
|
||||
Заказ, уже привязанный к кассе, опрашивается её ключом. Непривязанный (или
|
||||
переставший быть «своим» для своей кассы) — ключами всех касс по очереди:
|
||||
первая, для которой ТТН своя (`TrackingStatusOut.is_own`), его забирает.
|
||||
Если ТТН не своя ни для одной кассы, статус доставки всё равно сохраняется,
|
||||
но без наложки и без кассы — чек по такой посылке не создать.
|
||||
|
||||
Опрашиваются и заказы с уже созданным ЕТТН-чеком: именно после создания
|
||||
чека посылка доезжает и оплачивается, и этот переход кассир должен видеть.
|
||||
@@ -154,6 +214,11 @@ async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
|
||||
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))
|
||||
@@ -167,20 +232,65 @@ async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
|
||||
if not orders_by_waybill:
|
||||
return
|
||||
|
||||
waybill_numbers = list(orders_by_waybill)
|
||||
for i in range(0, len(waybill_numbers), _NP_BATCH_SIZE):
|
||||
batch = waybill_numbers[i : i + _NP_BATCH_SIZE]
|
||||
statuses = await np.get_statuses(waybill_numbers=batch)
|
||||
for tracking_status in statuses:
|
||||
order = orders_by_waybill.get(tracking_status.number)
|
||||
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
|
||||
order.np_status = tracking_status.status
|
||||
order.np_status_code = tracking_status.status_code
|
||||
order.np_cod_amount_kopecks = (
|
||||
_to_kopecks(tracking_status.cod_amount) if tracking_status.cod_amount else None
|
||||
)
|
||||
order.np_payment_status = tracking_status.payment_status
|
||||
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()
|
||||
|
||||
@@ -217,7 +327,7 @@ def _build_goods(data: OrderUpdateIn) -> tuple[list[dict[str, Any]], int]:
|
||||
gross = int((price * good.quantity).to_integral_value(ROUND_HALF_UP))
|
||||
net = gross - discount
|
||||
if net < 0:
|
||||
raise OrderEditError(f"Товар «{good.name}»: скидка больше суммы строки")
|
||||
raise OrderEditError(f"Товар «{good.name}»: знижка більша за суму рядка")
|
||||
goods.append(
|
||||
{
|
||||
"id": good.id or f"new-{uuid.uuid4().hex[:8]}",
|
||||
@@ -247,13 +357,13 @@ async def update_order(
|
||||
if order is None or order.is_deleted:
|
||||
return None
|
||||
if order.receipt_created_at is not None:
|
||||
raise OrderEditError("По заказу уже создан чек — редактирование недоступно")
|
||||
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} ₴"
|
||||
f"Сума замовлення {total / 100:.2f} ₴ більша за суму товарів {goods_total / 100:.2f} ₴"
|
||||
)
|
||||
|
||||
changed = [
|
||||
@@ -268,7 +378,8 @@ async def update_order(
|
||||
return order, changed
|
||||
|
||||
if "waybill_number" in changed:
|
||||
# Статус старой ТТН к новой не относится — worker опросит новую за минуту.
|
||||
# Статус и касса старой ТТН к новой не относятся — worker опросит новую за минуту.
|
||||
order.cash_register_id = None
|
||||
order.np_status = None
|
||||
order.np_status_code = None
|
||||
order.np_cod_amount_kopecks = None
|
||||
|
||||
@@ -68,6 +68,11 @@ _CHECKBOX_TO_STATUS = {
|
||||
# или 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 чек принял).
|
||||
@@ -128,7 +133,7 @@ def build_goods(order: Order, tax_codes: list[Any]) -> tuple[list[dict[str, Any]
|
||||
price = to_kopecks(good["price"])
|
||||
quantity = to_thousandths(good["quantity"])
|
||||
if quantity <= 0:
|
||||
raise ReceiptValidationError(f"Товар «{good['name']}»: количество должно быть больше 0")
|
||||
raise ReceiptValidationError(f"Товар «{good['name']}»: кількість має бути більшою за 0")
|
||||
gross = _line_sum(price, quantity)
|
||||
# `amount` — сумма строки после скидки CRM; скидку выводим из неё,
|
||||
# а не из discount_amount/percent, чтобы не расходиться с итогом CRM.
|
||||
@@ -136,7 +141,7 @@ def build_goods(order: Order, tax_codes: list[Any]) -> tuple[list[dict[str, Any]
|
||||
discount = gross - net
|
||||
if discount < 0:
|
||||
raise ReceiptValidationError(
|
||||
f"Товар «{good['name']}»: сумма строки больше цены × количество"
|
||||
f"Товар «{good['name']}»: сума рядка більша за ціну × кількість"
|
||||
)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
@@ -163,7 +168,7 @@ def build_ettn_body(
|
||||
order_discount = goods_total - amounts.total_kopecks
|
||||
if order_discount < 0:
|
||||
raise ReceiptValidationError(
|
||||
"Сумма товаров меньше суммы заказа — проверьте заказ в CRM"
|
||||
"Сума товарів менша за суму замовлення — перевірте замовлення в CRM"
|
||||
)
|
||||
# Предоплата и скидка заказа — одной обычной скидкой «Знижка», как в чеках из
|
||||
# портала Checkbox. Тип `PRE_PAYMENT` для ЕТТН не годится: Checkbox отвечает
|
||||
@@ -180,7 +185,7 @@ def build_ettn_body(
|
||||
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} ₴"
|
||||
f"Сума чека {receipt_total / 100:.2f} ₴ ≠ післяплата {amounts.cod_kopecks / 100:.2f} ₴"
|
||||
)
|
||||
|
||||
receipt_body: dict[str, Any] = {
|
||||
@@ -217,30 +222,30 @@ def resolve_amounts(order: Order, prepayment_kopecks: int | None) -> ReceiptAmou
|
||||
`prepayment_kopecks=None` — взять разницу между суммой заказа и наложкой.
|
||||
"""
|
||||
if order.is_deleted:
|
||||
raise ReceiptValidationError("Заказ удалён")
|
||||
raise ReceiptValidationError("Замовлення видалено")
|
||||
if not order.waybill_number:
|
||||
raise ReceiptValidationError("У заказа нет ТТН")
|
||||
raise ReceiptValidationError("У замовлення немає ТТН")
|
||||
if not order.np_cod_amount_kopecks:
|
||||
raise ReceiptValidationError("По ТТН нет суммы контроля оплаты (наложки)")
|
||||
raise ReceiptValidationError("За ТТН немає суми контролю оплати (післяплати)")
|
||||
if order.np_status_code in NP_FINAL_STATUS_CODES:
|
||||
raise ReceiptValidationError(
|
||||
f"Посылка уже не в пути ({order.np_status or order.np_status_code}) — "
|
||||
"ЕТТН-чек создать нельзя"
|
||||
f"Посилка вже не в дорозі ({order.np_status or order.np_status_code}) — "
|
||||
"ЕТТН-чек створити неможливо"
|
||||
)
|
||||
if not order.goods:
|
||||
raise ReceiptValidationError("В заказе нет товаров")
|
||||
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} ₴"
|
||||
f"Післяплата {cod / 100:.2f} ₴ більша за суму замовлення {total / 100:.2f} ₴"
|
||||
)
|
||||
if total - prepayment != cod:
|
||||
raise ReceiptValidationError(
|
||||
f"Сумма заказа {total / 100:.2f} ₴ − предоплата {prepayment / 100:.2f} ₴ "
|
||||
f"≠ наложка {cod / 100:.2f} ₴"
|
||||
f"Сума замовлення {total / 100:.2f} ₴ − передоплата {prepayment / 100:.2f} ₴ "
|
||||
f"≠ післяплата {cod / 100:.2f} ₴"
|
||||
)
|
||||
return ReceiptAmounts(total_kopecks=total, prepayment_kopecks=prepayment, cod_kopecks=cod)
|
||||
|
||||
@@ -256,13 +261,11 @@ def credentials(register: CashRegister) -> CheckboxCredentials:
|
||||
)
|
||||
|
||||
|
||||
async def get_default_register(session: AsyncSession) -> CashRegister | None:
|
||||
return await session.scalar(
|
||||
select(CashRegister)
|
||||
.where(CashRegister.is_active.is_(True))
|
||||
.order_by(CashRegister.is_default.desc(), CashRegister.created_at)
|
||||
.limit(1)
|
||||
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}
|
||||
|
||||
|
||||
# --- Создание ----------------------------------------------------------------
|
||||
@@ -295,12 +298,15 @@ async def request_receipts(
|
||||
Без commit и без обращения к Checkbox: вызывающий код коммитит и ставит
|
||||
задачи worker'у (`create_ettn_for_receipt`) по `result.created`.
|
||||
Ошибки по отдельным заказам не мешают остальным — массовое действие.
|
||||
|
||||
Чек создаётся от кассы, чей ключ НП видит ТТН заказа как свою
|
||||
(`orders.cash_register_id`, проставляет `orders.sync_np_statuses`).
|
||||
"""
|
||||
result = RequestResult(created=[], errors={})
|
||||
register = await get_default_register(session)
|
||||
if register is None:
|
||||
registers = await _active_registers(session)
|
||||
if not registers:
|
||||
for order_id, _ in items:
|
||||
result.errors[order_id] = "Не настроена касса Checkbox"
|
||||
result.errors[order_id] = "Не налаштовано касу Checkbox"
|
||||
return result
|
||||
|
||||
order_ids = [order_id for order_id, _ in items]
|
||||
@@ -313,10 +319,17 @@ async def request_receipts(
|
||||
for order_id, prepayment in items:
|
||||
order = orders.get(order_id)
|
||||
if order is None:
|
||||
result.errors[order_id] = "Заказ не найден"
|
||||
result.errors[order_id] = "Замовлення не знайдено"
|
||||
continue
|
||||
if order_id in busy:
|
||||
result.errors[order_id] = "По заказу уже есть чек"
|
||||
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)
|
||||
@@ -354,6 +367,7 @@ async def request_receipts(
|
||||
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,
|
||||
},
|
||||
@@ -471,7 +485,7 @@ async def cancel_receipt(
|
||||
if receipt is None:
|
||||
return None
|
||||
if receipt.status not in (ReceiptStatus.CREATED, ReceiptStatus.RECEIPT_ERROR):
|
||||
raise ReceiptStateError(f"Чек в статусе «{receipt.status}» отменить нельзя")
|
||||
raise ReceiptStateError(f"Чек у статусі «{receipt.status}» скасувати неможливо")
|
||||
|
||||
if receipt.checkbox_ettn_id:
|
||||
register = await session.get(CashRegister, receipt.cash_register_id)
|
||||
@@ -495,9 +509,20 @@ async def cancel_receipt(
|
||||
|
||||
|
||||
async def sync_ettn_statuses(session: AsyncSession, client: CheckboxClient) -> None:
|
||||
"""Опрашивает Checkbox по чекам, ожидающим оплаты посылки (`created`)."""
|
||||
"""Опрашивает Checkbox по чекам, ожидающим оплаты посылки (`created`).
|
||||
|
||||
За прогон — не больше `_ETTN_POLL_BATCH` давно не проверенных чеков, с
|
||||
коммитом после каждого: запросы к Checkbox идут с паузой, и опрос всех
|
||||
чеков разом не укладывался в таймаут cron-задачи, а откат по таймауту
|
||||
терял весь прогон — статусы не обновлялись вовсе.
|
||||
"""
|
||||
receipts = list(
|
||||
await session.scalars(select(Receipt).where(Receipt.status == ReceiptStatus.CREATED))
|
||||
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:
|
||||
@@ -521,7 +546,6 @@ async def sync_ettn_statuses(session: AsyncSession, client: CheckboxClient) -> N
|
||||
_apply_ettn(receipt, ettn)
|
||||
if receipt.status in CLOSED_STATUSES:
|
||||
await _release_order(session, receipt.order_id)
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Запускается отдельным процессом: `arq app.worker.WorkerSettings`.
|
||||
- `create_ettn_receipt` — задача, которую ставит API после запроса кассира;
|
||||
- `poll_np_statuses` — раз в минуту статусы ТТН по заказам без чека;
|
||||
- `poll_np_statuses` — раз в минуту статусы ТТН по ключам НП касс и привязка заказов к кассам;
|
||||
- `poll_receipts` — раз в минуту повтор зависших `pending`, статусы `created`-чеков
|
||||
и повтор смены статуса заказа в CRM (PACKED), если CRM была недоступна.
|
||||
"""
|
||||
@@ -20,7 +20,7 @@ from app.core.logging import configure_logging, get_logger
|
||||
from app.db.session import SessionFactory
|
||||
from app.services import receipts as receipts_service
|
||||
from app.services.checkbox.client import CheckboxRateLimitedError, get_checkbox_client
|
||||
from app.services.crm.exo_client import ExoCrmClient
|
||||
from app.services.crm.client import get_crm_client
|
||||
from app.services.nova_poshta.np_client import NpTrackingClient
|
||||
from app.services.orders import sync_np_statuses
|
||||
|
||||
@@ -33,9 +33,9 @@ _CREATE_ETTN_MAX_TRIES = 6
|
||||
|
||||
async def startup(ctx: dict[str, Any]) -> None:
|
||||
configure_logging()
|
||||
ctx["np_client"] = NpTrackingClient(settings)
|
||||
ctx["np_client"] = NpTrackingClient()
|
||||
ctx["checkbox_client"] = get_checkbox_client()
|
||||
ctx["crm_client"] = ExoCrmClient(settings)
|
||||
ctx["crm_client"] = get_crm_client()
|
||||
log.info("worker_starting", environment=settings.environment)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -8,7 +8,7 @@ import pytest
|
||||
import respx
|
||||
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.np_client import _API_URL, NpTrackingClient
|
||||
|
||||
@@ -27,12 +27,7 @@ STATUS_PAYLOAD = {
|
||||
}
|
||||
|
||||
|
||||
def _settings() -> Settings:
|
||||
return Settings(
|
||||
secret_key="test-secret-key",
|
||||
encryption_key="dGVzdC1lbmNyeXB0aW9uLWtleS0zMi1ieXRlcyEh",
|
||||
nova_poshta_api_key="np-apikey-123",
|
||||
) # type: ignore[arg-type]
|
||||
API_KEY = "np-apikey-123"
|
||||
|
||||
|
||||
class TestNpTrackingClientGetStatuses:
|
||||
@@ -41,13 +36,13 @@ class TestNpTrackingClientGetStatuses:
|
||||
route = respx.post(_API_URL).mock(
|
||||
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
|
||||
body = json.loads(sent.content)
|
||||
assert body["apiKey"] == "np-apikey-123"
|
||||
assert body["apiKey"] == API_KEY
|
||||
assert body["modelName"] == "TrackingDocument"
|
||||
assert body["calledMethod"] == "getStatusDocuments"
|
||||
assert body["methodProperties"]["Documents"] == [
|
||||
@@ -63,9 +58,9 @@ class TestNpTrackingClientGetStatuses:
|
||||
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
|
||||
status = statuses[0]
|
||||
@@ -75,6 +70,13 @@ class TestNpTrackingClientGetStatuses:
|
||||
# не "сколько заплатить сейчас" (`ExpressWaybillAmountToPay` = 827.48).
|
||||
assert status.cod_amount == "699"
|
||||
assert status.payment_status == "NeedPayment"
|
||||
assert not status.is_own # в STATUS_PAYLOAD нет PhoneSender
|
||||
|
||||
def test_is_own_by_sender_phone(self) -> None:
|
||||
own = TrackingStatusOut.model_validate({**STATUS_PAYLOAD, "PhoneSender": "380961112233"})
|
||||
foreign = TrackingStatusOut.model_validate({**STATUS_PAYLOAD, "PhoneSender": ""})
|
||||
assert own.is_own
|
||||
assert not foreign.is_own
|
||||
|
||||
@respx.mock
|
||||
async def test_falls_back_to_amount_to_pay_when_afterpayment_missing(self) -> None:
|
||||
@@ -82,9 +84,9 @@ class TestNpTrackingClientGetStatuses:
|
||||
respx.post(_API_URL).mock(
|
||||
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"
|
||||
|
||||
@@ -95,18 +97,20 @@ class TestNpTrackingClientGetStatuses:
|
||||
200, json={"success": False, "data": [], "errors": ["Invalid apiKey"]}
|
||||
)
|
||||
)
|
||||
client = NpTrackingClient(_settings())
|
||||
client = NpTrackingClient()
|
||||
|
||||
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:
|
||||
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:
|
||||
client = NpTrackingClient(_settings())
|
||||
client = NpTrackingClient()
|
||||
|
||||
with pytest.raises(NovaPoshtaError, match="Слишком много"):
|
||||
await client.get_statuses(waybill_numbers=[str(i) for i in range(101)])
|
||||
with pytest.raises(NovaPoshtaError, match="Забагато"):
|
||||
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
|
||||
@@ -88,7 +88,7 @@ def _patch_orders_service(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
session: object, order_id: str, data: object
|
||||
) -> tuple[Order, list[str]] | None:
|
||||
if order_id == "locked":
|
||||
raise orders_router.orders_service.OrderEditError("По заказу уже создан чек")
|
||||
raise orders_router.orders_service.OrderEditError("За замовленням уже створено чек")
|
||||
if order_id != "1":
|
||||
return None
|
||||
order = _order(order_id)
|
||||
|
||||
@@ -140,16 +140,16 @@ class TestUpdateOrder:
|
||||
assert order.goods == [stored]
|
||||
|
||||
def test_total_above_goods_sum_is_rejected(self) -> None:
|
||||
with pytest.raises(OrderEditError, match="больше суммы товаров"):
|
||||
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="скидка больше"):
|
||||
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="уже создан чек"):
|
||||
with pytest.raises(OrderEditError, match="уже створено чек"):
|
||||
_update(_order(receipt_created_at=datetime.now(UTC)), _payload())
|
||||
|
||||
def test_missing_or_deleted_order_returns_none(self) -> None:
|
||||
|
||||
@@ -45,7 +45,9 @@ def queue(monkeypatch: pytest.MonkeyPatch) -> FakeQueue:
|
||||
)
|
||||
|
||||
async def fake_request(session: Any, items: Any, *, user: Any, request: Any) -> Any:
|
||||
return receipts_service.RequestResult(created=[receipt], errors={"2": "У заказа нет ТТН"})
|
||||
return receipts_service.RequestResult(
|
||||
created=[receipt], errors={"2": "У замовлення немає ТТН"}
|
||||
)
|
||||
|
||||
async def fake_commit(self: AsyncSession) -> None:
|
||||
return None
|
||||
@@ -74,5 +76,5 @@ def test_cashier_creates_and_enqueues(queue: FakeQueue) -> None:
|
||||
assert response.status_code == 202
|
||||
body = response.json()
|
||||
assert [r["order_id"] for r in body["created"]] == ["1"]
|
||||
assert body["errors"] == {"2": "У заказа нет ТТН"}
|
||||
assert body["errors"] == {"2": "У замовлення немає ТТН"}
|
||||
assert queue.jobs == [("create_ettn_receipt", (body["created"][0]["id"],))]
|
||||
|
||||
@@ -16,6 +16,7 @@ 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
|
||||
@@ -76,17 +77,17 @@ class TestResolveAmounts:
|
||||
assert amounts.cod_kopecks == 100000
|
||||
|
||||
def test_explicit_prepayment_must_match_cod(self) -> None:
|
||||
with pytest.raises(svc.ReceiptValidationError, match="≠ наложка"):
|
||||
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}, "удалён"),
|
||||
({"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:
|
||||
@@ -182,6 +183,52 @@ class TestBuildBody:
|
||||
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}
|
||||
@@ -242,7 +289,7 @@ class TestCreateEttn:
|
||||
await svc.create_ettn_for_receipt(FakeSession(order, register, receipt), client, receipt.id)
|
||||
|
||||
assert receipt.status == ReceiptStatus.FAILED
|
||||
assert "уже привязана" in (receipt.error or "")
|
||||
assert "вже прив'язана" in (receipt.error or "")
|
||||
assert order.receipt_created_at is None
|
||||
|
||||
async def test_unavailable_keeps_pending_then_reconciles(self) -> None:
|
||||
|
||||
@@ -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 они пошли бы параллельно.
|
||||
migrate:
|
||||
# Один образ на migrate/api/worker, а не три одинаковых копии на диске.
|
||||
image: lux-fiscal-backend:latest
|
||||
build:
|
||||
context: ./backend
|
||||
env_file: .env
|
||||
@@ -48,6 +50,7 @@ services:
|
||||
restart: "no"
|
||||
|
||||
api:
|
||||
image: lux-fiscal-backend:latest
|
||||
build:
|
||||
context: ./backend
|
||||
restart: unless-stopped
|
||||
@@ -68,6 +71,7 @@ services:
|
||||
- receipt_storage:/app/storage
|
||||
|
||||
worker:
|
||||
image: lux-fiscal-backend:latest
|
||||
build:
|
||||
context: ./backend
|
||||
restart: unless-stopped
|
||||
|
||||
+8
-2
@@ -1,10 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<html lang="uk">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>lux_fiscal</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Unbounded:wght@500;600&display=swap"
|
||||
/>
|
||||
<title>Assistant System</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -27,10 +27,10 @@ async function readErrorMessage(response: Response): Promise<string> {
|
||||
// Тело не JSON или пустое — используем сообщение по статусу ниже.
|
||||
}
|
||||
|
||||
if (response.status === 401) return 'Требуется вход в систему'
|
||||
if (response.status === 403) return 'Недостаточно прав для этого действия'
|
||||
if (response.status >= 500) return 'Сервер временно недоступен, попробуйте позже'
|
||||
return `Ошибка запроса (${response.status})`
|
||||
if (response.status === 401) return 'Потрібно увійти в систему'
|
||||
if (response.status === 403) return 'Недостатньо прав для цієї дії'
|
||||
if (response.status >= 500) return 'Сервер тимчасово недоступний, спробуйте пізніше'
|
||||
return `Помилка запиту (${response.status})`
|
||||
}
|
||||
|
||||
// Параллельные 401 не должны порождать несколько запросов на refresh —
|
||||
@@ -42,7 +42,7 @@ async function refreshAccessToken(): Promise<string> {
|
||||
|
||||
const refreshToken = getRefreshToken()
|
||||
if (!refreshToken) {
|
||||
throw new ApiError(401, 'Сессия истекла, войдите снова')
|
||||
throw new ApiError(401, 'Сесія закінчилася, увійдіть знову')
|
||||
}
|
||||
|
||||
refreshPromise = (async () => {
|
||||
|
||||
@@ -2,8 +2,10 @@ import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
|
||||
import { LoginPage } from '@/features/auth/LoginPage'
|
||||
import { ProtectedRoute } from '@/features/auth/ProtectedRoute'
|
||||
import { ModuleRoute } from '@/features/menu/ModuleRoute'
|
||||
import { CashRegistersPage } from '@/pages/CashRegistersPage'
|
||||
import { DashboardPage } from '@/pages/DashboardPage'
|
||||
import { MainMenuPage } from '@/pages/MainMenuPage'
|
||||
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
@@ -11,9 +13,14 @@ export function AppRoutes() {
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/" element={<MainMenuPage />} />
|
||||
<Route element={<ModuleRoute moduleId="receipts" />}>
|
||||
<Route path="/receipts" element={<DashboardPage />} />
|
||||
</Route>
|
||||
<Route element={<ModuleRoute moduleId="cash-registers" />}>
|
||||
<Route path="/cash-registers" element={<CashRegistersPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -65,7 +65,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setUser(result.user)
|
||||
setStatus('authenticated')
|
||||
} catch (err) {
|
||||
const message = err instanceof ApiError ? err.message : 'Не удалось подключиться к серверу'
|
||||
const message = err instanceof ApiError ? err.message : 'Не вдалося підключитися до сервера'
|
||||
setError(message)
|
||||
throw err
|
||||
}
|
||||
|
||||
@@ -1,68 +1,230 @@
|
||||
.login-page {
|
||||
--login-accent: #7c3aed;
|
||||
--login-glass: rgba(255, 255, 255, 0.72);
|
||||
--login-glass-border: rgba(255, 255, 255, 0.6);
|
||||
--login-input-bg: rgba(255, 255, 255, 0.85);
|
||||
|
||||
position: relative;
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
padding: 24px 16px;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
background:
|
||||
radial-gradient(1200px 600px at 10% -10%, rgba(37, 99, 235, 0.14), transparent 60%),
|
||||
radial-gradient(900px 500px at 110% 110%, rgba(124, 58, 237, 0.14), transparent 60%),
|
||||
var(--color-bg);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.login-page {
|
||||
--login-accent: #a78bfa;
|
||||
--login-glass: rgba(23, 26, 33, 0.7);
|
||||
--login-glass-border: rgba(255, 255, 255, 0.08);
|
||||
--login-input-bg: rgba(15, 17, 21, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
/* Декоративные размытые пятна на фоне */
|
||||
.login-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-blob {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(70px);
|
||||
opacity: 0.55;
|
||||
animation: login-float 18s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.login-blob--a {
|
||||
width: 380px;
|
||||
height: 380px;
|
||||
top: -80px;
|
||||
left: -60px;
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.login-blob--b {
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
bottom: -90px;
|
||||
right: -40px;
|
||||
background: var(--login-accent);
|
||||
animation-delay: -6s;
|
||||
}
|
||||
|
||||
.login-blob--c {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
top: 55%;
|
||||
left: 60%;
|
||||
background: #06b6d4;
|
||||
opacity: 0.3;
|
||||
animation-delay: -12s;
|
||||
}
|
||||
|
||||
@keyframes login-float {
|
||||
from {
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
}
|
||||
to {
|
||||
transform: translate3d(40px, 30px, 0) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.login-blob {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 32px;
|
||||
max-width: 400px;
|
||||
background: var(--login-glass);
|
||||
border: 1px solid var(--login-glass-border);
|
||||
border-radius: 20px;
|
||||
box-shadow:
|
||||
0 20px 50px -12px rgba(16, 24, 40, 0.25),
|
||||
0 1px 2px rgba(16, 24, 40, 0.06);
|
||||
backdrop-filter: blur(18px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(140%);
|
||||
padding: 40px 32px 32px;
|
||||
animation: login-enter 0.45s cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
}
|
||||
|
||||
@keyframes login-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.login-card {
|
||||
padding: 32px 20px 24px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-header {
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin: 0 auto 16px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 16px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--color-primary), var(--login-accent));
|
||||
box-shadow: 0 10px 24px -8px var(--color-primary);
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: 22px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 6px;
|
||||
background: linear-gradient(135deg, var(--color-text) 30%, var(--color-primary));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.login-field {
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.login-field label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.login-field input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
height: 46px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg);
|
||||
border-radius: 12px;
|
||||
background: var(--login-input-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.login-field input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.login-field input:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-primary) 18%, transparent);
|
||||
}
|
||||
|
||||
.login-field input:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.login-password {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.login-password input {
|
||||
padding-right: 46px;
|
||||
}
|
||||
|
||||
.login-toggle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 6px;
|
||||
transform: translateY(-50%);
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.login-toggle:hover {
|
||||
color: var(--color-text);
|
||||
background: color-mix(in srgb, var(--color-text) 8%, transparent);
|
||||
}
|
||||
|
||||
.login-toggle:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -70,23 +232,29 @@
|
||||
background: var(--color-danger-bg);
|
||||
border: 1px solid var(--color-danger-border);
|
||||
color: var(--color-danger);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.login-submit {
|
||||
width: 100%;
|
||||
padding: 11px 16px;
|
||||
height: 48px;
|
||||
margin-top: 8px;
|
||||
padding: 0 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--color-primary);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--color-primary), var(--login-accent));
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
box-shadow: 0 10px 24px -10px var(--color-primary);
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
box-shadow 0.15s ease,
|
||||
filter 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -94,7 +262,18 @@
|
||||
}
|
||||
|
||||
.login-submit:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover);
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.06);
|
||||
box-shadow: 0 14px 28px -10px var(--color-primary);
|
||||
}
|
||||
|
||||
.login-submit:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.login-submit:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.login-submit:disabled {
|
||||
|
||||
@@ -19,6 +19,7 @@ export function LoginPage() {
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
|
||||
@@ -36,7 +37,7 @@ export function LoginPage() {
|
||||
try {
|
||||
await login(email, password)
|
||||
} catch (err) {
|
||||
const message = err instanceof ApiError ? err.message : 'Не удалось подключиться к серверу'
|
||||
const message = err instanceof ApiError ? err.message : 'Не вдалося підключитися до сервера'
|
||||
setFormError(message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
@@ -45,10 +46,22 @@ export function LoginPage() {
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-backdrop" aria-hidden="true">
|
||||
<span className="login-blob login-blob--a" />
|
||||
<span className="login-blob login-blob--b" />
|
||||
<span className="login-blob login-blob--c" />
|
||||
</div>
|
||||
|
||||
<div className="login-card">
|
||||
<div className="login-header">
|
||||
<h1>lux_fiscal</h1>
|
||||
<p>Вход в систему фискализации заказов</p>
|
||||
<div className="login-logo" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 3l7 4v5c0 4.5-3 8-7 9-4-1-7-4.5-7-9V7l7-4z" />
|
||||
<path d="M9 12l2 2 4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1>Assistant System</h1>
|
||||
<p>Вхід у систему</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
@@ -74,9 +87,10 @@ export function LoginPage() {
|
||||
|
||||
<div className="login-field">
|
||||
<label htmlFor={passwordId}>Пароль</label>
|
||||
<div className="login-password">
|
||||
<input
|
||||
id={passwordId}
|
||||
type="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
@@ -84,11 +98,25 @@ export function LoginPage() {
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="login-toggle"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? 'Приховати пароль' : 'Показати пароль'}
|
||||
aria-pressed={showPassword}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
{showPassword && <path d="M3 3l18 18" />}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="login-submit" disabled={submitting}>
|
||||
{submitting && <span className="spinner" aria-hidden="true" />}
|
||||
{submitting ? 'Входим…' : 'Войти'}
|
||||
{submitting ? 'Входимо…' : 'Увійти'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ export function ProtectedRoute() {
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="page-center">
|
||||
<span className="spinner" aria-label="Загрузка" />
|
||||
<span className="spinner" aria-label="Завантаження" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useAuth должен вызываться внутри <AuthProvider>')
|
||||
throw new Error('useAuth має викликатися всередині <AuthProvider>')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ export interface CashRegister {
|
||||
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
|
||||
@@ -15,6 +17,7 @@ export interface CashRegisterCreate {
|
||||
fiscal_number: string | null
|
||||
license_key: string
|
||||
pin_code: string
|
||||
np_api_key: string | null
|
||||
tax_codes: (number | string)[]
|
||||
is_default: 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
|
||||
}
|
||||
@@ -96,18 +96,18 @@ function orderDiscountOf(draft: Draft): number {
|
||||
|
||||
/** Проверяет черновик и собирает тело PATCH; строка — текст ошибки для кассира. */
|
||||
function toPayload(draft: Draft): OrderUpdate | string {
|
||||
if (draft.goods.length === 0) return 'Добавьте хотя бы один товар'
|
||||
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 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}»: скидка больше суммы строки`
|
||||
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(),
|
||||
@@ -118,9 +118,9 @@ function toPayload(draft: Draft): OrderUpdate | string {
|
||||
})
|
||||
}
|
||||
const total = toKopecks(draft.total)
|
||||
if (total === null) return 'Некорректная сумма заказа'
|
||||
if (total === null) return 'Некоректна сума замовлення'
|
||||
const sum = goodsSum(draft.goods) ?? 0
|
||||
if (total > sum) return `Сумма заказа больше суммы товаров (${formatKopecks(sum)} ₴)`
|
||||
if (total > sum) return `Сума замовлення більша за суму товарів (${formatKopecks(sum)} ₴)`
|
||||
|
||||
return {
|
||||
recipient_name: draft.recipient_name,
|
||||
@@ -134,7 +134,7 @@ function toPayload(draft: Draft): OrderUpdate | string {
|
||||
}
|
||||
|
||||
const TEXT_FIELDS: { field: TextField; label: string; type?: string }[] = [
|
||||
{ field: 'recipient_name', label: 'Клиент' },
|
||||
{ field: 'recipient_name', label: 'Клієнт' },
|
||||
{ field: 'recipient_phone', label: 'Телефон', type: 'tel' },
|
||||
{ field: 'recipient_email', label: 'Email', type: 'email' },
|
||||
{ field: 'waybill_number', label: 'Номер ТТН' },
|
||||
@@ -153,7 +153,7 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
|
||||
const editable = canEdit && !order.has_receipt
|
||||
|
||||
function requestClose() {
|
||||
if (dirty && !window.confirm('Есть несохранённые изменения. Закрыть без сохранения?')) return
|
||||
if (dirty && !window.confirm('Є незбережені зміни. Закрити без збереження?')) return
|
||||
onClose()
|
||||
}
|
||||
|
||||
@@ -203,10 +203,10 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
|
||||
setDraft(next)
|
||||
setSaved(next)
|
||||
setOrderDiscount(orderDiscountOf(next))
|
||||
setMessage({ tone: 'ok', text: 'Изменения сохранены' })
|
||||
setMessage({ tone: 'ok', text: 'Зміни збережено' })
|
||||
await queryClient.invalidateQueries({ queryKey: ['orders'] })
|
||||
} catch (err) {
|
||||
setMessage({ tone: 'error', text: err instanceof Error ? err.message : 'Не удалось сохранить заказ' })
|
||||
setMessage({ tone: 'error', text: err instanceof Error ? err.message : 'Не вдалося зберегти замовлення' })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -217,10 +217,10 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
|
||||
<div className="order-modal">
|
||||
<div className="order-modal-header">
|
||||
<h3>
|
||||
Заказ {order.id}
|
||||
{order.edited && <span className="order-modal-edited">изменён вручную</span>}
|
||||
Замовлення {order.id}
|
||||
{order.edited && <span className="order-modal-edited">змінено вручну</span>}
|
||||
</h3>
|
||||
<button type="button" className="order-modal-close" onClick={requestClose} aria-label="Закрыть">
|
||||
<button type="button" className="order-modal-close" onClick={requestClose} aria-label="Закрити">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@@ -232,19 +232,19 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
|
||||
</div>
|
||||
<div>
|
||||
<span className="order-modal-label">Статус ТТН</span>
|
||||
<span>{order.np_status || 'Нет данных'}</span>
|
||||
<span>{order.np_status || 'Немає даних'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="order-modal-label">Наложенный платёж</span>
|
||||
<span className="order-modal-label">Накладений платіж</span>
|
||||
<span>{order.np_cod_amount ? `${order.np_cod_amount} ₴` : '—'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="order-modal-label">Оплачено</span>
|
||||
<span>
|
||||
{order.np_payment_status === 'Payed'
|
||||
? 'Да'
|
||||
? 'Так'
|
||||
: order.np_payment_status === 'NeedPayment'
|
||||
? 'Нет'
|
||||
? 'Ні'
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -272,7 +272,7 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
|
||||
|
||||
{editable ? (
|
||||
<label className="order-modal-notes">
|
||||
<span className="order-modal-label">Заметки</span>
|
||||
<span className="order-modal-label">Нотатки</span>
|
||||
<textarea
|
||||
className="order-modal-input"
|
||||
rows={2}
|
||||
@@ -283,7 +283,7 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
|
||||
) : (
|
||||
draft.notes && (
|
||||
<div className="order-modal-notes">
|
||||
<span className="order-modal-label">Заметки</span>
|
||||
<span className="order-modal-label">Нотатки</span>
|
||||
<p>{draft.notes}</p>
|
||||
</div>
|
||||
)
|
||||
@@ -292,13 +292,13 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
|
||||
<table className="order-modal-goods">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Наименование</th>
|
||||
<th>Найменування</th>
|
||||
<th>SKU</th>
|
||||
<th>Цена</th>
|
||||
<th>Кол-во</th>
|
||||
<th>Скидка</th>
|
||||
<th>Сумма</th>
|
||||
{editable && <th aria-label="Удалить" />}
|
||||
<th>Ціна</th>
|
||||
<th>К-сть</th>
|
||||
<th>Знижка</th>
|
||||
<th>Сума</th>
|
||||
{editable && <th aria-label="Видалити" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -350,7 +350,7 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
|
||||
<button
|
||||
type="button"
|
||||
className="order-modal-remove"
|
||||
aria-label={`Удалить товар ${good.name}`}
|
||||
aria-label={`Видалити товар ${good.name}`}
|
||||
onClick={() => setGoods((goods) => goods.filter((g) => g.key !== good.key))}
|
||||
>
|
||||
×
|
||||
@@ -363,7 +363,7 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
|
||||
{draft.goods.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={editable ? 7 : 6} className="order-modal-goods-empty">
|
||||
Товары не указаны
|
||||
Товари не вказані
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -372,19 +372,19 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
|
||||
|
||||
{editable && (
|
||||
<button type="button" className="order-modal-add" onClick={addGood}>
|
||||
+ Добавить товар
|
||||
+ Додати товар
|
||||
</button>
|
||||
)}
|
||||
|
||||
<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="Сумма заказа после всех скидок"
|
||||
title="Сума замовлення після всіх знижок"
|
||||
onChange={(e) => setTotal(e.target.value)}
|
||||
/>{' '}
|
||||
₴
|
||||
@@ -403,12 +403,12 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="order-modal-label">Предоплата в чеке</span>
|
||||
<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 className="order-modal-label">Помилка</span>
|
||||
<span>{order.receipt_error}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -425,11 +425,11 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
|
||||
disabled={!dirty || saving}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
{saving ? 'Сохранение…' : 'Сохранить'}
|
||||
{saving ? 'Збереження…' : 'Зберегти'}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="order-modal-close-btn" onClick={requestClose}>
|
||||
Закрыть
|
||||
Закрити
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,13 +43,13 @@ export interface ReceiptCreateResponse {
|
||||
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' },
|
||||
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). */
|
||||
|
||||
@@ -74,6 +74,19 @@ input {
|
||||
|
||||
/* --- Общие утилиты, используются в нескольких экранах --- */
|
||||
|
||||
/* Скрыт визуально, но читается скринридером (подписи к полям без видимого label). */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.page-center {
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
|
||||
@@ -6,7 +6,7 @@ import '@/index.css'
|
||||
|
||||
const rootElement = document.getElementById('root')
|
||||
if (!rootElement) {
|
||||
throw new Error('Элемент #root не найден в index.html')
|
||||
throw new Error('Елемент #root не знайдено в index.html')
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
|
||||
@@ -44,3 +44,7 @@
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cr-missing {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { Link, Navigate } from 'react-router-dom'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import {
|
||||
checkCashRegister,
|
||||
@@ -19,6 +19,7 @@ interface FormState {
|
||||
fiscal_number: string
|
||||
license_key: string
|
||||
pin_code: string
|
||||
np_api_key: string
|
||||
tax_codes: string
|
||||
is_default: boolean
|
||||
}
|
||||
@@ -28,6 +29,7 @@ const EMPTY_FORM: FormState = {
|
||||
fiscal_number: '',
|
||||
license_key: '',
|
||||
pin_code: '',
|
||||
np_api_key: '',
|
||||
tax_codes: '',
|
||||
is_default: true,
|
||||
}
|
||||
@@ -53,8 +55,6 @@ export function CashRegistersPage() {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState<{ tone: 'ok' | 'error'; text: string } | null>(null)
|
||||
|
||||
if (user && user.role !== 'admin') return <Navigate to="/" replace />
|
||||
|
||||
async function run(action: () => Promise<unknown>, okText: string) {
|
||||
setBusy(true)
|
||||
try {
|
||||
@@ -62,7 +62,7 @@ export function CashRegistersPage() {
|
||||
setMessage({ tone: 'ok', text: okText })
|
||||
await queryClient.invalidateQueries({ queryKey: ['cash-registers'] })
|
||||
} catch (err) {
|
||||
setMessage({ tone: 'error', text: err instanceof Error ? err.message : 'Ошибка запроса' })
|
||||
setMessage({ tone: 'error', text: err instanceof Error ? err.message : 'Помилка запиту' })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -75,6 +75,7 @@ export function CashRegistersPage() {
|
||||
fiscal_number: register.fiscal_number ?? '',
|
||||
license_key: '',
|
||||
pin_code: '',
|
||||
np_api_key: '',
|
||||
tax_codes: register.tax_codes.join(', '),
|
||||
is_default: register.is_default,
|
||||
})
|
||||
@@ -88,7 +89,7 @@ export function CashRegistersPage() {
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
if (editingId) {
|
||||
// Пустые ключ/PIN при редактировании — «не менять».
|
||||
// Пустые ключи/PIN при редактировании — «не менять».
|
||||
const payload: CashRegisterUpdate = {
|
||||
name: form.name,
|
||||
fiscal_number: form.fiscal_number || null,
|
||||
@@ -97,7 +98,8 @@ export function CashRegistersPage() {
|
||||
}
|
||||
if (form.license_key) payload.license_key = form.license_key
|
||||
if (form.pin_code) payload.pin_code = form.pin_code
|
||||
await run(() => updateCashRegister(editingId, payload), 'Касса обновлена')
|
||||
if (form.np_api_key) payload.np_api_key = form.np_api_key
|
||||
await run(() => updateCashRegister(editingId, payload), 'Касу оновлено')
|
||||
} else {
|
||||
await run(
|
||||
() =>
|
||||
@@ -106,10 +108,11 @@ export function CashRegistersPage() {
|
||||
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()
|
||||
@@ -118,21 +121,23 @@ export function CashRegistersPage() {
|
||||
return (
|
||||
<div className="dashboard-shell">
|
||||
<header className="dashboard-topbar">
|
||||
<span className="dashboard-brand">lux_fiscal</span>
|
||||
<div className="dashboard-topbar-start">
|
||||
<Link to="/" className="orders-link-btn">
|
||||
← К заказам
|
||||
← Головне меню
|
||||
</Link>
|
||||
<span className="dashboard-brand">Assistant System</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="orders-body">
|
||||
<div className="orders-toolbar">
|
||||
<h2>Кассы Checkbox</h2>
|
||||
<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 type="button" className="orders-notice-close" onClick={() => setMessage(null)} aria-label="Сховати">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@@ -142,33 +147,34 @@ export function CashRegistersPage() {
|
||||
<table className="orders-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Название</th>
|
||||
<th>Фиск. номер</th>
|
||||
<th>Ключ лицензии</th>
|
||||
<th>Налоги</th>
|
||||
<th>Назва</th>
|
||||
<th>Фіск. номер</th>
|
||||
<th>Ключ ліцензії</th>
|
||||
<th>Ключ Нової Пошти</th>
|
||||
<th>Податки</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
<th>Дії</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td colSpan={6} className="orders-empty">
|
||||
Загрузка…
|
||||
<td colSpan={7} className="orders-empty">
|
||||
Завантаження…
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{isError && (
|
||||
<tr>
|
||||
<td colSpan={6} className="orders-empty">
|
||||
Не удалось загрузить кассы
|
||||
<td colSpan={7} className="orders-empty">
|
||||
Не вдалося завантажити каси
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{registers?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="orders-empty">
|
||||
Кассы не настроены — чеки создавать нельзя
|
||||
<td colSpan={7} className="orders-empty">
|
||||
Каси не налаштовані — створювати чеки неможливо
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -176,14 +182,15 @@ export function CashRegistersPage() {
|
||||
<tr key={register.id}>
|
||||
<td>
|
||||
{register.name}
|
||||
{register.is_default && <span className="orders-status orders-status--delivered cr-default">основная</span>}
|
||||
{register.is_default && <span className="orders-status orders-status--delivered cr-default">основна</span>}
|
||||
</td>
|
||||
<td>{register.fiscal_number || '—'}</td>
|
||||
<td>{register.license_key_masked}</td>
|
||||
<td>{register.tax_codes.length ? register.tax_codes.join(', ') : 'без налога'}</td>
|
||||
<td>{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 ? 'Активна' : 'Отключена'}
|
||||
{register.is_active ? 'Активна' : 'Вимкнена'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="orders-actions-cell">
|
||||
@@ -191,12 +198,12 @@ export function CashRegistersPage() {
|
||||
type="button"
|
||||
className="orders-view-btn"
|
||||
disabled={busy}
|
||||
onClick={() => void run(() => checkCashRegister(register.id), `Вход в «${register.name}» успешен`)}
|
||||
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"
|
||||
@@ -205,11 +212,11 @@ export function CashRegistersPage() {
|
||||
onClick={() =>
|
||||
void run(
|
||||
() => updateCashRegister(register.id, { is_active: !register.is_active }),
|
||||
register.is_active ? 'Касса отключена' : 'Касса включена',
|
||||
register.is_active ? 'Касу вимкнено' : 'Касу увімкнено',
|
||||
)
|
||||
}
|
||||
>
|
||||
{register.is_active ? 'Отключить' : 'Включить'}
|
||||
{register.is_active ? 'Вимкнути' : 'Увімкнути'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -219,38 +226,48 @@ export function CashRegistersPage() {
|
||||
</div>
|
||||
|
||||
<form className="cr-form" onSubmit={(e) => void handleSubmit(e)}>
|
||||
<h3>{editingId ? 'Изменить кассу' : 'Добавить кассу'}</h3>
|
||||
<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 ? 'не менять' : ''}
|
||||
placeholder={editingId ? 'не змінювати' : ''}
|
||||
value={form.license_key}
|
||||
onChange={(e) => setForm({ ...form, license_key: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
PIN-код кассира
|
||||
PIN-код касира
|
||||
<input
|
||||
required={!editingId}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={editingId ? 'не менять' : ''}
|
||||
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">
|
||||
@@ -259,15 +276,15 @@ export function CashRegistersPage() {
|
||||
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 ? 'Сохранить' : 'Добавить'}
|
||||
{editingId ? 'Зберегти' : 'Додати'}
|
||||
</button>
|
||||
{editingId && (
|
||||
<button type="button" className="orders-view-btn" onClick={resetForm}>
|
||||
Отмена
|
||||
Скасувати
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.dashboard-topbar-start {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dashboard-brand {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Link } from 'react-router-dom'
|
||||
import { deleteOrder } from '@/api/orders'
|
||||
import { cancelReceipt, createReceipts } from '@/api/receipts'
|
||||
import '@/pages/DashboardPage.css'
|
||||
import { ROLE_LABEL } from '@/features/auth/roles'
|
||||
import { useAuth } from '@/features/auth/useAuth'
|
||||
import { OrderDetailModal } from '@/features/orders/OrderDetailModal'
|
||||
import type { Order, OrderTab } from '@/features/orders/types'
|
||||
@@ -13,17 +14,11 @@ import { defaultPrepayment, prepaymentMatches, toKopecks } from '@/features/rece
|
||||
import { CANCELLABLE, RECEIPT_STATUS } from '@/features/receipts/types'
|
||||
import type { ReceiptRequestItem } from '@/features/receipts/types'
|
||||
|
||||
const ROLE_LABEL: Record<string, string> = {
|
||||
admin: 'Администратор',
|
||||
cashier: 'Кассир',
|
||||
viewer: 'Наблюдатель',
|
||||
}
|
||||
|
||||
const TABS: { key: OrderTab; label: string }[] = [
|
||||
{ key: 'no_receipt', label: 'Без чека' },
|
||||
{ key: 'has_receipt', label: 'Выписаны чеки' },
|
||||
{ key: 'received', label: 'Полученные' },
|
||||
{ key: 'refused', label: 'Отказы' },
|
||||
{ key: 'has_receipt', label: 'Виписані чеки' },
|
||||
{ key: 'received', label: 'Отримані' },
|
||||
{ key: 'refused', label: 'Відмови' },
|
||||
]
|
||||
|
||||
function npStatusTone(order: Order): 'delivered' | 'processing' | 'danger' | 'new' {
|
||||
@@ -45,7 +40,7 @@ function paymentBadge(order: Order): { label: string; tone: 'delivered' | 'proce
|
||||
return null
|
||||
}
|
||||
|
||||
const MONEY_FORMAT = new Intl.NumberFormat('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
const MONEY_FORMAT = new Intl.NumberFormat('uk-UA', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
|
||||
function formatMoney(amount: string): string {
|
||||
return MONEY_FORMAT.format(Number(amount))
|
||||
@@ -80,7 +75,7 @@ export function DashboardPage() {
|
||||
for (const order of targets) {
|
||||
const prepayment = toKopecks(prepaymentOf(order))
|
||||
if (prepayment === null) {
|
||||
localErrors.push(`${order.id}: некорректная сумма предоплаты`)
|
||||
localErrors.push(`${order.id}: некоректна сума передоплати`)
|
||||
continue
|
||||
}
|
||||
items.push({ order_id: order.id, prepayment_kopecks: prepayment })
|
||||
@@ -95,14 +90,14 @@ export function DashboardPage() {
|
||||
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}`] : []),
|
||||
...(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 : 'Не удалось создать чеки'] })
|
||||
setNotice({ tone: 'error', lines: [err instanceof Error ? err.message : 'Не вдалося створити чеки'] })
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -111,19 +106,19 @@ export function DashboardPage() {
|
||||
async function handleCancel(order: Order) {
|
||||
if (!order.receipt_id) return
|
||||
const outcome = {
|
||||
no_receipt: 'Заказ вернётся в очередь.',
|
||||
has_receipt: 'Заказ вернётся в очередь.',
|
||||
received: 'Заказ останется в полученных.',
|
||||
refused: 'Заказ останется в отказах.',
|
||||
no_receipt: 'Замовлення повернеться в чергу.',
|
||||
has_receipt: 'Замовлення повернеться в чергу.',
|
||||
received: 'Замовлення залишиться в отриманих.',
|
||||
refused: 'Замовлення залишиться у відмовах.',
|
||||
}[tab]
|
||||
if (!window.confirm(`Отменить ЕТТН-чек по заказу ${order.id}? ${outcome}`)) return
|
||||
if (!window.confirm(`Скасувати ЕТТН-чек за замовленням ${order.id}? ${outcome}`)) return
|
||||
setCancellingId(order.id)
|
||||
try {
|
||||
await cancelReceipt(order.receipt_id)
|
||||
setNotice({ tone: 'ok', lines: [`Чек по заказу ${order.id} отменён`] })
|
||||
setNotice({ tone: 'ok', lines: [`Чек за замовленням ${order.id} скасовано`] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['orders'] })
|
||||
} catch (err) {
|
||||
setNotice({ tone: 'error', lines: [err instanceof Error ? err.message : 'Не удалось отменить чек'] })
|
||||
setNotice({ tone: 'error', lines: [err instanceof Error ? err.message : 'Не вдалося скасувати чек'] })
|
||||
} finally {
|
||||
setCancellingId(null)
|
||||
}
|
||||
@@ -174,25 +169,25 @@ export function DashboardPage() {
|
||||
return (
|
||||
<div className="dashboard-shell">
|
||||
<header className="dashboard-topbar">
|
||||
<span className="dashboard-brand">lux_fiscal</span>
|
||||
<div className="dashboard-topbar-start">
|
||||
<Link to="/" className="orders-link-btn">
|
||||
← Головне меню
|
||||
</Link>
|
||||
<span className="dashboard-brand">Assistant System</span>
|
||||
</div>
|
||||
<div className="dashboard-user">
|
||||
<span>{user?.full_name}</span>
|
||||
<span className="dashboard-role">{user ? (ROLE_LABEL[user.role] ?? user.role) : ''}</span>
|
||||
<span className="dashboard-role">{user ? ROLE_LABEL[user.role] : ''}</span>
|
||||
<button type="button" className="dashboard-logout" onClick={() => void logout()}>
|
||||
Выйти
|
||||
Вийти
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="orders-body">
|
||||
<div className="orders-toolbar">
|
||||
<h2>Заказы</h2>
|
||||
<h2>Замовлення</h2>
|
||||
<div className="orders-toolbar-actions">
|
||||
{user?.role === 'admin' && (
|
||||
<Link to="/cash-registers" className="orders-link-btn">
|
||||
Кассы
|
||||
</Link>
|
||||
)}
|
||||
{tab === 'no_receipt' && canFiscalize && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -200,20 +195,20 @@ export function DashboardPage() {
|
||||
disabled={selected.size === 0 || submitting}
|
||||
onClick={() => void submitReceipts(filtered.filter((order) => selected.has(order.id)))}
|
||||
>
|
||||
Создать чеки по выбранным ({selected.size})
|
||||
Створити чеки за вибраними ({selected.size})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="orders-summary">
|
||||
<div className="orders-summary-card" title="Посылки с наложкой, которые ещё не забрали и по которым нет отказа">
|
||||
<span className="orders-summary-label">Наложка в пути</span>
|
||||
<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} посылок` : ' '}
|
||||
{summary ? `${summary.cod_in_transit_count} посилок` : ' '}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -225,7 +220,7 @@ export function DashboardPage() {
|
||||
<li key={line}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
<button type="button" className="orders-notice-close" onClick={() => setNotice(null)} aria-label="Скрыть">
|
||||
<button type="button" className="orders-notice-close" onClick={() => setNotice(null)} aria-label="Сховати">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@@ -248,16 +243,16 @@ export function DashboardPage() {
|
||||
</div>
|
||||
|
||||
<div className="orders-filterbar">
|
||||
<span className="orders-selected-count">Выбрано: {selected.size} заказов</span>
|
||||
<span className="orders-selected-count">Вибрано: {selected.size} замовлень</span>
|
||||
<input
|
||||
type="search"
|
||||
className="orders-search"
|
||||
placeholder="Поиск по ID, ТТН, клиенту"
|
||||
placeholder="Пошук за ID, ТТН, клієнтом"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<button type="button" className="orders-filter-btn">
|
||||
Фильтр
|
||||
Фільтр
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -266,31 +261,31 @@ export function DashboardPage() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="orders-checkbox-col">
|
||||
<input type="checkbox" checked={allSelected} onChange={toggleAll} aria-label="Выбрать всё" />
|
||||
<input type="checkbox" checked={allSelected} onChange={toggleAll} aria-label="Вибрати все" />
|
||||
</th>
|
||||
<th>ID заказа</th>
|
||||
<th>ID замовлення</th>
|
||||
<th>Номер ТТН</th>
|
||||
<th>Клиент</th>
|
||||
<th>Сумма</th>
|
||||
<th>Наложка</th>
|
||||
<th>Клієнт</th>
|
||||
<th>Сума</th>
|
||||
<th>Післяплата</th>
|
||||
<th>Оплачено</th>
|
||||
<th>Предоплата, ₴</th>
|
||||
<th>Передоплата, ₴</th>
|
||||
<th>Статус ТТН</th>
|
||||
<th>Действия</th>
|
||||
<th>Дії</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td colSpan={10} className="orders-empty">
|
||||
Загрузка…
|
||||
Завантаження…
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{isError && (
|
||||
<tr>
|
||||
<td colSpan={10} className="orders-empty">
|
||||
Не удалось загрузить заказы из CRM
|
||||
Не вдалося завантажити замовлення з CRM
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -305,7 +300,7 @@ export function DashboardPage() {
|
||||
type="checkbox"
|
||||
checked={selected.has(order.id)}
|
||||
onChange={() => toggleOne(order.id)}
|
||||
aria-label={`Выбрать заказ ${order.id}`}
|
||||
aria-label={`Вибрати замовлення ${order.id}`}
|
||||
/>
|
||||
</td>
|
||||
<td>{order.id}</td>
|
||||
@@ -334,7 +329,7 @@ export function DashboardPage() {
|
||||
placeholder="0.00"
|
||||
value={prepaymentOf(order)}
|
||||
disabled={!canFiscalize}
|
||||
title="Сумма заказа − предоплата должна равняться наложке"
|
||||
title="Сума замовлення − передоплата має дорівнювати післяплаті"
|
||||
onChange={(e) => setPrepayments((prev) => ({ ...prev, [order.id]: e.target.value }))}
|
||||
/>
|
||||
) : (
|
||||
@@ -343,19 +338,19 @@ export function DashboardPage() {
|
||||
</td>
|
||||
<td>
|
||||
<span className={`orders-status orders-status--${npStatusTone(order)}`}>
|
||||
{order.np_status || 'Нет данных'}
|
||||
{order.np_status || 'Немає даних'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="orders-actions-cell">
|
||||
<button type="button" className="orders-view-btn" onClick={() => setViewingOrder(order)}>
|
||||
Просмотр
|
||||
Перегляд
|
||||
</button>
|
||||
{tab === 'no_receipt' && canFiscalize && (
|
||||
<button
|
||||
type="button"
|
||||
className="orders-receipt-btn"
|
||||
disabled={!canCreateReceipt(order) || submitting}
|
||||
title={canCreateReceipt(order) ? 'Создать ЕТТН-чек в Checkbox' : 'Нет ТТН или наложки'}
|
||||
title={canCreateReceipt(order) ? 'Створити ЕТТН-чек у Checkbox' : 'Немає ТТН або післяплати'}
|
||||
onClick={() => void submitReceipts([order])}
|
||||
>
|
||||
Чек
|
||||
@@ -376,7 +371,7 @@ export function DashboardPage() {
|
||||
disabled={cancellingId === order.id}
|
||||
onClick={() => void handleCancel(order)}
|
||||
>
|
||||
Отменить
|
||||
Скасувати
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (tab === 'no_receipt' || ((tab === 'refused' || tab === 'received') && !order.has_receipt)) && (
|
||||
@@ -386,7 +381,7 @@ export function DashboardPage() {
|
||||
disabled={deletingId === order.id}
|
||||
onClick={() => void handleDelete(order)}
|
||||
>
|
||||
Удалить
|
||||
Видалити
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
@@ -396,7 +391,7 @@ export function DashboardPage() {
|
||||
{!isLoading && !isError && filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={10} className="orders-empty">
|
||||
Ничего не найдено
|
||||
Нічого не знайдено
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
@@ -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