9 Commits
Author SHA1 Message Date
lauadmin c2d0505a67 Merge pull request 'Add CRM stub flag, deploy/backup scripts, CI and prod runbook' (#10) from feature/safe-local-dev into main
CI / backend (push) Successful in 18s
CI / frontend (push) Successful in 1m36s
Reviewed-on: #10
2026-09-25 18:08:51 +00:00
lauadminandClaude Opus 5.5 2b7a92645a Add CRM stub flag, deploy/backup scripts, CI and prod runbook
CI / backend (pull_request) Successful in 3m7s
CI / frontend (pull_request) Failing after 15m55s
- CRM_USE_STUB: local runs no longer reach the live CRM. With only the
  Checkbox stub, a stub receipt would still move the real order to PACKED.
  Refused in production, same as CHECKBOX_USE_STUB.
- scripts/deploy.sh: backup, fast-forward main, build, health check and
  code rollback on failure. scripts/backup.sh: pg_dump with verification
  and 14-day rotation (used by cron and deploy.sh).
- Gitea Actions CI: ruff + pytest, oxlint + build.
- DEPLOY.md runbook; CLAUDE.md rules for safe local development.

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:21:11 +03:00
59 changed files with 1567 additions and 296 deletions
+4 -1
View File
@@ -56,6 +56,9 @@ CRM_API_KEY=change-me-crm-apikey
CRM_SECRET_KEY=change-me-crm-secretkey CRM_SECRET_KEY=change-me-crm-secretkey
CRM_SHOP_KEY=change-me-crm-shopkey CRM_SHOP_KEY=change-me-crm-shopkey
CRM_SID=1 CRM_SID=1
# Стаб вместо реальной CRM (локально — обязательно вместе с CHECKBOX_USE_STUB):
# иначе после стаб-чека боевой заказ уйдёт в PACKED. В production запрещено.
CRM_USE_STUB=true
# --- Nova Poshta ---------------------------------------------------------- # --- Nova Poshta ----------------------------------------------------------
# Ключи API Nova Poshta задаются у касс (страница «Кассы»). Эта переменная нужна # Ключи API Nova Poshta задаются у касс (страница «Кассы»). Эта переменная нужна
@@ -72,4 +75,4 @@ CHECKBOX_CLIENT_VERSION=0.1.0
CHECKBOX_MIN_REQUEST_INTERVAL_MS=1000 CHECKBOX_MIN_REQUEST_INTERVAL_MS=1000
# ЕТТН-чеки на тестовой кассе Checkbox не работают: локально весь цикл # ЕТТН-чеки на тестовой кассе Checkbox не работают: локально весь цикл
# прогоняется через стаб (чек «фискализируется» на втором опросе). В production запрещено. # прогоняется через стаб (чек «фискализируется» на втором опросе). В production запрещено.
CHECKBOX_USE_STUB=false CHECKBOX_USE_STUB=true
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+37
View File
@@ -0,0 +1,37 @@
# Проверки на каждый PR и push в main. Нужен зарегистрированный act_runner (см. DEPLOY.md).
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
backend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -e ".[dev]"
- run: ruff check app tests
# SECRET_KEY/ENCRYPTION_KEY подставляет tests/conftest.py.
- run: pytest -q
frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- run: npm ci
- run: npm run lint
- run: npm run build
+16 -1
View File
@@ -19,6 +19,10 @@ backend/ FastAPI + SQLAlchemy (async) + Alembic + Postgres — see backend/ap
frontend/ React 19 + TypeScript + Vite — see frontend/src/ frontend/ React 19 + TypeScript + Vite — see frontend/src/
docker/ nginx.frontend.conf (SPA + /api reverse proxy to the api container) docker/ nginx.frontend.conf (SPA + /api reverse proxy to the api container)
docker-compose.yml postgres, redis, migrate (one-shot), api, worker, frontend docker-compose.yml postgres, redis, migrate (one-shot), api, worker, frontend
docker-compose.prod.yml prod overlay: no host ports, frontend joins the external `web` network (Nginx Proxy Manager)
scripts/ deploy.sh (runs on the prod server), backup.sh (pg_dump + rotation)
.gitea/workflows/ci.yml ruff + pytest, oxlint + build on every PR
DEPLOY.md production runbook — read before touching the server
``` ```
Backend and frontend are independent projects with their own dependency files (`backend/pyproject.toml`, `frontend/package.json`) — always `cd` into the right one before running tooling. Backend and frontend are independent projects with their own dependency files (`backend/pyproject.toml`, `frontend/package.json`) — always `cd` into the right one before running tooling.
@@ -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. `worker` runs ARQ (`arq app.worker.WorkerSettings`): cron polls Nova Poshta statuses and Checkbox ETTN receipts every minute, plus the on-demand `create_ettn_receipt` job enqueued by the API. Its Dockerfile HEALTHCHECK is explicitly disabled in `docker-compose.yml` because the worker doesn't serve HTTP; don't re-enable it without giving it something to check.
## Production and safe local development
Prod runs on `websrv` (`ssh lux-prod`, user `deploy`), `https://asist.ystyle.com.ua`, behind the server's Nginx Proxy Manager. The full runbook (deploy, rollback, backups, restore, CI runner) is in `DEPLOY.md`.
- **The local DB is a copy of prod data.** Local must never reach live systems: `.env` always has `ENVIRONMENT=local`, `CHECKBOX_USE_STUB=true` **and** `CRM_USE_STUB=true`. The Checkbox stub alone is not enough — a stub receipt counts as accepted and `sync_crm_statuses` would move the real CRM order to `PACKED`. Both factories (`get_checkbox_client`, `get_crm_client`) refuse stubs in production. After restoring a prod dump locally, overwrite `cash_registers` keys before starting `api`/`worker` (snippet in `DEPLOY.md`).
- **Workflow:** `feature/*` branch → PR in Gitea (CI green) → merge to `main` → on the server `~/lux_fiscal/scripts/deploy.sh`. `main` is always what prod runs. Never edit tracked files on the server or commit/push to `main` directly.
- **Migrations must be backward-compatible with existing data:** new columns nullable or with `server_default`; drop/rename a column only in a later release after code stopped using it (code rollback does not roll back the schema). Test a new migration locally on a fresh (sanitized) prod backup.
- **Never change prod `ENCRYPTION_KEY`** — cash register secrets in the DB are encrypted with it.
- New integrations with side effects on real systems (CRM, Checkbox, NP, anything that writes) get their own `*_USE_STUB` flag with the same production guard, selected in one factory function.
- Claude has SSH access as `deploy`, but reading/decrypting prod secrets and writing to prod `.env` is left to the user.
## Backend architecture ## Backend architecture
- **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. - **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.
@@ -128,4 +143,4 @@ Stages 1–4 (scaffolding, auth/audit, CRM order queue, Nova Poshta tracking) ar
- 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"}}`. - 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. - Nova Poshta's rate limit comes back through Checkbox as a 4xx with `code=third_party.generic` and «To many requests» / `20000401501`, not as a 429. `http_client._transient_error` maps it to `CheckboxRateLimitedError`: the receipt stays `pending` and the worker retries with `arq.Retry`. The client also sends requests one at a time with a `CHECKBOX_MIN_REQUEST_INTERVAL_MS` pause, so don't parallelize Checkbox calls in the worker.
- Each cash register has its own Nova Poshta API key (`cash_registers.np_api_key_enc`, Fernet). `sync_np_statuses` polls TTNs with register keys and binds the order to the register whose key sees the TTN as its own (`orders.cash_register_id`; ownership = response contains `PhoneSender` — a foreign key gets a truncated reply without sender/`AfterpaymentOnGoodsCost`). Receipts are created from the order's register, not the default one; an unbound order is rejected. `NOVA_POSHTA_API_KEY` env is only read by migration 0008. - 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`; client selection is only in `services/checkbox/client.get_checkbox_client()`. - ETTN does **not** work on a Checkbox test cash register. Locally use `CHECKBOX_USE_STUB=true` together with `CRM_USE_STUB=true`; client selection is only in `services/checkbox/client.get_checkbox_client()` and `services/crm/client.get_crm_client()`.
+143
View File
@@ -0,0 +1,143 @@
# Выкладка в прод
## Где что
| Что | Где |
|---|---|
| Сервер | `websrv`, `192.168.88.100`, Ubuntu; SSH: пользователь `deploy`, порт 22 (алиас `lux-prod` в `~/.ssh/config` разработчика) |
| Приложение | `https://asist.ystyle.com.ua` |
| Код | `/home/deploy/lux_fiscal` — клон `main` из Gitea (deploy key только на чтение, `~/.ssh/config` → `Host gitea` = `127.0.0.1:2222`) |
| Секреты | `/home/deploy/lux_fiscal/.env` (права 600, в git не попадает) |
| Бэкапы | `/home/deploy/backups/lux_fiscal/*.dump`, журнал — `backup.log` там же |
| TLS / домен | Nginx Proxy Manager на том же сервере (`http://192.168.88.100:81`), Proxy Host `asist.ystyle.com.ua` → `lux-fiscal-frontend:80` |
Сервисы поднимаются из двух файлов: `docker-compose.yml` + `docker-compose.prod.yml` (прод-оверлей убирает порты на хосте
и подключает `frontend` к внешней сети `web`, где живёт NPM). Во всех командах ниже:
```bash
C="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
```
## Обычная выкладка
1. Изменения попадают в `main` только через PR в Gitea; CI (`.gitea/workflows/ci.yml`) должен быть зелёным.
2. На сервере:
```bash
ssh lux-prod
~/lux_fiscal/scripts/deploy.sh
```
`deploy.sh` делает: бэкап БД (`scripts/backup.sh`) → `git fetch` + fast-forward `main` → `up -d --build --wait`
(миграции применяет одноразовый контейнер `migrate`) → проверка `/api/v1/health` через nginx фронтенда.
Если новая версия не поднялась — откатывает **код** на предыдущий коммит и печатает путь к бэкапу.
**БД автоматически не откатывается.** `--force` — пересобрать и перезапустить без новых коммитов.
После выкладки:
```bash
$C ps
$C logs --since 10m api worker | grep -iE "error|exception" | tail -20
```
## Правила, чтобы не сломать прод
- **Миграции — только совместимые с данными.** Новые колонки — `nullable` или с `server_default`. Удаление/переименование
колонки — отдельным релизом, после того как код перестал её использовать (иначе откат кода не спасёт).
- Перед мержем миграцию прогоняют локально на копии свежего прод-бэкапа (см. «Прод-данные локально»).
- `ENCRYPTION_KEY` в прод-`.env` **никогда не меняется**: им зашифрованы ключи касс в БД. Копия `.env` хранится отдельно от
сервера (менеджер паролей).
- В проде запрещены `CHECKBOX_USE_STUB=true` и `CRM_USE_STUB=true` — приложение упадёт на старте клиента.
- Руками на сервере файлы репозитория не правятся: `deploy.sh` откажется работать с грязным деревом.
- Выкладка — не в часы пик работы кассиров: во время `up` API недоступен несколько секунд, а воркер перезапускается.
## Откат
Код:
```bash
cd ~/lux_fiscal
git log --oneline -10
git checkout --detach <коммит>
$C up -d --build --wait
```
Следующий `deploy.sh` сам вернётся на `main`. Правильный путь исправления — revert-коммит через PR.
БД из бэкапа (всё, что было после бэкапа, потеряется):
```bash
cd ~/lux_fiscal
$C stop api worker
$C exec -T postgres sh -c 'pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists --no-owner' \
< ~/backups/lux_fiscal/<файл>.dump
$C start api worker
```
Код при этом должен соответствовать версии схемы в дампе (`alembic_version`).
## Бэкапы
- `scripts/backup.sh`: `pg_dump -Fc` → проверка `pg_restore --list` → ротация 14 дней.
- Cron пользователя `deploy` (`crontab -l`): каждый день в 03:30 UTC.
- Бэкапы лежат на том же диске, что и БД, — периодически копируйте их за пределы сервера:
```bash
scp "lux-prod:backups/lux_fiscal/*.dump" D:/Backups/lux_fiscal/
```
## Прод-данные локально
Прод-дамп содержит зашифрованные ключи касс, а локальный `.env` — ключи CRM. Чтобы локальный воркер не создавал боевые
чеки и не менял статусы заказов в CRM, локально **всегда**:
```ini
ENVIRONMENT=local
CHECKBOX_USE_STUB=true
CRM_USE_STUB=true
```
После восстановления прод-дампа в локальную БД сразу обнулите ключи касс (лицензия/PIN — фиктивные, ключ НП — `NULL`):
```bash
docker compose run --rm --no-deps -T api python - <<'EOF'
import asyncio
from sqlalchemy import text
from app.core import crypto
from app.core.config import settings
from app.db.session import engine
assert not settings.is_production
async def main():
async with engine.begin() as c:
await c.execute(text("update cash_registers set license_key_enc=:l, cashier_pin_enc=:p, np_api_key_enc=null"),
{"l": crypto.encrypt("local-stub-license"), "p": crypto.encrypt("0000")})
asyncio.run(main())
EOF
```
Зашифрованы только ключи касс, и скрипт их перезаписывает — поэтому локальный `ENCRYPTION_KEY` может быть любым.
Делайте это до первого запуска локальных `api`/`worker`.
Варнинги воркера `ettn_poll_failed … не знайдено` на прод-копии — норма: стаб Checkbox не знает ID настоящих чеков.
## CI (Gitea Actions)
`.gitea/workflows/ci.yml`: backend — `ruff check` + `pytest`; frontend — `npm run lint` + `npm run build`.
Workflow выполняется только при зарегистрированном `act_runner`:
1. Gitea → Site Administration → Actions → Runners → Create new Runner — скопировать registration token.
2. Запустить раннер контейнером рядом с Gitea (образ `gitea/act_runner`, переменные `GITEA_INSTANCE_URL`,
`GITEA_RUNNER_REGISTRATION_TOKEN`, проброс `/var/run/docker.sock`).
Сервер слабый (2.5 ГБ RAM): при выкладке во время прогона CI сборка фронтенда может упереться в память.
## Первичная установка (для справки)
1. Пользователь `deploy` в группе `docker`, SSH-ключ разработчика в `~deploy/.ssh/authorized_keys` (700/600, владелец `deploy`).
2. Deploy key `~deploy/.ssh/gitea_deploy` добавлен в Gitea (repo → Settings → Deploy Keys, только чтение);
`git clone gitea:lauadmin/lux_fiscal.git ~/lux_fiscal`.
3. `.env` из `.env.example`: `ENVIRONMENT=production`, `DEBUG=false`, `BASE_URL`/`CORS_ORIGINS` = `https://asist.ystyle.com.ua`,
сгенерированные `SECRET_KEY`/`POSTGRES_PASSWORD`, `ENCRYPTION_KEY` — тот, которым зашифрованы ключи касс в переносимой БД.
4. `$C up -d --build --wait`; на пустой БД — `$C run --rm api python -m app.cli bootstrap`.
5. NPM: Proxy Host → `lux-fiscal-frontend:80`, Let's Encrypt, Force SSL.
6. Cron: `30 3 * * * $HOME/lux_fiscal/scripts/backup.sh >> $HOME/backups/lux_fiscal/backup.log 2>&1`.
+3 -9
View File
@@ -10,13 +10,11 @@ from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.security import TokenError, decode_access_token from app.core.security import TokenError, decode_access_token
from app.db.models.user import User, UserRole from app.db.models.user import User, UserRole
from app.db.session import get_session from app.db.session import get_session
from app.services.checkbox.client import CheckboxClient, get_checkbox_client from app.services.checkbox.client import CheckboxClient, get_checkbox_client
from app.services.crm.client import CrmClient from app.services.crm.client import CrmClient, get_crm_client
from app.services.crm.exo_client import ExoCrmClient
from app.services.task_queue import TaskQueue, get_task_queue from app.services.task_queue import TaskQueue, get_task_queue
bearer_scheme = HTTPBearer(auto_error=False) bearer_scheme = HTTPBearer(auto_error=False)
@@ -25,7 +23,7 @@ SessionDep = Annotated[AsyncSession, Depends(get_session)]
_UNAUTHORIZED = HTTPException( _UNAUTHORIZED = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Требуется аутентификация", detail="Потрібна автентифікація",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
@@ -76,7 +74,7 @@ def require_roles(*roles: UserRole) -> Callable[..., Coroutine[Any, Any, User]]:
if user.role not in allowed: if user.role not in allowed:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Недостаточно прав для этого действия", detail="Недостатньо прав для цієї дії",
) )
return user return user
@@ -92,10 +90,6 @@ AdminUser = Annotated[User, Depends(require_admin)]
CashierUser = Annotated[User, Depends(require_cashier)] CashierUser = Annotated[User, Depends(require_cashier)]
def get_crm_client() -> CrmClient:
return ExoCrmClient(settings)
CrmClientDep = Annotated[CrmClient, Depends(get_crm_client)] CrmClientDep = Annotated[CrmClient, Depends(get_crm_client)]
+1 -1
View File
@@ -70,7 +70,7 @@ async def change_password(
) -> Response: ) -> Response:
if not verify_password(payload.current_password, user.password_hash): if not verify_password(payload.current_password, user.password_hash):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Текущий пароль указан неверно" status_code=status.HTTP_400_BAD_REQUEST, detail="Поточний пароль вказано неправильно"
) )
user.password_hash = hash_password(payload.new_password) user.password_hash = hash_password(payload.new_password)
+3 -3
View File
@@ -44,7 +44,7 @@ async def _ensure_np_key_unique(
if same: if same:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
detail=f"Этот ключ Новой Почты уже привязан к кассе «{register.name}»", detail=f"Цей ключ Нової Пошти вже прив'язаний до каси «{register.name}»",
) )
@@ -101,7 +101,7 @@ async def update_cash_register(
) -> CashRegisterOut: ) -> CashRegisterOut:
register = await session.get(CashRegister, register_id) register = await session.get(CashRegister, register_id)
if register is None: 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) changes = payload.model_dump(exclude_unset=True)
if "license_key" in changes: if "license_key" in changes:
@@ -139,7 +139,7 @@ async def check_cash_register(
"""Пробный вход кассира в Checkbox — проверка ключа лицензии и PIN.""" """Пробный вход кассира в Checkbox — проверка ключа лицензии и PIN."""
register = await session.get(CashRegister, register_id) register = await session.get(CashRegister, register_id)
if register is None: 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: try:
await checkbox.sign_in(credentials(register)) await checkbox.sign_in(credentials(register))
except (CheckboxError, crypto.DecryptionError) as exc: except (CheckboxError, crypto.DecryptionError) as exc:
+2 -2
View File
@@ -50,7 +50,7 @@ async def update_order(
except orders_service.OrderEditError as exc: except orders_service.OrderEditError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
if result is None: 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 order, changed = result
if changed: if changed:
@@ -75,7 +75,7 @@ async def delete_order(
) -> None: ) -> None:
order = await orders_service.delete_order(session, order_id) order = await orders_service.delete_order(session, order_id)
if order is None: if order is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Заказ не найден") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Замовлення не знайдено")
await audit.record( await audit.record(
session, session,
+2 -2
View File
@@ -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: async def get_receipt(receipt_id: uuid.UUID, session: SessionDep) -> ReceiptOut:
receipt = await session.get(Receipt, receipt_id) receipt = await session.get(Receipt, receipt_id)
if receipt is None: 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) return ReceiptOut.from_receipt(receipt)
@@ -83,5 +83,5 @@ async def cancel_receipt(
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Checkbox: {exc}" status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Checkbox: {exc}"
) from exc ) from exc
if receipt is None: 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) return ReceiptOut.from_receipt(receipt)
+4 -4
View File
@@ -71,7 +71,7 @@ async def create_user(
await session.rollback() await session.rollback()
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
detail="Пользователь с таким email уже существует", detail="Користувач із таким email уже існує",
) from exc ) from exc
return UserOut.model_validate(user) return UserOut.model_validate(user)
@@ -87,7 +87,7 @@ async def update_user(
) -> UserOut: ) -> UserOut:
user = await session.get(User, user_id) user = await session.get(User, user_id)
if user is None: if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Користувача не знайдено")
changes = payload.model_dump(exclude_unset=True) changes = payload.model_dump(exclude_unset=True)
@@ -97,12 +97,12 @@ async def update_user(
if changes.get("is_active") is False: if changes.get("is_active") is False:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Нельзя отключить собственную учётную запись", detail="Не можна вимкнути власний обліковий запис",
) )
if "role" in changes and changes["role"] != UserRole.ADMIN: if "role" in changes and changes["role"] != UserRole.ADMIN:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Нельзя снять с себя роль администратора", detail="Не можна зняти із себе роль адміністратора",
) )
if (new_password := changes.pop("password", None)) is not None: if (new_password := changes.pop("password", None)) is not None:
+5 -5
View File
@@ -25,7 +25,7 @@ async def bootstrap() -> int:
Идемпотентна: повторный запуск ничего не меняет и пароль не сбрасывает. Идемпотентна: повторный запуск ничего не меняет и пароль не сбрасывает.
""" """
if not settings.first_admin_password: if not settings.first_admin_password:
print("FIRST_ADMIN_PASSWORD не задан в .env", file=sys.stderr) print("FIRST_ADMIN_PASSWORD не задано в .env", file=sys.stderr)
return 1 return 1
email = settings.first_admin_email.strip().lower() email = settings.first_admin_email.strip().lower()
@@ -33,7 +33,7 @@ async def bootstrap() -> int:
async with session_scope() as session: async with session_scope() as session:
existing = await session.scalar(select(User).where(User.email == email)) existing = await session.scalar(select(User).where(User.email == email))
if existing is not None: if existing is not None:
print(f"Пользователь {email} уже существует — ничего не изменено.") print(f"Користувач {email} уже існує — нічого не змінено.")
return 0 return 0
session.add( session.add(
@@ -45,8 +45,8 @@ async def bootstrap() -> int:
) )
) )
print(f"Администратор {email} создан.") print(f"Адміністратора {email} створено.")
print("Смените пароль после первого входа и уберите FIRST_ADMIN_PASSWORD из .env.") print("Змініть пароль після першого входу та приберіть FIRST_ADMIN_PASSWORD з .env.")
return 0 return 0
@@ -59,7 +59,7 @@ def gen_keys() -> int:
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(prog="app.cli", description="Служебные команды lux_fiscal") parser = argparse.ArgumentParser(prog="app.cli", description="Службові команди lux_fiscal")
parser.add_argument("command", choices=["bootstrap", "gen-keys"]) parser.add_argument("command", choices=["bootstrap", "gen-keys"])
args = parser.parse_args() args = parser.parse_args()
+4 -1
View File
@@ -53,7 +53,7 @@ class Settings(BaseSettings):
# --- Первый администратор (только для команды bootstrap) --- # --- Первый администратор (только для команды bootstrap) ---
first_admin_email: str = "admin@example.com" first_admin_email: str = "admin@example.com"
first_admin_password: str = "" first_admin_password: str = ""
first_admin_name: str = "Администратор" first_admin_name: str = "Адміністратор"
# --- CRM --- # --- CRM ---
crm_base_url: str = "https://optstore.exocrm.com/api/1.1/" crm_base_url: str = "https://optstore.exocrm.com/api/1.1/"
@@ -61,6 +61,9 @@ class Settings(BaseSettings):
crm_secret_key: str = "" crm_secret_key: str = ""
crm_shop_key: str = "" crm_shop_key: str = ""
crm_sid: int = 1 crm_sid: int = 1
# Стаб вместо реальной CRM: иначе локальный worker переводил бы боевые заказы
# в PACKED после стаб-чеков Checkbox. В проде запрещено.
crm_use_stub: bool = False
# --- Nova Poshta --- # --- Nova Poshta ---
# Ключи НП хранятся у касс (`cash_registers.np_api_key_enc`). Эта переменная # Ключи НП хранятся у касс (`cash_registers.np_api_key_enc`). Эта переменная
+3 -3
View File
@@ -26,7 +26,7 @@ def _fernet() -> Fernet:
return Fernet(settings.encryption_key.encode()) return Fernet(settings.encryption_key.encode())
except (ValueError, TypeError) as exc: except (ValueError, TypeError) as exc:
raise RuntimeError( raise RuntimeError(
"ENCRYPTION_KEY некорректен. Сгенерируйте валидный ключ: " "ENCRYPTION_KEY некоректний. Згенеруйте валідний ключ: "
"python -c \"from cryptography.fernet import Fernet; " "python -c \"from cryptography.fernet import Fernet; "
'print(Fernet.generate_key().decode())"' 'print(Fernet.generate_key().decode())"'
) from exc ) from exc
@@ -41,8 +41,8 @@ def decrypt(value: str) -> str:
return _fernet().decrypt(value.encode()).decode() return _fernet().decrypt(value.encode()).decode()
except InvalidToken as exc: except InvalidToken as exc:
raise DecryptionError( raise DecryptionError(
"Не удалось расшифровать значение. Вероятная причина — ENCRYPTION_KEY " "Не вдалося розшифрувати значення. Імовірна причина — ENCRYPTION_KEY "
"изменился с момента сохранения. Секрет нужно ввести заново." "змінився з моменту збереження. Секрет потрібно ввести заново."
) from exc ) from exc
+3 -3
View File
@@ -67,13 +67,13 @@ def decode_access_token(token: str) -> dict[str, Any]:
try: try:
payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM]) payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
except jwt.ExpiredSignatureError as exc: except jwt.ExpiredSignatureError as exc:
raise TokenError("Срок действия токена истёк") from exc raise TokenError("Термін дії токена минув") from exc
except jwt.InvalidTokenError as exc: except jwt.InvalidTokenError as exc:
raise TokenError("Некорректный токен") from exc raise TokenError("Некоректний токен") from exc
# Без этой проверки refresh-токен можно было бы предъявить как access. # Без этой проверки refresh-токен можно было бы предъявить как access.
if payload.get("type") != "access": if payload.get("type") != "access":
raise TokenError("Ожидался access-токен") raise TokenError("Очікувався access-токен")
return payload return payload
+1 -1
View File
@@ -78,7 +78,7 @@ def create_app() -> FastAPI:
app = FastAPI( app = FastAPI(
title=settings.project_name, title=settings.project_name,
version="0.1.0", version="0.1.0",
description="Фискализация заказов через Checkbox", description="Фіскалізація замовлень через Checkbox",
lifespan=lifespan, lifespan=lifespan,
# В проде интерактивная документация закрыта: схема API — лишняя # В проде интерактивная документация закрыта: схема API — лишняя
# подсказка для того, кто ищет незащищённый эндпоинт. # подсказка для того, кто ищет незащищённый эндпоинт.
+1 -1
View File
@@ -22,7 +22,7 @@ class TokenPair(BaseModel):
access_token: str access_token: str
refresh_token: str refresh_token: str
token_type: str = "bearer" token_type: str = "bearer"
expires_in: int = Field(description="Время жизни access-токена в секундах") expires_in: int = Field(description="Час життя access-токена в секундах")
class UserOut(BaseModel): class UserOut(BaseModel):
+1 -1
View File
@@ -74,7 +74,7 @@ def _masked(encrypted: str) -> str:
try: try:
return crypto.mask(crypto.decrypt(encrypted)) return crypto.mask(crypto.decrypt(encrypted))
except crypto.DecryptionError: except crypto.DecryptionError:
return "не расшифровывается — введите заново" return "не розшифровується — введіть заново"
class CashRegisterOut(BaseModel): class CashRegisterOut(BaseModel):
+6 -6
View File
@@ -53,7 +53,7 @@ async def authenticate(
request=request, request=request,
) )
await session.commit() await session.commit()
raise AuthError("Неверный email или пароль") raise AuthError("Невірний email або пароль")
if not user.is_active: if not user.is_active:
await audit.record( await audit.record(
@@ -64,7 +64,7 @@ async def authenticate(
request=request, request=request,
) )
await session.commit() await session.commit()
raise AuthError("Учётная запись отключена") raise AuthError("Обліковий запис вимкнено")
# Параметры argon2 со временем ужесточаются — обновляем хеш на живом пароле. # Параметры argon2 со временем ужесточаются — обновляем хеш на живом пароле.
if password_needs_rehash(user.password_hash): if password_needs_rehash(user.password_hash):
@@ -117,7 +117,7 @@ async def rotate_refresh_token(
stored = await session.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash)) stored = await session.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash))
if stored is None: if stored is None:
raise AuthError("Некорректный refresh-токен") raise AuthError("Некоректний refresh-токен")
if stored.revoked_at is not None: if stored.revoked_at is not None:
await revoke_all_for_user(session, stored.user_id) await revoke_all_for_user(session, stored.user_id)
@@ -131,14 +131,14 @@ async def rotate_refresh_token(
) )
await session.commit() await session.commit()
log.warning("refresh_token_reuse_detected", user_id=str(stored.user_id)) log.warning("refresh_token_reuse_detected", user_id=str(stored.user_id))
raise AuthError("Сессия отозвана, требуется повторный вход") raise AuthError("Сесію відкликано, потрібен повторний вхід")
if stored.expires_at <= datetime.now(UTC): if stored.expires_at <= datetime.now(UTC):
raise AuthError("Срок действия refresh-токена истёк") raise AuthError("Термін дії refresh-токена минув")
user = await session.get(User, stored.user_id) user = await session.get(User, stored.user_id)
if user is None or not user.is_active: if user is None or not user.is_active:
raise AuthError("Учётная запись недоступна") raise AuthError("Обліковий запис недоступний")
pair = await issue_token_pair(session, user, request=request, replaces=stored) pair = await issue_token_pair(session, user, request=request, replaces=stored)
await audit.record( await audit.record(
+1 -1
View File
@@ -63,7 +63,7 @@ def get_checkbox_client() -> CheckboxClient:
"""Один экземпляр на процесс: внутри — кэш токенов кассиров.""" """Один экземпляр на процесс: внутри — кэш токенов кассиров."""
if settings.checkbox_use_stub: if settings.checkbox_use_stub:
if settings.is_production: 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 from app.services.checkbox.stub_client import StubCheckboxClient
return StubCheckboxClient(auto_complete_after=2) return StubCheckboxClient(auto_complete_after=2)
+3 -3
View File
@@ -57,7 +57,7 @@ def _error_message(response: httpx.Response) -> str:
# {"code": "third_party.*", "message": "Internal Server Error"} — # {"code": "third_party.*", "message": "Internal Server Error"} —
# без кода кассир видит только бесполезное «Internal Server Error». # без кода кассир видит только бесполезное «Internal Server Error».
if code := data.get("code"): if code := data.get("code"):
message = f"{message or 'Ошибка'} ({code})" message = f"{message or 'Помилка'} ({code})"
if isinstance(detail, list) and detail: if isinstance(detail, list) and detail:
parts = [ parts = [
f"{'.'.join(str(p) for p in item.get('loc', []))}: {item.get('msg')}" 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 method, path, headers=headers, json=json, params=params
) )
except httpx.HTTPError as exc: 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): if error := _transient_error(response):
raise error raise error
return response return response
@@ -155,7 +155,7 @@ class HttpCheckboxClient:
json={"pin_code": creds.pin_code}, json={"pin_code": creds.pin_code},
) )
if response.status_code != 200: if response.status_code != 200:
raise CheckboxError(f"Вход кассира не удался: {_error_message(response)}") raise CheckboxError(f"Вхід касира не вдався: {_error_message(response)}")
token = response.json()["access_token"] token = response.json()["access_token"]
self._tokens[creds.cash_register_id] = token self._tokens[creds.cash_register_id] = token
return token return token
+3 -3
View File
@@ -32,12 +32,12 @@ class StubCheckboxClient:
async def sign_in(self, creds: CheckboxCredentials) -> None: async def sign_in(self, creds: CheckboxCredentials) -> None:
if not creds.license_key or not creds.pin_code: 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: async def create_ettn(self, creds: CheckboxCredentials, body: dict[str, Any]) -> EttnOut:
waybill = body["receipt_body"]["payments"][0]["ettn"] waybill = body["receipt_body"]["payments"][0]["ettn"]
if await self.find_ettn(creds, waybill) is not None: if await self.find_ettn(creds, waybill) is not None:
raise CheckboxError(f"ЕТТН {waybill} уже привязана к чеку") raise CheckboxError(f"ЕТТН {waybill} вже прив'язана до чека")
ettn = EttnOut( ettn = EttnOut(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
status=EttnStatus.CREATED, status=EttnStatus.CREATED,
@@ -50,7 +50,7 @@ class StubCheckboxClient:
async def get_ettn(self, creds: CheckboxCredentials, ettn_id: str) -> EttnOut: async def get_ettn(self, creds: CheckboxCredentials, ettn_id: str) -> EttnOut:
if ettn_id not in self.orders: 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 self._polls[ettn_id] = self._polls.get(ettn_id, 0) + 1
if ( if (
self.auto_complete_after is not None self.auto_complete_after is not None
+17
View File
@@ -2,8 +2,10 @@
from __future__ import annotations from __future__ import annotations
from functools import lru_cache
from typing import Protocol from typing import Protocol
from app.core.config import settings
from app.schemas.orders import OrderOut from app.schemas.orders import OrderOut
@@ -15,3 +17,18 @@ class CrmClient(Protocol):
async def get_orders(self, *, status: str) -> list[OrderOut]: ... async def get_orders(self, *, status: str) -> list[OrderOut]: ...
async def set_status(self, *, order_id: str, status: str) -> None: ... 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)
+5 -5
View File
@@ -39,10 +39,10 @@ class ExoCrmClient:
data = response.json() data = response.json()
except ValueError as exc: except ValueError as exc:
# PHP-notice'ы CRM перед JSON — признак неверных параметров. # 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): if not isinstance(data, dict):
raise CrmError(f"Неожиданный ответ CRM: {str(data)[:300]}") raise CrmError(f"Неочікувана відповідь CRM: {str(data)[:300]}")
return data return data
async def get_orders(self, *, status: str) -> list[OrderOut]: async def get_orders(self, *, status: str) -> list[OrderOut]:
@@ -57,7 +57,7 @@ class ExoCrmClient:
}, },
) )
if data.get("status") != "OK": 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 []] return [OrderOut.model_validate(order) for order in data.get("result") or []]
async def set_status(self, *, order_id: str, status: str) -> None: 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": if isinstance(result, dict) and result.get("Status") == "Success":
return return
details = _errors_text(data) or ( 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}")
+2 -2
View File
@@ -1,4 +1,4 @@
"""Фикстурный CRM-клиент для тестов — не ходит в сеть.""" """Фикстурный CRM-клиент: тесты и локальный запуск (`CRM_USE_STUB=true`) — не ходит в сеть."""
from __future__ import annotations from __future__ import annotations
@@ -12,7 +12,7 @@ _FIXTURE_ORDERS: list[dict] = [
"RecipientPhone": "+380501112233", "RecipientPhone": "+380501112233",
"RecipientEmail": None, "RecipientEmail": None,
"Waybill_Number": "20450123456789", "Waybill_Number": "20450123456789",
"Notes": "Тестовий заказ", "Notes": "Тестове замовлення",
"Total": { "Total": {
"Cost": "0.00", "Cost": "0.00",
"Quantity": "2", "Quantity": "2",
@@ -21,7 +21,7 @@ class NpTrackingClient:
return [] return []
if len(waybill_numbers) > _MAX_DOCUMENTS_PER_REQUEST: if len(waybill_numbers) > _MAX_DOCUMENTS_PER_REQUEST:
raise NovaPoshtaError( raise NovaPoshtaError(
f"Слишком много ТТН за один запрос: {len(waybill_numbers)} " f"Забагато ТТН за один запит: {len(waybill_numbers)} "
f"(максимум {_MAX_DOCUMENTS_PER_REQUEST})" f"(максимум {_MAX_DOCUMENTS_PER_REQUEST})"
) )
@@ -42,6 +42,6 @@ class NpTrackingClient:
if not data.get("success"): if not data.get("success"):
errors = data.get("errors") or [] errors = data.get("errors") or []
message = "; ".join(str(error) for error in errors) message = "; ".join(str(error) for error in errors)
raise NovaPoshtaError(f"NP вернул ошибку: {message or 'неизвестная ошибка'}") raise NovaPoshtaError(f"NP повернув помилку: {message or 'невідома помилка'}")
return [TrackingStatusOut.model_validate(item) for item in data.get("data", [])] return [TrackingStatusOut.model_validate(item) for item in data.get("data", [])]
+4 -4
View File
@@ -216,7 +216,7 @@ async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
""" """
accounts = await _np_accounts(session) accounts = await _np_accounts(session)
if not accounts: if not accounts:
log.warning("np_no_api_keys", hint="Укажите ключ API Новой Почты у кассы") log.warning("np_no_api_keys", hint="Вкажіть ключ API Нової Пошти в касі")
return return
orders = await session.scalars( orders = await session.scalars(
@@ -327,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)) gross = int((price * good.quantity).to_integral_value(ROUND_HALF_UP))
net = gross - discount net = gross - discount
if net < 0: if net < 0:
raise OrderEditError(f"Товар «{good.name}»: скидка больше суммы строки") raise OrderEditError(f"Товар «{good.name}»: знижка більша за суму рядка")
goods.append( goods.append(
{ {
"id": good.id or f"new-{uuid.uuid4().hex[:8]}", "id": good.id or f"new-{uuid.uuid4().hex[:8]}",
@@ -357,13 +357,13 @@ async def update_order(
if order is None or order.is_deleted: if order is None or order.is_deleted:
return None return None
if order.receipt_created_at is not None: if order.receipt_created_at is not None:
raise OrderEditError("По заказу уже создан чек — редактирование недоступно") raise OrderEditError("За замовленням уже створено чек — редагування недоступне")
goods, goods_total = _build_goods(data) goods, goods_total = _build_goods(data)
total = _to_kopecks(str(data.total_amount)) total = _to_kopecks(str(data.total_amount))
if total > goods_total: if total > goods_total:
raise OrderEditError( raise OrderEditError(
f"Сумма заказа {total / 100:.2f} ₴ больше суммы товаров {goods_total / 100:.2f} ₴" f"Сума замовлення {total / 100:.2f} ₴ більша за суму товарів {goods_total / 100:.2f} ₴"
) )
changed = [ changed = [
+19 -19
View File
@@ -133,7 +133,7 @@ def build_goods(order: Order, tax_codes: list[Any]) -> tuple[list[dict[str, Any]
price = to_kopecks(good["price"]) price = to_kopecks(good["price"])
quantity = to_thousandths(good["quantity"]) quantity = to_thousandths(good["quantity"])
if quantity <= 0: if quantity <= 0:
raise ReceiptValidationError(f"Товар «{good['name']}»: количество должно быть больше 0") raise ReceiptValidationError(f"Товар «{good['name']}»: кількість має бути більшою за 0")
gross = _line_sum(price, quantity) gross = _line_sum(price, quantity)
# `amount` — сумма строки после скидки CRM; скидку выводим из неё, # `amount` — сумма строки после скидки CRM; скидку выводим из неё,
# а не из discount_amount/percent, чтобы не расходиться с итогом CRM. # а не из discount_amount/percent, чтобы не расходиться с итогом CRM.
@@ -141,7 +141,7 @@ def build_goods(order: Order, tax_codes: list[Any]) -> tuple[list[dict[str, Any]
discount = gross - net discount = gross - net
if discount < 0: if discount < 0:
raise ReceiptValidationError( raise ReceiptValidationError(
f"Товар «{good['name']}»: сумма строки больше цены × количество" f"Товар «{good['name']}»: сума рядка більша за ціну × кількість"
) )
payload: dict[str, Any] = { payload: dict[str, Any] = {
@@ -168,7 +168,7 @@ def build_ettn_body(
order_discount = goods_total - amounts.total_kopecks order_discount = goods_total - amounts.total_kopecks
if order_discount < 0: if order_discount < 0:
raise ReceiptValidationError( raise ReceiptValidationError(
"Сумма товаров меньше суммы заказа — проверьте заказ в CRM" "Сума товарів менша за суму замовлення — перевірте замовлення в CRM"
) )
# Предоплата и скидка заказа — одной обычной скидкой «Знижка», как в чеках из # Предоплата и скидка заказа — одной обычной скидкой «Знижка», как в чеках из
# портала Checkbox. Тип `PRE_PAYMENT` для ЕТТН не годится: Checkbox отвечает # портала Checkbox. Тип `PRE_PAYMENT` для ЕТТН не годится: Checkbox отвечает
@@ -185,7 +185,7 @@ def build_ettn_body(
receipt_total = goods_total - order_discount - amounts.prepayment_kopecks receipt_total = goods_total - order_discount - amounts.prepayment_kopecks
if receipt_total != amounts.cod_kopecks: if receipt_total != amounts.cod_kopecks:
raise ReceiptValidationError( 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] = { receipt_body: dict[str, Any] = {
@@ -222,30 +222,30 @@ def resolve_amounts(order: Order, prepayment_kopecks: int | None) -> ReceiptAmou
`prepayment_kopecks=None` — взять разницу между суммой заказа и наложкой. `prepayment_kopecks=None` — взять разницу между суммой заказа и наложкой.
""" """
if order.is_deleted: if order.is_deleted:
raise ReceiptValidationError("Заказ удалён") raise ReceiptValidationError("Замовлення видалено")
if not order.waybill_number: if not order.waybill_number:
raise ReceiptValidationError("У заказа нет ТТН") raise ReceiptValidationError("У замовлення немає ТТН")
if not order.np_cod_amount_kopecks: if not order.np_cod_amount_kopecks:
raise ReceiptValidationError("По ТТН нет суммы контроля оплаты (наложки)") raise ReceiptValidationError("За ТТН немає суми контролю оплати (післяплати)")
if order.np_status_code in NP_FINAL_STATUS_CODES: if order.np_status_code in NP_FINAL_STATUS_CODES:
raise ReceiptValidationError( raise ReceiptValidationError(
f"Посылка уже не в пути ({order.np_status or order.np_status_code}) — " f"Посилка вже не в дорозі ({order.np_status or order.np_status_code}) — "
"ЕТТН-чек создать нельзя" "ЕТТН-чек створити неможливо"
) )
if not order.goods: if not order.goods:
raise ReceiptValidationError("В заказе нет товаров") raise ReceiptValidationError("У замовленні немає товарів")
total = order.total_amount_kopecks total = order.total_amount_kopecks
cod = order.np_cod_amount_kopecks cod = order.np_cod_amount_kopecks
prepayment = total - cod if prepayment_kopecks is None else prepayment_kopecks prepayment = total - cod if prepayment_kopecks is None else prepayment_kopecks
if prepayment < 0: if prepayment < 0:
raise ReceiptValidationError( raise ReceiptValidationError(
f"Наложка {cod / 100:.2f} ₴ больше суммы заказа {total / 100:.2f} ₴" f"Післяплата {cod / 100:.2f} ₴ більша за суму замовлення {total / 100:.2f} ₴"
) )
if total - prepayment != cod: if total - prepayment != cod:
raise ReceiptValidationError( raise ReceiptValidationError(
f"Сумма заказа {total / 100:.2f} ₴ − предоплата {prepayment / 100:.2f} ₴ " f"Сума замовлення {total / 100:.2f} ₴ − передоплата {prepayment / 100:.2f} ₴ "
f"≠ наложка {cod / 100:.2f} ₴" f"≠ післяплата {cod / 100:.2f} ₴"
) )
return ReceiptAmounts(total_kopecks=total, prepayment_kopecks=prepayment, cod_kopecks=cod) return ReceiptAmounts(total_kopecks=total, prepayment_kopecks=prepayment, cod_kopecks=cod)
@@ -306,7 +306,7 @@ async def request_receipts(
registers = await _active_registers(session) registers = await _active_registers(session)
if not registers: if not registers:
for order_id, _ in items: for order_id, _ in items:
result.errors[order_id] = "Не настроена касса Checkbox" result.errors[order_id] = "Не налаштовано касу Checkbox"
return result return result
order_ids = [order_id for order_id, _ in items] order_ids = [order_id for order_id, _ in items]
@@ -319,16 +319,16 @@ async def request_receipts(
for order_id, prepayment in items: for order_id, prepayment in items:
order = orders.get(order_id) order = orders.get(order_id)
if order is None: if order is None:
result.errors[order_id] = "Заказ не найден" result.errors[order_id] = "Замовлення не знайдено"
continue continue
if order_id in busy: if order_id in busy:
result.errors[order_id] = "По заказу уже есть чек" result.errors[order_id] = "За замовленням уже є чек"
continue continue
register = registers.get(order.cash_register_id) if order.cash_register_id else None register = registers.get(order.cash_register_id) if order.cash_register_id else None
if register is None: if register is None:
result.errors[order_id] = ( result.errors[order_id] = (
"Касса не определена: ТТН не найдена ни одним ключом Новой Почты " "Касу не визначено: ТТН не знайдено жодним ключем Нової Пошти "
"активных касс" "активних кас"
) )
continue continue
try: try:
@@ -485,7 +485,7 @@ async def cancel_receipt(
if receipt is None: if receipt is None:
return None return None
if receipt.status not in (ReceiptStatus.CREATED, ReceiptStatus.RECEIPT_ERROR): 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: if receipt.checkbox_ettn_id:
register = await session.get(CashRegister, receipt.cash_register_id) register = await session.get(CashRegister, receipt.cash_register_id)
+2 -2
View File
@@ -20,7 +20,7 @@ from app.core.logging import configure_logging, get_logger
from app.db.session import SessionFactory from app.db.session import SessionFactory
from app.services import receipts as receipts_service from app.services import receipts as receipts_service
from app.services.checkbox.client import CheckboxRateLimitedError, get_checkbox_client 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.nova_poshta.np_client import NpTrackingClient
from app.services.orders import sync_np_statuses from app.services.orders import sync_np_statuses
@@ -35,7 +35,7 @@ async def startup(ctx: dict[str, Any]) -> None:
configure_logging() configure_logging()
ctx["np_client"] = NpTrackingClient() ctx["np_client"] = NpTrackingClient()
ctx["checkbox_client"] = get_checkbox_client() ctx["checkbox_client"] = get_checkbox_client()
ctx["crm_client"] = ExoCrmClient(settings) ctx["crm_client"] = get_crm_client()
log.info("worker_starting", environment=settings.environment) log.info("worker_starting", environment=settings.environment)
+37
View File
@@ -0,0 +1,37 @@
"""Выбор реализации CRM-клиента по `CRM_USE_STUB`."""
from __future__ import annotations
from collections.abc import Iterator
import pytest
from app.core.config import settings
from app.services.crm.client import get_crm_client
from app.services.crm.exo_client import ExoCrmClient
from app.services.crm.stub_client import StubCrmClient
@pytest.fixture(autouse=True)
def _fresh_factory() -> Iterator[None]:
get_crm_client.cache_clear()
yield
get_crm_client.cache_clear()
def test_real_client_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "crm_use_stub", False)
assert isinstance(get_crm_client(), ExoCrmClient)
def test_stub_when_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "crm_use_stub", True)
monkeypatch.setattr(settings, "environment", "local")
assert isinstance(get_crm_client(), StubCrmClient)
def test_stub_forbidden_in_production(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "crm_use_stub", True)
monkeypatch.setattr(settings, "environment", "production")
with pytest.raises(RuntimeError, match="CRM_USE_STUB"):
get_crm_client()
+1 -1
View File
@@ -110,7 +110,7 @@ class TestNpTrackingClientGetStatuses:
async def test_rejects_too_many_documents(self) -> None: async def test_rejects_too_many_documents(self) -> None:
client = NpTrackingClient() client = NpTrackingClient()
with pytest.raises(NovaPoshtaError, match="Слишком много"): with pytest.raises(NovaPoshtaError, match="Забагато"):
await client.get_statuses( await client.get_statuses(
api_key=API_KEY, waybill_numbers=[str(i) for i in range(101)] api_key=API_KEY, waybill_numbers=[str(i) for i in range(101)]
) )
+1 -1
View File
@@ -88,7 +88,7 @@ def _patch_orders_service(monkeypatch: pytest.MonkeyPatch) -> None:
session: object, order_id: str, data: object session: object, order_id: str, data: object
) -> tuple[Order, list[str]] | None: ) -> tuple[Order, list[str]] | None:
if order_id == "locked": if order_id == "locked":
raise orders_router.orders_service.OrderEditError("По заказу уже создан чек") raise orders_router.orders_service.OrderEditError("За замовленням уже створено чек")
if order_id != "1": if order_id != "1":
return None return None
order = _order(order_id) order = _order(order_id)
+3 -3
View File
@@ -140,16 +140,16 @@ class TestUpdateOrder:
assert order.goods == [stored] assert order.goods == [stored]
def test_total_above_goods_sum_is_rejected(self) -> None: 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")) _update(_order(), _payload(total_amount="1200.00"))
def test_discount_above_line_sum_is_rejected(self) -> None: def test_discount_above_line_sum_is_rejected(self) -> None:
good = {"name": "Товар", "price": "10.00", "quantity": "1", "discount_amount": "11"} 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")) _update(_order(), _payload(goods=[good], total_amount="0"))
def test_order_with_receipt_is_locked(self) -> None: 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()) _update(_order(receipt_created_at=datetime.now(UTC)), _payload())
def test_missing_or_deleted_order_returns_none(self) -> None: def test_missing_or_deleted_order_returns_none(self) -> None:
+4 -2
View File
@@ -45,7 +45,9 @@ def queue(monkeypatch: pytest.MonkeyPatch) -> FakeQueue:
) )
async def fake_request(session: Any, items: Any, *, user: Any, request: Any) -> Any: 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: async def fake_commit(self: AsyncSession) -> None:
return None return None
@@ -74,5 +76,5 @@ def test_cashier_creates_and_enqueues(queue: FakeQueue) -> None:
assert response.status_code == 202 assert response.status_code == 202
body = response.json() body = response.json()
assert [r["order_id"] for r in body["created"]] == ["1"] 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"],))] assert queue.jobs == [("create_ettn_receipt", (body["created"][0]["id"],))]
+8 -8
View File
@@ -77,17 +77,17 @@ class TestResolveAmounts:
assert amounts.cod_kopecks == 100000 assert amounts.cod_kopecks == 100000
def test_explicit_prepayment_must_match_cod(self) -> None: 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) svc.resolve_amounts(_order(np_cod_amount_kopecks=100000), 10000)
@pytest.mark.parametrize( @pytest.mark.parametrize(
("overrides", "message"), ("overrides", "message"),
[ [
({"waybill_number": None}, "нет ТТН"), ({"waybill_number": None}, "немає ТТН"),
({"np_cod_amount_kopecks": None}, "контроля оплаты"), ({"np_cod_amount_kopecks": None}, "контролю оплати"),
({"np_status_code": "9"}, "не в пути"), ({"np_status_code": "9"}, "не в дорозі"),
({"np_cod_amount_kopecks": 130000}, "больше суммы заказа"), ({"np_cod_amount_kopecks": 130000}, "більша за суму замовлення"),
({"is_deleted": True}, "удалён"), ({"is_deleted": True}, "видалено"),
], ],
) )
def test_rejects(self, overrides: dict[str, Any], message: str) -> None: def test_rejects(self, overrides: dict[str, Any], message: str) -> None:
@@ -226,7 +226,7 @@ class TestRequestReceipts:
) )
assert result.created == [] assert result.created == []
assert "Касса не определена" in result.errors[order.id] assert "Касу не визначено" in result.errors[order.id]
class FakeSession: class FakeSession:
@@ -289,7 +289,7 @@ class TestCreateEttn:
await svc.create_ettn_for_receipt(FakeSession(order, register, receipt), client, receipt.id) await svc.create_ettn_for_receipt(FakeSession(order, register, receipt), client, receipt.id)
assert receipt.status == ReceiptStatus.FAILED assert receipt.status == ReceiptStatus.FAILED
assert "уже привязана" in (receipt.error or "") assert "вже прив'язана" in (receipt.error or "")
assert order.receipt_created_at is None assert order.receipt_created_at is None
async def test_unavailable_keeps_pending_then_reconciles(self) -> None: async def test_unavailable_keeps_pending_then_reconciles(self) -> None:
+21
View File
@@ -0,0 +1,21 @@
# Прод-оверлей: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
#
# TLS и публичный домен держит Nginx Proxy Manager сервера (внешняя сеть `web`).
# Наружу торчит только frontend — и то лишь в сеть `web`, без портов на хосте;
# api/postgres/redis доступны исключительно внутри сети compose.
services:
api:
ports: !reset []
frontend:
ports: !reset []
networks:
default:
web:
# Имя, по которому NPM проксирует: Forward Hostname = lux-fiscal-frontend, порт 80.
aliases: [lux-fiscal-frontend]
networks:
web:
external: true
+4
View File
@@ -35,6 +35,8 @@ services:
# Миграции выполняются отдельным одноразовым контейнером, а не при старте api. # Миграции выполняются отдельным одноразовым контейнером, а не при старте api.
# Иначе при нескольких репликах api они пошли бы параллельно. # Иначе при нескольких репликах api они пошли бы параллельно.
migrate: migrate:
# Один образ на migrate/api/worker, а не три одинаковых копии на диске.
image: lux-fiscal-backend:latest
build: build:
context: ./backend context: ./backend
env_file: .env env_file: .env
@@ -48,6 +50,7 @@ services:
restart: "no" restart: "no"
api: api:
image: lux-fiscal-backend:latest
build: build:
context: ./backend context: ./backend
restart: unless-stopped restart: unless-stopped
@@ -68,6 +71,7 @@ services:
- receipt_storage:/app/storage - receipt_storage:/app/storage
worker: worker:
image: lux-fiscal-backend:latest
build: build:
context: ./backend context: ./backend
restart: unless-stopped restart: unless-stopped
+8 -2
View File
@@ -1,10 +1,16 @@
<!doctype html> <!doctype html>
<html lang="ru"> <html lang="uk">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>lux_fiscal</title> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Unbounded:wght@500;600&display=swap"
/>
<title>Assistant System</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+5 -5
View File
@@ -27,10 +27,10 @@ async function readErrorMessage(response: Response): Promise<string> {
// Тело не JSON или пустое — используем сообщение по статусу ниже. // Тело не JSON или пустое — используем сообщение по статусу ниже.
} }
if (response.status === 401) return 'Требуется вход в систему' if (response.status === 401) return 'Потрібно увійти в систему'
if (response.status === 403) return 'Недостаточно прав для этого действия' if (response.status === 403) return 'Недостатньо прав для цієї дії'
if (response.status >= 500) return 'Сервер временно недоступен, попробуйте позже' if (response.status >= 500) return 'Сервер тимчасово недоступний, спробуйте пізніше'
return `Ошибка запроса (${response.status})` return `Помилка запиту (${response.status})`
} }
// Параллельные 401 не должны порождать несколько запросов на refresh — // Параллельные 401 не должны порождать несколько запросов на refresh —
@@ -42,7 +42,7 @@ async function refreshAccessToken(): Promise<string> {
const refreshToken = getRefreshToken() const refreshToken = getRefreshToken()
if (!refreshToken) { if (!refreshToken) {
throw new ApiError(401, 'Сессия истекла, войдите снова') throw new ApiError(401, 'Сесія закінчилася, увійдіть знову')
} }
refreshPromise = (async () => { refreshPromise = (async () => {
+9 -2
View File
@@ -2,8 +2,10 @@ import { Navigate, Route, Routes } from 'react-router-dom'
import { LoginPage } from '@/features/auth/LoginPage' import { LoginPage } from '@/features/auth/LoginPage'
import { ProtectedRoute } from '@/features/auth/ProtectedRoute' import { ProtectedRoute } from '@/features/auth/ProtectedRoute'
import { ModuleRoute } from '@/features/menu/ModuleRoute'
import { CashRegistersPage } from '@/pages/CashRegistersPage' import { CashRegistersPage } from '@/pages/CashRegistersPage'
import { DashboardPage } from '@/pages/DashboardPage' import { DashboardPage } from '@/pages/DashboardPage'
import { MainMenuPage } from '@/pages/MainMenuPage'
export function AppRoutes() { export function AppRoutes() {
return ( return (
@@ -11,8 +13,13 @@ export function AppRoutes() {
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route element={<ProtectedRoute />}> <Route element={<ProtectedRoute />}>
<Route path="/" element={<DashboardPage />} /> <Route path="/" element={<MainMenuPage />} />
<Route path="/cash-registers" element={<CashRegistersPage />} /> <Route element={<ModuleRoute moduleId="receipts" />}>
<Route path="/receipts" element={<DashboardPage />} />
</Route>
<Route element={<ModuleRoute moduleId="cash-registers" />}>
<Route path="/cash-registers" element={<CashRegistersPage />} />
</Route>
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
+1 -1
View File
@@ -65,7 +65,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(result.user) setUser(result.user)
setStatus('authenticated') setStatus('authenticated')
} catch (err) { } catch (err) {
const message = err instanceof ApiError ? err.message : 'Не удалось подключиться к серверу' const message = err instanceof ApiError ? err.message : 'Не вдалося підключитися до сервера'
setError(message) setError(message)
throw err throw err
} }
+207 -28
View File
@@ -1,68 +1,230 @@
.login-page { .login-page {
--login-accent: #7c3aed;
--login-glass: rgba(255, 255, 255, 0.72);
--login-glass-border: rgba(255, 255, 255, 0.6);
--login-input-bg: rgba(255, 255, 255, 0.85);
position: relative;
min-height: 100svh; min-height: 100svh;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 24px; padding: 24px 16px;
overflow: hidden;
isolation: isolate;
background:
radial-gradient(1200px 600px at 10% -10%, rgba(37, 99, 235, 0.14), transparent 60%),
radial-gradient(900px 500px at 110% 110%, rgba(124, 58, 237, 0.14), transparent 60%),
var(--color-bg);
}
@media (prefers-color-scheme: dark) {
.login-page {
--login-accent: #a78bfa;
--login-glass: rgba(23, 26, 33, 0.7);
--login-glass-border: rgba(255, 255, 255, 0.08);
--login-input-bg: rgba(15, 17, 21, 0.7);
}
}
/* Декоративные размытые пятна на фоне */
.login-backdrop {
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
}
.login-blob {
position: absolute;
border-radius: 50%;
filter: blur(70px);
opacity: 0.55;
animation: login-float 18s ease-in-out infinite alternate;
}
.login-blob--a {
width: 380px;
height: 380px;
top: -80px;
left: -60px;
background: var(--color-primary);
}
.login-blob--b {
width: 320px;
height: 320px;
bottom: -90px;
right: -40px;
background: var(--login-accent);
animation-delay: -6s;
}
.login-blob--c {
width: 220px;
height: 220px;
top: 55%;
left: 60%;
background: #06b6d4;
opacity: 0.3;
animation-delay: -12s;
}
@keyframes login-float {
from {
transform: translate3d(0, 0, 0) scale(1);
}
to {
transform: translate3d(40px, 30px, 0) scale(1.1);
}
}
@media (prefers-reduced-motion: reduce) {
.login-blob {
animation: none;
}
} }
.login-card { .login-card {
width: 100%; width: 100%;
max-width: 380px; max-width: 400px;
background: var(--color-surface); background: var(--login-glass);
border: 1px solid var(--color-border); border: 1px solid var(--login-glass-border);
border-radius: 12px; border-radius: 20px;
box-shadow: var(--shadow-card); box-shadow:
padding: 32px; 0 20px 50px -12px rgba(16, 24, 40, 0.25),
0 1px 2px rgba(16, 24, 40, 0.06);
backdrop-filter: blur(18px) saturate(140%);
-webkit-backdrop-filter: blur(18px) saturate(140%);
padding: 40px 32px 32px;
animation: login-enter 0.45s cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
@keyframes login-enter {
from {
opacity: 0;
transform: translateY(12px) scale(0.98);
}
}
@media (max-width: 420px) {
.login-card {
padding: 32px 20px 24px;
border-radius: 16px;
}
} }
.login-header { .login-header {
margin-bottom: 24px; margin-bottom: 28px;
text-align: center; text-align: center;
} }
.login-logo {
width: 56px;
height: 56px;
margin: 0 auto 16px;
display: grid;
place-items: center;
border-radius: 16px;
color: #fff;
background: linear-gradient(135deg, var(--color-primary), var(--login-accent));
box-shadow: 0 10px 24px -8px var(--color-primary);
}
.login-header h1 { .login-header h1 {
font-size: 22px; font-size: 26px;
margin-bottom: 4px; font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 6px;
background: linear-gradient(135deg, var(--color-text) 30%, var(--color-primary));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
} }
.login-header p { .login-header p {
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 14px; font-size: 15px;
} }
.login-field { .login-field {
margin-bottom: 16px; margin-bottom: 18px;
} }
.login-field label { .login-field label {
display: block; display: block;
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 600;
margin-bottom: 6px; margin-bottom: 8px;
color: var(--color-text); color: var(--color-text);
} }
.login-field input { .login-field input {
width: 100%; width: 100%;
padding: 10px 12px; height: 46px;
padding: 0 14px;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 8px; border-radius: 12px;
background: var(--color-bg); background: var(--login-input-bg);
color: var(--color-text); color: var(--color-text);
font-size: 14px; font-size: 15px;
outline: none; outline: none;
transition: border-color 0.15s ease; transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.login-field input::placeholder {
color: var(--color-text-muted);
opacity: 0.7;
} }
.login-field input:focus { .login-field input:focus {
border-color: var(--color-primary); border-color: var(--color-primary);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-primary) 18%, transparent);
} }
.login-field input:disabled { .login-field input:disabled {
opacity: 0.6; opacity: 0.6;
} }
.login-password {
position: relative;
}
.login-password input {
padding-right: 46px;
}
.login-toggle {
position: absolute;
top: 50%;
right: 6px;
transform: translateY(-50%);
width: 34px;
height: 34px;
display: grid;
place-items: center;
border: none;
border-radius: 8px;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
transition:
color 0.15s ease,
background-color 0.15s ease;
}
.login-toggle:hover {
color: var(--color-text);
background: color-mix(in srgb, var(--color-text) 8%, transparent);
}
.login-toggle:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
.login-error { .login-error {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -70,23 +232,29 @@
background: var(--color-danger-bg); background: var(--color-danger-bg);
border: 1px solid var(--color-danger-border); border: 1px solid var(--color-danger-border);
color: var(--color-danger); color: var(--color-danger);
border-radius: 8px; border-radius: 12px;
padding: 10px 12px; padding: 10px 14px;
font-size: 13px; font-size: 13px;
margin-bottom: 16px; margin-bottom: 18px;
} }
.login-submit { .login-submit {
width: 100%; width: 100%;
padding: 11px 16px; height: 48px;
margin-top: 8px;
padding: 0 16px;
border: none; border: none;
border-radius: 8px; border-radius: 12px;
background: var(--color-primary); background: linear-gradient(135deg, var(--color-primary), var(--login-accent));
color: #fff; color: #fff;
font-size: 14px; font-size: 15px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: background-color 0.15s ease; box-shadow: 0 10px 24px -10px var(--color-primary);
transition:
transform 0.15s ease,
box-shadow 0.15s ease,
filter 0.15s ease;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -94,7 +262,18 @@
} }
.login-submit:hover:not(:disabled) { .login-submit:hover:not(:disabled) {
background: var(--color-primary-hover); transform: translateY(-1px);
filter: brightness(1.06);
box-shadow: 0 14px 28px -10px var(--color-primary);
}
.login-submit:active:not(:disabled) {
transform: translateY(0);
}
.login-submit:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
} }
.login-submit:disabled { .login-submit:disabled {
+42 -14
View File
@@ -19,6 +19,7 @@ export function LoginPage() {
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [formError, setFormError] = useState<string | null>(null) const [formError, setFormError] = useState<string | null>(null)
@@ -36,7 +37,7 @@ export function LoginPage() {
try { try {
await login(email, password) await login(email, password)
} catch (err) { } catch (err) {
const message = err instanceof ApiError ? err.message : 'Не удалось подключиться к серверу' const message = err instanceof ApiError ? err.message : 'Не вдалося підключитися до сервера'
setFormError(message) setFormError(message)
} finally { } finally {
setSubmitting(false) setSubmitting(false)
@@ -45,10 +46,22 @@ export function LoginPage() {
return ( return (
<div className="login-page"> <div className="login-page">
<div className="login-backdrop" aria-hidden="true">
<span className="login-blob login-blob--a" />
<span className="login-blob login-blob--b" />
<span className="login-blob login-blob--c" />
</div>
<div className="login-card"> <div className="login-card">
<div className="login-header"> <div className="login-header">
<h1>lux_fiscal</h1> <div className="login-logo" aria-hidden="true">
<p>Вход в систему фискализации заказов</p> <svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3l7 4v5c0 4.5-3 8-7 9-4-1-7-4.5-7-9V7l7-4z" />
<path d="M9 12l2 2 4-4" />
</svg>
</div>
<h1>Assistant System</h1>
<p>Вхід у систему</p>
</div> </div>
<form onSubmit={handleSubmit} noValidate> <form onSubmit={handleSubmit} noValidate>
@@ -74,21 +87,36 @@ export function LoginPage() {
<div className="login-field"> <div className="login-field">
<label htmlFor={passwordId}>Пароль</label> <label htmlFor={passwordId}>Пароль</label>
<input <div className="login-password">
id={passwordId} <input
type="password" id={passwordId}
autoComplete="current-password" type={showPassword ? 'text' : 'password'}
required autoComplete="current-password"
value={password} required
disabled={submitting} value={password}
onChange={(e) => setPassword(e.target.value)} disabled={submitting}
placeholder="••••••••" 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> </div>
<button type="submit" className="login-submit" disabled={submitting}> <button type="submit" className="login-submit" disabled={submitting}>
{submitting && <span className="spinner" aria-hidden="true" />} {submitting && <span className="spinner" aria-hidden="true" />}
{submitting ? 'Входим…' : 'Войти'} {submitting ? 'Входимо…' : 'Увійти'}
</button> </button>
</form> </form>
</div> </div>
@@ -10,7 +10,7 @@ export function ProtectedRoute() {
if (status === 'loading') { if (status === 'loading') {
return ( return (
<div className="page-center"> <div className="page-center">
<span className="spinner" aria-label="Загрузка" /> <span className="spinner" aria-label="Завантаження" />
</div> </div>
) )
} }
+7
View File
@@ -0,0 +1,7 @@
import type { UserRole } from '@/api/types'
export const ROLE_LABEL: Record<UserRole, string> = {
admin: 'Адміністратор',
cashier: 'Касир',
viewer: 'Спостерігач',
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { AuthContext } from '@/features/auth/AuthContext'
export function useAuth() { export function useAuth() {
const ctx = useContext(AuthContext) const ctx = useContext(AuthContext)
if (!ctx) { if (!ctx) {
throw new Error('useAuth должен вызываться внутри <AuthProvider>') throw new Error('useAuth має викликатися всередині <AuthProvider>')
} }
return ctx return ctx
} }
@@ -0,0 +1,11 @@
import { Navigate, Outlet } from 'react-router-dom'
import { useAuth } from '@/features/auth/useAuth'
import { canAccess, getModule } from '@/features/menu/modules'
/** Пускает в модуль только роли из реестра; остальных возвращает в главное меню. */
export function ModuleRoute({ moduleId }: { moduleId: string }) {
const { user } = useAuth()
if (!canAccess(user, getModule(moduleId).roles)) return <Navigate to="/" replace />
return <Outlet />
}
+46
View File
@@ -0,0 +1,46 @@
import type { User, UserRole } from '@/api/types'
/**
* Реестр модулей главного меню. Единственное место, где модуль связывается
* с путём и ролями: по нему рисуется меню и защищается роут модуля.
*/
export interface AppModule {
id: string
title: string
description: string
path: string
/** Роли, которым модуль доступен; администратор видит всё и в списке не нужен. */
roles: readonly UserRole[]
/** `d` для stroke-иконки 24×24. */
icon: string
}
export const MODULES: readonly AppModule[] = [
{
id: 'receipts',
title: 'Чеки',
description: 'Замовлення з CRM, ЕТТН-чеки Checkbox і статуси Нової Пошти',
path: '/receipts',
roles: ['cashier', 'viewer'],
icon: 'M6 3h12v18l-3-2-3 2-3-2-3 2z M9 8h6 M9 12h6 M9 16h3',
},
{
id: 'cash-registers',
title: 'Каси',
description: 'Каси Checkbox, ліцензійні ключі та ключі Нової Пошти',
path: '/cash-registers',
roles: [],
icon: 'M4 10h16v10H4z M7 10V4h10v6 M8 14h2 M14 14h2 M8 17h2 M14 17h2',
},
]
export function canAccess(user: User | null | undefined, roles: readonly UserRole[]): boolean {
if (!user) return false
return user.role === 'admin' || roles.includes(user.role)
}
export function getModule(id: string): AppModule {
const module = MODULES.find((m) => m.id === id)
if (!module) throw new Error(`Невідомий модуль: ${id}`)
return module
}
@@ -96,18 +96,18 @@ function orderDiscountOf(draft: Draft): number {
/** Проверяет черновик и собирает тело PATCH; строка — текст ошибки для кассира. */ /** Проверяет черновик и собирает тело PATCH; строка — текст ошибки для кассира. */
function toPayload(draft: Draft): OrderUpdate | string { function toPayload(draft: Draft): OrderUpdate | string {
if (draft.goods.length === 0) return 'Добавьте хотя бы один товар' if (draft.goods.length === 0) return 'Додайте хоча б один товар'
const goods = [] const goods = []
for (const good of draft.goods) { for (const good of draft.goods) {
const label = good.name.trim() || 'без названия' const label = good.name.trim() || 'без назви'
if (!good.name.trim()) return 'У товара не указано наименование' if (!good.name.trim()) return 'У товару не вказано найменування'
const price = toKopecks(good.price) const price = toKopecks(good.price)
const quantity = toThousandths(good.quantity) const quantity = toThousandths(good.quantity)
const discount = toKopecks(good.discount) const discount = toKopecks(good.discount)
if (price === null) return `Товар «${label}»: некорректная цена` if (price === null) return `Товар «${label}»: некоректна ціна`
if (quantity === null || quantity <= 0) return `Товар «${label}»: некорректное количество` if (quantity === null || quantity <= 0) return `Товар «${label}»: некоректна кількість`
if (discount === null) return `Товар «${label}»: некорректная скидка` if (discount === null) return `Товар «${label}»: некоректна знижка`
if ((lineAmount(good) ?? 0) < 0) return `Товар «${label}»: скидка больше суммы строки` if ((lineAmount(good) ?? 0) < 0) return `Товар «${label}»: знижка більша за суму рядка`
goods.push({ goods.push({
id: good.id, id: good.id,
sku: good.sku.trim(), sku: good.sku.trim(),
@@ -118,9 +118,9 @@ function toPayload(draft: Draft): OrderUpdate | string {
}) })
} }
const total = toKopecks(draft.total) const total = toKopecks(draft.total)
if (total === null) return 'Некорректная сумма заказа' if (total === null) return 'Некоректна сума замовлення'
const sum = goodsSum(draft.goods) ?? 0 const sum = goodsSum(draft.goods) ?? 0
if (total > sum) return `Сумма заказа больше суммы товаров (${formatKopecks(sum)} ₴)` if (total > sum) return `Сума замовлення більша за суму товарів (${formatKopecks(sum)} ₴)`
return { return {
recipient_name: draft.recipient_name, recipient_name: draft.recipient_name,
@@ -134,7 +134,7 @@ function toPayload(draft: Draft): OrderUpdate | string {
} }
const TEXT_FIELDS: { field: TextField; label: string; type?: 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_phone', label: 'Телефон', type: 'tel' },
{ field: 'recipient_email', label: 'Email', type: 'email' }, { field: 'recipient_email', label: 'Email', type: 'email' },
{ field: 'waybill_number', label: 'Номер ТТН' }, { field: 'waybill_number', label: 'Номер ТТН' },
@@ -153,7 +153,7 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
const editable = canEdit && !order.has_receipt const editable = canEdit && !order.has_receipt
function requestClose() { function requestClose() {
if (dirty && !window.confirm('Есть несохранённые изменения. Закрыть без сохранения?')) return if (dirty && !window.confirm('Є незбережені зміни. Закрити без збереження?')) return
onClose() onClose()
} }
@@ -203,10 +203,10 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
setDraft(next) setDraft(next)
setSaved(next) setSaved(next)
setOrderDiscount(orderDiscountOf(next)) setOrderDiscount(orderDiscountOf(next))
setMessage({ tone: 'ok', text: 'Изменения сохранены' }) setMessage({ tone: 'ok', text: 'Зміни збережено' })
await queryClient.invalidateQueries({ queryKey: ['orders'] }) await queryClient.invalidateQueries({ queryKey: ['orders'] })
} catch (err) { } catch (err) {
setMessage({ tone: 'error', text: err instanceof Error ? err.message : 'Не удалось сохранить заказ' }) setMessage({ tone: 'error', text: err instanceof Error ? err.message : 'Не вдалося зберегти замовлення' })
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -217,10 +217,10 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
<div className="order-modal"> <div className="order-modal">
<div className="order-modal-header"> <div className="order-modal-header">
<h3> <h3>
Заказ {order.id} Замовлення {order.id}
{order.edited && <span className="order-modal-edited">изменён вручную</span>} {order.edited && <span className="order-modal-edited">змінено вручну</span>}
</h3> </h3>
<button type="button" className="order-modal-close" onClick={requestClose} aria-label="Закрыть"> <button type="button" className="order-modal-close" onClick={requestClose} aria-label="Закрити">
× ×
</button> </button>
</div> </div>
@@ -232,19 +232,19 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
</div> </div>
<div> <div>
<span className="order-modal-label">Статус ТТН</span> <span className="order-modal-label">Статус ТТН</span>
<span>{order.np_status || 'Нет данных'}</span> <span>{order.np_status || 'Немає даних'}</span>
</div> </div>
<div> <div>
<span className="order-modal-label">Наложенный платёж</span> <span className="order-modal-label">Накладений платіж</span>
<span>{order.np_cod_amount ? `${order.np_cod_amount} ₴` : '—'}</span> <span>{order.np_cod_amount ? `${order.np_cod_amount} ₴` : '—'}</span>
</div> </div>
<div> <div>
<span className="order-modal-label">Оплачено</span> <span className="order-modal-label">Оплачено</span>
<span> <span>
{order.np_payment_status === 'Payed' {order.np_payment_status === 'Payed'
? 'Да' ? 'Так'
: order.np_payment_status === 'NeedPayment' : order.np_payment_status === 'NeedPayment'
? 'Нет' ? 'Ні'
: '—'} : '—'}
</span> </span>
</div> </div>
@@ -272,7 +272,7 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
{editable ? ( {editable ? (
<label className="order-modal-notes"> <label className="order-modal-notes">
<span className="order-modal-label">Заметки</span> <span className="order-modal-label">Нотатки</span>
<textarea <textarea
className="order-modal-input" className="order-modal-input"
rows={2} rows={2}
@@ -283,7 +283,7 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
) : ( ) : (
draft.notes && ( draft.notes && (
<div className="order-modal-notes"> <div className="order-modal-notes">
<span className="order-modal-label">Заметки</span> <span className="order-modal-label">Нотатки</span>
<p>{draft.notes}</p> <p>{draft.notes}</p>
</div> </div>
) )
@@ -292,13 +292,13 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
<table className="order-modal-goods"> <table className="order-modal-goods">
<thead> <thead>
<tr> <tr>
<th>Наименование</th> <th>Найменування</th>
<th>SKU</th> <th>SKU</th>
<th>Цена</th> <th>Ціна</th>
<th>Кол-во</th> <th>К-сть</th>
<th>Скидка</th> <th>Знижка</th>
<th>Сумма</th> <th>Сума</th>
{editable && <th aria-label="Удалить" />} {editable && <th aria-label="Видалити" />}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -350,7 +350,7 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
<button <button
type="button" type="button"
className="order-modal-remove" className="order-modal-remove"
aria-label={`Удалить товар ${good.name}`} aria-label={`Видалити товар ${good.name}`}
onClick={() => setGoods((goods) => goods.filter((g) => g.key !== good.key))} 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 && ( {draft.goods.length === 0 && (
<tr> <tr>
<td colSpan={editable ? 7 : 6} className="order-modal-goods-empty"> <td colSpan={editable ? 7 : 6} className="order-modal-goods-empty">
Товары не указаны Товари не вказані
</td> </td>
</tr> </tr>
)} )}
@@ -372,19 +372,19 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
{editable && ( {editable && (
<button type="button" className="order-modal-add" onClick={addGood}> <button type="button" className="order-modal-add" onClick={addGood}>
+ Добавить товар + Додати товар
</button> </button>
)} )}
<div className="order-modal-total"> <div className="order-modal-total">
<span>Итого</span> <span>Разом</span>
{editable ? ( {editable ? (
<span> <span>
<input <input
className="order-modal-input order-modal-input--num" className="order-modal-input order-modal-input--num"
inputMode="decimal" inputMode="decimal"
value={draft.total} value={draft.total}
title="Сумма заказа после всех скидок" title="Сума замовлення після всіх знижок"
onChange={(e) => setTotal(e.target.value)} onChange={(e) => setTotal(e.target.value)}
/>{' '} />{' '}
₴ ₴
@@ -403,12 +403,12 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
</span> </span>
</div> </div>
<div> <div>
<span className="order-modal-label">Предоплата в чеке</span> <span className="order-modal-label">Передоплата в чеку</span>
<span>{order.receipt_prepayment ? `${order.receipt_prepayment} ₴` : '—'}</span> <span>{order.receipt_prepayment ? `${order.receipt_prepayment} ₴` : '—'}</span>
</div> </div>
{order.receipt_error && ( {order.receipt_error && (
<div> <div>
<span className="order-modal-label">Ошибка</span> <span className="order-modal-label">Помилка</span>
<span>{order.receipt_error}</span> <span>{order.receipt_error}</span>
</div> </div>
)} )}
@@ -425,11 +425,11 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
disabled={!dirty || saving} disabled={!dirty || saving}
onClick={() => void handleSave()} onClick={() => void handleSave()}
> >
{saving ? 'Сохранение…' : 'Сохранить'} {saving ? 'Збереження…' : 'Зберегти'}
</button> </button>
)} )}
<button type="button" className="order-modal-close-btn" onClick={requestClose}> <button type="button" className="order-modal-close-btn" onClick={requestClose}>
Закрыть Закрити
</button> </button>
</div> </div>
</div> </div>
+7 -7
View File
@@ -43,13 +43,13 @@ export interface ReceiptCreateResponse {
export type Tone = 'delivered' | 'processing' | 'danger' | 'new' export type Tone = 'delivered' | 'processing' | 'danger' | 'new'
export const RECEIPT_STATUS: Record<ReceiptStatus, { label: string; tone: Tone }> = { export const RECEIPT_STATUS: Record<ReceiptStatus, { label: string; tone: Tone }> = {
pending: { label: 'Отправляется', tone: 'new' }, pending: { label: 'Надсилається', tone: 'new' },
created: { label: 'Ждёт оплаты', tone: 'processing' }, created: { label: 'Очікує оплати', tone: 'processing' },
done: { label: 'Фискализирован', tone: 'delivered' }, done: { label: 'Фіскалізовано', tone: 'delivered' },
returned: { label: 'Возврат посылки', tone: 'danger' }, returned: { label: 'Повернення посилки', tone: 'danger' },
receipt_error: { label: 'Ошибка фискализации', tone: 'danger' }, receipt_error: { label: 'Помилка фіскалізації', tone: 'danger' },
cancelled: { label: 'Отменён', tone: 'new' }, cancelled: { label: 'Скасовано', tone: 'new' },
failed: { label: 'Не создан', tone: 'danger' }, failed: { label: 'Не створено', tone: 'danger' },
} }
/** Статусы, из которых чек можно отменить (см. services/receipts.cancel_receipt). */ /** Статусы, из которых чек можно отменить (см. services/receipts.cancel_receipt). */
+13
View File
@@ -74,6 +74,19 @@ input {
/* --- Общие утилиты, используются в нескольких экранах --- */ /* --- Общие утилиты, используются в нескольких экранах --- */
/* Скрыт визуально, но читается скринридером (подписи к полям без видимого label). */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.page-center { .page-center {
min-height: 100svh; min-height: 100svh;
display: flex; display: flex;
+1 -1
View File
@@ -6,7 +6,7 @@ import '@/index.css'
const rootElement = document.getElementById('root') const rootElement = document.getElementById('root')
if (!rootElement) { if (!rootElement) {
throw new Error('Элемент #root не найден в index.html') throw new Error('Елемент #root не знайдено в index.html')
} }
createRoot(rootElement).render( createRoot(rootElement).render(
+43 -43
View File
@@ -1,7 +1,7 @@
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react' import { useState } from 'react'
import type { FormEvent } from 'react' import type { FormEvent } from 'react'
import { Link, Navigate } from 'react-router-dom' import { Link } from 'react-router-dom'
import { import {
checkCashRegister, checkCashRegister,
@@ -55,8 +55,6 @@ export function CashRegistersPage() {
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [message, setMessage] = useState<{ tone: 'ok' | 'error'; text: string } | null>(null) 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) { async function run(action: () => Promise<unknown>, okText: string) {
setBusy(true) setBusy(true)
try { try {
@@ -64,7 +62,7 @@ export function CashRegistersPage() {
setMessage({ tone: 'ok', text: okText }) setMessage({ tone: 'ok', text: okText })
await queryClient.invalidateQueries({ queryKey: ['cash-registers'] }) await queryClient.invalidateQueries({ queryKey: ['cash-registers'] })
} catch (err) { } catch (err) {
setMessage({ tone: 'error', text: err instanceof Error ? err.message : 'Ошибка запроса' }) setMessage({ tone: 'error', text: err instanceof Error ? err.message : 'Помилка запиту' })
} finally { } finally {
setBusy(false) setBusy(false)
} }
@@ -101,7 +99,7 @@ export function CashRegistersPage() {
if (form.license_key) payload.license_key = form.license_key if (form.license_key) payload.license_key = form.license_key
if (form.pin_code) payload.pin_code = form.pin_code if (form.pin_code) payload.pin_code = form.pin_code
if (form.np_api_key) payload.np_api_key = form.np_api_key if (form.np_api_key) payload.np_api_key = form.np_api_key
await run(() => updateCashRegister(editingId, payload), 'Касса обновлена') await run(() => updateCashRegister(editingId, payload), 'Касу оновлено')
} else { } else {
await run( await run(
() => () =>
@@ -114,7 +112,7 @@ export function CashRegistersPage() {
tax_codes: parseTaxCodes(form.tax_codes), tax_codes: parseTaxCodes(form.tax_codes),
is_default: form.is_default, is_default: form.is_default,
}), }),
'Касса добавлена', 'Касу додано',
) )
} }
resetForm() resetForm()
@@ -123,21 +121,23 @@ export function CashRegistersPage() {
return ( return (
<div className="dashboard-shell"> <div className="dashboard-shell">
<header className="dashboard-topbar"> <header className="dashboard-topbar">
<span className="dashboard-brand">lux_fiscal</span> <div className="dashboard-topbar-start">
<Link to="/" className="orders-link-btn"> <Link to="/" className="orders-link-btn">
← К заказам ← Головне меню
</Link> </Link>
<span className="dashboard-brand">Assistant System</span>
</div>
</header> </header>
<main className="orders-body"> <main className="orders-body">
<div className="orders-toolbar"> <div className="orders-toolbar">
<h2>Кассы Checkbox</h2> <h2>Каси Checkbox</h2>
</div> </div>
{message && ( {message && (
<div className={`orders-notice orders-notice--${message.tone}`}> <div className={`orders-notice orders-notice--${message.tone}`}>
<span>{message.text}</span> <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> </button>
</div> </div>
@@ -147,34 +147,34 @@ export function CashRegistersPage() {
<table className="orders-table"> <table className="orders-table">
<thead> <thead>
<tr> <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>
<th>Действия</th> <th>Дії</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{isLoading && ( {isLoading && (
<tr> <tr>
<td colSpan={7} className="orders-empty"> <td colSpan={7} className="orders-empty">
Загрузка… Завантаження…
</td> </td>
</tr> </tr>
)} )}
{isError && ( {isError && (
<tr> <tr>
<td colSpan={7} className="orders-empty"> <td colSpan={7} className="orders-empty">
Не удалось загрузить кассы Не вдалося завантажити каси
</td> </td>
</tr> </tr>
)} )}
{registers?.length === 0 && ( {registers?.length === 0 && (
<tr> <tr>
<td colSpan={7} className="orders-empty"> <td colSpan={7} className="orders-empty">
Кассы не настроены — чеки создавать нельзя Каси не налаштовані — створювати чеки неможливо
</td> </td>
</tr> </tr>
)} )}
@@ -182,15 +182,15 @@ export function CashRegistersPage() {
<tr key={register.id}> <tr key={register.id}>
<td> <td>
{register.name} {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>
<td>{register.fiscal_number || '—'}</td> <td>{register.fiscal_number || '—'}</td>
<td>{register.license_key_masked}</td> <td>{register.license_key_masked}</td>
<td>{register.np_api_key_masked ?? <span className="cr-missing">не задан</span>}</td> <td>{register.np_api_key_masked ?? <span className="cr-missing">не задано</span>}</td>
<td>{register.tax_codes.length ? register.tax_codes.join(', ') : 'без налога'}</td> <td>{register.tax_codes.length ? register.tax_codes.join(', ') : 'без податку'}</td>
<td> <td>
<span className={`orders-status orders-status--${register.is_active ? 'delivered' : 'new'}`}> <span className={`orders-status orders-status--${register.is_active ? 'delivered' : 'new'}`}>
{register.is_active ? 'Активна' : 'Отключена'} {register.is_active ? 'Активна' : 'Вимкнена'}
</span> </span>
</td> </td>
<td className="orders-actions-cell"> <td className="orders-actions-cell">
@@ -198,12 +198,12 @@ export function CashRegistersPage() {
type="button" type="button"
className="orders-view-btn" className="orders-view-btn"
disabled={busy} disabled={busy}
onClick={() => void run(() => checkCashRegister(register.id), `Вход в «${register.name}» успешен`)} onClick={() => void run(() => checkCashRegister(register.id), `Вхід у «${register.name}» успішний`)}
> >
Проверить Перевірити
</button> </button>
<button type="button" className="orders-view-btn" disabled={busy} onClick={() => startEdit(register)}> <button type="button" className="orders-view-btn" disabled={busy} onClick={() => startEdit(register)}>
Изменить Змінити
</button> </button>
<button <button
type="button" type="button"
@@ -212,11 +212,11 @@ export function CashRegistersPage() {
onClick={() => onClick={() =>
void run( void run(
() => updateCashRegister(register.id, { is_active: !register.is_active }), () => updateCashRegister(register.id, { is_active: !register.is_active }),
register.is_active ? 'Касса отключена' : 'Касса включена', register.is_active ? 'Касу вимкнено' : 'Касу увімкнено',
) )
} }
> >
{register.is_active ? 'Отключить' : 'Включить'} {register.is_active ? 'Вимкнути' : 'Увімкнути'}
</button> </button>
</td> </td>
</tr> </tr>
@@ -226,48 +226,48 @@ export function CashRegistersPage() {
</div> </div>
<form className="cr-form" onSubmit={(e) => void handleSubmit(e)}> <form className="cr-form" onSubmit={(e) => void handleSubmit(e)}>
<h3>{editingId ? 'Изменить кассу' : 'Добавить кассу'}</h3> <h3>{editingId ? 'Змінити касу' : 'Додати касу'}</h3>
<label> <label>
Название Назва
<input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /> <input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</label> </label>
<label> <label>
Фискальный номер Фіскальний номер
<input value={form.fiscal_number} onChange={(e) => setForm({ ...form, fiscal_number: e.target.value })} /> <input value={form.fiscal_number} onChange={(e) => setForm({ ...form, fiscal_number: e.target.value })} />
</label> </label>
<label> <label>
Ключ лицензии кассы Ключ ліцензії каси
<input <input
required={!editingId} required={!editingId}
autoComplete="off" autoComplete="off"
placeholder={editingId ? 'не менять' : ''} placeholder={editingId ? 'не змінювати' : ''}
value={form.license_key} value={form.license_key}
onChange={(e) => setForm({ ...form, license_key: e.target.value })} onChange={(e) => setForm({ ...form, license_key: e.target.value })}
/> />
</label> </label>
<label> <label>
PIN-код кассира PIN-код касира
<input <input
required={!editingId} required={!editingId}
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
placeholder={editingId ? 'не менять' : ''} placeholder={editingId ? 'не змінювати' : ''}
value={form.pin_code} value={form.pin_code}
onChange={(e) => setForm({ ...form, pin_code: e.target.value })} onChange={(e) => setForm({ ...form, pin_code: e.target.value })}
/> />
</label> </label>
<label> <label>
Ключ API Новой Почты (посылки этого кабинета пробиваются через эту кассу) Ключ API Нової Пошти (посилки цього кабінету пробиваються через цю касу)
<input <input
required={!editingId} required={!editingId}
autoComplete="off" autoComplete="off"
placeholder={editingId ? 'не менять' : ''} placeholder={editingId ? 'не змінювати' : ''}
value={form.np_api_key} value={form.np_api_key}
onChange={(e) => setForm({ ...form, np_api_key: e.target.value })} onChange={(e) => setForm({ ...form, np_api_key: e.target.value })}
/> />
</label> </label>
<label> <label>
Коды налогов (через запятую, пусто — без налога) Коди податків (через кому, порожньо — без податку)
<input value={form.tax_codes} onChange={(e) => setForm({ ...form, tax_codes: e.target.value })} /> <input value={form.tax_codes} onChange={(e) => setForm({ ...form, tax_codes: e.target.value })} />
</label> </label>
<label className="cr-checkbox"> <label className="cr-checkbox">
@@ -276,15 +276,15 @@ export function CashRegistersPage() {
checked={form.is_default} checked={form.is_default}
onChange={(e) => setForm({ ...form, is_default: e.target.checked })} onChange={(e) => setForm({ ...form, is_default: e.target.checked })}
/> />
Основная касса (первой проверяет новые посылки) Основна каса (першою перевіряє нові посилки)
</label> </label>
<div className="cr-form-actions"> <div className="cr-form-actions">
<button type="submit" className="orders-create-btn" disabled={busy}> <button type="submit" className="orders-create-btn" disabled={busy}>
{editingId ? 'Сохранить' : 'Добавить'} {editingId ? 'Зберегти' : 'Додати'}
</button> </button>
{editingId && ( {editingId && (
<button type="button" className="orders-view-btn" onClick={resetForm}> <button type="button" className="orders-view-btn" onClick={resetForm}>
Отмена Скасувати
</button> </button>
)} )}
</div> </div>
+6
View File
@@ -13,6 +13,12 @@
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
} }
.dashboard-topbar-start {
display: flex;
align-items: center;
gap: 16px;
}
.dashboard-brand { .dashboard-brand {
font-size: 16px; font-size: 16px;
font-weight: 700; font-weight: 700;
+49 -54
View File
@@ -5,6 +5,7 @@ import { Link } from 'react-router-dom'
import { deleteOrder } from '@/api/orders' import { deleteOrder } from '@/api/orders'
import { cancelReceipt, createReceipts } from '@/api/receipts' import { cancelReceipt, createReceipts } from '@/api/receipts'
import '@/pages/DashboardPage.css' import '@/pages/DashboardPage.css'
import { ROLE_LABEL } from '@/features/auth/roles'
import { useAuth } from '@/features/auth/useAuth' import { useAuth } from '@/features/auth/useAuth'
import { OrderDetailModal } from '@/features/orders/OrderDetailModal' import { OrderDetailModal } from '@/features/orders/OrderDetailModal'
import type { Order, OrderTab } from '@/features/orders/types' 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 { CANCELLABLE, RECEIPT_STATUS } from '@/features/receipts/types'
import type { ReceiptRequestItem } 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 }[] = [ const TABS: { key: OrderTab; label: string }[] = [
{ key: 'no_receipt', label: 'Без чека' }, { key: 'no_receipt', label: 'Без чека' },
{ key: 'has_receipt', label: 'Выписаны чеки' }, { key: 'has_receipt', label: 'Виписані чеки' },
{ key: 'received', label: 'Полученные' }, { key: 'received', label: 'Отримані' },
{ key: 'refused', label: 'Отказы' }, { key: 'refused', label: 'Відмови' },
] ]
function npStatusTone(order: Order): 'delivered' | 'processing' | 'danger' | 'new' { function npStatusTone(order: Order): 'delivered' | 'processing' | 'danger' | 'new' {
@@ -45,7 +40,7 @@ function paymentBadge(order: Order): { label: string; tone: 'delivered' | 'proce
return null 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 { function formatMoney(amount: string): string {
return MONEY_FORMAT.format(Number(amount)) return MONEY_FORMAT.format(Number(amount))
@@ -80,7 +75,7 @@ export function DashboardPage() {
for (const order of targets) { for (const order of targets) {
const prepayment = toKopecks(prepaymentOf(order)) const prepayment = toKopecks(prepaymentOf(order))
if (prepayment === null) { if (prepayment === null) {
localErrors.push(`${order.id}: некорректная сумма предоплаты`) localErrors.push(`${order.id}: некоректна сума передоплати`)
continue continue
} }
items.push({ order_id: order.id, prepayment_kopecks: prepayment }) items.push({ order_id: order.id, prepayment_kopecks: prepayment })
@@ -95,14 +90,14 @@ export function DashboardPage() {
const result = await createReceipts(items) const result = await createReceipts(items)
const errors = [...localErrors, ...Object.entries(result.errors).map(([id, msg]) => `${id}: ${msg}`)] const errors = [...localErrors, ...Object.entries(result.errors).map(([id, msg]) => `${id}: ${msg}`)]
const lines = [ const lines = [
...(result.created.length ? [`Отправлено в Checkbox: ${result.created.length}`] : []), ...(result.created.length ? [`Надіслано в Checkbox: ${result.created.length}`] : []),
...errors, ...errors,
] ]
setNotice({ tone: errors.length ? 'error' : 'ok', lines }) setNotice({ tone: errors.length ? 'error' : 'ok', lines })
setSelected(new Set()) setSelected(new Set())
await queryClient.invalidateQueries({ queryKey: ['orders'] }) await queryClient.invalidateQueries({ queryKey: ['orders'] })
} catch (err) { } catch (err) {
setNotice({ tone: 'error', lines: [err instanceof Error ? err.message : 'Не удалось создать чеки'] }) setNotice({ tone: 'error', lines: [err instanceof Error ? err.message : 'Не вдалося створити чеки'] })
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
@@ -111,19 +106,19 @@ export function DashboardPage() {
async function handleCancel(order: Order) { async function handleCancel(order: Order) {
if (!order.receipt_id) return if (!order.receipt_id) return
const outcome = { const outcome = {
no_receipt: 'Заказ вернётся в очередь.', no_receipt: 'Замовлення повернеться в чергу.',
has_receipt: 'Заказ вернётся в очередь.', has_receipt: 'Замовлення повернеться в чергу.',
received: 'Заказ останется в полученных.', received: 'Замовлення залишиться в отриманих.',
refused: 'Заказ останется в отказах.', refused: 'Замовлення залишиться у відмовах.',
}[tab] }[tab]
if (!window.confirm(`Отменить ЕТТН-чек по заказу ${order.id}? ${outcome}`)) return if (!window.confirm(`Скасувати ЕТТН-чек за замовленням ${order.id}? ${outcome}`)) return
setCancellingId(order.id) setCancellingId(order.id)
try { try {
await cancelReceipt(order.receipt_id) await cancelReceipt(order.receipt_id)
setNotice({ tone: 'ok', lines: [`Чек по заказу ${order.id} отменён`] }) setNotice({ tone: 'ok', lines: [`Чек за замовленням ${order.id} скасовано`] })
await queryClient.invalidateQueries({ queryKey: ['orders'] }) await queryClient.invalidateQueries({ queryKey: ['orders'] })
} catch (err) { } catch (err) {
setNotice({ tone: 'error', lines: [err instanceof Error ? err.message : 'Не удалось отменить чек'] }) setNotice({ tone: 'error', lines: [err instanceof Error ? err.message : 'Не вдалося скасувати чек'] })
} finally { } finally {
setCancellingId(null) setCancellingId(null)
} }
@@ -174,25 +169,25 @@ export function DashboardPage() {
return ( return (
<div className="dashboard-shell"> <div className="dashboard-shell">
<header className="dashboard-topbar"> <header className="dashboard-topbar">
<span className="dashboard-brand">lux_fiscal</span> <div className="dashboard-topbar-start">
<Link to="/" className="orders-link-btn">
← Головне меню
</Link>
<span className="dashboard-brand">Assistant System</span>
</div>
<div className="dashboard-user"> <div className="dashboard-user">
<span>{user?.full_name}</span> <span>{user?.full_name}</span>
<span className="dashboard-role">{user ? (ROLE_LABEL[user.role] ?? user.role) : ''}</span> <span className="dashboard-role">{user ? ROLE_LABEL[user.role] : ''}</span>
<button type="button" className="dashboard-logout" onClick={() => void logout()}> <button type="button" className="dashboard-logout" onClick={() => void logout()}>
Выйти Вийти
</button> </button>
</div> </div>
</header> </header>
<main className="orders-body"> <main className="orders-body">
<div className="orders-toolbar"> <div className="orders-toolbar">
<h2>Заказы</h2> <h2>Замовлення</h2>
<div className="orders-toolbar-actions"> <div className="orders-toolbar-actions">
{user?.role === 'admin' && (
<Link to="/cash-registers" className="orders-link-btn">
Кассы
</Link>
)}
{tab === 'no_receipt' && canFiscalize && ( {tab === 'no_receipt' && canFiscalize && (
<button <button
type="button" type="button"
@@ -200,20 +195,20 @@ export function DashboardPage() {
disabled={selected.size === 0 || submitting} disabled={selected.size === 0 || submitting}
onClick={() => void submitReceipts(filtered.filter((order) => selected.has(order.id)))} onClick={() => void submitReceipts(filtered.filter((order) => selected.has(order.id)))}
> >
Создать чеки по выбранным ({selected.size}) Створити чеки за вибраними ({selected.size})
</button> </button>
)} )}
</div> </div>
</div> </div>
<div className="orders-summary"> <div className="orders-summary">
<div className="orders-summary-card" title="Посылки с наложкой, которые ещё не забрали и по которым нет отказа"> <div className="orders-summary-card" title="Посилки з післяплатою, які ще не забрали і за якими немає відмови">
<span className="orders-summary-label">Наложка в пути</span> <span className="orders-summary-label">Післяплата в дорозі</span>
<span className="orders-summary-value"> <span className="orders-summary-value">
{summary ? `${formatMoney(summary.cod_in_transit_amount)} ₴` : '—'} {summary ? `${formatMoney(summary.cod_in_transit_amount)} ₴` : '—'}
</span> </span>
<span className="orders-summary-hint"> <span className="orders-summary-hint">
{summary ? `${summary.cod_in_transit_count} посылок` : ' '} {summary ? `${summary.cod_in_transit_count} посилок` : ' '}
</span> </span>
</div> </div>
</div> </div>
@@ -225,7 +220,7 @@ export function DashboardPage() {
<li key={line}>{line}</li> <li key={line}>{line}</li>
))} ))}
</ul> </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> </button>
</div> </div>
@@ -248,16 +243,16 @@ export function DashboardPage() {
</div> </div>
<div className="orders-filterbar"> <div className="orders-filterbar">
<span className="orders-selected-count">Выбрано: {selected.size} заказов</span> <span className="orders-selected-count">Вибрано: {selected.size} замовлень</span>
<input <input
type="search" type="search"
className="orders-search" className="orders-search"
placeholder="Поиск по ID, ТТН, клиенту" placeholder="Пошук за ID, ТТН, клієнтом"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
<button type="button" className="orders-filter-btn"> <button type="button" className="orders-filter-btn">
Фильтр Фільтр
</button> </button>
</div> </div>
@@ -266,31 +261,31 @@ export function DashboardPage() {
<thead> <thead>
<tr> <tr>
<th className="orders-checkbox-col"> <th className="orders-checkbox-col">
<input type="checkbox" checked={allSelected} onChange={toggleAll} aria-label="Выбрать всё" /> <input type="checkbox" checked={allSelected} onChange={toggleAll} aria-label="Вибрати все" />
</th> </th>
<th>ID заказа</th> <th>ID замовлення</th>
<th>Номер ТТН</th> <th>Номер ТТН</th>
<th>Клиент</th> <th>Клієнт</th>
<th>Сумма</th> <th>Сума</th>
<th>Наложка</th> <th>Післяплата</th>
<th>Оплачено</th> <th>Оплачено</th>
<th>Предоплата, ₴</th> <th>Передоплата, ₴</th>
<th>Статус ТТН</th> <th>Статус ТТН</th>
<th>Действия</th> <th>Дії</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{isLoading && ( {isLoading && (
<tr> <tr>
<td colSpan={10} className="orders-empty"> <td colSpan={10} className="orders-empty">
Загрузка… Завантаження…
</td> </td>
</tr> </tr>
)} )}
{isError && ( {isError && (
<tr> <tr>
<td colSpan={10} className="orders-empty"> <td colSpan={10} className="orders-empty">
Не удалось загрузить заказы из CRM Не вдалося завантажити замовлення з CRM
</td> </td>
</tr> </tr>
)} )}
@@ -305,7 +300,7 @@ export function DashboardPage() {
type="checkbox" type="checkbox"
checked={selected.has(order.id)} checked={selected.has(order.id)}
onChange={() => toggleOne(order.id)} onChange={() => toggleOne(order.id)}
aria-label={`Выбрать заказ ${order.id}`} aria-label={`Вибрати замовлення ${order.id}`}
/> />
</td> </td>
<td>{order.id}</td> <td>{order.id}</td>
@@ -334,7 +329,7 @@ export function DashboardPage() {
placeholder="0.00" placeholder="0.00"
value={prepaymentOf(order)} value={prepaymentOf(order)}
disabled={!canFiscalize} disabled={!canFiscalize}
title="Сумма заказа − предоплата должна равняться наложке" title="Сума замовлення − передоплата має дорівнювати післяплаті"
onChange={(e) => setPrepayments((prev) => ({ ...prev, [order.id]: e.target.value }))} onChange={(e) => setPrepayments((prev) => ({ ...prev, [order.id]: e.target.value }))}
/> />
) : ( ) : (
@@ -343,19 +338,19 @@ export function DashboardPage() {
</td> </td>
<td> <td>
<span className={`orders-status orders-status--${npStatusTone(order)}`}> <span className={`orders-status orders-status--${npStatusTone(order)}`}>
{order.np_status || 'Нет данных'} {order.np_status || 'Немає даних'}
</span> </span>
</td> </td>
<td className="orders-actions-cell"> <td className="orders-actions-cell">
<button type="button" className="orders-view-btn" onClick={() => setViewingOrder(order)}> <button type="button" className="orders-view-btn" onClick={() => setViewingOrder(order)}>
Просмотр Перегляд
</button> </button>
{tab === 'no_receipt' && canFiscalize && ( {tab === 'no_receipt' && canFiscalize && (
<button <button
type="button" type="button"
className="orders-receipt-btn" className="orders-receipt-btn"
disabled={!canCreateReceipt(order) || submitting} disabled={!canCreateReceipt(order) || submitting}
title={canCreateReceipt(order) ? 'Создать ЕТТН-чек в Checkbox' : 'Нет ТТН или наложки'} title={canCreateReceipt(order) ? 'Створити ЕТТН-чек у Checkbox' : 'Немає ТТН або післяплати'}
onClick={() => void submitReceipts([order])} onClick={() => void submitReceipts([order])}
> >
Чек Чек
@@ -376,7 +371,7 @@ export function DashboardPage() {
disabled={cancellingId === order.id} disabled={cancellingId === order.id}
onClick={() => void handleCancel(order)} onClick={() => void handleCancel(order)}
> >
Отменить Скасувати
</button> </button>
)} )}
{canDelete && (tab === 'no_receipt' || ((tab === 'refused' || tab === 'received') && !order.has_receipt)) && ( {canDelete && (tab === 'no_receipt' || ((tab === 'refused' || tab === 'received') && !order.has_receipt)) && (
@@ -386,7 +381,7 @@ export function DashboardPage() {
disabled={deletingId === order.id} disabled={deletingId === order.id}
onClick={() => void handleDelete(order)} onClick={() => void handleDelete(order)}
> >
Удалить Видалити
</button> </button>
)} )}
</td> </td>
@@ -396,7 +391,7 @@ export function DashboardPage() {
{!isLoading && !isError && filtered.length === 0 && ( {!isLoading && !isError && filtered.length === 0 && (
<tr> <tr>
<td colSpan={10} className="orders-empty"> <td colSpan={10} className="orders-empty">
Ничего не найдено Нічого не знайдено
</td> </td>
</tr> </tr>
)} )}
+458
View File
@@ -0,0 +1,458 @@
/* Главное меню: плитки модулей. Палитра и «стекло» — как на странице входа. */
.menu-page {
--menu-accent: #7c3aed;
--menu-gradient: linear-gradient(135deg, var(--color-primary), var(--menu-accent));
--menu-glass: rgba(255, 255, 255, 0.72);
--menu-glass-border: rgba(255, 255, 255, 0.6);
--menu-tint: color-mix(in srgb, var(--color-primary) 10%, transparent);
--menu-shadow: 0 1px 2px rgba(16, 24, 40, 0.06);
--menu-shadow-hover: 0 20px 40px -16px color-mix(in srgb, var(--color-primary) 45%, transparent);
--menu-font-display: 'Unbounded', var(--font-sans);
--menu-font-body: 'Manrope', var(--font-sans);
min-height: 100svh;
display: flex;
flex-direction: column;
gap: 44px;
padding: 32px 64px 48px;
/* Тот же фон, что на странице входа. */
background:
radial-gradient(1200px 600px at 10% -10%, rgba(37, 99, 235, 0.14), transparent 60%),
radial-gradient(900px 500px at 110% 110%, rgba(124, 58, 237, 0.14), transparent 60%),
var(--color-bg);
background-attachment: fixed;
color: var(--color-text);
font-family: var(--menu-font-body);
}
@media (prefers-color-scheme: dark) {
.menu-page {
--menu-accent: #a78bfa;
--menu-glass: rgba(23, 26, 33, 0.7);
--menu-glass-border: rgba(255, 255, 255, 0.08);
--menu-tint: color-mix(in srgb, var(--color-primary) 18%, transparent);
--menu-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
}
/* Стеклянная поверхность, как карточка входа. */
.menu-search,
.menu-profile,
.menu-tile {
background: var(--menu-glass);
border: 1px solid var(--menu-glass-border);
box-shadow: var(--menu-shadow);
backdrop-filter: blur(18px) saturate(140%);
-webkit-backdrop-filter: blur(18px) saturate(140%);
}
/* --- Шапка --- */
.menu-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
flex-wrap: wrap;
}
.menu-brand {
display: flex;
align-items: center;
gap: 14px;
}
.menu-logo {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background: var(--menu-gradient);
color: #fff;
box-shadow: 0 10px 24px -8px var(--color-primary);
}
.menu-brand-name {
font-family: var(--menu-font-display);
font-weight: 600;
font-size: 18px;
letter-spacing: -0.01em;
}
.menu-header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.menu-search {
width: 380px;
height: 48px;
padding: 0 16px;
display: flex;
align-items: center;
gap: 10px;
border-radius: 14px;
color: var(--color-text-muted);
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.menu-search:focus-within {
border-color: var(--color-primary);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-primary) 18%, transparent);
}
.menu-search input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
background: transparent;
color: var(--color-text);
font: inherit;
font-size: 15px;
}
.menu-kbd {
font-family: inherit;
font-size: 12px;
font-weight: 600;
padding: 3px 7px;
border-radius: 6px;
background: var(--menu-tint);
color: var(--color-text-muted);
white-space: nowrap;
}
.menu-profile {
height: 48px;
padding: 0 6px;
display: flex;
align-items: center;
gap: 10px;
border-radius: 14px;
}
.menu-avatar {
width: 36px;
height: 36px;
flex-shrink: 0;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: var(--menu-gradient);
color: #fff;
font-weight: 700;
font-size: 14px;
}
.menu-profile-text {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.menu-profile-name {
font-size: 15px;
font-weight: 600;
}
.menu-profile-role {
font-size: 12px;
color: var(--color-text-muted);
}
.menu-logout {
width: 36px;
height: 36px;
border: 0;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
}
.menu-logout:hover {
background: color-mix(in srgb, var(--color-text) 8%, transparent);
color: var(--color-text);
}
.menu-logout:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* --- Приветствие --- */
.menu-main {
display: flex;
flex-direction: column;
gap: 44px;
}
.menu-hero {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 40px;
}
.menu-hero-title {
display: flex;
flex-direction: column;
gap: 14px;
}
.menu-eyebrow {
font-size: 14px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-primary);
}
.menu-hero h1 {
font-family: var(--menu-font-display);
font-weight: 600;
font-size: 54px;
line-height: 1.05;
letter-spacing: -0.03em;
background: linear-gradient(135deg, var(--color-text) 30%, var(--color-primary));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.menu-hero p {
max-width: 360px;
font-size: 16px;
line-height: 1.55;
color: var(--color-text-muted);
}
/* --- Плитки модулей --- */
.menu-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 20px;
}
.menu-tile {
height: 100%;
min-height: 220px;
padding: 26px;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 16px;
border-radius: 22px;
color: var(--color-text);
text-decoration: none;
transition:
transform 0.18s ease,
box-shadow 0.18s ease,
border-color 0.18s ease;
}
.menu-tile:hover {
transform: translateY(-3px);
box-shadow: var(--menu-shadow-hover);
border-color: color-mix(in srgb, var(--color-primary) 55%, transparent);
}
.menu-tile:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 3px;
}
.menu-tile-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.menu-tile-icon {
width: 56px;
height: 56px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
background: var(--menu-tint);
color: var(--color-primary);
}
.menu-tile-num {
font-family: var(--menu-font-display);
font-size: 13px;
font-weight: 500;
color: var(--color-text-muted);
}
.menu-tile-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.menu-tile-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-family: var(--menu-font-display);
font-size: 20px;
font-weight: 600;
letter-spacing: -0.01em;
}
.menu-tile-arrow {
display: flex;
color: var(--color-primary);
opacity: 0.35;
transform: translateX(-4px);
transition:
opacity 0.18s ease,
transform 0.18s ease;
}
.menu-tile:hover .menu-tile-arrow,
.menu-tile:focus-visible .menu-tile-arrow {
opacity: 1;
transform: translateX(0);
}
.menu-tile-desc {
font-size: 14px;
line-height: 1.5;
color: var(--color-text-muted);
}
.menu-empty {
padding: 48px 0;
text-align: center;
color: var(--color-text-muted);
}
@media (prefers-reduced-motion: reduce) {
.menu-tile,
.menu-tile-arrow {
transition: none;
}
.menu-tile:hover {
transform: none;
}
}
/* --- Адаптив --- */
@media (max-width: 1200px) {
.menu-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 960px) {
.menu-page {
padding: 24px 32px 40px;
}
.menu-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.menu-hero {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.menu-hero h1 {
font-size: 40px;
}
}
@media (max-width: 640px) {
.menu-page {
gap: 22px;
padding: 20px 16px 28px;
}
.menu-main {
gap: 22px;
}
.menu-header-actions {
display: contents;
}
/* Поиск уходит на отдельную строку под шапкой, на всю ширину. */
.menu-search {
order: 1;
width: 100%;
}
.menu-kbd,
.menu-profile-text,
.menu-hero p {
display: none;
}
.menu-brand-name {
font-size: 15px;
}
.menu-eyebrow {
font-size: 12px;
}
.menu-hero-title {
gap: 8px;
}
.menu-hero h1 {
font-size: 30px;
line-height: 1.1;
}
.menu-grid {
gap: 12px;
}
.menu-tile {
min-height: 0;
padding: 16px;
gap: 12px;
border-radius: 18px;
}
.menu-tile-icon {
width: 44px;
height: 44px;
border-radius: 12px;
}
.menu-tile-num,
.menu-tile-arrow,
.menu-tile-desc {
display: none;
}
.menu-tile-title {
font-size: 15px;
}
}
+157
View File
@@ -0,0 +1,157 @@
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import '@/pages/MainMenuPage.css'
import { ROLE_LABEL } from '@/features/auth/roles'
import { useAuth } from '@/features/auth/useAuth'
import { canAccess, MODULES } from '@/features/menu/modules'
function initials(fullName: string): string {
const letters = fullName
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((word) => word[0])
.join('')
return letters.toUpperCase() || '?'
}
function Icon({ d, size }: { d: string; size: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d={d} />
</svg>
)
}
export function MainMenuPage() {
const { user, logout } = useAuth()
const [query, setQuery] = useState('')
const searchRef = useRef<HTMLInputElement>(null)
const searchId = useId()
const available = useMemo(() => MODULES.filter((module) => canAccess(user, module.roles)), [user])
const visible = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return available
return available.filter(
(module) => module.title.toLowerCase().includes(q) || module.description.toLowerCase().includes(q),
)
}, [available, query])
// Ctrl+K / ⌘K — фокус на поиске модулей.
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
event.preventDefault()
searchRef.current?.focus()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
return (
<div className="menu-page">
<header className="menu-header">
<div className="menu-brand">
<span className="menu-logo">
<Icon d="M4 4h7v7H4z M13 13h7v7h-7z M13 4h7v7h-7z" size={22} />
</span>
<span className="menu-brand-name">Assistant System</span>
</div>
<div className="menu-header-actions">
<div className="menu-search">
<label htmlFor={searchId} className="visually-hidden">
Пошук модулів
</label>
<Icon d="M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14z M20 20l-4-4" size={18} />
<input
ref={searchRef}
id={searchId}
type="search"
placeholder="Знайти модуль…"
autoComplete="off"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') setQuery('')
}}
/>
<kbd className="menu-kbd">Ctrl K</kbd>
</div>
{user && (
<div className="menu-profile">
<span className="menu-avatar" aria-hidden="true">
{initials(user.full_name)}
</span>
<span className="menu-profile-text">
<span className="menu-profile-name">{user.full_name}</span>
<span className="menu-profile-role">{ROLE_LABEL[user.role]}</span>
</span>
<button type="button" className="menu-logout" onClick={() => void logout()} aria-label="Вийти">
<Icon d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3 M10 17l5-5-5-5 M15 12H4" size={18} />
</button>
</div>
)}
</div>
</header>
<main className="menu-main">
<section className="menu-hero">
<div className="menu-hero-title">
<span className="menu-eyebrow">Головне меню</span>
<h1>З чого почнемо сьогодні?</h1>
</div>
<p>Оберіть модуль, щоб перейти до роботи.</p>
</section>
<nav aria-label="Модулі">
{visible.length > 0 ? (
<ul className="menu-grid">
{visible.map((module, index) => (
<li key={module.id}>
<Link to={module.path} className="menu-tile">
<span className="menu-tile-top">
<span className="menu-tile-icon">
<Icon d={module.icon} size={26} />
</span>
<span className="menu-tile-num" aria-hidden="true">
{String(index + 1).padStart(2, '0')}
</span>
</span>
<span className="menu-tile-body">
<span className="menu-tile-title">
{module.title}
<span className="menu-tile-arrow">
<Icon d="M5 12h14 M13 6l6 6-6 6" size={20} />
</span>
</span>
<span className="menu-tile-desc">{module.description}</span>
</span>
</Link>
</li>
))}
</ul>
) : (
<p className="menu-empty">
{available.length === 0 ? 'Для вашої ролі поки немає доступних модулів' : 'Нічого не знайдено'}
</p>
)}
</nav>
</main>
</div>
)
}
+25
View File
@@ -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)"
+56
View File
@@ -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