Compare commits
6
Commits
0ba2ca01c4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2d0505a67 | ||
|
|
2b7a92645a | ||
|
|
418f6b0352 | ||
|
|
44fe648ebc | ||
|
|
d92c715c11 | ||
|
|
8fffdc3ce6 |
+4
-1
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Проверки на каждый PR и push в main. Нужен зарегистрированный act_runner (см. DEPLOY.md).
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: backend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- run: pip install -e ".[dev]"
|
||||||
|
- run: ruff check app tests
|
||||||
|
# SECRET_KEY/ENCRYPTION_KEY подставляет tests/conftest.py.
|
||||||
|
- run: pytest -q
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run lint
|
||||||
|
- run: npm run build
|
||||||
@@ -19,6 +19,10 @@ backend/ FastAPI + SQLAlchemy (async) + Alembic + Postgres — see backend/ap
|
|||||||
frontend/ React 19 + TypeScript + Vite — see frontend/src/
|
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()`.
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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)
|
||||||
@@ -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)]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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`). Эта переменная
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Фикстурный CRM-клиент для тестов — не ходит в сеть."""
|
"""Фикстурный CRM-клиент: тесты и локальный запуск (`CRM_USE_STUB=true`) — не ходит в сеть."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Прод-оверлей: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
||||||
|
#
|
||||||
|
# TLS и публичный домен держит Nginx Proxy Manager сервера (внешняя сеть `web`).
|
||||||
|
# Наружу торчит только frontend — и то лишь в сеть `web`, без портов на хосте;
|
||||||
|
# api/postgres/redis доступны исключительно внутри сети compose.
|
||||||
|
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
ports: !reset []
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
ports: !reset []
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
web:
|
||||||
|
# Имя, по которому NPM проксирует: Forward Hostname = lux-fiscal-frontend, порт 80.
|
||||||
|
aliases: [lux-fiscal-frontend]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
web:
|
||||||
|
external: true
|
||||||
@@ -35,6 +35,8 @@ services:
|
|||||||
# Миграции выполняются отдельным одноразовым контейнером, а не при старте api.
|
# Миграции выполняются отдельным одноразовым контейнером, а не при старте api.
|
||||||
# Иначе при нескольких репликах api они пошли бы параллельно.
|
# Иначе при нескольких репликах api они пошли бы параллельно.
|
||||||
migrate:
|
migrate:
|
||||||
|
# Один образ на migrate/api/worker, а не три одинаковых копии на диске.
|
||||||
|
image: lux-fiscal-backend:latest
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
env_file: .env
|
env_file: .env
|
||||||
@@ -48,6 +50,7 @@ services:
|
|||||||
restart: "no"
|
restart: "no"
|
||||||
|
|
||||||
api:
|
api:
|
||||||
|
image: lux-fiscal-backend:latest
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -68,6 +71,7 @@ services:
|
|||||||
- receipt_storage:/app/storage
|
- receipt_storage:/app/storage
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
|
image: lux-fiscal-backend:latest
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -4,6 +4,12 @@
|
|||||||
<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" />
|
||||||
|
<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>
|
<title>Assistant System</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { UserRole } from '@/api/types'
|
||||||
|
|
||||||
|
export const ROLE_LABEL: Record<UserRole, string> = {
|
||||||
|
admin: 'Адміністратор',
|
||||||
|
cashier: 'Касир',
|
||||||
|
viewer: 'Спостерігач',
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Navigate, Outlet } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { useAuth } from '@/features/auth/useAuth'
|
||||||
|
import { canAccess, getModule } from '@/features/menu/modules'
|
||||||
|
|
||||||
|
/** Пускает в модуль только роли из реестра; остальных возвращает в главное меню. */
|
||||||
|
export function ModuleRoute({ moduleId }: { moduleId: string }) {
|
||||||
|
const { user } = useAuth()
|
||||||
|
if (!canAccess(user, getModule(moduleId).roles)) return <Navigate to="/" replace />
|
||||||
|
return <Outlet />
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { User, UserRole } from '@/api/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Реестр модулей главного меню. Единственное место, где модуль связывается
|
||||||
|
* с путём и ролями: по нему рисуется меню и защищается роут модуля.
|
||||||
|
*/
|
||||||
|
export interface AppModule {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
path: string
|
||||||
|
/** Роли, которым модуль доступен; администратор видит всё и в списке не нужен. */
|
||||||
|
roles: readonly UserRole[]
|
||||||
|
/** `d` для stroke-иконки 24×24. */
|
||||||
|
icon: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MODULES: readonly AppModule[] = [
|
||||||
|
{
|
||||||
|
id: 'receipts',
|
||||||
|
title: 'Чеки',
|
||||||
|
description: 'Замовлення з CRM, ЕТТН-чеки Checkbox і статуси Нової Пошти',
|
||||||
|
path: '/receipts',
|
||||||
|
roles: ['cashier', 'viewer'],
|
||||||
|
icon: 'M6 3h12v18l-3-2-3 2-3-2-3 2z M9 8h6 M9 12h6 M9 16h3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cash-registers',
|
||||||
|
title: 'Каси',
|
||||||
|
description: 'Каси Checkbox, ліцензійні ключі та ключі Нової Пошти',
|
||||||
|
path: '/cash-registers',
|
||||||
|
roles: [],
|
||||||
|
icon: 'M4 10h16v10H4z M7 10V4h10v6 M8 14h2 M14 14h2 M8 17h2 M14 17h2',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function canAccess(user: User | null | undefined, roles: readonly UserRole[]): boolean {
|
||||||
|
if (!user) return false
|
||||||
|
return user.role === 'admin' || roles.includes(user.role)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getModule(id: string): AppModule {
|
||||||
|
const module = MODULES.find((m) => m.id === id)
|
||||||
|
if (!module) throw new Error(`Невідомий модуль: ${id}`)
|
||||||
|
return module
|
||||||
|
}
|
||||||
@@ -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,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 {
|
||||||
@@ -123,10 +121,12 @@ 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">Assistant System</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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,12 +14,6 @@ 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: 'Виписані чеки' },
|
||||||
@@ -174,10 +169,15 @@ 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">Assistant System</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>
|
||||||
@@ -188,11 +188,6 @@ export function DashboardPage() {
|
|||||||
<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"
|
||||||
|
|||||||
@@ -0,0 +1,458 @@
|
|||||||
|
/* Главное меню: плитки модулей. Палитра и «стекло» — как на странице входа. */
|
||||||
|
|
||||||
|
.menu-page {
|
||||||
|
--menu-accent: #7c3aed;
|
||||||
|
--menu-gradient: linear-gradient(135deg, var(--color-primary), var(--menu-accent));
|
||||||
|
--menu-glass: rgba(255, 255, 255, 0.72);
|
||||||
|
--menu-glass-border: rgba(255, 255, 255, 0.6);
|
||||||
|
--menu-tint: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||||
|
--menu-shadow: 0 1px 2px rgba(16, 24, 40, 0.06);
|
||||||
|
--menu-shadow-hover: 0 20px 40px -16px color-mix(in srgb, var(--color-primary) 45%, transparent);
|
||||||
|
--menu-font-display: 'Unbounded', var(--font-sans);
|
||||||
|
--menu-font-body: 'Manrope', var(--font-sans);
|
||||||
|
|
||||||
|
min-height: 100svh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 44px;
|
||||||
|
padding: 32px 64px 48px;
|
||||||
|
/* Тот же фон, что на странице входа. */
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 600px at 10% -10%, rgba(37, 99, 235, 0.14), transparent 60%),
|
||||||
|
radial-gradient(900px 500px at 110% 110%, rgba(124, 58, 237, 0.14), transparent 60%),
|
||||||
|
var(--color-bg);
|
||||||
|
background-attachment: fixed;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--menu-font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.menu-page {
|
||||||
|
--menu-accent: #a78bfa;
|
||||||
|
--menu-glass: rgba(23, 26, 33, 0.7);
|
||||||
|
--menu-glass-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--menu-tint: color-mix(in srgb, var(--color-primary) 18%, transparent);
|
||||||
|
--menu-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стеклянная поверхность, как карточка входа. */
|
||||||
|
.menu-search,
|
||||||
|
.menu-profile,
|
||||||
|
.menu-tile {
|
||||||
|
background: var(--menu-glass);
|
||||||
|
border: 1px solid var(--menu-glass-border);
|
||||||
|
box-shadow: var(--menu-shadow);
|
||||||
|
backdrop-filter: blur(18px) saturate(140%);
|
||||||
|
-webkit-backdrop-filter: blur(18px) saturate(140%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Шапка --- */
|
||||||
|
|
||||||
|
.menu-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-logo {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--menu-gradient);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 10px 24px -8px var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-brand-name {
|
||||||
|
font-family: var(--menu-font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-search {
|
||||||
|
width: 380px;
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
border-radius: 14px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-search:focus-within {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-primary) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-search input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border: 0;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-kbd {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 3px 7px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--menu-tint);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-profile {
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--menu-gradient);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-profile-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-profile-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-profile-role {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-logout {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-logout:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-text) 8%, transparent);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-logout:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Приветствие --- */
|
||||||
|
|
||||||
|
.menu-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-hero-title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-eyebrow {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-hero h1 {
|
||||||
|
font-family: var(--menu-font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 54px;
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
background: linear-gradient(135deg, var(--color-text) 30%, var(--color-primary));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-hero p {
|
||||||
|
max-width: 360px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Плитки модулей --- */
|
||||||
|
|
||||||
|
.menu-grid {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-tile {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 220px;
|
||||||
|
padding: 26px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
border-radius: 22px;
|
||||||
|
color: var(--color-text);
|
||||||
|
text-decoration: none;
|
||||||
|
transition:
|
||||||
|
transform 0.18s ease,
|
||||||
|
box-shadow 0.18s ease,
|
||||||
|
border-color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-tile:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: var(--menu-shadow-hover);
|
||||||
|
border-color: color-mix(in srgb, var(--color-primary) 55%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-tile:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-tile-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-tile-icon {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--menu-tint);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-tile-num {
|
||||||
|
font-family: var(--menu-font-display);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-tile-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-tile-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
font-family: var(--menu-font-display);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-tile-arrow {
|
||||||
|
display: flex;
|
||||||
|
color: var(--color-primary);
|
||||||
|
opacity: 0.35;
|
||||||
|
transform: translateX(-4px);
|
||||||
|
transition:
|
||||||
|
opacity 0.18s ease,
|
||||||
|
transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-tile:hover .menu-tile-arrow,
|
||||||
|
.menu-tile:focus-visible .menu-tile-arrow {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-tile-desc {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-empty {
|
||||||
|
padding: 48px 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.menu-tile,
|
||||||
|
.menu-tile-arrow {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-tile:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Адаптив --- */
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.menu-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.menu-page {
|
||||||
|
padding: 24px 32px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-hero {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-hero h1 {
|
||||||
|
font-size: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.menu-page {
|
||||||
|
gap: 22px;
|
||||||
|
padding: 20px 16px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-main {
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-header-actions {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Поиск уходит на отдельную строку под шапкой, на всю ширину. */
|
||||||
|
.menu-search {
|
||||||
|
order: 1;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-kbd,
|
||||||
|
.menu-profile-text,
|
||||||
|
.menu-hero p {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-brand-name {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-eyebrow {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-hero-title {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-hero h1 {
|
||||||
|
font-size: 30px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-grid {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-tile {
|
||||||
|
min-height: 0;
|
||||||
|
padding: 16px;
|
||||||
|
gap: 12px;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-tile-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-tile-num,
|
||||||
|
.menu-tile-arrow,
|
||||||
|
.menu-tile-desc {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-tile-title {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
import '@/pages/MainMenuPage.css'
|
||||||
|
import { ROLE_LABEL } from '@/features/auth/roles'
|
||||||
|
import { useAuth } from '@/features/auth/useAuth'
|
||||||
|
import { canAccess, MODULES } from '@/features/menu/modules'
|
||||||
|
|
||||||
|
function initials(fullName: string): string {
|
||||||
|
const letters = fullName
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((word) => word[0])
|
||||||
|
.join('')
|
||||||
|
return letters.toUpperCase() || '?'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Icon({ d, size }: { d: string; size: number }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d={d} />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MainMenuPage() {
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null)
|
||||||
|
const searchId = useId()
|
||||||
|
|
||||||
|
const available = useMemo(() => MODULES.filter((module) => canAccess(user, module.roles)), [user])
|
||||||
|
const visible = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
if (!q) return available
|
||||||
|
return available.filter(
|
||||||
|
(module) => module.title.toLowerCase().includes(q) || module.description.toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
}, [available, query])
|
||||||
|
|
||||||
|
// Ctrl+K / ⌘K — фокус на поиске модулей.
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
|
||||||
|
event.preventDefault()
|
||||||
|
searchRef.current?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="menu-page">
|
||||||
|
<header className="menu-header">
|
||||||
|
<div className="menu-brand">
|
||||||
|
<span className="menu-logo">
|
||||||
|
<Icon d="M4 4h7v7H4z M13 13h7v7h-7z M13 4h7v7h-7z" size={22} />
|
||||||
|
</span>
|
||||||
|
<span className="menu-brand-name">Assistant System</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="menu-header-actions">
|
||||||
|
<div className="menu-search">
|
||||||
|
<label htmlFor={searchId} className="visually-hidden">
|
||||||
|
Пошук модулів
|
||||||
|
</label>
|
||||||
|
<Icon d="M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14z M20 20l-4-4" size={18} />
|
||||||
|
<input
|
||||||
|
ref={searchRef}
|
||||||
|
id={searchId}
|
||||||
|
type="search"
|
||||||
|
placeholder="Знайти модуль…"
|
||||||
|
autoComplete="off"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') setQuery('')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<kbd className="menu-kbd">Ctrl K</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<div className="menu-profile">
|
||||||
|
<span className="menu-avatar" aria-hidden="true">
|
||||||
|
{initials(user.full_name)}
|
||||||
|
</span>
|
||||||
|
<span className="menu-profile-text">
|
||||||
|
<span className="menu-profile-name">{user.full_name}</span>
|
||||||
|
<span className="menu-profile-role">{ROLE_LABEL[user.role]}</span>
|
||||||
|
</span>
|
||||||
|
<button type="button" className="menu-logout" onClick={() => void logout()} aria-label="Вийти">
|
||||||
|
<Icon d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3 M10 17l5-5-5-5 M15 12H4" size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="menu-main">
|
||||||
|
<section className="menu-hero">
|
||||||
|
<div className="menu-hero-title">
|
||||||
|
<span className="menu-eyebrow">Головне меню</span>
|
||||||
|
<h1>З чого почнемо сьогодні?</h1>
|
||||||
|
</div>
|
||||||
|
<p>Оберіть модуль, щоб перейти до роботи.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav aria-label="Модулі">
|
||||||
|
{visible.length > 0 ? (
|
||||||
|
<ul className="menu-grid">
|
||||||
|
{visible.map((module, index) => (
|
||||||
|
<li key={module.id}>
|
||||||
|
<Link to={module.path} className="menu-tile">
|
||||||
|
<span className="menu-tile-top">
|
||||||
|
<span className="menu-tile-icon">
|
||||||
|
<Icon d={module.icon} size={26} />
|
||||||
|
</span>
|
||||||
|
<span className="menu-tile-num" aria-hidden="true">
|
||||||
|
{String(index + 1).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="menu-tile-body">
|
||||||
|
<span className="menu-tile-title">
|
||||||
|
{module.title}
|
||||||
|
<span className="menu-tile-arrow">
|
||||||
|
<Icon d="M5 12h14 M13 6l6 6-6 6" size={20} />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="menu-tile-desc">{module.description}</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="menu-empty">
|
||||||
|
{available.length === 0 ? 'Для вашої ролі поки немає доступних модулів' : 'Нічого не знайдено'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Бэкап прод-БД lux_fiscal: cron пользователя deploy (ежедневно) и scripts/deploy.sh (перед выкладкой).
|
||||||
|
# Дампы содержат зашифрованные ключи касс: для восстановления нужен тот же ENCRYPTION_KEY из .env.
|
||||||
|
# Последняя строка вывода — "ok <путь к дампу> <размер>", её читает deploy.sh.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-$HOME/backups/lux_fiscal}"
|
||||||
|
KEEP_DAYS=14
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
C="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
|
||||||
|
FILE="$BACKUP_DIR/lux_fiscal_$(date +%F_%H%M%S).dump"
|
||||||
|
$C exec -T postgres sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$FILE.tmp"
|
||||||
|
|
||||||
|
# Битый/пустой дамп не должен вытеснить ротацией хорошие.
|
||||||
|
$C exec -T postgres pg_restore --list < "$FILE.tmp" > /dev/null
|
||||||
|
mv "$FILE.tmp" "$FILE"
|
||||||
|
|
||||||
|
find "$BACKUP_DIR" -name "lux_fiscal_*.dump" -mtime +"$KEEP_DAYS" -delete
|
||||||
|
find "$BACKUP_DIR" -name "*.tmp" -mtime +1 -delete
|
||||||
|
echo "$(date -Is) ok $FILE $(du -h "$FILE" | cut -f1)"
|
||||||
Executable
+56
@@ -0,0 +1,56 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Выкладка main на прод: бэкап БД → git pull → сборка и запуск → проверка health.
|
||||||
|
# Если новая версия не поднялась — откат кода на предыдущий коммит (БД не откатывается,
|
||||||
|
# путь к свежему бэкапу печатается). Запуск на сервере: ~/lux_fiscal/scripts/deploy.sh [--force]
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
C="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
|
||||||
|
|
||||||
|
health() {
|
||||||
|
# Цепочка целиком: nginx фронтенда → api → БД/Redis.
|
||||||
|
$C exec -T frontend wget -qO- http://127.0.0.1/api/v1/health | grep -q '"status":"ok"'
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
|
||||||
|
echo "На сервере есть незакоммиченные правки в отслеживаемых файлах — разберитесь вручную:" >&2
|
||||||
|
git status --short --untracked-files=no >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# PREV — то, что крутится сейчас (после неудачной выкладки это detached-коммит отката, а не main).
|
||||||
|
PREV=$(git rev-parse HEAD)
|
||||||
|
git checkout -q main
|
||||||
|
git fetch -q origin main
|
||||||
|
git merge -q --ff-only origin/main
|
||||||
|
NEW=$(git rev-parse HEAD)
|
||||||
|
|
||||||
|
if [ "$PREV" = "$NEW" ] && [ "${1:-}" != "--force" ]; then
|
||||||
|
echo "Нечего выкладывать: уже на $(git log --oneline -1). Пересобрать всё равно: --force"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== Бэкап БД"
|
||||||
|
BACKUP=$(scripts/backup.sh | tail -1 | awk '{print $3}')
|
||||||
|
echo " $BACKUP"
|
||||||
|
|
||||||
|
echo "== Выкладка $(git log --oneline -1 "$PREV") -> $(git log --oneline -1 "$NEW")"
|
||||||
|
if $C up -d --build --wait --remove-orphans && health; then
|
||||||
|
echo "== Готово: $(git log --oneline -1)"
|
||||||
|
docker image prune -f --filter "label=com.docker.compose.project=lux-fiscal" > /dev/null || true
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "!! Новая версия не поднялась — откат кода на $PREV" >&2
|
||||||
|
$C logs --tail 50 migrate api worker >&2 || true
|
||||||
|
git checkout -q --detach "$PREV"
|
||||||
|
$C up -d --build --wait --remove-orphans || true
|
||||||
|
if health; then
|
||||||
|
echo "!! Откат выполнен, работает $(git log --oneline -1). Репозиторий в detached HEAD —" >&2
|
||||||
|
echo "!! следующий deploy.sh сам вернётся на main." >&2
|
||||||
|
else
|
||||||
|
echo "!! Откат не помог: сервис не отвечает." >&2
|
||||||
|
fi
|
||||||
|
echo "!! Если новая версия успела применить миграции, а старый код с ними несовместим —" >&2
|
||||||
|
echo "!! восстановите БД из бэкапа, снятого перед выкладкой: $BACKUP (см. DEPLOY.md)." >&2
|
||||||
|
exit 1
|
||||||
Reference in New Issue
Block a user