Compare commits
12 Commits
stabilize-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99bc9aa6a1 | ||
|
|
58ff6ff614 | ||
| 5e5582664a | |||
|
|
8982299e71 | ||
|
|
59bc6ebd4f | ||
|
|
22b9b40d78 | ||
|
|
2d5695fdce | ||
|
|
0f6d6e31e1 | ||
|
|
fa703acce1 | ||
|
|
f4be38f9b9 | ||
|
|
8ab296b675 | ||
|
|
c98432ca7d |
@@ -22,3 +22,8 @@ OCR_PROVIDER=tesseract
|
|||||||
OCR_LANGUAGES=eng+rus+kor
|
OCR_LANGUAGES=eng+rus+kor
|
||||||
ADMIN_TELEGRAM_IDS=
|
ADMIN_TELEGRAM_IDS=
|
||||||
ADMIN_BOOTSTRAP_TOKEN=
|
ADMIN_BOOTSTRAP_TOKEN=
|
||||||
|
ADMIN_NOTIFICATION_CHAT_ID=
|
||||||
|
ADMIN_NOTIFY_NEW_USERS=true
|
||||||
|
ADMIN_NOTIFY_STO_APPLICATIONS=true
|
||||||
|
ADMIN_NOTIFY_SECURITY_EVENTS=true
|
||||||
|
ADMIN_NOTIFY_SYSTEM_ERRORS=true
|
||||||
|
|||||||
201
ADMIN.md
Normal file
201
ADMIN.md
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
# CarPass Admin Control Center
|
||||||
|
|
||||||
|
Admin Control Center дает администраторам закрытого пилота безопасный доступ к событиям сервиса, модерации СТО, просмотру данных и экспорту без прямого SQL.
|
||||||
|
|
||||||
|
## Доступ
|
||||||
|
|
||||||
|
Админка открывается в Mini App по `/admin.html` или командой бота `/admin`.
|
||||||
|
|
||||||
|
Роли:
|
||||||
|
|
||||||
|
- `super_admin`: полный доступ к пользователям, СТО, заявкам, заказ-нарядам, расходам, audit, export и системным настройкам.
|
||||||
|
- `admin`: пользователи, СТО, модерация, заказ-наряды, базовая аналитика и экспорт без секретов.
|
||||||
|
- `moderator`: заявки СТО, отзывы, блокировки и комментарии модерации.
|
||||||
|
- `support`: поиск пользователя, авто, история действий и помощь без расширенных финансовых агрегатов.
|
||||||
|
- `analyst`: агрегированная аналитика и обезличенные выгрузки без персональных данных.
|
||||||
|
|
||||||
|
Все чувствительные admin actions пишутся в `AuditLog`.
|
||||||
|
|
||||||
|
## Уведомления
|
||||||
|
|
||||||
|
Система создает `AdminNotification` в БД и best-effort отправляет Telegram-сообщение администраторам. Ошибка Telegram не ломает бизнес-flow.
|
||||||
|
|
||||||
|
Поддержанные события:
|
||||||
|
|
||||||
|
- новый пользователь;
|
||||||
|
- первое авто пользователя;
|
||||||
|
- первая запись пользователя;
|
||||||
|
- новая заявка СТО;
|
||||||
|
- обновление документов или повторная отправка заявки СТО;
|
||||||
|
- изменение статуса заявки СТО;
|
||||||
|
- одобрение, блокировка и разблокировка СТО;
|
||||||
|
- новая запись в СТО, отмена записи СТО;
|
||||||
|
- создание, завершение и отклонение заказ-наряда;
|
||||||
|
- запрос и решение коррекции заказ-наряда;
|
||||||
|
- низкая оценка СТО;
|
||||||
|
- OCR/upload/rate-limit события;
|
||||||
|
- security/system события через общий admin notification service.
|
||||||
|
|
||||||
|
Idempotency key защищает от дублей. Telegram-сообщение содержит кнопку открытия соответствующего раздела админки.
|
||||||
|
|
||||||
|
Env:
|
||||||
|
|
||||||
|
```env
|
||||||
|
ADMIN_TELEGRAM_IDS=123,456
|
||||||
|
ADMIN_NOTIFICATION_CHAT_ID=
|
||||||
|
ADMIN_NOTIFY_NEW_USERS=true
|
||||||
|
ADMIN_NOTIFY_STO_APPLICATIONS=true
|
||||||
|
ADMIN_NOTIFY_SECURITY_EVENTS=true
|
||||||
|
ADMIN_NOTIFY_SYSTEM_ERRORS=true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Admin API
|
||||||
|
|
||||||
|
Dashboard:
|
||||||
|
|
||||||
|
- `GET /api/admin/dashboard`
|
||||||
|
|
||||||
|
Notifications:
|
||||||
|
|
||||||
|
- `GET /api/admin/notifications`
|
||||||
|
- `POST /api/admin/notifications/{id}/read`
|
||||||
|
- `POST /api/admin/notifications/read-all`
|
||||||
|
- `POST /api/admin/notifications/retry`
|
||||||
|
- `POST /api/admin/notifications/{id}/dismiss`
|
||||||
|
|
||||||
|
Data Explorer:
|
||||||
|
|
||||||
|
- `GET /api/admin/data/sources`
|
||||||
|
- `POST /api/admin/data/query`
|
||||||
|
- `PATCH /api/admin/data/{source}/{id}`
|
||||||
|
- `DELETE /api/admin/data/{source}/{id}`
|
||||||
|
- `POST /api/admin/data/export`
|
||||||
|
|
||||||
|
Users:
|
||||||
|
|
||||||
|
- `GET /api/admin/users`
|
||||||
|
- `GET /api/admin/users/{id}`
|
||||||
|
- `GET /api/admin/users/{id}/activity`
|
||||||
|
- `POST /api/admin/users/{id}/note`
|
||||||
|
- `POST /api/admin/users/{id}/block`
|
||||||
|
- `POST /api/admin/users/{id}/unblock`
|
||||||
|
|
||||||
|
СТО:
|
||||||
|
|
||||||
|
- `GET /api/admin/sto`
|
||||||
|
- `GET /api/admin/sto/{id}`
|
||||||
|
- `GET /api/admin/sto-applications`
|
||||||
|
- `POST /api/admin/sto-applications/{id}/approve`
|
||||||
|
- `POST /api/admin/sto-applications/{id}/reject`
|
||||||
|
- `POST /api/admin/sto-applications/{id}/request-changes`
|
||||||
|
- `POST /api/admin/sto/{id}/suspend`
|
||||||
|
- `POST /api/admin/sto/{id}/unsuspend`
|
||||||
|
|
||||||
|
Audit and exports:
|
||||||
|
|
||||||
|
- `GET /api/admin/audit-log`
|
||||||
|
- `GET /api/admin/exports`
|
||||||
|
- `GET /api/admin/exports/{id}`
|
||||||
|
|
||||||
|
## Data Explorer
|
||||||
|
|
||||||
|
Data Explorer работает только по whitelist источников и полей. Произвольный SQL из UI не принимается.
|
||||||
|
|
||||||
|
Источники:
|
||||||
|
|
||||||
|
- `users`
|
||||||
|
- `vehicles`
|
||||||
|
- `fuel_entries`
|
||||||
|
- `service_entries`
|
||||||
|
- `expense_entries`
|
||||||
|
- `sto_profiles`
|
||||||
|
- `sto_applications`
|
||||||
|
- `sto_employees`
|
||||||
|
- `vehicle_sto_links`
|
||||||
|
- `appointments`
|
||||||
|
- `work_orders`
|
||||||
|
- `work_order_items`
|
||||||
|
- `work_order_products`
|
||||||
|
- `reviews`
|
||||||
|
- `notifications`
|
||||||
|
- `admin_notifications`
|
||||||
|
- `audit_logs`
|
||||||
|
- `ocr_results`
|
||||||
|
- `imports_exports`
|
||||||
|
|
||||||
|
Поддержаны фильтры по дате, статусу, пользователю, Telegram ID, авто, СТО, городу, роли, категории, сумме, ошибкам и текстовому поиску. Каждый запрос ограничен `limit` до 500 строк и пишет audit log.
|
||||||
|
|
||||||
|
Редактирование и удаление из Data Explorer:
|
||||||
|
|
||||||
|
- доступно только `admin` и `super_admin`;
|
||||||
|
- работает только по whitelist полей, который возвращает `GET /api/admin/data/sources`;
|
||||||
|
- требует `reason` минимум 5 символов;
|
||||||
|
- пишет `AuditLog` с old/new values;
|
||||||
|
- для пользователей, СТО, заявок, записей и уведомлений используется soft-delete/status change;
|
||||||
|
- hard-delete разрешен только для ограниченных журналов записей, где это явно включено;
|
||||||
|
- удаление автомобиля требует `super_admin`.
|
||||||
|
|
||||||
|
## Privacy
|
||||||
|
|
||||||
|
По умолчанию маскируются Telegram ID, VIN, госномер, телефон и регистрационные данные СТО.
|
||||||
|
|
||||||
|
Полный просмотр sensitive data:
|
||||||
|
|
||||||
|
- доступен только `admin` и `super_admin`;
|
||||||
|
- требует `reason`;
|
||||||
|
- пишет audit log;
|
||||||
|
- не раскрывает bot token, env, internal token, secret fields.
|
||||||
|
|
||||||
|
`analyst` видит только обезличенные или замаскированные персональные данные.
|
||||||
|
|
||||||
|
## Модерация СТО
|
||||||
|
|
||||||
|
Очередь заявок доступна в `/admin.html?section=sto-applications`.
|
||||||
|
|
||||||
|
Действия:
|
||||||
|
|
||||||
|
- approve;
|
||||||
|
- reject with reason;
|
||||||
|
- request changes with reason;
|
||||||
|
- suspend;
|
||||||
|
- unsuspend.
|
||||||
|
|
||||||
|
При изменении статуса создаются audit log, admin notification и уведомление владельцу СТО.
|
||||||
|
|
||||||
|
## Bot Commands
|
||||||
|
|
||||||
|
Админские команды бота:
|
||||||
|
|
||||||
|
- `/admin`
|
||||||
|
- `/admin_stats`
|
||||||
|
- `/admin_users`
|
||||||
|
- `/admin_sto`
|
||||||
|
- `/admin_pending_sto`
|
||||||
|
- `/admin_alerts`
|
||||||
|
|
||||||
|
API дополнительно проверяет роль пользователя, поэтому команда не дает доступа без admin-role в БД.
|
||||||
|
|
||||||
|
## Deploy Reports
|
||||||
|
|
||||||
|
Для временного rsync-деплоя есть `scripts/rsync_deploy.sh`. Скрипт:
|
||||||
|
|
||||||
|
- запускает локальные `ruff` и `pytest`;
|
||||||
|
- отправляет Telegram progress/failure/success отчеты;
|
||||||
|
- делает remote code backup без `.env`;
|
||||||
|
- синхронизирует код через `rsync`;
|
||||||
|
- собирает Docker images;
|
||||||
|
- применяет Alembic migrations;
|
||||||
|
- поднимает `api` и `bot`;
|
||||||
|
- проверяет `/health`, `/ready`, `/metrics`, `/admin.html`, `/sto.html`, `/work_order.html`.
|
||||||
|
|
||||||
|
Утилита `scripts/send_telegram_report.py` берет получателей из `ADMIN_NOTIFICATION_CHAT_ID`, `ADMIN_TELEGRAM_IDS` и, если env пустой, из пользователей БД с ролями `admin`, `super_admin`, `moderator`, `support`.
|
||||||
|
|
||||||
|
## Load Check
|
||||||
|
|
||||||
|
Быстрая проверка одновременных соединений:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/load_check.py --base-url http://127.0.0.1:8000 --requests 200 --concurrency 25
|
||||||
|
```
|
||||||
|
|
||||||
|
Скрипт проверяет `/health`, `/ready`, `/`, `/admin.html`, `/sto.html`, считает RPS, avg/p95/max latency и завершится с ошибкой при 5xx или сетевых сбоях.
|
||||||
@@ -33,6 +33,13 @@ python -m scripts.bootstrap_admin
|
|||||||
curl -fsS http://127.0.0.1:8000/ready
|
curl -fsS http://127.0.0.1:8000/ready
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If port `8000` is already used on the host, set `APP_PORT` in `.env` and point the reverse proxy to that local port:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APP_PORT=8010
|
||||||
|
curl -fsS http://127.0.0.1:8010/ready
|
||||||
|
```
|
||||||
|
|
||||||
The default compose stack includes Postgres, Redis, API and bot services with health checks, restart policies and log rotation.
|
The default compose stack includes Postgres, Redis, API and bot services with health checks, restart policies and log rotation.
|
||||||
Telegram notifications are the primary pilot notification channel. Browser push currently stores subscriptions and is treated as beta until server-side Web Push delivery is enabled.
|
Telegram notifications are the primary pilot notification channel. Browser push currently stores subscriptions and is treated as beta until server-side Web Push delivery is enabled.
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ CarPass создает рекомендации обслуживания из д
|
|||||||
|
|
||||||
Telegram-уведомления являются основным каналом закрытого пилота. Browser push уже умеет сохранять подписки в Mini App и принимать push-события в service worker, но серверная Web Push-доставка помечена как beta и не считается критическим каналом пилота.
|
Telegram-уведомления являются основным каналом закрытого пилота. Browser push уже умеет сохранять подписки в Mini App и принимать push-события в service worker, но серверная Web Push-доставка помечена как beta и не считается критическим каналом пилота.
|
||||||
|
|
||||||
|
## Администрирование
|
||||||
|
|
||||||
|
Admin Control Center доступен по `/admin.html` и через команды бота `/admin`, `/admin_stats`, `/admin_users`, `/admin_sto`, `/admin_pending_sto`, `/admin_alerts`.
|
||||||
|
|
||||||
|
Админка включает dashboard сервиса, admin notifications, очередь заявок СТО, пользователей, автомобили, записи, заказ-наряды, audit log, экспорт и безопасный Data Explorer без произвольного SQL. Подробности по ролям, privacy, env и API описаны в [ADMIN.md](ADMIN.md).
|
||||||
|
|
||||||
## Безопасность данных
|
## Безопасность данных
|
||||||
|
|
||||||
CarPass не раскрывает историю автомобиля по одному VIN или госномеру. СТО видит только разрешенный владельцем объем данных: базовую карточку, историю обслуживания или полный доступ. Любые чувствительные изменения, включая VIN, номер, пробег и технические параметры, проходят подтверждение владельца.
|
CarPass не раскрывает историю автомобиля по одному VIN или госномеру. СТО видит только разрешенный владельцем объем данных: базовую карточку, историю обслуживания или полный доступ. Любые чувствительные изменения, включая VIN, номер, пробег и технические параметры, проходят подтверждение владельца.
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""admin notifications and data explorer jobs
|
||||||
|
|
||||||
|
Revision ID: 202605170001
|
||||||
|
Revises: 202605160002
|
||||||
|
Create Date: 2026-05-17 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "202605170001"
|
||||||
|
down_revision: str | None = "202605160002"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"admin_notifications",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("event_type", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("severity", sa.String(length=24), server_default="info", nullable=False),
|
||||||
|
sa.Column("title", sa.String(length=180), nullable=False),
|
||||||
|
sa.Column("body", sa.Text(), nullable=True),
|
||||||
|
sa.Column("entity_type", sa.String(length=80), nullable=True),
|
||||||
|
sa.Column("entity_id", sa.String(length=80), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=24), server_default="unread", nullable=False),
|
||||||
|
sa.Column("idempotency_key", sa.String(length=180), nullable=False),
|
||||||
|
sa.Column("metadata_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("telegram_status", sa.String(length=24), server_default="pending", nullable=False),
|
||||||
|
sa.Column("telegram_error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("dismissed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_admin_notifications_created_at", "admin_notifications", ["created_at"])
|
||||||
|
op.create_index("ix_admin_notifications_entity_id", "admin_notifications", ["entity_id"])
|
||||||
|
op.create_index("ix_admin_notifications_entity_type", "admin_notifications", ["entity_type"])
|
||||||
|
op.create_index("ix_admin_notifications_event_type", "admin_notifications", ["event_type"])
|
||||||
|
op.create_index("ix_admin_notifications_idempotency_key", "admin_notifications", ["idempotency_key"], unique=True)
|
||||||
|
op.create_index("ix_admin_notifications_severity", "admin_notifications", ["severity"])
|
||||||
|
op.create_index("ix_admin_notifications_status", "admin_notifications", ["status"])
|
||||||
|
op.create_index("ix_admin_notifications_telegram_status", "admin_notifications", ["telegram_status"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"admin_export_jobs",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("requested_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("source", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("export_format", sa.String(length=16), server_default="json", nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=24), server_default="ready", nullable=False),
|
||||||
|
sa.Column("reason", sa.Text(), nullable=True),
|
||||||
|
sa.Column("filters_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("result_text", sa.Text(), nullable=True),
|
||||||
|
sa.Column("row_count", sa.Integer(), server_default="0", nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["requested_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_admin_export_jobs_created_at", "admin_export_jobs", ["created_at"])
|
||||||
|
op.create_index("ix_admin_export_jobs_requested_by_user_id", "admin_export_jobs", ["requested_by_user_id"])
|
||||||
|
op.create_index("ix_admin_export_jobs_source", "admin_export_jobs", ["source"])
|
||||||
|
op.create_index("ix_admin_export_jobs_status", "admin_export_jobs", ["status"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_admin_export_jobs_status", table_name="admin_export_jobs")
|
||||||
|
op.drop_index("ix_admin_export_jobs_source", table_name="admin_export_jobs")
|
||||||
|
op.drop_index("ix_admin_export_jobs_requested_by_user_id", table_name="admin_export_jobs")
|
||||||
|
op.drop_index("ix_admin_export_jobs_created_at", table_name="admin_export_jobs")
|
||||||
|
op.drop_table("admin_export_jobs")
|
||||||
|
op.drop_index("ix_admin_notifications_telegram_status", table_name="admin_notifications")
|
||||||
|
op.drop_index("ix_admin_notifications_status", table_name="admin_notifications")
|
||||||
|
op.drop_index("ix_admin_notifications_severity", table_name="admin_notifications")
|
||||||
|
op.drop_index("ix_admin_notifications_idempotency_key", table_name="admin_notifications")
|
||||||
|
op.drop_index("ix_admin_notifications_event_type", table_name="admin_notifications")
|
||||||
|
op.drop_index("ix_admin_notifications_entity_type", table_name="admin_notifications")
|
||||||
|
op.drop_index("ix_admin_notifications_entity_id", table_name="admin_notifications")
|
||||||
|
op.drop_index("ix_admin_notifications_created_at", table_name="admin_notifications")
|
||||||
|
op.drop_table("admin_notifications")
|
||||||
52
alembic/versions/202605190001_ocr_results.py
Normal file
52
alembic/versions/202605190001_ocr_results.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""persist ocr preview results for admin explorer
|
||||||
|
|
||||||
|
Revision ID: 202605190001
|
||||||
|
Revises: 202605170001
|
||||||
|
Create Date: 2026-05-19 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "202605190001"
|
||||||
|
down_revision: str | None = "202605170001"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"ocr_results",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("vehicle_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("scope", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("filename", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("content_type", sa.String(length=120), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=24), server_default="preview", nullable=False),
|
||||||
|
sa.Column("provider", sa.String(length=80), nullable=True),
|
||||||
|
sa.Column("confidence", sa.Numeric(5, 4), nullable=True),
|
||||||
|
sa.Column("recognized_text", sa.Text(), nullable=True),
|
||||||
|
sa.Column("candidates_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["vehicle_id"], ["cars.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_ocr_results_created_at", "ocr_results", ["created_at"])
|
||||||
|
op.create_index("ix_ocr_results_scope", "ocr_results", ["scope"])
|
||||||
|
op.create_index("ix_ocr_results_status", "ocr_results", ["status"])
|
||||||
|
op.create_index("ix_ocr_results_user_id", "ocr_results", ["user_id"])
|
||||||
|
op.create_index("ix_ocr_results_vehicle_id", "ocr_results", ["vehicle_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_ocr_results_vehicle_id", table_name="ocr_results")
|
||||||
|
op.drop_index("ix_ocr_results_user_id", table_name="ocr_results")
|
||||||
|
op.drop_index("ix_ocr_results_status", table_name="ocr_results")
|
||||||
|
op.drop_index("ix_ocr_results_scope", table_name="ocr_results")
|
||||||
|
op.drop_index("ix_ocr_results_created_at", table_name="ocr_results")
|
||||||
|
op.drop_table("ocr_results")
|
||||||
1143
app/api/admin.py
1143
app/api/admin.py
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import get_current_telegram_user
|
from app.api.deps import get_current_telegram_user
|
||||||
@@ -7,6 +7,7 @@ from app.db.session import get_session
|
|||||||
from app.models.car import Car, VehicleAccess
|
from app.models.car import Car, VehicleAccess
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.car import CarCreate, CarRead, CarUpdate
|
from app.schemas.car import CarCreate, CarRead, CarUpdate
|
||||||
|
from app.services.admin_notifications import create_admin_notification
|
||||||
from app.services.odometer import add_odometer_history, validate_odometer_change
|
from app.services.odometer import add_odometer_history, validate_odometer_change
|
||||||
from app.services.vehicle_identity import normalize_license_plate, validate_vin
|
from app.services.vehicle_identity import normalize_license_plate, validate_vin
|
||||||
|
|
||||||
@@ -42,6 +43,28 @@ async def create_car(
|
|||||||
source_record_id=None,
|
source_record_id=None,
|
||||||
changed_by=current_user.id,
|
changed_by=current_user.id,
|
||||||
)
|
)
|
||||||
|
vehicle_count = int(
|
||||||
|
(await session.execute(select(func.count(Car.id)).where(Car.owner_id == current_user.id))).scalar_one()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
if vehicle_count == 1:
|
||||||
|
await create_admin_notification(
|
||||||
|
session,
|
||||||
|
event_type="vehicle_created",
|
||||||
|
title="Пользователь впервые добавил авто",
|
||||||
|
body="\n".join(
|
||||||
|
[
|
||||||
|
f"User ID: {current_user.id}",
|
||||||
|
f"Telegram ID: {current_user.telegram_id}",
|
||||||
|
f"Авто: {car.name}",
|
||||||
|
f"Пробег: {car.current_odometer or '-'}",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
entity_type="vehicle",
|
||||||
|
entity_id=car.id,
|
||||||
|
idempotency_key=f"vehicle_created:{current_user.id}",
|
||||||
|
metadata={"user_id": current_user.id, "vehicle_id": car.id},
|
||||||
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(car)
|
await session.refresh(car)
|
||||||
return car
|
return car
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from app.core.config import settings
|
|||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models.car import AuditLog, Car, ServiceCenter, ServiceEmployee, VehicleAccess
|
from app.models.car import AuditLog, Car, ServiceCenter, ServiceEmployee, VehicleAccess
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.services.admin_notifications import create_admin_notification
|
||||||
from app.services.telegram_auth import verify_webapp_init_data
|
from app.services.telegram_auth import verify_webapp_init_data
|
||||||
|
|
||||||
|
|
||||||
@@ -36,6 +37,24 @@ async def get_or_create_telegram_user(
|
|||||||
if user is None:
|
if user is None:
|
||||||
user = User(**{key: value for key, value in payload.items() if value is not None})
|
user = User(**{key: value for key, value in payload.items() if value is not None})
|
||||||
session.add(user)
|
session.add(user)
|
||||||
|
await session.flush()
|
||||||
|
await create_admin_notification(
|
||||||
|
session,
|
||||||
|
event_type="user_registered",
|
||||||
|
title="Новый пользователь",
|
||||||
|
body="\n".join(
|
||||||
|
item
|
||||||
|
for item in [
|
||||||
|
f"Имя: {' '.join(part for part in [first_name, last_name] if part) or '-'}",
|
||||||
|
f"Telegram ID: {telegram_id}",
|
||||||
|
f"Username: @{username}" if username else "Username: -",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=user.id,
|
||||||
|
idempotency_key=f"user_registered:{telegram_id}",
|
||||||
|
metadata={"telegram_id": telegram_id, "username": username},
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
for field, value in payload.items():
|
for field, value in payload.items():
|
||||||
if value is not None:
|
if value is not None:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from io import BytesIO
|
|||||||
|
|
||||||
import matplotlib.pyplot as plt
|
import matplotlib.pyplot as plt
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import get_current_telegram_user
|
from app.api.deps import get_current_telegram_user
|
||||||
@@ -25,6 +25,7 @@ from app.schemas.expense import (
|
|||||||
ServiceEntryRead,
|
ServiceEntryRead,
|
||||||
ServiceEntryUpdate,
|
ServiceEntryUpdate,
|
||||||
)
|
)
|
||||||
|
from app.services.admin_notifications import create_admin_notification
|
||||||
from app.services.calculations import dataframe_from_query, get_ownership_stats, predict_odometer
|
from app.services.calculations import dataframe_from_query, get_ownership_stats, predict_odometer
|
||||||
from app.services.odometer import (
|
from app.services.odometer import (
|
||||||
apply_odometer_from_record,
|
apply_odometer_from_record,
|
||||||
@@ -53,6 +54,59 @@ async def ensure_entry_owner(
|
|||||||
return entry
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
async def maybe_notify_first_record(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
user: User,
|
||||||
|
car: Car,
|
||||||
|
record_type: str,
|
||||||
|
record_id: int,
|
||||||
|
) -> None:
|
||||||
|
fuel_count = int(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(func.count(FuelEntry.id)).join(Car, FuelEntry.car_id == Car.id).where(Car.owner_id == user.id)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
service_count = int(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(func.count(ServiceEntry.id)).join(Car, ServiceEntry.car_id == Car.id).where(Car.owner_id == user.id)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
expense_count = int(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(func.count(ExpenseEntry.id)).join(Car, ExpenseEntry.car_id == Car.id).where(Car.owner_id == user.id)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
if fuel_count + service_count + expense_count != 1:
|
||||||
|
return
|
||||||
|
await create_admin_notification(
|
||||||
|
session,
|
||||||
|
event_type="first_record_created",
|
||||||
|
title="Пользователь впервые создал запись",
|
||||||
|
body="\n".join(
|
||||||
|
[
|
||||||
|
f"User ID: {user.id}",
|
||||||
|
f"Telegram ID: {user.telegram_id}",
|
||||||
|
f"Авто: {car.name}",
|
||||||
|
f"Тип записи: {record_type}",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
entity_type="vehicle",
|
||||||
|
entity_id=car.id,
|
||||||
|
idempotency_key=f"first_record_created:{user.id}",
|
||||||
|
metadata={"user_id": user.id, "vehicle_id": car.id, "record_type": record_type, "record_id": record_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/fuel", response_model=FuelEntryRead, status_code=status.HTTP_201_CREATED)
|
@router.post("/fuel", response_model=FuelEntryRead, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_fuel_entry(
|
async def create_fuel_entry(
|
||||||
payload: FuelEntryCreate,
|
payload: FuelEntryCreate,
|
||||||
@@ -78,6 +132,7 @@ async def create_fuel_entry(
|
|||||||
changed_by=current_user.id,
|
changed_by=current_user.id,
|
||||||
confirm_lower_odometer=payload.confirm_lower_odometer,
|
confirm_lower_odometer=payload.confirm_lower_odometer,
|
||||||
)
|
)
|
||||||
|
await maybe_notify_first_record(session, user=current_user, car=car, record_type="fuel", record_id=entry.id)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(entry)
|
await session.refresh(entry)
|
||||||
return entry
|
return entry
|
||||||
@@ -174,6 +229,7 @@ async def create_service_entry(
|
|||||||
changed_by=current_user.id,
|
changed_by=current_user.id,
|
||||||
confirm_lower_odometer=payload.confirm_lower_odometer,
|
confirm_lower_odometer=payload.confirm_lower_odometer,
|
||||||
)
|
)
|
||||||
|
await maybe_notify_first_record(session, user=current_user, car=car, record_type="service", record_id=entry.id)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(entry)
|
await session.refresh(entry)
|
||||||
return entry
|
return entry
|
||||||
@@ -266,6 +322,7 @@ async def create_expense_entry(
|
|||||||
changed_by=current_user.id,
|
changed_by=current_user.id,
|
||||||
confirm_lower_odometer=payload.confirm_lower_odometer,
|
confirm_lower_odometer=payload.confirm_lower_odometer,
|
||||||
)
|
)
|
||||||
|
await maybe_notify_first_record(session, user=current_user, car=car, record_type="expense", record_id=entry.id)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(entry)
|
await session.refresh(entry)
|
||||||
return entry
|
return entry
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from app.schemas.service_center import (
|
|||||||
VehicleUpdate,
|
VehicleUpdate,
|
||||||
)
|
)
|
||||||
from app.schemas.user import UserRead
|
from app.schemas.user import UserRead
|
||||||
|
from app.services.admin_notifications import create_admin_notification
|
||||||
from app.services.odometer import (
|
from app.services.odometer import (
|
||||||
add_odometer_history,
|
add_odometer_history,
|
||||||
recalculate_current_odometer,
|
recalculate_current_odometer,
|
||||||
@@ -381,6 +382,20 @@ async def create_vehicle(
|
|||||||
changed_by=current_user.id,
|
changed_by=current_user.id,
|
||||||
)
|
)
|
||||||
await log_audit(session, actor=current_user, action="vehicle.create", target_type="vehicle", target_id=car.id)
|
await log_audit(session, actor=current_user, action="vehicle.create", target_type="vehicle", target_id=car.id)
|
||||||
|
vehicle_count = (
|
||||||
|
await session.execute(select(Car.id).where(Car.owner_id == current_user.id).limit(2))
|
||||||
|
).scalars().all()
|
||||||
|
if len(vehicle_count) == 1:
|
||||||
|
await create_admin_notification(
|
||||||
|
session,
|
||||||
|
event_type="vehicle_created",
|
||||||
|
title="Пользователь впервые добавил авто",
|
||||||
|
body=f"{current_user.first_name or current_user.username or current_user.telegram_id}: {car.name}",
|
||||||
|
entity_type="vehicle",
|
||||||
|
entity_id=car.id,
|
||||||
|
idempotency_key=f"first_vehicle:{current_user.id}",
|
||||||
|
metadata={"user_id": current_user.id, "vehicle_id": car.id},
|
||||||
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(car)
|
await session.refresh(car)
|
||||||
return car
|
return car
|
||||||
|
|||||||
270
app/api/ocr.py
270
app/api/ocr.py
@@ -1,15 +1,18 @@
|
|||||||
import re
|
import re
|
||||||
|
import time
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Request, UploadFile
|
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import get_current_telegram_user
|
from app.api.deps import get_current_telegram_user
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
|
from app.models.car import OCRResult
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.ocr_provider import get_ocr_provider
|
from app.services.admin_notifications import create_admin_notification
|
||||||
|
from app.services.ocr_provider import OcrResult, get_ocr_provider
|
||||||
from app.services.rate_limit import check_rate_limit
|
from app.services.rate_limit import check_rate_limit
|
||||||
from app.services.uploads import SAFE_IMAGE_TYPES, SAFE_TEXT_TYPES, validate_upload
|
from app.services.uploads import SAFE_IMAGE_TYPES, SAFE_TEXT_TYPES, validate_upload
|
||||||
|
|
||||||
@@ -40,6 +43,135 @@ class OCRResultRead(BaseModel):
|
|||||||
provider: str = "heuristic"
|
provider: str = "heuristic"
|
||||||
|
|
||||||
|
|
||||||
|
def ocr_candidates_json(result: OcrResult | None) -> list[dict] | None:
|
||||||
|
if result is None:
|
||||||
|
return None
|
||||||
|
return [
|
||||||
|
{"type": candidate.type, "value": candidate.value, "confidence": candidate.confidence}
|
||||||
|
for candidate in result.candidates
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def ocr_confidence(result: OcrResult | None) -> Decimal | None:
|
||||||
|
if result is None or not result.candidates:
|
||||||
|
return None
|
||||||
|
return Decimal(str(round(max(candidate.confidence for candidate in result.candidates), 4)))
|
||||||
|
|
||||||
|
|
||||||
|
async def save_ocr_result(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
current_user: User,
|
||||||
|
scope: str,
|
||||||
|
filename: str | None,
|
||||||
|
content_type: str | None,
|
||||||
|
status: str,
|
||||||
|
result: OcrResult | None = None,
|
||||||
|
recognized_text: str | None = None,
|
||||||
|
provider: str | None = None,
|
||||||
|
error: str | None = None,
|
||||||
|
) -> OCRResult:
|
||||||
|
record = OCRResult(
|
||||||
|
user_id=current_user.id,
|
||||||
|
scope=scope,
|
||||||
|
filename=filename,
|
||||||
|
content_type=content_type,
|
||||||
|
status=status,
|
||||||
|
provider=result.provider if result is not None else provider,
|
||||||
|
confidence=ocr_confidence(result),
|
||||||
|
recognized_text=result.recognized_text if result is not None else recognized_text,
|
||||||
|
candidates_json=ocr_candidates_json(result),
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
|
session.add(record)
|
||||||
|
await session.flush()
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_ocr_upload(
|
||||||
|
*,
|
||||||
|
session: AsyncSession,
|
||||||
|
current_user: User,
|
||||||
|
content: bytes,
|
||||||
|
filename: str | None,
|
||||||
|
content_type: str | None,
|
||||||
|
) -> str:
|
||||||
|
try:
|
||||||
|
return validate_upload(
|
||||||
|
content=content,
|
||||||
|
filename=filename,
|
||||||
|
content_type=content_type,
|
||||||
|
max_bytes=MAX_OCR_FILE_BYTES,
|
||||||
|
allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES,
|
||||||
|
)
|
||||||
|
except HTTPException as exc:
|
||||||
|
await save_ocr_result(
|
||||||
|
session,
|
||||||
|
current_user=current_user,
|
||||||
|
scope="upload_validation",
|
||||||
|
filename=filename,
|
||||||
|
content_type=content_type,
|
||||||
|
status="blocked",
|
||||||
|
error=str(exc.detail),
|
||||||
|
)
|
||||||
|
await create_admin_notification(
|
||||||
|
session,
|
||||||
|
event_type="upload_blocked",
|
||||||
|
title="Upload blocked",
|
||||||
|
body=f"OCR upload blocked: {filename or '-'}\nReason: {exc.detail}",
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=current_user.id,
|
||||||
|
severity="warning",
|
||||||
|
idempotency_key=(
|
||||||
|
f"upload_blocked:{current_user.id}:{filename or 'upload'}:{exc.status_code}:"
|
||||||
|
f"{int(time.time() // 60)}"
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"filename": filename,
|
||||||
|
"content_type": content_type,
|
||||||
|
"status_code": exc.status_code,
|
||||||
|
"detail": exc.detail,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def recognize_with_alert(
|
||||||
|
*,
|
||||||
|
session: AsyncSession,
|
||||||
|
current_user: User,
|
||||||
|
content: bytes,
|
||||||
|
filename: str | None,
|
||||||
|
scope: str,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await get_ocr_provider().recognize(content, filename)
|
||||||
|
except Exception as exc: # noqa: BLE001 - OCR must fail gracefully and alert admins
|
||||||
|
await save_ocr_result(
|
||||||
|
session,
|
||||||
|
current_user=current_user,
|
||||||
|
scope=scope,
|
||||||
|
filename=filename,
|
||||||
|
content_type=None,
|
||||||
|
status="failed",
|
||||||
|
error=type(exc).__name__,
|
||||||
|
)
|
||||||
|
await create_admin_notification(
|
||||||
|
session,
|
||||||
|
event_type="ocr_failed",
|
||||||
|
title="OCR provider failed",
|
||||||
|
body=f"Scope: {scope}\nFile: {filename or '-'}\nError: {type(exc).__name__}",
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=current_user.id,
|
||||||
|
severity="error",
|
||||||
|
idempotency_key=f"ocr_failed:{scope}:{current_user.id}:{int(time.time() // 60)}",
|
||||||
|
metadata={"scope": scope, "filename": filename, "error_type": type(exc).__name__},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/parse-text-receipt", response_model=ReceiptSuggestion)
|
@router.post("/parse-text-receipt", response_model=ReceiptSuggestion)
|
||||||
async def parse_text_receipt(
|
async def parse_text_receipt(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -49,21 +181,48 @@ async def parse_text_receipt(
|
|||||||
) -> ReceiptSuggestion:
|
) -> ReceiptSuggestion:
|
||||||
await check_rate_limit(scope="ocr", limit=10, window_seconds=60, request=request, user=current_user, session=session)
|
await check_rate_limit(scope="ocr", limit=10, window_seconds=60, request=request, user=current_user, session=session)
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
validate_upload(
|
await validate_ocr_upload(
|
||||||
content=content,
|
session=session,
|
||||||
|
current_user=current_user,
|
||||||
filename=file.filename,
|
filename=file.filename,
|
||||||
content_type=file.content_type,
|
content_type=file.content_type,
|
||||||
max_bytes=MAX_OCR_FILE_BYTES,
|
content=content,
|
||||||
allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES,
|
|
||||||
)
|
)
|
||||||
content_type = (file.content_type or "").lower()
|
content_type = (file.content_type or "").lower()
|
||||||
if content_type.startswith("image/") or content_type == "application/pdf":
|
if content_type.startswith("image/") or content_type == "application/pdf":
|
||||||
result = await get_ocr_provider().recognize(content, file.filename)
|
result = await recognize_with_alert(
|
||||||
if not result.recognized_text:
|
session=session,
|
||||||
|
current_user=current_user,
|
||||||
|
content=content,
|
||||||
|
filename=file.filename,
|
||||||
|
scope="parse_text_receipt",
|
||||||
|
)
|
||||||
|
if not result or not result.recognized_text:
|
||||||
|
if result is not None:
|
||||||
|
await save_ocr_result(
|
||||||
|
session,
|
||||||
|
current_user=current_user,
|
||||||
|
scope="parse_text_receipt",
|
||||||
|
filename=file.filename,
|
||||||
|
content_type=file.content_type,
|
||||||
|
status="preview",
|
||||||
|
result=result,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
return ReceiptSuggestion(
|
return ReceiptSuggestion(
|
||||||
confidence=0,
|
confidence=0,
|
||||||
message="Не удалось уверенно распознать чек. Открылся ручной ввод: проверьте дату, сумму, литры и цену.",
|
message="Не удалось уверенно распознать чек. Открылся ручной ввод: проверьте дату, сумму, литры и цену.",
|
||||||
)
|
)
|
||||||
|
await save_ocr_result(
|
||||||
|
session,
|
||||||
|
current_user=current_user,
|
||||||
|
scope="parse_text_receipt",
|
||||||
|
filename=file.filename,
|
||||||
|
content_type=file.content_type,
|
||||||
|
status="preview",
|
||||||
|
result=result,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
return parse_receipt_text(result.recognized_text)
|
return parse_receipt_text(result.recognized_text)
|
||||||
text = " ".join(
|
text = " ".join(
|
||||||
[
|
[
|
||||||
@@ -71,6 +230,17 @@ async def parse_text_receipt(
|
|||||||
content.decode("utf-8", errors="ignore"),
|
content.decode("utf-8", errors="ignore"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
await save_ocr_result(
|
||||||
|
session,
|
||||||
|
current_user=current_user,
|
||||||
|
scope="parse_text_receipt",
|
||||||
|
filename=file.filename,
|
||||||
|
content_type=file.content_type,
|
||||||
|
status="preview",
|
||||||
|
recognized_text=text,
|
||||||
|
provider="text",
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
return parse_receipt_text(text)
|
return parse_receipt_text(text)
|
||||||
|
|
||||||
|
|
||||||
@@ -133,8 +303,32 @@ async def recognize_license_plate(
|
|||||||
) -> OCRResultRead:
|
) -> OCRResultRead:
|
||||||
await check_rate_limit(scope="ocr_license_plate", limit=8, window_seconds=60, request=request, user=current_user, session=session)
|
await check_rate_limit(scope="ocr_license_plate", limit=8, window_seconds=60, request=request, user=current_user, session=session)
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
validate_upload(content=content, filename=file.filename, content_type=file.content_type, max_bytes=MAX_OCR_FILE_BYTES, allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES)
|
await validate_ocr_upload(
|
||||||
result = await get_ocr_provider().recognize(content, file.filename)
|
session=session,
|
||||||
|
current_user=current_user,
|
||||||
|
content=content,
|
||||||
|
filename=file.filename,
|
||||||
|
content_type=file.content_type,
|
||||||
|
)
|
||||||
|
result = await recognize_with_alert(
|
||||||
|
session=session,
|
||||||
|
current_user=current_user,
|
||||||
|
content=content,
|
||||||
|
filename=file.filename,
|
||||||
|
scope="license_plate",
|
||||||
|
)
|
||||||
|
if result is None:
|
||||||
|
return OCRResultRead(recognized_text="", candidates=[], provider="error")
|
||||||
|
await save_ocr_result(
|
||||||
|
session,
|
||||||
|
current_user=current_user,
|
||||||
|
scope="license_plate",
|
||||||
|
filename=file.filename,
|
||||||
|
content_type=file.content_type,
|
||||||
|
status="preview",
|
||||||
|
result=result,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
return OCRResultRead(
|
return OCRResultRead(
|
||||||
recognized_text=result.recognized_text,
|
recognized_text=result.recognized_text,
|
||||||
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "license_plate"],
|
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "license_plate"],
|
||||||
@@ -151,8 +345,32 @@ async def recognize_vin(
|
|||||||
) -> OCRResultRead:
|
) -> OCRResultRead:
|
||||||
await check_rate_limit(scope="ocr_vin", limit=8, window_seconds=60, request=request, user=current_user, session=session)
|
await check_rate_limit(scope="ocr_vin", limit=8, window_seconds=60, request=request, user=current_user, session=session)
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
validate_upload(content=content, filename=file.filename, content_type=file.content_type, max_bytes=MAX_OCR_FILE_BYTES, allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES)
|
await validate_ocr_upload(
|
||||||
result = await get_ocr_provider().recognize(content, file.filename)
|
session=session,
|
||||||
|
current_user=current_user,
|
||||||
|
content=content,
|
||||||
|
filename=file.filename,
|
||||||
|
content_type=file.content_type,
|
||||||
|
)
|
||||||
|
result = await recognize_with_alert(
|
||||||
|
session=session,
|
||||||
|
current_user=current_user,
|
||||||
|
content=content,
|
||||||
|
filename=file.filename,
|
||||||
|
scope="vin",
|
||||||
|
)
|
||||||
|
if result is None:
|
||||||
|
return OCRResultRead(recognized_text="", candidates=[], provider="error")
|
||||||
|
await save_ocr_result(
|
||||||
|
session,
|
||||||
|
current_user=current_user,
|
||||||
|
scope="vin",
|
||||||
|
filename=file.filename,
|
||||||
|
content_type=file.content_type,
|
||||||
|
status="preview",
|
||||||
|
result=result,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
return OCRResultRead(
|
return OCRResultRead(
|
||||||
recognized_text=result.recognized_text,
|
recognized_text=result.recognized_text,
|
||||||
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "vin"],
|
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "vin"],
|
||||||
@@ -169,8 +387,32 @@ async def recognize_service_document(
|
|||||||
) -> OCRResultRead:
|
) -> OCRResultRead:
|
||||||
await check_rate_limit(scope="ocr_service_document", limit=8, window_seconds=60, request=request, user=current_user, session=session)
|
await check_rate_limit(scope="ocr_service_document", limit=8, window_seconds=60, request=request, user=current_user, session=session)
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
validate_upload(content=content, filename=file.filename, content_type=file.content_type, max_bytes=MAX_OCR_FILE_BYTES, allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES)
|
await validate_ocr_upload(
|
||||||
result = await get_ocr_provider().recognize(content, file.filename)
|
session=session,
|
||||||
|
current_user=current_user,
|
||||||
|
content=content,
|
||||||
|
filename=file.filename,
|
||||||
|
content_type=file.content_type,
|
||||||
|
)
|
||||||
|
result = await recognize_with_alert(
|
||||||
|
session=session,
|
||||||
|
current_user=current_user,
|
||||||
|
content=content,
|
||||||
|
filename=file.filename,
|
||||||
|
scope="service_document",
|
||||||
|
)
|
||||||
|
if result is None:
|
||||||
|
return OCRResultRead(recognized_text="", candidates=[], provider="error")
|
||||||
|
await save_ocr_result(
|
||||||
|
session,
|
||||||
|
current_user=current_user,
|
||||||
|
scope="service_document",
|
||||||
|
filename=file.filename,
|
||||||
|
content_type=file.content_type,
|
||||||
|
status="preview",
|
||||||
|
result=result,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
return OCRResultRead(
|
return OCRResultRead(
|
||||||
recognized_text=result.recognized_text,
|
recognized_text=result.recognized_text,
|
||||||
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates],
|
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates],
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ from app.schemas.service_center import (
|
|||||||
VehicleSearchRequest,
|
VehicleSearchRequest,
|
||||||
VehicleSearchResult,
|
VehicleSearchResult,
|
||||||
)
|
)
|
||||||
|
from app.services.admin_notifications import create_admin_notification
|
||||||
from app.services.notifications import notify_platform_moderators
|
from app.services.notifications import notify_platform_moderators
|
||||||
from app.services.odometer import validate_odometer_change
|
from app.services.odometer import validate_odometer_change
|
||||||
from app.services.rate_limit import check_rate_limit
|
from app.services.rate_limit import check_rate_limit
|
||||||
@@ -147,6 +148,25 @@ async def create_service_center(
|
|||||||
target_type="service_center",
|
target_type="service_center",
|
||||||
target_id=center.id,
|
target_id=center.id,
|
||||||
)
|
)
|
||||||
|
await create_admin_notification(
|
||||||
|
session,
|
||||||
|
event_type="sto_application_created",
|
||||||
|
title="Новая заявка СТО",
|
||||||
|
body="\n".join(
|
||||||
|
item
|
||||||
|
for item in [
|
||||||
|
f"Название: {center.display_name or center.name}",
|
||||||
|
f"Город: {center.city or '-'}",
|
||||||
|
f"Телефон: {center.phone or center.contact_phone or '-'}",
|
||||||
|
f"Документы: {len(center.document_photo_urls or [])}",
|
||||||
|
"Статус: pending",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
entity_type="service_center",
|
||||||
|
entity_id=center.id,
|
||||||
|
idempotency_key=f"sto_application_created:{center.id}",
|
||||||
|
metadata={"city": center.city, "owner_user_id": current_user.id},
|
||||||
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(center)
|
await session.refresh(center)
|
||||||
await notify_platform_moderators(
|
await notify_platform_moderators(
|
||||||
@@ -189,6 +209,22 @@ async def update_service_center_application(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
await log_audit(session, actor=current_user, action="service_center.update", target_type="service_center", target_id=center.id)
|
await log_audit(session, actor=current_user, action="service_center.update", target_type="service_center", target_id=center.id)
|
||||||
|
await create_admin_notification(
|
||||||
|
session,
|
||||||
|
event_type="sto_application_updated",
|
||||||
|
title="СТО обновило заявку",
|
||||||
|
body="\n".join(
|
||||||
|
[
|
||||||
|
f"Название: {center.display_name or center.name}",
|
||||||
|
f"Город: {center.city or '-'}",
|
||||||
|
f"Статус: {center.verification_status}",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
entity_type="service_center",
|
||||||
|
entity_id=center.id,
|
||||||
|
idempotency_key=f"sto_application_updated:{center.id}:{int(datetime.now(UTC).timestamp() // 60)}",
|
||||||
|
metadata={"city": center.city, "owner_user_id": current_user.id},
|
||||||
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(center)
|
await session.refresh(center)
|
||||||
return center
|
return center
|
||||||
@@ -827,6 +863,18 @@ async def create_service_center_review(
|
|||||||
await log_audit(session, actor=current_user, action="service_review.upsert", target_type="service_center", target_id=service_center_id)
|
await log_audit(session, actor=current_user, action="service_review.upsert", target_type="service_center", target_id=service_center_id)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
await refresh_service_rating(session, service_center_id)
|
await refresh_service_rating(session, service_center_id)
|
||||||
|
if review.rating <= 2:
|
||||||
|
await create_admin_notification(
|
||||||
|
session,
|
||||||
|
event_type="sto_low_review",
|
||||||
|
title="Низкая оценка СТО",
|
||||||
|
body=f"СТО ID: {service_center_id}\nОценка: {review.rating}\nОтзыв: {review.text or '-'}",
|
||||||
|
entity_type="service_center",
|
||||||
|
entity_id=service_center_id,
|
||||||
|
severity="warning",
|
||||||
|
idempotency_key=f"sto_low_review:{review.id}:{review.rating}",
|
||||||
|
metadata={"review_id": review.id, "rating": review.rating, "user_id": current_user.id},
|
||||||
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(review)
|
await session.refresh(review)
|
||||||
return review
|
return review
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from app.schemas.sto_booking import (
|
|||||||
ServiceCenterHolidayRead,
|
ServiceCenterHolidayRead,
|
||||||
STODashboardRead,
|
STODashboardRead,
|
||||||
)
|
)
|
||||||
|
from app.services.admin_notifications import create_admin_notification
|
||||||
from app.services.rate_limit import check_rate_limit
|
from app.services.rate_limit import check_rate_limit
|
||||||
from app.services.sto_booking import (
|
from app.services.sto_booking import (
|
||||||
calculate_available_slots,
|
calculate_available_slots,
|
||||||
@@ -238,6 +239,28 @@ async def create_appointment(
|
|||||||
body=f"{appointment.service_name}: {appointment.requested_start_at:%Y-%m-%d %H:%M}",
|
body=f"{appointment.service_name}: {appointment.requested_start_at:%Y-%m-%d %H:%M}",
|
||||||
appointment_id=appointment.id,
|
appointment_id=appointment.id,
|
||||||
)
|
)
|
||||||
|
await create_admin_notification(
|
||||||
|
session,
|
||||||
|
event_type="appointment_created",
|
||||||
|
title="Новая запись в СТО",
|
||||||
|
body="\n".join(
|
||||||
|
[
|
||||||
|
f"СТО ID: {appointment.service_center_id}",
|
||||||
|
f"User ID: {current_user.id}",
|
||||||
|
f"Авто ID: {appointment.vehicle_id}",
|
||||||
|
f"Услуга: {appointment.service_name}",
|
||||||
|
f"Время: {appointment.requested_start_at:%Y-%m-%d %H:%M}",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
entity_type="appointment",
|
||||||
|
entity_id=appointment.id,
|
||||||
|
idempotency_key=f"appointment_created:{appointment.id}",
|
||||||
|
metadata={
|
||||||
|
"service_center_id": appointment.service_center_id,
|
||||||
|
"vehicle_id": appointment.vehicle_id,
|
||||||
|
"owner_id": appointment.owner_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
await log_audit(
|
await log_audit(
|
||||||
session,
|
session,
|
||||||
actor=current_user,
|
actor=current_user,
|
||||||
@@ -554,6 +577,17 @@ async def reject_appointment(
|
|||||||
title="СТО отклонило запись",
|
title="СТО отклонило запись",
|
||||||
body=payload.comment,
|
body=payload.comment,
|
||||||
)
|
)
|
||||||
|
await create_admin_notification(
|
||||||
|
session,
|
||||||
|
event_type="appointment_cancelled",
|
||||||
|
title="СТО отклонило запись",
|
||||||
|
body=payload.comment,
|
||||||
|
entity_type="appointment",
|
||||||
|
entity_id=appointment.id,
|
||||||
|
severity="warning",
|
||||||
|
idempotency_key=f"appointment_rejected_by_sto:{appointment.id}",
|
||||||
|
metadata={"service_center_id": appointment.service_center_id, "owner_id": appointment.owner_id},
|
||||||
|
)
|
||||||
await log_audit(session, actor=current_user, action="appointment.reject", target_type="service_appointment", target_id=appointment_id)
|
await log_audit(session, actor=current_user, action="appointment.reject", target_type="service_appointment", target_id=appointment_id)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(appointment)
|
await session.refresh(appointment)
|
||||||
@@ -579,6 +613,17 @@ async def delete_appointment_by_sto(
|
|||||||
body=f"{appointment.service_name}: {appointment.requested_start_at:%Y-%m-%d %H:%M}",
|
body=f"{appointment.service_name}: {appointment.requested_start_at:%Y-%m-%d %H:%M}",
|
||||||
idempotency_key=f"appointment:{appointment.id}:deleted_by_sto",
|
idempotency_key=f"appointment:{appointment.id}:deleted_by_sto",
|
||||||
)
|
)
|
||||||
|
await create_admin_notification(
|
||||||
|
session,
|
||||||
|
event_type="appointment_cancelled",
|
||||||
|
title="СТО удалило запись",
|
||||||
|
body=f"{appointment.service_name}: {appointment.requested_start_at:%Y-%m-%d %H:%M}",
|
||||||
|
entity_type="appointment",
|
||||||
|
entity_id=appointment.id,
|
||||||
|
severity="warning",
|
||||||
|
idempotency_key=f"appointment_deleted_by_sto:{appointment.id}",
|
||||||
|
metadata={"service_center_id": appointment.service_center_id, "owner_id": appointment.owner_id},
|
||||||
|
)
|
||||||
await log_audit(
|
await log_audit(
|
||||||
session,
|
session,
|
||||||
actor=current_user,
|
actor=current_user,
|
||||||
@@ -677,6 +722,21 @@ async def create_work_order_from_appointment(
|
|||||||
body=visit.work_order_number,
|
body=visit.work_order_number,
|
||||||
idempotency_key=f"work_order:{visit.id}:created",
|
idempotency_key=f"work_order:{visit.id}:created",
|
||||||
)
|
)
|
||||||
|
await create_admin_notification(
|
||||||
|
session,
|
||||||
|
event_type="work_order_created",
|
||||||
|
title="Создан заказ-наряд",
|
||||||
|
body=f"{visit.work_order_number or visit.id}: СТО {visit.service_center_id}, авто {visit.vehicle_id}",
|
||||||
|
entity_type="work_order",
|
||||||
|
entity_id=visit.id,
|
||||||
|
idempotency_key=f"work_order_created:{visit.id}",
|
||||||
|
metadata={
|
||||||
|
"appointment_id": appointment.id,
|
||||||
|
"service_center_id": visit.service_center_id,
|
||||||
|
"vehicle_id": visit.vehicle_id,
|
||||||
|
"owner_id": visit.owner_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
await log_audit(session, actor=current_user, action="appointment.create_work_order", target_type="service_appointment", target_id=appointment_id, metadata={"service_visit_id": visit.id})
|
await log_audit(session, actor=current_user, action="appointment.create_work_order", target_type="service_appointment", target_id=appointment_id, metadata={"service_visit_id": visit.id})
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(visit)
|
await session.refresh(visit)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ from app.schemas.service_center import (
|
|||||||
WorkOrderStatusHistoryRead,
|
WorkOrderStatusHistoryRead,
|
||||||
WorkOrderUpdate,
|
WorkOrderUpdate,
|
||||||
)
|
)
|
||||||
|
from app.services.admin_notifications import create_admin_notification
|
||||||
from app.services.sto_booking import create_service_notification
|
from app.services.sto_booking import create_service_notification
|
||||||
from app.services.work_orders import (
|
from app.services.work_orders import (
|
||||||
add_labor_item,
|
add_labor_item,
|
||||||
@@ -461,6 +462,17 @@ async def reject_work_order(
|
|||||||
visit.owner_comment = payload.comment
|
visit.owner_comment = payload.comment
|
||||||
visit.owner_resolved_at = datetime.now(UTC)
|
visit.owner_resolved_at = datetime.now(UTC)
|
||||||
await add_status_history(session, visit, to_status="rejected_by_owner", actor=current_user, comment=payload.comment)
|
await add_status_history(session, visit, to_status="rejected_by_owner", actor=current_user, comment=payload.comment)
|
||||||
|
await create_admin_notification(
|
||||||
|
session,
|
||||||
|
event_type="work_order_rejected_by_owner",
|
||||||
|
title="Владелец отклонил смету",
|
||||||
|
body=payload.comment,
|
||||||
|
entity_type="work_order",
|
||||||
|
entity_id=visit.id,
|
||||||
|
severity="warning",
|
||||||
|
idempotency_key=f"work_order_rejected_by_owner:{visit.id}",
|
||||||
|
metadata={"service_center_id": visit.service_center_id, "vehicle_id": visit.vehicle_id, "owner_id": current_user.id},
|
||||||
|
)
|
||||||
await log_audit(session, actor=current_user, action="work_order.reject", target_type="service_visit", target_id=visit.id)
|
await log_audit(session, actor=current_user, action="work_order.reject", target_type="service_visit", target_id=visit.id)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(visit)
|
await session.refresh(visit)
|
||||||
@@ -502,6 +514,21 @@ async def complete_work_order(
|
|||||||
actor=current_user,
|
actor=current_user,
|
||||||
confirm_lower_odometer=payload.confirm_lower_odometer,
|
confirm_lower_odometer=payload.confirm_lower_odometer,
|
||||||
)
|
)
|
||||||
|
await create_admin_notification(
|
||||||
|
session,
|
||||||
|
event_type="work_order_completed",
|
||||||
|
title="Заказ-наряд завершён",
|
||||||
|
body=f"{visit.work_order_number or visit.id}: {visit.final_total} {visit.currency}",
|
||||||
|
entity_type="work_order",
|
||||||
|
entity_id=visit.id,
|
||||||
|
idempotency_key=f"work_order_completed:{visit.id}",
|
||||||
|
metadata={
|
||||||
|
"service_center_id": visit.service_center_id,
|
||||||
|
"vehicle_id": visit.vehicle_id,
|
||||||
|
"owner_id": visit.owner_id,
|
||||||
|
"final_total": str(visit.final_total),
|
||||||
|
},
|
||||||
|
)
|
||||||
await log_audit(session, actor=current_user, action="work_order.complete", target_type="service_visit", target_id=visit.id)
|
await log_audit(session, actor=current_user, action="work_order.complete", target_type="service_visit", target_id=visit.id)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(visit)
|
await session.refresh(visit)
|
||||||
@@ -599,6 +626,21 @@ async def create_work_order_correction(
|
|||||||
web_app_url=work_order_webapp_url(visit.id),
|
web_app_url=work_order_webapp_url(visit.id),
|
||||||
button_text="Открыть заказ-наряд",
|
button_text="Открыть заказ-наряд",
|
||||||
)
|
)
|
||||||
|
await create_admin_notification(
|
||||||
|
session,
|
||||||
|
event_type="work_order_correction_requested",
|
||||||
|
title="Запрошена коррекция заказ-наряда",
|
||||||
|
body=payload.reason,
|
||||||
|
entity_type="work_order",
|
||||||
|
entity_id=visit.id,
|
||||||
|
severity="warning",
|
||||||
|
idempotency_key=f"work_order_correction_requested:{visit.id}:{visit.version or 1}:{payload.reason[:80]}",
|
||||||
|
metadata={
|
||||||
|
"service_center_id": visit.service_center_id,
|
||||||
|
"vehicle_id": visit.vehicle_id,
|
||||||
|
"owner_approval_required": payload.owner_approval_required,
|
||||||
|
},
|
||||||
|
)
|
||||||
await log_audit(
|
await log_audit(
|
||||||
session,
|
session,
|
||||||
actor=current_user,
|
actor=current_user,
|
||||||
@@ -626,6 +668,16 @@ async def approve_work_order_correction(
|
|||||||
raise HTTPException(status_code=409, detail="Correction is already resolved")
|
raise HTTPException(status_code=409, detail="Correction is already resolved")
|
||||||
correction.status = "approved"
|
correction.status = "approved"
|
||||||
correction.resolved_at = datetime.now(UTC)
|
correction.resolved_at = datetime.now(UTC)
|
||||||
|
await create_admin_notification(
|
||||||
|
session,
|
||||||
|
event_type="work_order_correction_resolved",
|
||||||
|
title="Коррекция заказ-наряда согласована",
|
||||||
|
body=payload.comment,
|
||||||
|
entity_type="work_order",
|
||||||
|
entity_id=visit.id,
|
||||||
|
idempotency_key=f"work_order_correction_approved:{correction.id}",
|
||||||
|
metadata={"correction_id": correction.id, "status": "approved"},
|
||||||
|
)
|
||||||
await log_audit(
|
await log_audit(
|
||||||
session,
|
session,
|
||||||
actor=current_user,
|
actor=current_user,
|
||||||
@@ -653,6 +705,17 @@ async def reject_work_order_correction(
|
|||||||
raise HTTPException(status_code=409, detail="Correction is already resolved")
|
raise HTTPException(status_code=409, detail="Correction is already resolved")
|
||||||
correction.status = "rejected"
|
correction.status = "rejected"
|
||||||
correction.resolved_at = datetime.now(UTC)
|
correction.resolved_at = datetime.now(UTC)
|
||||||
|
await create_admin_notification(
|
||||||
|
session,
|
||||||
|
event_type="work_order_correction_resolved",
|
||||||
|
title="Коррекция заказ-наряда отклонена",
|
||||||
|
body=payload.comment,
|
||||||
|
entity_type="work_order",
|
||||||
|
entity_id=visit.id,
|
||||||
|
severity="warning",
|
||||||
|
idempotency_key=f"work_order_correction_rejected:{correction.id}",
|
||||||
|
metadata={"correction_id": correction.id, "status": "rejected"},
|
||||||
|
)
|
||||||
await log_audit(
|
await log_audit(
|
||||||
session,
|
session,
|
||||||
actor=current_user,
|
actor=current_user,
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ class Settings(BaseSettings):
|
|||||||
ocr_languages: str = "eng+rus+kor"
|
ocr_languages: str = "eng+rus+kor"
|
||||||
admin_telegram_ids: str = ""
|
admin_telegram_ids: str = ""
|
||||||
admin_bootstrap_token: str = ""
|
admin_bootstrap_token: str = ""
|
||||||
|
admin_notification_chat_id: str = ""
|
||||||
|
admin_notify_new_users: bool = True
|
||||||
|
admin_notify_sto_applications: bool = True
|
||||||
|
admin_notify_security_events: bool = True
|
||||||
|
admin_notify_system_errors: bool = True
|
||||||
|
|
||||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||||
|
|
||||||
|
|||||||
26
app/main.py
26
app/main.py
@@ -25,7 +25,8 @@ from app.api import (
|
|||||||
work_orders,
|
work_orders,
|
||||||
)
|
)
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.session import get_session
|
from app.db.session import async_session_factory, get_session
|
||||||
|
from app.services.admin_notifications import create_admin_notification
|
||||||
from app.services.rate_limit import get_redis_client
|
from app.services.rate_limit import get_redis_client
|
||||||
|
|
||||||
|
|
||||||
@@ -49,8 +50,29 @@ async def production_headers_and_metrics(request: Request, call_next):
|
|||||||
start = monotonic()
|
start = monotonic()
|
||||||
try:
|
try:
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
REQUEST_ERRORS += 1
|
REQUEST_ERRORS += 1
|
||||||
|
try:
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
await create_admin_notification(
|
||||||
|
session,
|
||||||
|
event_type="system_error",
|
||||||
|
title="Unhandled API error",
|
||||||
|
body=f"{request.method} {request.url.path}\nError: {type(exc).__name__}",
|
||||||
|
entity_type="system",
|
||||||
|
entity_id=request.url.path,
|
||||||
|
severity="error",
|
||||||
|
idempotency_key=f"system_error:{request.url.path}:{type(exc).__name__}:{int(start // 60)}",
|
||||||
|
metadata={
|
||||||
|
"path": request.url.path,
|
||||||
|
"method": request.method,
|
||||||
|
"request_id": request_id,
|
||||||
|
"error_type": type(exc).__name__,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
raise
|
raise
|
||||||
duration = monotonic() - start
|
duration = monotonic() - start
|
||||||
REQUEST_COUNT += 1
|
REQUEST_COUNT += 1
|
||||||
|
|||||||
@@ -432,6 +432,47 @@ class ServiceNotification(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminNotification(Base):
|
||||||
|
__tablename__ = "admin_notifications"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
event_type: Mapped[str] = mapped_column(String(80), index=True)
|
||||||
|
severity: Mapped[str] = mapped_column(String(24), default="info", server_default="info", index=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(180))
|
||||||
|
body: Mapped[str | None] = mapped_column(Text)
|
||||||
|
entity_type: Mapped[str | None] = mapped_column(String(80), index=True)
|
||||||
|
entity_id: Mapped[str | None] = mapped_column(String(80), index=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(24), default="unread", server_default="unread", index=True)
|
||||||
|
idempotency_key: Mapped[str] = mapped_column(String(180), unique=True, index=True)
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSON)
|
||||||
|
telegram_status: Mapped[str] = mapped_column(String(24), default="pending", server_default="pending", index=True)
|
||||||
|
telegram_error: Mapped[str | None] = mapped_column(Text)
|
||||||
|
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
dismissed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OCRResult(Base):
|
||||||
|
__tablename__ = "ocr_results"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
|
||||||
|
vehicle_id: Mapped[int | None] = mapped_column(ForeignKey("cars.id", ondelete="SET NULL"), index=True)
|
||||||
|
scope: Mapped[str] = mapped_column(String(80), index=True)
|
||||||
|
filename: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
content_type: Mapped[str | None] = mapped_column(String(120))
|
||||||
|
status: Mapped[str] = mapped_column(String(24), default="preview", server_default="preview", index=True)
|
||||||
|
provider: Mapped[str | None] = mapped_column(String(80))
|
||||||
|
confidence: Mapped[Decimal | None] = mapped_column(Numeric(5, 4))
|
||||||
|
recognized_text: Mapped[str | None] = mapped_column(Text)
|
||||||
|
candidates_json: Mapped[list | None] = mapped_column(JSON)
|
||||||
|
error: Mapped[str | None] = mapped_column(Text)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||||
|
|
||||||
|
|
||||||
class ServiceWorkItem(Base):
|
class ServiceWorkItem(Base):
|
||||||
__tablename__ = "service_work_items"
|
__tablename__ = "service_work_items"
|
||||||
|
|
||||||
@@ -625,3 +666,19 @@ class AuditLog(Base):
|
|||||||
user_agent: Mapped[str | None] = mapped_column(String(256))
|
user_agent: Mapped[str | None] = mapped_column(String(256))
|
||||||
metadata_json: Mapped[dict | None] = mapped_column(JSON)
|
metadata_json: Mapped[dict | None] = mapped_column(JSON)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminExportJob(Base):
|
||||||
|
__tablename__ = "admin_export_jobs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
requested_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
|
||||||
|
source: Mapped[str] = mapped_column(String(80), index=True)
|
||||||
|
export_format: Mapped[str] = mapped_column(String(16), default="json", server_default="json")
|
||||||
|
status: Mapped[str] = mapped_column(String(24), default="ready", server_default="ready", index=True)
|
||||||
|
reason: Mapped[str | None] = mapped_column(Text)
|
||||||
|
filters_json: Mapped[dict | None] = mapped_column(JSON)
|
||||||
|
result_text: Mapped[str | None] = mapped_column(Text)
|
||||||
|
row_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||||
|
|||||||
185
app/services/admin_notifications.py
Normal file
185
app/services/admin_notifications.py
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from html import escape
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.car import AdminNotification
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
ADMIN_EVENT_FLAGS = {
|
||||||
|
"user_registered": "admin_notify_new_users",
|
||||||
|
"vehicle_created": "admin_notify_new_users",
|
||||||
|
"first_record_created": "admin_notify_new_users",
|
||||||
|
"sto_application_created": "admin_notify_sto_applications",
|
||||||
|
"sto_application_updated": "admin_notify_sto_applications",
|
||||||
|
"sto_approved": "admin_notify_sto_applications",
|
||||||
|
"sto_suspended": "admin_notify_sto_applications",
|
||||||
|
"sto_low_review": "admin_notify_sto_applications",
|
||||||
|
"appointment_created": "admin_notify_sto_applications",
|
||||||
|
"appointment_cancelled": "admin_notify_sto_applications",
|
||||||
|
"work_order_created": "admin_notify_sto_applications",
|
||||||
|
"work_order_completed": "admin_notify_sto_applications",
|
||||||
|
"work_order_rejected_by_owner": "admin_notify_sto_applications",
|
||||||
|
"work_order_correction_requested": "admin_notify_sto_applications",
|
||||||
|
"work_order_correction_resolved": "admin_notify_sto_applications",
|
||||||
|
"security_event": "admin_notify_security_events",
|
||||||
|
"rate_limit_exceeded": "admin_notify_security_events",
|
||||||
|
"upload_blocked": "admin_notify_security_events",
|
||||||
|
"system_error": "admin_notify_system_errors",
|
||||||
|
"ocr_failed": "admin_notify_system_errors",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def admin_event_enabled(event_type: str) -> bool:
|
||||||
|
flag = ADMIN_EVENT_FLAGS.get(event_type)
|
||||||
|
return bool(getattr(settings, flag, True)) if flag else True
|
||||||
|
|
||||||
|
|
||||||
|
def admin_recipients() -> list[str]:
|
||||||
|
recipients: list[str] = []
|
||||||
|
if settings.admin_notification_chat_id:
|
||||||
|
recipients.append(settings.admin_notification_chat_id)
|
||||||
|
recipients.extend(str(item) for item in settings.admin_telegram_id_list)
|
||||||
|
return list(dict.fromkeys(recipients))
|
||||||
|
|
||||||
|
|
||||||
|
def admin_notification_url(entity_type: str | None = None, entity_id: str | int | None = None) -> str:
|
||||||
|
base = settings.effective_webapp_url
|
||||||
|
if entity_type == "service_center" and entity_id:
|
||||||
|
return f"{base}/admin.html?section=sto-applications&entity_id={entity_id}"
|
||||||
|
if entity_type == "user" and entity_id:
|
||||||
|
return f"{base}/admin.html?section=users&entity_id={entity_id}"
|
||||||
|
if entity_type == "vehicle" and entity_id:
|
||||||
|
return f"{base}/admin.html?section=vehicles&entity_id={entity_id}"
|
||||||
|
if entity_type == "appointment" and entity_id:
|
||||||
|
return f"{base}/admin.html?section=appointments&entity_id={entity_id}"
|
||||||
|
if entity_type == "work_order" and entity_id:
|
||||||
|
return f"{base}/admin.html?section=work-orders&entity_id={entity_id}"
|
||||||
|
if entity_type == "ocr_result" and entity_id:
|
||||||
|
return f"{base}/admin.html?section=data&source=ocr_results&entity_id={entity_id}"
|
||||||
|
return f"{base}/admin.html"
|
||||||
|
|
||||||
|
|
||||||
|
async def create_admin_notification(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
event_type: str,
|
||||||
|
title: str,
|
||||||
|
body: str | None = None,
|
||||||
|
entity_type: str | None = None,
|
||||||
|
entity_id: int | str | None = None,
|
||||||
|
severity: str = "info",
|
||||||
|
idempotency_key: str | None = None,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
send_telegram: bool = True,
|
||||||
|
) -> AdminNotification:
|
||||||
|
key = idempotency_key or f"{event_type}:{entity_type or 'system'}:{entity_id or title}"
|
||||||
|
existing = (
|
||||||
|
await session.execute(select(AdminNotification).where(AdminNotification.idempotency_key == key))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
notification = AdminNotification(
|
||||||
|
event_type=event_type,
|
||||||
|
title=title,
|
||||||
|
body=body,
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=str(entity_id) if entity_id is not None else None,
|
||||||
|
severity=severity,
|
||||||
|
idempotency_key=key,
|
||||||
|
metadata_json=metadata,
|
||||||
|
telegram_status="pending" if send_telegram else "skipped",
|
||||||
|
)
|
||||||
|
session.add(notification)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
if send_telegram and admin_event_enabled(event_type):
|
||||||
|
await send_admin_telegram_notification(notification)
|
||||||
|
elif send_telegram:
|
||||||
|
notification.telegram_status = "skipped"
|
||||||
|
return notification
|
||||||
|
|
||||||
|
|
||||||
|
async def send_admin_telegram_notification(notification: AdminNotification) -> None:
|
||||||
|
recipients = admin_recipients()
|
||||||
|
if not recipients or not settings.bot_token:
|
||||||
|
notification.telegram_status = "skipped"
|
||||||
|
return
|
||||||
|
|
||||||
|
link = admin_notification_url(notification.entity_type, notification.entity_id)
|
||||||
|
text = "\n".join(
|
||||||
|
item
|
||||||
|
for item in [
|
||||||
|
f"<b>{escape(notification.title)}</b>",
|
||||||
|
escape(notification.body or ""),
|
||||||
|
f"Событие: <code>{escape(notification.event_type)}</code>",
|
||||||
|
f"Открыть: {escape(link)}",
|
||||||
|
]
|
||||||
|
if item
|
||||||
|
)
|
||||||
|
errors: list[str] = []
|
||||||
|
async with httpx.AsyncClient(timeout=8) as client:
|
||||||
|
for chat_id in recipients:
|
||||||
|
try:
|
||||||
|
response = await client.post(
|
||||||
|
f"https://api.telegram.org/bot{settings.bot_token}/sendMessage",
|
||||||
|
json={
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"text": text,
|
||||||
|
"parse_mode": "HTML",
|
||||||
|
"disable_web_page_preview": True,
|
||||||
|
"reply_markup": {
|
||||||
|
"inline_keyboard": [[{"text": "Открыть в админке", "url": link}]]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
except Exception as error: # noqa: BLE001 - notification delivery is best-effort
|
||||||
|
logger.warning("Admin Telegram notification failed: %s", error)
|
||||||
|
errors.append(str(error))
|
||||||
|
if errors:
|
||||||
|
notification.telegram_status = "failed"
|
||||||
|
notification.telegram_error = "; ".join(errors)[:2000]
|
||||||
|
else:
|
||||||
|
notification.telegram_status = "sent"
|
||||||
|
|
||||||
|
|
||||||
|
async def retry_admin_telegram_notifications(session: AsyncSession, *, limit: int = 50) -> int:
|
||||||
|
result = await session.execute(
|
||||||
|
select(AdminNotification)
|
||||||
|
.where(AdminNotification.telegram_status.in_(["pending", "failed"]))
|
||||||
|
.order_by(AdminNotification.created_at.asc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
delivered = 0
|
||||||
|
for notification in result.scalars():
|
||||||
|
await send_admin_telegram_notification(notification)
|
||||||
|
if notification.telegram_status == "sent":
|
||||||
|
delivered += 1
|
||||||
|
await session.commit()
|
||||||
|
return delivered
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_admin_notification_read(
|
||||||
|
session: AsyncSession, notification: AdminNotification
|
||||||
|
) -> AdminNotification:
|
||||||
|
notification.status = "read"
|
||||||
|
notification.read_at = datetime.now(UTC)
|
||||||
|
await session.flush()
|
||||||
|
return notification
|
||||||
|
|
||||||
|
|
||||||
|
async def dismiss_admin_notification(
|
||||||
|
session: AsyncSession, notification: AdminNotification
|
||||||
|
) -> AdminNotification:
|
||||||
|
notification.status = "dismissed"
|
||||||
|
notification.dismissed_at = datetime.now(UTC)
|
||||||
|
await session.flush()
|
||||||
|
return notification
|
||||||
@@ -41,7 +41,13 @@ async def check_rate_limit(
|
|||||||
if settings.redis_url:
|
if settings.redis_url:
|
||||||
allowed = await check_redis_rate_limit(scope, identifiers, limit, window_seconds)
|
allowed = await check_redis_rate_limit(scope, identifiers, limit, window_seconds)
|
||||||
if not allowed:
|
if not allowed:
|
||||||
await log_rate_limit_event(session, scope=scope, identifier="redis")
|
await log_rate_limit_event(
|
||||||
|
session,
|
||||||
|
scope=scope,
|
||||||
|
identifier="redis",
|
||||||
|
user=user,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
raise_rate_limit(scope, window_seconds)
|
raise_rate_limit(scope, window_seconds)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -52,7 +58,13 @@ async def check_rate_limit(
|
|||||||
while bucket and now - bucket[0] > window_seconds:
|
while bucket and now - bucket[0] > window_seconds:
|
||||||
bucket.popleft()
|
bucket.popleft()
|
||||||
if len(bucket) >= limit:
|
if len(bucket) >= limit:
|
||||||
await log_rate_limit_event(session, scope=scope, identifier=str(identifier))
|
await log_rate_limit_event(
|
||||||
|
session,
|
||||||
|
scope=scope,
|
||||||
|
identifier=str(identifier),
|
||||||
|
user=user,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
raise_rate_limit(scope, window_seconds)
|
raise_rate_limit(scope, window_seconds)
|
||||||
for identifier in identifiers:
|
for identifier in identifiers:
|
||||||
_buckets[(scope, identifier)].append(now)
|
_buckets[(scope, identifier)].append(now)
|
||||||
@@ -107,18 +119,82 @@ async def log_rate_limit_event(
|
|||||||
*,
|
*,
|
||||||
scope: str,
|
scope: str,
|
||||||
identifier: str,
|
identifier: str,
|
||||||
|
user: User | None = None,
|
||||||
|
request: Request | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if session is None:
|
client_host = request.client.host if request and request.client else None
|
||||||
return
|
user_agent = request.headers.get("user-agent") if request else None
|
||||||
from app.models.car import AuditLog
|
metadata = {
|
||||||
|
"scope": scope,
|
||||||
|
"identifier": identifier,
|
||||||
|
"telegram_id": user.telegram_id if user else None,
|
||||||
|
"user_id": user.id if user else None,
|
||||||
|
"ip": client_host,
|
||||||
|
}
|
||||||
|
|
||||||
session.add(
|
if session is None:
|
||||||
|
from app.db.session import async_session_factory
|
||||||
|
|
||||||
|
async with async_session_factory() as event_session:
|
||||||
|
await persist_rate_limit_event(
|
||||||
|
event_session,
|
||||||
|
scope=scope,
|
||||||
|
identifier=identifier,
|
||||||
|
user=user,
|
||||||
|
client_host=client_host,
|
||||||
|
user_agent=user_agent,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await persist_rate_limit_event(
|
||||||
|
session,
|
||||||
|
scope=scope,
|
||||||
|
identifier=identifier,
|
||||||
|
user=user,
|
||||||
|
client_host=client_host,
|
||||||
|
user_agent=user_agent,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def persist_rate_limit_event(
|
||||||
|
event_session: AsyncSession,
|
||||||
|
*,
|
||||||
|
scope: str,
|
||||||
|
identifier: str,
|
||||||
|
user: User | None,
|
||||||
|
client_host: str | None,
|
||||||
|
user_agent: str | None,
|
||||||
|
metadata: dict,
|
||||||
|
) -> None:
|
||||||
|
from app.models.car import AuditLog
|
||||||
|
from app.services.admin_notifications import create_admin_notification
|
||||||
|
|
||||||
|
try:
|
||||||
|
event_session.add(
|
||||||
AuditLog(
|
AuditLog(
|
||||||
actor_user_id=None,
|
actor_user_id=user.id if user else None,
|
||||||
actor_role="system",
|
actor_role=user.platform_role if user else "system",
|
||||||
action="rate_limit.exceeded",
|
action="rate_limit.exceeded",
|
||||||
target_type=scope,
|
target_type=scope,
|
||||||
target_id=identifier[:80],
|
target_id=identifier[:80],
|
||||||
metadata_json={"scope": scope, "identifier": identifier},
|
metadata_json=metadata,
|
||||||
|
ip=client_host,
|
||||||
|
user_agent=user_agent[:256] if user_agent else None,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
await create_admin_notification(
|
||||||
|
event_session,
|
||||||
|
event_type="rate_limit_exceeded",
|
||||||
|
title="Rate limit exceeded",
|
||||||
|
body=f"Scope: {scope}\nIdentifier: {identifier}",
|
||||||
|
entity_type="user" if user else "system",
|
||||||
|
entity_id=user.id if user else scope,
|
||||||
|
severity="warning",
|
||||||
|
idempotency_key=f"rate_limit:{scope}:{identifier}:{int(time.time() // max(60, 1))}",
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
await event_session.commit()
|
||||||
|
except Exception:
|
||||||
|
await event_session.rollback()
|
||||||
|
|||||||
@@ -8,6 +8,21 @@ from app.core.config import settings
|
|||||||
class ApiClient:
|
class ApiClient:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.base_url = settings.api_base_url.rstrip("/")
|
self.base_url = settings.api_base_url.rstrip("/")
|
||||||
|
self._client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client(self) -> httpx.AsyncClient:
|
||||||
|
if self._client is None or self._client.is_closed:
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
base_url=self.base_url,
|
||||||
|
timeout=httpx.Timeout(15.0, connect=5.0),
|
||||||
|
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
|
||||||
|
)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self._client is not None:
|
||||||
|
await self._client.aclose()
|
||||||
|
|
||||||
def headers(self, telegram_id: int | None = None) -> dict[str, str]:
|
def headers(self, telegram_id: int | None = None) -> dict[str, str]:
|
||||||
headers = {"X-Internal-API-Token": settings.internal_api_token}
|
headers = {"X-Internal-API-Token": settings.internal_api_token}
|
||||||
@@ -24,8 +39,7 @@ class ApiClient:
|
|||||||
json: dict[str, Any] | None = None,
|
json: dict[str, Any] | None = None,
|
||||||
params: dict[str, Any] | None = None,
|
params: dict[str, Any] | None = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
async with httpx.AsyncClient(base_url=self.base_url, timeout=15) as client:
|
response = await self.client.request(
|
||||||
response = await client.request(
|
|
||||||
method,
|
method,
|
||||||
path,
|
path,
|
||||||
json=json,
|
json=json,
|
||||||
@@ -44,22 +58,19 @@ class ApiClient:
|
|||||||
"first_name": telegram_user.first_name,
|
"first_name": telegram_user.first_name,
|
||||||
"last_name": telegram_user.last_name,
|
"last_name": telegram_user.last_name,
|
||||||
}
|
}
|
||||||
async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client:
|
response = await self.client.post("/api/users", json=payload, headers=self.headers())
|
||||||
response = await client.post("/api/users", json=payload, headers=self.headers())
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
async def list_cars(self, owner_id: int, telegram_id: int) -> list[dict[str, Any]]:
|
async def list_cars(self, owner_id: int, telegram_id: int) -> list[dict[str, Any]]:
|
||||||
async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client:
|
response = await self.client.get(
|
||||||
response = await client.get(
|
|
||||||
"/api/cars", params={"owner_id": owner_id}, headers=self.headers(telegram_id)
|
"/api/cars", params={"owner_id": owner_id}, headers=self.headers(telegram_id)
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
async def create_car(self, owner_id: int, name: str, telegram_id: int) -> dict[str, Any]:
|
async def create_car(self, owner_id: int, name: str, telegram_id: int) -> dict[str, Any]:
|
||||||
async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client:
|
response = await self.client.post(
|
||||||
response = await client.post(
|
|
||||||
"/api/cars",
|
"/api/cars",
|
||||||
json={"owner_id": owner_id, "name": name},
|
json={"owner_id": owner_id, "name": name},
|
||||||
headers=self.headers(telegram_id),
|
headers=self.headers(telegram_id),
|
||||||
@@ -68,8 +79,7 @@ class ApiClient:
|
|||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
async def stats(self, car_id: int, telegram_id: int) -> dict[str, Any]:
|
async def stats(self, car_id: int, telegram_id: int) -> dict[str, Any]:
|
||||||
async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client:
|
response = await self.client.get(f"/api/cars/{car_id}/stats", headers=self.headers(telegram_id))
|
||||||
response = await client.get(f"/api/cars/{car_id}/stats", headers=self.headers(telegram_id))
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
@@ -126,6 +136,15 @@ class ApiClient:
|
|||||||
async def pending_service_centers(self, telegram_id: int) -> list[dict[str, Any]]:
|
async def pending_service_centers(self, telegram_id: int) -> list[dict[str, Any]]:
|
||||||
return await self.request("GET", "/api/admin/service-centers/pending", telegram_id=telegram_id)
|
return await self.request("GET", "/api/admin/service-centers/pending", telegram_id=telegram_id)
|
||||||
|
|
||||||
|
async def admin_dashboard(self, telegram_id: int) -> dict[str, Any]:
|
||||||
|
return await self.request("GET", "/api/admin/dashboard", telegram_id=telegram_id)
|
||||||
|
|
||||||
|
async def admin_users(self, telegram_id: int) -> dict[str, Any]:
|
||||||
|
return await self.request("GET", "/api/admin/users", telegram_id=telegram_id, params={"limit": 10})
|
||||||
|
|
||||||
|
async def admin_alerts(self, telegram_id: int) -> dict[str, Any]:
|
||||||
|
return await self.request("GET", "/api/admin/notifications", telegram_id=telegram_id, params={"limit": 10})
|
||||||
|
|
||||||
async def moderate_service_center(
|
async def moderate_service_center(
|
||||||
self,
|
self,
|
||||||
telegram_id: int,
|
telegram_id: int,
|
||||||
|
|||||||
84
bot/main.py
84
bot/main.py
@@ -433,6 +433,7 @@ async def register_sto(message: Message, command: CommandObject) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@dp.message(Command("admin_sto_pending"))
|
@dp.message(Command("admin_sto_pending"))
|
||||||
|
@dp.message(Command("admin_pending_sto"))
|
||||||
async def admin_sto_pending(message: Message) -> None:
|
async def admin_sto_pending(message: Message) -> None:
|
||||||
await upsert(message)
|
await upsert(message)
|
||||||
try:
|
try:
|
||||||
@@ -459,6 +460,77 @@ async def admin_sto_pending(message: Message) -> None:
|
|||||||
await message.answer(text, reply_markup=admin_card_keyboard(center["id"]))
|
await message.answer(text, reply_markup=admin_card_keyboard(center["id"]))
|
||||||
|
|
||||||
|
|
||||||
|
@dp.message(Command("admin"))
|
||||||
|
async def admin_home(message: Message) -> None:
|
||||||
|
await upsert(message)
|
||||||
|
try:
|
||||||
|
await api.admin_dashboard(message.from_user.id)
|
||||||
|
except httpx.HTTPStatusError as error:
|
||||||
|
await message.answer(f"Админка недоступна: {error.response.text}")
|
||||||
|
return
|
||||||
|
await message.answer(
|
||||||
|
"Admin Control Center: уведомления, пользователи, СТО, заявки, Data Explorer и Audit Log.",
|
||||||
|
reply_markup=webapp_inline_keyboard("Открыть админку", "admin.html"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dp.message(Command("admin_stats"))
|
||||||
|
async def admin_stats(message: Message) -> None:
|
||||||
|
await upsert(message)
|
||||||
|
try:
|
||||||
|
dashboard = await api.admin_dashboard(message.from_user.id)
|
||||||
|
except httpx.HTTPStatusError as error:
|
||||||
|
await message.answer(f"Нет доступа к admin stats: {error.response.text}")
|
||||||
|
return
|
||||||
|
await message.answer(
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
"Admin stats",
|
||||||
|
f"Users today: {dashboard['users_today']}",
|
||||||
|
f"Users total: {dashboard['users_total']}",
|
||||||
|
f"STO pending: {dashboard['pending_sto_applications']}",
|
||||||
|
f"Appointments today: {dashboard['appointments_today']}",
|
||||||
|
f"Work orders active: {dashboard['active_work_orders']}",
|
||||||
|
f"Errors/security: {dashboard['system_errors']} / {dashboard['security_events']}",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
reply_markup=webapp_inline_keyboard("Admin dashboard", "admin.html"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dp.message(Command("admin_users"))
|
||||||
|
async def admin_users(message: Message) -> None:
|
||||||
|
await upsert(message)
|
||||||
|
try:
|
||||||
|
data = await api.admin_users(message.from_user.id)
|
||||||
|
except httpx.HTTPStatusError as error:
|
||||||
|
await message.answer(f"Нет доступа к admin users: {error.response.text}")
|
||||||
|
return
|
||||||
|
lines = ["Последние пользователи:"]
|
||||||
|
for row in data.get("rows", [])[:10]:
|
||||||
|
lines.append(f"#{row.get('id')} {row.get('username') or '-'} · {row.get('platform_role')} · {row.get('created_at')}")
|
||||||
|
await message.answer("\n".join(lines), reply_markup=webapp_inline_keyboard("Users", "admin.html?section=users"))
|
||||||
|
|
||||||
|
|
||||||
|
@dp.message(Command("admin_sto"))
|
||||||
|
async def admin_sto(message: Message) -> None:
|
||||||
|
await admin_sto_pending(message)
|
||||||
|
|
||||||
|
|
||||||
|
@dp.message(Command("admin_alerts"))
|
||||||
|
async def admin_alerts(message: Message) -> None:
|
||||||
|
await upsert(message)
|
||||||
|
try:
|
||||||
|
data = await api.admin_alerts(message.from_user.id)
|
||||||
|
except httpx.HTTPStatusError as error:
|
||||||
|
await message.answer(f"Нет доступа к admin alerts: {error.response.text}")
|
||||||
|
return
|
||||||
|
lines = ["Admin alerts:"]
|
||||||
|
for row in data.get("rows", [])[:10]:
|
||||||
|
lines.append(f"#{row.get('id')} {row.get('severity')} · {row.get('title')} · {row.get('status')}")
|
||||||
|
await message.answer("\n".join(lines), reply_markup=webapp_inline_keyboard("Alerts", "admin.html?section=notifications"))
|
||||||
|
|
||||||
|
|
||||||
async def admin_action(message: Message, command: CommandObject, action: str) -> None:
|
async def admin_action(message: Message, command: CommandObject, action: str) -> None:
|
||||||
args = (command.args or "").split(maxsplit=1)
|
args = (command.args or "").split(maxsplit=1)
|
||||||
if not args:
|
if not args:
|
||||||
@@ -577,7 +649,14 @@ async def admin_callback(callback: CallbackQuery) -> None:
|
|||||||
@dp.message(F.text == "Помощь")
|
@dp.message(F.text == "Помощь")
|
||||||
@dp.message(Command("help"))
|
@dp.message(Command("help"))
|
||||||
async def help_message(message: Message) -> None:
|
async def help_message(message: Message) -> None:
|
||||||
|
user = await api.upsert_user(message.from_user)
|
||||||
centers = await sto_workplace_centers(message.from_user.id)
|
centers = await sto_workplace_centers(message.from_user.id)
|
||||||
|
admin_help = (
|
||||||
|
"Админ: /admin — панель, /admin_stats — метрики, /admin_users — последние пользователи, "
|
||||||
|
"/admin_pending_sto — заявки СТО, /admin_alerts — события.\n"
|
||||||
|
if user.get("platform_role") in {"admin", "super_admin", "moderator", "support", "analyst"}
|
||||||
|
else ""
|
||||||
|
)
|
||||||
sto_workplace_help = (
|
sto_workplace_help = (
|
||||||
"• /sto_bookings или /sto_workplace — панель подтвержденного СТО;\n"
|
"• /sto_bookings или /sto_workplace — панель подтвержденного СТО;\n"
|
||||||
"• /accept_sto_invite <token> — принять приглашение сотрудника;\n"
|
"• /accept_sto_invite <token> — принять приглашение сотрудника;\n"
|
||||||
@@ -601,6 +680,7 @@ async def help_message(message: Message) -> None:
|
|||||||
"• /sto — каталог проверенных СТО;\n"
|
"• /sto — каталог проверенных СТО;\n"
|
||||||
"• /appointments — мои записи в СТО;\n"
|
"• /appointments — мои записи в СТО;\n"
|
||||||
f"{sto_workplace_help}"
|
f"{sto_workplace_help}"
|
||||||
|
f"{admin_help}"
|
||||||
"\n"
|
"\n"
|
||||||
"Владелец: добавь авто, выбери проверенное СТО, создай запись, согласуй заказ-наряд и смотри завершенные работы в истории автомобиля.\n"
|
"Владелец: добавь авто, выбери проверенное СТО, создай запись, согласуй заказ-наряд и смотри завершенные работы в истории автомобиля.\n"
|
||||||
f"{sto_business_help}"
|
f"{sto_business_help}"
|
||||||
@@ -702,7 +782,11 @@ async def main() -> None:
|
|||||||
raise RuntimeError("INTERNAL_API_TOKEN is empty")
|
raise RuntimeError("INTERNAL_API_TOKEN is empty")
|
||||||
settings.validate_webapp_url_for_telegram()
|
settings.validate_webapp_url_for_telegram()
|
||||||
bot = Bot(settings.bot_token)
|
bot = Bot(settings.bot_token)
|
||||||
|
try:
|
||||||
await dp.start_polling(bot)
|
await dp.start_polling(bot)
|
||||||
|
finally:
|
||||||
|
await api.close()
|
||||||
|
await bot.session.close()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ services:
|
|||||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
||||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8000:8000"
|
- "127.0.0.1:${APP_PORT:-8000}:8000"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/ready', timeout=3).read()\""]
|
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/ready', timeout=3).read()\""]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|||||||
81
scripts/load_check.py
Normal file
81
scripts/load_check.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import statistics
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Result:
|
||||||
|
path: str
|
||||||
|
status: int | None
|
||||||
|
elapsed_ms: float
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Small HTTP concurrency smoke test for CarPass.")
|
||||||
|
parser.add_argument("--base-url", default="http://127.0.0.1:8000")
|
||||||
|
parser.add_argument("--requests", type=int, default=200)
|
||||||
|
parser.add_argument("--concurrency", type=int, default=25)
|
||||||
|
parser.add_argument(
|
||||||
|
"--path",
|
||||||
|
action="append",
|
||||||
|
dest="paths",
|
||||||
|
default=None,
|
||||||
|
help="Path to request. Can be repeated.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--timeout", type=float, default=10.0)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch(client: httpx.AsyncClient, semaphore: asyncio.Semaphore, path: str) -> Result:
|
||||||
|
async with semaphore:
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
response = await client.get(path)
|
||||||
|
elapsed_ms = (time.perf_counter() - started) * 1000
|
||||||
|
return Result(path=path, status=response.status_code, elapsed_ms=elapsed_ms)
|
||||||
|
except Exception as error: # noqa: BLE001
|
||||||
|
elapsed_ms = (time.perf_counter() - started) * 1000
|
||||||
|
return Result(path=path, status=None, elapsed_ms=elapsed_ms, error=str(error))
|
||||||
|
|
||||||
|
|
||||||
|
async def run() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
paths = args.paths or ["/health", "/ready", "/", "/admin.html", "/sto.html"]
|
||||||
|
semaphore = asyncio.Semaphore(max(args.concurrency, 1))
|
||||||
|
limits = httpx.Limits(
|
||||||
|
max_connections=max(args.concurrency * 2, 10),
|
||||||
|
max_keepalive_connections=max(args.concurrency, 10),
|
||||||
|
)
|
||||||
|
timeout = httpx.Timeout(args.timeout, connect=min(args.timeout, 5.0))
|
||||||
|
started = time.perf_counter()
|
||||||
|
async with httpx.AsyncClient(base_url=args.base_url.rstrip("/"), timeout=timeout, limits=limits) as client:
|
||||||
|
tasks = [fetch(client, semaphore, paths[index % len(paths)]) for index in range(args.requests)]
|
||||||
|
results = await asyncio.gather(*tasks)
|
||||||
|
elapsed = time.perf_counter() - started
|
||||||
|
failures = [result for result in results if result.error or not result.status or result.status >= 500]
|
||||||
|
latencies = [result.elapsed_ms for result in results]
|
||||||
|
p95 = statistics.quantiles(latencies, n=20)[18] if len(latencies) >= 20 else max(latencies, default=0)
|
||||||
|
print(
|
||||||
|
"load_check "
|
||||||
|
f"base_url={args.base_url} requests={len(results)} concurrency={args.concurrency} "
|
||||||
|
f"ok={len(results) - len(failures)} failures={len(failures)} "
|
||||||
|
f"rps={len(results) / elapsed:.2f} avg_ms={statistics.fmean(latencies):.1f} "
|
||||||
|
f"p95_ms={p95:.1f} max_ms={max(latencies, default=0):.1f}"
|
||||||
|
)
|
||||||
|
if failures:
|
||||||
|
for result in failures[:10]:
|
||||||
|
print(f"failure path={result.path} status={result.status} error={result.error or '-'}")
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(asyncio.run(run()))
|
||||||
96
scripts/rsync_deploy.sh
Executable file
96
scripts/rsync_deploy.sh
Executable file
@@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
REMOTE="${REMOTE:-root@drivers.smartsoltech.kr}"
|
||||||
|
REMOTE_DIR="${REMOTE_DIR:-/opt/drivers_bot}"
|
||||||
|
BASE_URL="${BASE_URL:-http://127.0.0.1:8000}"
|
||||||
|
COMPOSE="${COMPOSE:-docker compose}"
|
||||||
|
RUN_LOCAL_CHECKS="${RUN_LOCAL_CHECKS:-true}"
|
||||||
|
BACKUP_BEFORE_DEPLOY="${BACKUP_BEFORE_DEPLOY:-true}"
|
||||||
|
BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"
|
||||||
|
REVISION="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
||||||
|
|
||||||
|
EXCLUDES=(
|
||||||
|
"--exclude=.git/"
|
||||||
|
"--exclude=.env"
|
||||||
|
"--exclude=.env.*"
|
||||||
|
"--exclude=.venv/"
|
||||||
|
"--exclude=venv/"
|
||||||
|
"--exclude=__pycache__/"
|
||||||
|
"--exclude=.pytest_cache/"
|
||||||
|
"--exclude=.ruff_cache/"
|
||||||
|
"--exclude=.history/"
|
||||||
|
"--exclude=backups/"
|
||||||
|
"--exclude=*.sqlite"
|
||||||
|
"--exclude=*.sqlite3"
|
||||||
|
"--exclude=*.db"
|
||||||
|
)
|
||||||
|
|
||||||
|
send_remote_report() {
|
||||||
|
local text="$1"
|
||||||
|
ssh "$REMOTE" "cd '$REMOTE_DIR' && export CARPASS_REPORT_TEXT=\$(cat); if $COMPOSE exec -T api test -f scripts/send_telegram_report.py >/dev/null 2>&1; then $COMPOSE exec -T -e CARPASS_REPORT_TEXT api python scripts/send_telegram_report.py; else $COMPOSE run --rm --no-deps -e CARPASS_REPORT_TEXT api python scripts/send_telegram_report.py; fi" <<<"$text" || true
|
||||||
|
}
|
||||||
|
|
||||||
|
fail_report() {
|
||||||
|
local line="${1:-unknown}"
|
||||||
|
send_remote_report "❌ CarPass rsync deploy failed
|
||||||
|
Branch: $BRANCH
|
||||||
|
Revision: $REVISION
|
||||||
|
Step line: $line
|
||||||
|
Target: $REMOTE"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap 'fail_report "$LINENO"' ERR
|
||||||
|
|
||||||
|
if [[ "$RUN_LOCAL_CHECKS" == "true" ]]; then
|
||||||
|
echo "Running local checks..."
|
||||||
|
.venv/bin/ruff check app bot tests
|
||||||
|
.venv/bin/pytest -q
|
||||||
|
fi
|
||||||
|
|
||||||
|
send_remote_report "🚀 CarPass rsync deploy started
|
||||||
|
Branch: $BRANCH
|
||||||
|
Revision: $REVISION
|
||||||
|
Target: $REMOTE
|
||||||
|
Checks: local=${RUN_LOCAL_CHECKS}"
|
||||||
|
|
||||||
|
echo "Checking remote..."
|
||||||
|
ssh "$REMOTE" "test -d '$REMOTE_DIR' && test -f '$REMOTE_DIR/docker-compose.yml'"
|
||||||
|
|
||||||
|
if [[ "$BACKUP_BEFORE_DEPLOY" == "true" ]]; then
|
||||||
|
echo "Creating remote code backup..."
|
||||||
|
ssh "$REMOTE" "cd '$(dirname "$REMOTE_DIR")' && mkdir -p '$REMOTE_DIR/backups' && tar --exclude='$(basename "$REMOTE_DIR")/backups' --exclude='$(basename "$REMOTE_DIR")/.git' --exclude='$(basename "$REMOTE_DIR")/.env' --exclude='$(basename "$REMOTE_DIR")/.venv' -czf '$REMOTE_DIR/backups/code_pre_rsync_$(date +%Y%m%d%H%M%S).tgz' '$(basename "$REMOTE_DIR")'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Syncing code with rsync..."
|
||||||
|
rsync -az --delete "${EXCLUDES[@]}" ./ "$REMOTE:$REMOTE_DIR/"
|
||||||
|
|
||||||
|
echo "Building remote images..."
|
||||||
|
ssh "$REMOTE" "cd '$REMOTE_DIR' && $COMPOSE build"
|
||||||
|
send_remote_report "🧱 CarPass rsync deploy progress
|
||||||
|
Branch: $BRANCH
|
||||||
|
Step: docker build completed"
|
||||||
|
|
||||||
|
echo "Applying migrations..."
|
||||||
|
ssh "$REMOTE" "cd '$REMOTE_DIR' && $COMPOSE run --rm api alembic upgrade head"
|
||||||
|
send_remote_report "🗄️ CarPass rsync deploy progress
|
||||||
|
Branch: $BRANCH
|
||||||
|
Step: migrations applied"
|
||||||
|
|
||||||
|
echo "Starting services..."
|
||||||
|
ssh "$REMOTE" "cd '$REMOTE_DIR' && $COMPOSE up -d"
|
||||||
|
|
||||||
|
echo "Waiting for API readiness..."
|
||||||
|
ssh "$REMOTE" "cd '$REMOTE_DIR' && for i in \$(seq 1 30); do status=\$(docker inspect -f '{{.State.Health.Status}}' drivers_bot-api-1 2>/dev/null || echo missing); echo \"api_health=\$status\"; [ \"\$status\" = healthy ] && exit 0; sleep 2; done; $COMPOSE logs --tail=120 api; exit 1"
|
||||||
|
|
||||||
|
echo "Running remote smoke tests..."
|
||||||
|
ssh "$REMOTE" "cd '$REMOTE_DIR' && BASE_URL='$BASE_URL' ./scripts/smoke_test.sh && curl -fsSI '$BASE_URL/admin.html' | head -5 && $COMPOSE ps"
|
||||||
|
|
||||||
|
send_remote_report "✅ CarPass rsync deploy completed
|
||||||
|
Branch: $BRANCH
|
||||||
|
Revision: $REVISION
|
||||||
|
Migration: $(ssh "$REMOTE" "cd '$REMOTE_DIR' && curl -fsS '$BASE_URL/ready'" | tr '\n' ' ')
|
||||||
|
Checks: /health ok, /ready ok, /metrics ok, /admin.html 200
|
||||||
|
Services: api healthy, bot restarted"
|
||||||
|
|
||||||
|
echo "Deploy completed."
|
||||||
81
scripts/send_telegram_report.py
Executable file
81
scripts/send_telegram_report.py
Executable file
@@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.db.session import async_session_factory
|
||||||
|
from app.models import car, expense, gamification, push # noqa: F401
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
REPORT_ROLES = {"admin", "super_admin", "moderator", "support"}
|
||||||
|
|
||||||
|
|
||||||
|
def env_recipients() -> list[str]:
|
||||||
|
recipients: list[str] = []
|
||||||
|
if settings.admin_notification_chat_id:
|
||||||
|
recipients.append(settings.admin_notification_chat_id)
|
||||||
|
recipients.extend(str(item) for item in settings.admin_telegram_id_list)
|
||||||
|
return recipients
|
||||||
|
|
||||||
|
|
||||||
|
async def db_recipients() -> list[str]:
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(User.telegram_id).where(User.platform_role.in_(REPORT_ROLES))
|
||||||
|
)
|
||||||
|
return [str(row[0]) for row in result.all() if row[0]]
|
||||||
|
|
||||||
|
|
||||||
|
def unique(values: Iterable[str]) -> list[str]:
|
||||||
|
return list(dict.fromkeys(item.strip() for item in values if item and item.strip()))
|
||||||
|
|
||||||
|
|
||||||
|
async def send_report(text: str, *, dry_run: bool = False) -> int:
|
||||||
|
recipients = unique([*env_recipients(), *(await db_recipients())])
|
||||||
|
if dry_run:
|
||||||
|
print(f"telegram_report_dry_run recipients={len(recipients)}")
|
||||||
|
return len(recipients)
|
||||||
|
if not settings.bot_token or not recipients:
|
||||||
|
print("telegram_report_skipped")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
sent = 0
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
for chat_id in recipients:
|
||||||
|
try:
|
||||||
|
response = await client.post(
|
||||||
|
f"https://api.telegram.org/bot{settings.bot_token}/sendMessage",
|
||||||
|
json={"chat_id": chat_id, "text": text, "disable_web_page_preview": True},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
sent += 1
|
||||||
|
except Exception as exc: # noqa: BLE001 - deploy report must never fail deploy
|
||||||
|
print(f"telegram_report_failed chat_id={chat_id} error={type(exc).__name__}")
|
||||||
|
print(f"telegram_report_sent_count {sent}")
|
||||||
|
return sent
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Send a CarPass operational Telegram report.")
|
||||||
|
parser.add_argument("--text", help="Report text. Defaults to CARPASS_REPORT_TEXT.")
|
||||||
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
args = parse_args()
|
||||||
|
text = args.text or os.getenv("CARPASS_REPORT_TEXT") or ""
|
||||||
|
if not text.strip():
|
||||||
|
raise SystemExit("Report text is required")
|
||||||
|
await send_report(text, dry_run=args.dry_run)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -13,4 +13,10 @@ echo
|
|||||||
|
|
||||||
echo "Checking metrics..."
|
echo "Checking metrics..."
|
||||||
curl -fsS "$BASE_URL/metrics" | grep -q "carpass_requests_total"
|
curl -fsS "$BASE_URL/metrics" | grep -q "carpass_requests_total"
|
||||||
|
|
||||||
|
for path in / /sto.html /admin.html /work_order.html; do
|
||||||
|
echo "Checking static page $path..."
|
||||||
|
curl -fsSI "$BASE_URL$path" | grep -q "200 OK"
|
||||||
|
done
|
||||||
|
|
||||||
echo "Smoke test passed."
|
echo "Smoke test passed."
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ def configure_settings() -> None:
|
|||||||
settings.internal_api_token = TEST_INTERNAL_TOKEN
|
settings.internal_api_token = TEST_INTERNAL_TOKEN
|
||||||
settings.app_env = "test"
|
settings.app_env = "test"
|
||||||
settings.allow_dev_auth = False
|
settings.allow_dev_auth = False
|
||||||
|
settings.admin_telegram_ids = ""
|
||||||
|
settings.admin_notification_chat_id = ""
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
477
tests/test_admin_control_center.py
Normal file
477
tests/test_admin_control_center.py
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
import pytest
|
||||||
|
from conftest import make_init_data
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.services import admin_notifications
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_admin(client, internal_headers) -> None:
|
||||||
|
await client.post(
|
||||||
|
"/api/users",
|
||||||
|
headers=internal_headers,
|
||||||
|
json={"telegram_id": 9001, "first_name": "Admin", "platform_role": "admin"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_analyst(client, internal_headers) -> dict[str, str]:
|
||||||
|
await client.post(
|
||||||
|
"/api/users",
|
||||||
|
headers=internal_headers,
|
||||||
|
json={"telegram_id": 7001, "first_name": "Analyst", "platform_role": "analyst"},
|
||||||
|
)
|
||||||
|
return {"X-Telegram-Init-Data": make_init_data(7001, "Analyst")}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_new_user_creates_admin_notification(client, admin_auth_headers, internal_headers) -> None:
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
response = await client.post(
|
||||||
|
"/api/users",
|
||||||
|
headers=internal_headers,
|
||||||
|
json={"telegram_id": 123456, "first_name": "Ivan", "username": "ivan"},
|
||||||
|
)
|
||||||
|
|
||||||
|
notifications = await client.get("/api/admin/notifications?limit=100", headers=admin_auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert any(
|
||||||
|
item["event_type"] == "user_registered" and item["idempotency_key"] == "user_registered:123456"
|
||||||
|
for item in notifications.json()["rows"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_notification_idempotency_for_user_registration(
|
||||||
|
client, admin_auth_headers, internal_headers
|
||||||
|
) -> None:
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
payload = {"telegram_id": 223344, "first_name": "Repeat"}
|
||||||
|
|
||||||
|
await client.post("/api/users", headers=internal_headers, json=payload)
|
||||||
|
await client.post("/api/users", headers=internal_headers, json=payload)
|
||||||
|
notifications = await client.get("/api/admin/notifications?limit=100", headers=admin_auth_headers)
|
||||||
|
|
||||||
|
matches = [
|
||||||
|
item
|
||||||
|
for item in notifications.json()["rows"]
|
||||||
|
if item["idempotency_key"] == "user_registered:223344"
|
||||||
|
]
|
||||||
|
assert len(matches) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_telegram_admin_delivery_failure_does_not_break_user_flow(
|
||||||
|
client, admin_auth_headers, internal_headers, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
class BrokenTelegramClient:
|
||||||
|
def __init__(self, *args, **kwargs): # noqa: D107
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def post(self, *args, **kwargs): # noqa: ARG002
|
||||||
|
raise RuntimeError("telegram down")
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "admin_telegram_ids", "777")
|
||||||
|
monkeypatch.setattr(admin_notifications.httpx, "AsyncClient", BrokenTelegramClient)
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/users",
|
||||||
|
headers=internal_headers,
|
||||||
|
json={"telegram_id": 334455, "first_name": "Best Effort"},
|
||||||
|
)
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
notifications = await client.get("/api/admin/notifications?limit=100", headers=admin_auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
created = [
|
||||||
|
item
|
||||||
|
for item in notifications.json()["rows"]
|
||||||
|
if item["idempotency_key"] == "user_registered:334455"
|
||||||
|
][0]
|
||||||
|
assert created["telegram_status"] == "failed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_new_sto_application_creates_admin_notification(
|
||||||
|
client, auth_headers, admin_auth_headers, internal_headers
|
||||||
|
) -> None:
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
|
||||||
|
center = await client.post(
|
||||||
|
"/api/service-centers",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={
|
||||||
|
"display_name": "Auto Master",
|
||||||
|
"country": "KR",
|
||||||
|
"city": "Gwangju",
|
||||||
|
"phone": "+82-10-0000-0000",
|
||||||
|
"document_photo_urls": ["doc-a.jpg", "doc-b.jpg"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
notifications = await client.get("/api/admin/notifications?limit=100", headers=admin_auth_headers)
|
||||||
|
|
||||||
|
assert center.status_code == 201
|
||||||
|
assert any(
|
||||||
|
item["event_type"] == "sto_application_created"
|
||||||
|
and item["entity_id"] == str(center.json()["id"])
|
||||||
|
for item in notifications.json()["rows"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_first_vehicle_and_first_record_create_admin_notifications(
|
||||||
|
client, auth_headers, admin_auth_headers, internal_headers
|
||||||
|
) -> None:
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
vehicle = (
|
||||||
|
await client.post(
|
||||||
|
"/api/cars",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"name": "First admin-visible car", "current_odometer": 1500},
|
||||||
|
)
|
||||||
|
).json()
|
||||||
|
fuel = await client.post(
|
||||||
|
"/api/fuel",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={
|
||||||
|
"car_id": vehicle["id"],
|
||||||
|
"entry_date": "2026-05-19",
|
||||||
|
"odometer": 1510,
|
||||||
|
"liters": 30,
|
||||||
|
"price_per_liter": 2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
notifications = await client.get("/api/admin/notifications?limit=100", headers=admin_auth_headers)
|
||||||
|
events = {item["event_type"] for item in notifications.json()["rows"]}
|
||||||
|
|
||||||
|
assert fuel.status_code == 201
|
||||||
|
assert "vehicle_created" in events
|
||||||
|
assert "first_record_created" in events
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_dashboard_requires_admin_role(client, auth_headers, admin_auth_headers, internal_headers) -> None:
|
||||||
|
forbidden = await client.get("/api/admin/dashboard", headers=auth_headers)
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
allowed = await client.get("/api/admin/dashboard", headers=admin_auth_headers)
|
||||||
|
|
||||||
|
assert forbidden.status_code == 403
|
||||||
|
assert allowed.status_code == 200
|
||||||
|
assert "users_total" in allowed.json()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_data_explorer_rejects_unknown_source_and_field(
|
||||||
|
client, admin_auth_headers, internal_headers
|
||||||
|
) -> None:
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
|
||||||
|
unknown_source = await client.post(
|
||||||
|
"/api/admin/data/query",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
json={"source": "raw_sql", "limit": 25},
|
||||||
|
)
|
||||||
|
forbidden_field = await client.post(
|
||||||
|
"/api/admin/data/query",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
json={"source": "users", "limit": 25, "sql": "select * from users"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert unknown_source.status_code == 400
|
||||||
|
assert forbidden_field.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_data_explorer_masks_sensitive_data_and_applies_limit(
|
||||||
|
client, internal_headers
|
||||||
|
) -> None:
|
||||||
|
analyst_headers = await ensure_analyst(client, internal_headers)
|
||||||
|
await client.post(
|
||||||
|
"/api/users",
|
||||||
|
headers=internal_headers,
|
||||||
|
json={"telegram_id": 889900, "first_name": "Visible", "platform_role": "user"},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/admin/data/query",
|
||||||
|
headers=analyst_headers,
|
||||||
|
json={"source": "users", "limit": 1},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
rows = response.json()["rows"]
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert isinstance(rows[0]["telegram_id"], str)
|
||||||
|
assert rows[0]["telegram_id"] != "889900"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sensitive_data_requires_admin_and_reason(
|
||||||
|
client, admin_auth_headers, internal_headers
|
||||||
|
) -> None:
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
|
||||||
|
missing_reason = await client.post(
|
||||||
|
"/api/admin/data/query",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
json={"source": "users", "include_sensitive": True, "limit": 25},
|
||||||
|
)
|
||||||
|
with_reason = await client.post(
|
||||||
|
"/api/admin/data/query",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
json={
|
||||||
|
"source": "users",
|
||||||
|
"include_sensitive": True,
|
||||||
|
"reason": "support request",
|
||||||
|
"telegram_id": 9001,
|
||||||
|
"limit": 25,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert missing_reason.status_code == 400
|
||||||
|
assert with_reason.status_code == 200
|
||||||
|
assert with_reason.json()["rows"][0]["telegram_id"] == 9001
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_data_query_creates_audit_log(client, admin_auth_headers, internal_headers) -> None:
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
|
||||||
|
await client.post(
|
||||||
|
"/api/admin/data/query",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
json={"source": "users", "limit": 25},
|
||||||
|
)
|
||||||
|
audit = await client.get("/api/admin/audit-log?action=admin.data.query", headers=admin_auth_headers)
|
||||||
|
|
||||||
|
assert audit.status_code == 200
|
||||||
|
assert any(item["action"] == "admin.data.query" for item in audit.json())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_data_update_requires_reason_and_audits(
|
||||||
|
client, admin_auth_headers, internal_headers
|
||||||
|
) -> None:
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
user = (
|
||||||
|
await client.post(
|
||||||
|
"/api/users",
|
||||||
|
headers=internal_headers,
|
||||||
|
json={"telegram_id": 456789, "first_name": "Before"},
|
||||||
|
)
|
||||||
|
).json()
|
||||||
|
|
||||||
|
missing_reason = await client.patch(
|
||||||
|
f"/api/admin/data/users/{user['id']}",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
json={"values": {"first_name": "After"}, "reason": ""},
|
||||||
|
)
|
||||||
|
updated = await client.patch(
|
||||||
|
f"/api/admin/data/users/{user['id']}",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
json={"values": {"first_name": "After"}, "reason": "support correction"},
|
||||||
|
)
|
||||||
|
audit = await client.get("/api/admin/audit-log?action=admin.data.update", headers=admin_auth_headers)
|
||||||
|
|
||||||
|
assert missing_reason.status_code == 400
|
||||||
|
assert updated.status_code == 200
|
||||||
|
assert updated.json()["row"]["first_name"] == "After"
|
||||||
|
assert any(item["action"] == "admin.data.update" for item in audit.json())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_data_update_rejects_forbidden_field(
|
||||||
|
client, admin_auth_headers, internal_headers
|
||||||
|
) -> None:
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
user = (
|
||||||
|
await client.post(
|
||||||
|
"/api/users",
|
||||||
|
headers=internal_headers,
|
||||||
|
json={"telegram_id": 556677, "first_name": "Nope"},
|
||||||
|
)
|
||||||
|
).json()
|
||||||
|
|
||||||
|
response = await client.patch(
|
||||||
|
f"/api/admin/data/users/{user['id']}",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
json={"values": {"telegram_id": 1}, "reason": "support correction"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "Forbidden fields" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_data_delete_soft_blocks_user(
|
||||||
|
client, admin_auth_headers, internal_headers
|
||||||
|
) -> None:
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
user = (
|
||||||
|
await client.post(
|
||||||
|
"/api/users",
|
||||||
|
headers=internal_headers,
|
||||||
|
json={"telegram_id": 667788, "first_name": "Blocked soon"},
|
||||||
|
)
|
||||||
|
).json()
|
||||||
|
|
||||||
|
deleted = await client.request(
|
||||||
|
"DELETE",
|
||||||
|
f"/api/admin/data/users/{user['id']}",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
json={"reason": "support requested block"},
|
||||||
|
)
|
||||||
|
query = await client.post(
|
||||||
|
"/api/admin/data/query",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
json={"source": "users", "user_id": user["id"], "limit": 25},
|
||||||
|
)
|
||||||
|
audit = await client.get("/api/admin/audit-log?action=admin.data.delete", headers=admin_auth_headers)
|
||||||
|
|
||||||
|
assert deleted.status_code == 200
|
||||||
|
assert deleted.json()["mode"] == "soft"
|
||||||
|
assert query.json()["rows"][0]["platform_role"] == "blocked"
|
||||||
|
assert any(item["action"] == "admin.data.delete" for item in audit.json())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_data_delete_hard_deletes_fuel_entry(
|
||||||
|
client, auth_headers, admin_auth_headers, internal_headers
|
||||||
|
) -> None:
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
car = (await client.post("/api/cars", headers=auth_headers, json={"name": "Admin delete fuel"})).json()
|
||||||
|
fuel = (
|
||||||
|
await client.post(
|
||||||
|
"/api/fuel",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={
|
||||||
|
"car_id": car["id"],
|
||||||
|
"entry_date": "2026-05-18",
|
||||||
|
"odometer": 1200,
|
||||||
|
"liters": 35,
|
||||||
|
"price_per_liter": 2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
).json()
|
||||||
|
|
||||||
|
deleted = await client.request(
|
||||||
|
"DELETE",
|
||||||
|
f"/api/admin/data/fuel_entries/{fuel['id']}",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
json={"reason": "duplicate record cleanup"},
|
||||||
|
)
|
||||||
|
query = await client.post(
|
||||||
|
"/api/admin/data/query",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
json={"source": "fuel_entries", "vehicle_id": car["id"], "limit": 25},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert deleted.status_code == 200
|
||||||
|
assert deleted.json()["mode"] == "hard"
|
||||||
|
assert query.json()["rows"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pending_sto_queue_and_approve_audit(
|
||||||
|
client, auth_headers, admin_auth_headers, internal_headers
|
||||||
|
) -> None:
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
center = (
|
||||||
|
await client.post(
|
||||||
|
"/api/service-centers",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"display_name": "Pending Admin Queue", "country": "KR", "city": "Seoul"},
|
||||||
|
)
|
||||||
|
).json()
|
||||||
|
|
||||||
|
pending = await client.get("/api/admin/sto-applications", headers=admin_auth_headers)
|
||||||
|
approved = await client.post(
|
||||||
|
f"/api/admin/sto-applications/{center['id']}/approve",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
json={"comment": "ok"},
|
||||||
|
)
|
||||||
|
audit = await client.get("/api/admin/audit-log?action=service_center.verify", headers=admin_auth_headers)
|
||||||
|
|
||||||
|
assert center["id"] in [item["id"] for item in pending.json()["rows"]]
|
||||||
|
assert approved.status_code == 200
|
||||||
|
assert approved.json()["verification_status"] == "approved"
|
||||||
|
assert any(item["action"] == "service_center.verify" for item in audit.json())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_blocked_ocr_upload_creates_admin_notification(
|
||||||
|
client, auth_headers, admin_auth_headers, internal_headers
|
||||||
|
) -> None:
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/ocr/vin",
|
||||||
|
headers=auth_headers,
|
||||||
|
files={"file": ("invoice.exe", b"not an image", "image/jpeg")},
|
||||||
|
)
|
||||||
|
notifications = await client.get("/api/admin/notifications?limit=100", headers=admin_auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 415
|
||||||
|
assert any(item["event_type"] == "upload_blocked" for item in notifications.json()["rows"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ocr_preview_is_available_in_admin_data_explorer(
|
||||||
|
client, auth_headers, admin_auth_headers, internal_headers
|
||||||
|
) -> None:
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/ocr/vin",
|
||||||
|
headers=auth_headers,
|
||||||
|
files={"file": ("vin.txt", b"VIN KMHCT41BAHU123456", "text/plain")},
|
||||||
|
)
|
||||||
|
query = await client.post(
|
||||||
|
"/api/admin/data/query",
|
||||||
|
headers=admin_auth_headers,
|
||||||
|
json={"source": "ocr_results", "category": "vin", "limit": 25},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert query.status_code == 200
|
||||||
|
assert query.json()["rows"][0]["scope"] == "vin"
|
||||||
|
assert query.json()["rows"][0]["status"] == "preview"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rate_limit_creates_admin_notification(
|
||||||
|
client, auth_headers, admin_auth_headers, internal_headers
|
||||||
|
) -> None:
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
|
||||||
|
last_response = None
|
||||||
|
for index in range(9):
|
||||||
|
last_response = await client.post(
|
||||||
|
"/api/ocr/vin",
|
||||||
|
headers=auth_headers,
|
||||||
|
files={"file": (f"vin-{index}.txt", b"VIN KMHCT41BAHU123456", "text/plain")},
|
||||||
|
)
|
||||||
|
notifications = await client.get("/api/admin/notifications?limit=100", headers=admin_auth_headers)
|
||||||
|
|
||||||
|
assert last_response is not None
|
||||||
|
assert last_response.status_code == 429
|
||||||
|
assert any(item["event_type"] == "rate_limit_exceeded" for item in notifications.json()["rows"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_can_retry_notification_queues(
|
||||||
|
client, admin_auth_headers, internal_headers
|
||||||
|
) -> None:
|
||||||
|
await ensure_admin(client, internal_headers)
|
||||||
|
|
||||||
|
response = await client.post("/api/admin/notifications/retry", headers=admin_auth_headers)
|
||||||
|
audit = await client.get("/api/admin/audit-log?action=admin.notifications.retry", headers=admin_auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert {"service_delivered", "admin_delivered", "limit"} <= response.json().keys()
|
||||||
|
assert any(item["action"] == "admin.notifications.retry" for item in audit.json())
|
||||||
@@ -243,6 +243,9 @@ async def test_work_order_completion_creates_vehicle_records_and_updates_costs(
|
|||||||
assert refreshed.json()["engine_oil_type"] == "5W-30"
|
assert refreshed.json()["engine_oil_type"] == "5W-30"
|
||||||
assert refreshed.json()["engine_oil_volume_l"] == "4.00"
|
assert refreshed.json()["engine_oil_volume_l"] == "4.00"
|
||||||
assert stats.json()["total_cost"] == "130.00"
|
assert stats.json()["total_cost"] == "130.00"
|
||||||
|
admin_notifications = await client.get("/api/admin/notifications?limit=100", headers=admin_auth_headers)
|
||||||
|
admin_events = {item["event_type"] for item in admin_notifications.json()["rows"]}
|
||||||
|
assert {"work_order_completed", "work_order_correction_requested", "work_order_correction_resolved"} <= admin_events
|
||||||
|
|
||||||
cannot_edit = await client.patch(
|
cannot_edit = await client.patch(
|
||||||
f"/api/work-orders/{work_order['id']}",
|
f"/api/work-orders/{work_order['id']}",
|
||||||
|
|||||||
@@ -204,6 +204,9 @@ async def test_customer_booking_lifecycle_capacity_calendar_work_order_and_notif
|
|||||||
)
|
)
|
||||||
assert work_order.status_code == 201
|
assert work_order.status_code == 201
|
||||||
assert work_order.json()["vehicle_id"] == vehicle["id"]
|
assert work_order.json()["vehicle_id"] == vehicle["id"]
|
||||||
|
admin_notifications = await client.get("/api/admin/notifications?limit=100", headers=admin_auth_headers)
|
||||||
|
admin_events = {item["event_type"] for item in admin_notifications.json()["rows"]}
|
||||||
|
assert {"appointment_created", "work_order_created"} <= admin_events
|
||||||
|
|
||||||
my_appointments = await client.get("/api/appointments/my", headers=other_auth_headers)
|
my_appointments = await client.get("/api/appointments/my", headers=other_auth_headers)
|
||||||
assert my_appointments.json()[0]["linked_work_order_id"] == work_order.json()["id"]
|
assert my_appointments.json()[0]["linked_work_order_id"] == work_order.json()["id"]
|
||||||
|
|||||||
245
web/admin.html
Normal file
245
web/admin.html
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#16806a" />
|
||||||
|
<title>Admin Control Center</title>
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="auth-required admin-page">
|
||||||
|
<div class="auth-overlay" id="authOverlay">
|
||||||
|
<div class="auth-panel">
|
||||||
|
<p class="eyebrow">CarPass</p>
|
||||||
|
<h1>Админ-панель</h1>
|
||||||
|
<p id="authMessage">Откройте страницу через Telegram, чтобы подтвердить права администратора.</p>
|
||||||
|
<div class="auth-actions">
|
||||||
|
<a id="telegramLoginLink" class="telegram-login-link hidden" href="#" rel="noreferrer">Открыть в Telegram</a>
|
||||||
|
<button id="telegramRetryBtn" class="telegram-secondary-btn" type="button">Проверить вход</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="shell admin-shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">CarPass Admin</p>
|
||||||
|
<h1>Control Center</h1>
|
||||||
|
</div>
|
||||||
|
<div class="top-actions">
|
||||||
|
<button class="icon-btn" id="refreshBtn" title="Обновить" aria-label="Обновить">↻</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="passport-panel admin-hero">
|
||||||
|
<div class="passport-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Пилотный контур</p>
|
||||||
|
<h2>Операционный обзор</h2>
|
||||||
|
<small id="adminMeta">Загружаю доступ и источники данных...</small>
|
||||||
|
</div>
|
||||||
|
<span class="trust-badge" id="adminRoleBadge">Проверка</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav class="admin-tabs" aria-label="Разделы админки">
|
||||||
|
<button type="button" data-admin-tab="dashboard">Dashboard</button>
|
||||||
|
<button type="button" data-admin-tab="notifications">Notifications</button>
|
||||||
|
<button type="button" data-admin-tab="users">Users</button>
|
||||||
|
<button type="button" data-admin-tab="sto">СТО</button>
|
||||||
|
<button type="button" data-admin-tab="sto-applications">Заявки СТО</button>
|
||||||
|
<button type="button" data-admin-tab="vehicles">Авто</button>
|
||||||
|
<button type="button" data-admin-tab="appointments">Записи</button>
|
||||||
|
<button type="button" data-admin-tab="work-orders">Заказ-наряды</button>
|
||||||
|
<button type="button" data-admin-tab="data">Data Explorer</button>
|
||||||
|
<button type="button" data-admin-tab="audit">Audit Log</button>
|
||||||
|
<button type="button" data-admin-tab="exports">Exports</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section id="panel-dashboard" class="admin-panel workspace">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Сервис</p>
|
||||||
|
<h2>Dashboard</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stats admin-stats" id="dashboardStats"></div>
|
||||||
|
<div class="admin-grid">
|
||||||
|
<section>
|
||||||
|
<h3>Последние события</h3>
|
||||||
|
<div id="dashboardAlerts" class="stack-list"></div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h3>Быстрые переходы</h3>
|
||||||
|
<div id="quickLinks" class="admin-link-grid"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="panel-notifications" class="admin-panel workspace hidden">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">События</p>
|
||||||
|
<h2>Admin notifications</h2>
|
||||||
|
</div>
|
||||||
|
<div class="row-actions">
|
||||||
|
<button type="button" id="retryNotificationsBtn">Retry</button>
|
||||||
|
<button type="button" id="readAllBtn">Прочитать все</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="notificationsList" class="stack-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="panel-users" class="admin-panel workspace hidden">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Аккаунты</p>
|
||||||
|
<h2>Users</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form class="admin-filter" data-list-filter="users">
|
||||||
|
<input name="search" placeholder="Поиск по имени или username" />
|
||||||
|
<button type="submit">Найти</button>
|
||||||
|
</form>
|
||||||
|
<div id="usersTable" class="admin-table-wrap"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="panel-sto" class="admin-panel workspace hidden">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Партнеры</p>
|
||||||
|
<h2>СТО</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form class="admin-filter" data-list-filter="sto">
|
||||||
|
<input name="city" placeholder="Город" />
|
||||||
|
<select name="status">
|
||||||
|
<option value="">Любой статус</option>
|
||||||
|
<option value="pending">pending</option>
|
||||||
|
<option value="approved">approved</option>
|
||||||
|
<option value="needs_changes">needs_changes</option>
|
||||||
|
<option value="rejected">rejected</option>
|
||||||
|
<option value="suspended">suspended</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit">Фильтр</button>
|
||||||
|
</form>
|
||||||
|
<div id="stoTable" class="admin-table-wrap"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="panel-sto-applications" class="admin-panel workspace hidden">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Модерация</p>
|
||||||
|
<h2>Заявки СТО</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="applicationsList" class="stack-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="panel-vehicles" class="admin-panel workspace hidden">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Гараж</p>
|
||||||
|
<h2>Автомобили</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="vehiclesTable" class="admin-table-wrap"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="panel-appointments" class="admin-panel workspace hidden">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Календарь</p>
|
||||||
|
<h2>Записи</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="appointmentsTable" class="admin-table-wrap"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="panel-work-orders" class="admin-panel workspace hidden">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Работы</p>
|
||||||
|
<h2>Заказ-наряды</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="workOrdersTable" class="admin-table-wrap"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="panel-data" class="admin-panel workspace hidden">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Без SQL</p>
|
||||||
|
<h2>Data Explorer</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form id="dataForm" class="grid-form admin-data-form">
|
||||||
|
<label>Тип данных<select name="source" id="sourceSelect"></select></label>
|
||||||
|
<label>Дата от<input name="date_from" type="date" /></label>
|
||||||
|
<label>Дата до<input name="date_to" type="date" /></label>
|
||||||
|
<label>Status<input name="status" /></label>
|
||||||
|
<label>User ID<input name="user_id" type="number" /></label>
|
||||||
|
<label>Telegram ID<input name="telegram_id" type="number" /></label>
|
||||||
|
<label>Vehicle ID<input name="vehicle_id" type="number" /></label>
|
||||||
|
<label>STO ID<input name="sto_id" type="number" /></label>
|
||||||
|
<label>City<input name="city" /></label>
|
||||||
|
<label>Role<input name="role" /></label>
|
||||||
|
<label>Search<input name="search" /></label>
|
||||||
|
<label>Sort
|
||||||
|
<select name="sort" id="sortSelect"></select>
|
||||||
|
</label>
|
||||||
|
<label>Limit
|
||||||
|
<select name="limit">
|
||||||
|
<option>25</option>
|
||||||
|
<option selected>50</option>
|
||||||
|
<option>100</option>
|
||||||
|
<option>500</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="check admin-check"><input name="include_sensitive" type="checkbox" /> Show sensitive</label>
|
||||||
|
<label>Reason<input name="reason" placeholder="Обязательно для sensitive/export" /></label>
|
||||||
|
<div class="admin-form-actions">
|
||||||
|
<button type="submit">Запросить</button>
|
||||||
|
<button type="button" class="ghost-btn" id="exportJsonBtn">JSON export</button>
|
||||||
|
<button type="button" class="ghost-btn" id="exportCsvBtn">CSV export</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div id="sourceHint" class="admin-source-hint"></div>
|
||||||
|
<div id="dataResult" class="admin-table-wrap"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="panel-audit" class="admin-panel workspace hidden">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Контроль</p>
|
||||||
|
<h2>Audit Log</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form id="auditForm" class="admin-filter">
|
||||||
|
<input name="action" placeholder="Action" />
|
||||||
|
<input name="actor_id" type="number" placeholder="Actor ID" />
|
||||||
|
<input name="entity_type" placeholder="Entity type" />
|
||||||
|
<input name="entity_id" placeholder="Entity ID" />
|
||||||
|
<button type="submit">Показать</button>
|
||||||
|
</form>
|
||||||
|
<div id="auditTable" class="admin-table-wrap"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="panel-exports" class="admin-panel workspace hidden">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Выгрузки</p>
|
||||||
|
<h2>Exports</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="exportsTable" class="admin-table-wrap"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="toast hidden" id="toast" role="status" aria-live="polite"></div>
|
||||||
|
<script src="/static/page_common.js"></script>
|
||||||
|
<script src="/static/admin.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -53,6 +53,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form id="filterForm" class="grid-form drawer-form compact-form">
|
<form id="filterForm" class="grid-form drawer-form compact-form">
|
||||||
|
<details class="advanced-fields compact-filter">
|
||||||
|
<summary>Фильтры</summary>
|
||||||
<label>
|
<label>
|
||||||
Город
|
Город
|
||||||
<input name="city" placeholder="Seoul" />
|
<input name="city" placeholder="Seoul" />
|
||||||
@@ -62,6 +64,7 @@
|
|||||||
<input name="specialization" placeholder="BMW, масло, тормоза" />
|
<input name="specialization" placeholder="BMW, масло, тормоза" />
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">Найти</button>
|
<button type="submit">Найти</button>
|
||||||
|
</details>
|
||||||
</form>
|
</form>
|
||||||
<div id="serviceList" class="stack-list"></div>
|
<div id="serviceList" class="stack-list"></div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -89,6 +92,12 @@
|
|||||||
<option value="other">Другое</option>
|
<option value="other">Другое</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<div class="preset-row quick-service-pills wide" aria-label="Быстрый выбор услуги">
|
||||||
|
<button type="button" data-booking-service="oil_change">Масло</button>
|
||||||
|
<button type="button" data-booking-service="diagnostics">Диагностика</button>
|
||||||
|
<button type="button" data-booking-service="tire_service">Шины</button>
|
||||||
|
<button type="button" data-booking-service="brakes">Тормоза</button>
|
||||||
|
</div>
|
||||||
<label>
|
<label>
|
||||||
Длительность
|
Длительность
|
||||||
<select name="estimated_duration_minutes" id="durationSelect">
|
<select name="estimated_duration_minutes" id="durationSelect">
|
||||||
|
|||||||
@@ -78,23 +78,19 @@
|
|||||||
<span>/100</span>
|
<span>/100</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="passport-vehicle-summary" id="passportVehicleSummary"></div>
|
||||||
<div class="passport-grid">
|
<div class="passport-grid">
|
||||||
<div class="passport-metric">
|
<div class="passport-metric">
|
||||||
<span>Profile quality</span>
|
<span>Качество данных</span>
|
||||||
<strong id="scoreTitle">Старт</strong>
|
<strong id="scoreTitle">Старт</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="passport-metric">
|
<div class="passport-metric passport-metric-combo">
|
||||||
<span>Verified history</span>
|
<span>История и ТО</span>
|
||||||
<strong id="verifiedHistoryStatus">Self-reported</strong>
|
<strong id="verifiedHistoryStatus">Self-reported</strong>
|
||||||
</div>
|
<small id="maintenanceStatus">Unknown</small>
|
||||||
<div class="passport-metric">
|
|
||||||
<span>Maintenance health</span>
|
|
||||||
<strong id="maintenanceStatus">Unknown</strong>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="passport-actions" id="scoreActions"></div>
|
<div class="passport-actions" id="scoreActions"></div>
|
||||||
<div class="achievement-strip" id="achievementList"></div>
|
|
||||||
<div class="vehicle-timeline" id="vehicleTimeline"></div>
|
|
||||||
</section>
|
</section>
|
||||||
<div class="status-bar" id="statusBar" aria-live="polite">Готов к работе</div>
|
<div class="status-bar" id="statusBar" aria-live="polite">Готов к работе</div>
|
||||||
|
|
||||||
@@ -224,6 +220,9 @@
|
|||||||
Исполнитель
|
Исполнитель
|
||||||
<input name="vendor" placeholder="СТО / магазин" />
|
<input name="vendor" placeholder="СТО / магазин" />
|
||||||
</label>
|
</label>
|
||||||
|
<details class="advanced-fields wide">
|
||||||
|
<summary>Напоминание о следующем ТО</summary>
|
||||||
|
<div class="grid-form drawer-form compact-inner-form">
|
||||||
<label>
|
<label>
|
||||||
Следующая дата
|
Следующая дата
|
||||||
<input name="next_due_date" type="date" />
|
<input name="next_due_date" type="date" />
|
||||||
@@ -232,6 +231,8 @@
|
|||||||
Следующий пробег
|
Следующий пробег
|
||||||
<input name="next_due_odometer" type="number" min="0" />
|
<input name="next_due_odometer" type="number" min="0" />
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
<button type="submit">Сохранить запись</button>
|
<button type="submit">Сохранить запись</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
@@ -296,6 +297,28 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="drawer-content">
|
<div class="drawer-content">
|
||||||
|
<section class="drawer-section hidden" id="quickAddSection">
|
||||||
|
<h2>Добавить запись</h2>
|
||||||
|
<div class="quick-entry-grid">
|
||||||
|
<button type="button" data-quick-entry="fuelSection">
|
||||||
|
<span>Заправка</span>
|
||||||
|
<small>дата, пробег, литры, цена</small>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-quick-entry="serviceSection">
|
||||||
|
<span>ТО и ремонт</span>
|
||||||
|
<small>работа, стоимость, следующий срок</small>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-quick-entry="expensesSection">
|
||||||
|
<span>Расход</span>
|
||||||
|
<small>страховка, штраф, парковка, прочее</small>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-quick-entry="scan">
|
||||||
|
<span>Скан чека</span>
|
||||||
|
<small>фото или файл</small>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="drawer-section hidden" id="carsSection">
|
<section class="drawer-section hidden" id="carsSection">
|
||||||
<h2>Автомобили</h2>
|
<h2>Автомобили</h2>
|
||||||
<div id="drawerCars" class="cars drawer-cars"></div>
|
<div id="drawerCars" class="cars drawer-cars"></div>
|
||||||
@@ -354,6 +377,9 @@
|
|||||||
Поставщик / место
|
Поставщик / место
|
||||||
<input name="vendor" />
|
<input name="vendor" />
|
||||||
</label>
|
</label>
|
||||||
|
<details class="advanced-fields wide">
|
||||||
|
<summary>Дополнительно</summary>
|
||||||
|
<div class="grid-form drawer-form compact-inner-form">
|
||||||
<label>
|
<label>
|
||||||
Одометр
|
Одометр
|
||||||
<input name="odometer" type="number" min="0" />
|
<input name="odometer" type="number" min="0" />
|
||||||
@@ -397,6 +423,8 @@
|
|||||||
<input name="is_recurring" type="checkbox" />
|
<input name="is_recurring" type="checkbox" />
|
||||||
Регулярный расход
|
Регулярный расход
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
<button type="submit">Сохранить расход</button>
|
<button type="submit">Сохранить расход</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
479
web/static/admin.js
Normal file
479
web/static/admin.js
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
const AdminPage = (() => {
|
||||||
|
const { api, boot, toast, escapeHtml, formData, formatDateTime } = CarPassPage;
|
||||||
|
const state = {
|
||||||
|
active: "dashboard",
|
||||||
|
sources: [],
|
||||||
|
sourcesByName: {},
|
||||||
|
sorts: [],
|
||||||
|
lastDataPayload: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const panels = {
|
||||||
|
dashboard: "#panel-dashboard",
|
||||||
|
notifications: "#panel-notifications",
|
||||||
|
users: "#panel-users",
|
||||||
|
sto: "#panel-sto",
|
||||||
|
"sto-applications": "#panel-sto-applications",
|
||||||
|
vehicles: "#panel-vehicles",
|
||||||
|
appointments: "#panel-appointments",
|
||||||
|
"work-orders": "#panel-work-orders",
|
||||||
|
data: "#panel-data",
|
||||||
|
audit: "#panel-audit",
|
||||||
|
exports: "#panel-exports",
|
||||||
|
};
|
||||||
|
|
||||||
|
const quickLinks = [
|
||||||
|
["notifications", "Notifications"],
|
||||||
|
["users", "Users"],
|
||||||
|
["sto-applications", "Заявки СТО"],
|
||||||
|
["vehicles", "Авто"],
|
||||||
|
["data", "Data Explorer"],
|
||||||
|
["audit", "Audit Log"],
|
||||||
|
];
|
||||||
|
|
||||||
|
function qs(selector) {
|
||||||
|
return document.querySelector(selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueOrDash(value) {
|
||||||
|
if (value === null || value === undefined || value === "") return "-";
|
||||||
|
if (typeof value === "string" && value.includes("T")) return formatDateTime(value);
|
||||||
|
return escapeHtml(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActive(section) {
|
||||||
|
state.active = panels[section] ? section : "dashboard";
|
||||||
|
Object.entries(panels).forEach(([name, selector]) => {
|
||||||
|
qs(selector)?.classList.toggle("hidden", name !== state.active);
|
||||||
|
});
|
||||||
|
document.querySelectorAll("[data-admin-tab]").forEach((button) => {
|
||||||
|
button.classList.toggle("active", button.dataset.adminTab === state.active);
|
||||||
|
});
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set("section", state.active);
|
||||||
|
window.history.replaceState({}, "", url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEmpty(root, text = "Нет данных") {
|
||||||
|
root.innerHTML = `<div class="tip-card">${escapeHtml(text)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderError(root, error) {
|
||||||
|
root.innerHTML = `<div class="tip-card error-state">${escapeHtml(error.message || "Ошибка")}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceConfig(source) {
|
||||||
|
return source ? state.sourcesByName[source] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function editableValues(row, source) {
|
||||||
|
const config = sourceConfig(source);
|
||||||
|
const fields = config?.editable || [];
|
||||||
|
return fields.reduce((payload, field) => {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(row, field)) payload[field] = row[field];
|
||||||
|
return payload;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSourceHint(source) {
|
||||||
|
const hint = qs("#sourceHint");
|
||||||
|
if (!hint) return;
|
||||||
|
const config = sourceConfig(source);
|
||||||
|
if (!config) {
|
||||||
|
hint.textContent = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const editable = config.editable?.length ? config.editable.join(", ") : "нет";
|
||||||
|
const deleteMode = config.deletable ? config.delete_mode : "нет";
|
||||||
|
hint.textContent = `Редактируемые поля: ${editable}. Удаление: ${deleteMode}. Все изменения требуют reason и пишутся в Audit Log.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mutateRow(action, source, row) {
|
||||||
|
const config = sourceConfig(source);
|
||||||
|
if (!config || !row?.id) return;
|
||||||
|
if (action === "edit") {
|
||||||
|
const draft = editableValues(row, source);
|
||||||
|
const raw = window.prompt("JSON с изменяемыми полями", JSON.stringify(draft, null, 2));
|
||||||
|
if (!raw) return;
|
||||||
|
let values;
|
||||||
|
try {
|
||||||
|
values = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
toast("Некорректный JSON", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reason = window.prompt("Причина изменения") || "";
|
||||||
|
if (!reason.trim()) return;
|
||||||
|
await api(`/admin/data/${source}/${row.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ values, reason }),
|
||||||
|
});
|
||||||
|
toast("Запись обновлена");
|
||||||
|
} else {
|
||||||
|
const reason = window.prompt(`Причина удаления ${source} #${row.id}`) || "";
|
||||||
|
if (!reason.trim()) return;
|
||||||
|
if (!window.confirm(`Удалить ${source} #${row.id}?`)) return;
|
||||||
|
await api(`/admin/data/${source}/${row.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
body: JSON.stringify({ reason }),
|
||||||
|
});
|
||||||
|
toast(config.delete_mode === "hard" ? "Запись удалена" : "Запись скрыта/отключена");
|
||||||
|
}
|
||||||
|
await loadActiveSection();
|
||||||
|
if (state.active === "data" && state.lastDataPayload) {
|
||||||
|
await submitDataQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindTableActions(root, source, rows) {
|
||||||
|
root.querySelectorAll("[data-admin-row-action]").forEach((button) => {
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
const row = rows[Number(button.dataset.rowIndex)];
|
||||||
|
try {
|
||||||
|
await mutateRow(button.dataset.adminRowAction, source, row);
|
||||||
|
} catch (error) {
|
||||||
|
toast(error.message || "Ошибка", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable(root, rows, preferredColumns = [], source = null) {
|
||||||
|
if (!rows?.length) {
|
||||||
|
renderEmpty(root);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const columns = preferredColumns.length ? preferredColumns : Object.keys(rows[0]);
|
||||||
|
const config = sourceConfig(source);
|
||||||
|
const hasActions = Boolean(config && rows.some((row) => row.id) && (config.editable?.length || config.deletable));
|
||||||
|
root.innerHTML = `
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
${columns.map((column) => `<th>${escapeHtml(column)}</th>`).join("")}
|
||||||
|
${hasActions ? "<th class=\"admin-actions-head\">Действия</th>" : ""}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${rows
|
||||||
|
.map(
|
||||||
|
(row) => `
|
||||||
|
<tr>
|
||||||
|
${columns.map((column) => `<td>${valueOrDash(row[column])}</td>`).join("")}
|
||||||
|
${
|
||||||
|
hasActions
|
||||||
|
? `<td class="admin-action-cell">
|
||||||
|
${
|
||||||
|
config.editable?.length
|
||||||
|
? `<button type="button" class="ghost-btn compact-btn" data-admin-row-action="edit" data-row-index="${rows.indexOf(row)}">Edit</button>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
${
|
||||||
|
config.deletable
|
||||||
|
? `<button type="button" class="danger-btn compact-btn" data-admin-row-action="delete" data-row-index="${rows.indexOf(row)}">Delete</button>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
</td>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
if (hasActions) bindTableActions(root, source, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
function badge(value) {
|
||||||
|
return `<span class="admin-badge">${escapeHtml(value || "-")}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDashboard() {
|
||||||
|
const data = await api("/admin/dashboard");
|
||||||
|
const statLabels = [
|
||||||
|
["users_today", "Новые сегодня"],
|
||||||
|
["users_7d", "Новые 7 дней"],
|
||||||
|
["users_total", "Всего пользователей"],
|
||||||
|
["active_users", "Активные"],
|
||||||
|
["vehicles_total", "Авто"],
|
||||||
|
["pending_sto_applications", "Pending СТО"],
|
||||||
|
["approved_sto", "Approved СТО"],
|
||||||
|
["appointments_today", "Записи сегодня"],
|
||||||
|
["active_work_orders", "Активные ЗН"],
|
||||||
|
["completed_work_orders", "Завершенные ЗН"],
|
||||||
|
["system_errors", "Ошибки"],
|
||||||
|
["security_events", "Security"],
|
||||||
|
];
|
||||||
|
qs("#dashboardStats").innerHTML = statLabels
|
||||||
|
.map(([key, label]) => `<div class="stat"><span>${label}</span><strong>${data[key] ?? 0}</strong></div>`)
|
||||||
|
.join("");
|
||||||
|
const alerts = qs("#dashboardAlerts");
|
||||||
|
alerts.innerHTML = data.latest_alerts?.length
|
||||||
|
? data.latest_alerts
|
||||||
|
.map(
|
||||||
|
(item) => `
|
||||||
|
<article class="stack-item">
|
||||||
|
<div>
|
||||||
|
<strong>${escapeHtml(item.title)}</strong>
|
||||||
|
<small>${badge(item.event_type)} ${formatDateTime(item.created_at)}</small>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
: `<div class="tip-card">Критичных событий нет</div>`;
|
||||||
|
qs("#quickLinks").innerHTML = quickLinks
|
||||||
|
.map(([section, label]) => `<button type="button" data-admin-tab="${section}">${label}</button>`)
|
||||||
|
.join("");
|
||||||
|
bindTabButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNotifications() {
|
||||||
|
const root = qs("#notificationsList");
|
||||||
|
try {
|
||||||
|
const data = await api("/admin/notifications?limit=100");
|
||||||
|
if (!data.rows.length) return renderEmpty(root);
|
||||||
|
root.innerHTML = data.rows
|
||||||
|
.map(
|
||||||
|
(item) => `
|
||||||
|
<article class="stack-item">
|
||||||
|
<div>
|
||||||
|
<strong>${escapeHtml(item.title)}</strong>
|
||||||
|
<small>${badge(item.event_type)} ${badge(item.severity)} ${badge(item.status)} ${formatDateTime(item.created_at)}</small>
|
||||||
|
<p>${escapeHtml(item.body || "")}</p>
|
||||||
|
</div>
|
||||||
|
<div class="row-actions">
|
||||||
|
<button type="button" data-read-notification="${item.id}">Read</button>
|
||||||
|
<button type="button" class="ghost-btn" data-dismiss-notification="${item.id}">Dismiss</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
root.querySelectorAll("[data-read-notification]").forEach((button) => {
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
await api(`/admin/notifications/${button.dataset.readNotification}/read`, { method: "POST" });
|
||||||
|
await loadNotifications();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
root.querySelectorAll("[data-dismiss-notification]").forEach((button) => {
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
await api(`/admin/notifications/${button.dataset.dismissNotification}/dismiss`, { method: "POST" });
|
||||||
|
await loadNotifications();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
renderError(root, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers(search = "") {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (search) query.set("search", search);
|
||||||
|
const data = await api(`/admin/users?${query.toString()}`);
|
||||||
|
renderTable(qs("#usersTable"), data.rows, ["id", "telegram_id", "username", "first_name", "platform_role", "created_at"], "users");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSto(filters = {}) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
Object.entries(filters).forEach(([key, value]) => {
|
||||||
|
if (value) query.set(key, value);
|
||||||
|
});
|
||||||
|
const data = await api(`/admin/sto?${query.toString()}`);
|
||||||
|
renderTable(qs("#stoTable"), data.rows, ["id", "display_name", "city", "phone", "verification_status", "owner_user_id", "created_at"], "sto_profiles");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadApplications() {
|
||||||
|
const root = qs("#applicationsList");
|
||||||
|
try {
|
||||||
|
const data = await api("/admin/sto-applications");
|
||||||
|
if (!data.rows.length) return renderEmpty(root, "Очередь модерации пуста");
|
||||||
|
root.innerHTML = data.rows
|
||||||
|
.map(
|
||||||
|
(item) => `
|
||||||
|
<article class="stack-item">
|
||||||
|
<div>
|
||||||
|
<strong>${escapeHtml(item.display_name || item.legal_name || `СТО #${item.id}`)}</strong>
|
||||||
|
<small>${badge(item.verification_status)} ${escapeHtml(item.city || "-")} ${formatDateTime(item.created_at)}</small>
|
||||||
|
</div>
|
||||||
|
<div class="row-actions">
|
||||||
|
<button type="button" data-application-action="approve" data-application-id="${item.id}">Approve</button>
|
||||||
|
<button type="button" class="ghost-btn" data-application-action="request-changes" data-application-id="${item.id}">Правки</button>
|
||||||
|
<button type="button" class="danger-btn" data-application-action="reject" data-application-id="${item.id}">Reject</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
root.querySelectorAll("[data-application-action]").forEach((button) => {
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
const action = button.dataset.applicationAction;
|
||||||
|
const reason = action === "approve" ? "Approved in admin panel" : window.prompt("Причина") || "";
|
||||||
|
if (action !== "approve" && !reason) return;
|
||||||
|
await api(`/admin/sto-applications/${button.dataset.applicationId}/${action}`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ reason, comment: reason }),
|
||||||
|
});
|
||||||
|
toast("Статус заявки обновлен");
|
||||||
|
await loadApplications();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
renderError(root, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSourceTable(source, rootSelector, columns) {
|
||||||
|
const root = qs(rootSelector);
|
||||||
|
try {
|
||||||
|
const data = await api("/admin/data/query", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ source, limit: 100 }),
|
||||||
|
});
|
||||||
|
renderTable(root, data.rows, columns, source);
|
||||||
|
} catch (error) {
|
||||||
|
renderError(root, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanPayload(payload) {
|
||||||
|
const cleaned = {};
|
||||||
|
Object.entries(payload).forEach(([key, value]) => {
|
||||||
|
if (value === "" || value === null || value === undefined) return;
|
||||||
|
if (["user_id", "telegram_id", "vehicle_id", "sto_id", "limit"].includes(key)) {
|
||||||
|
cleaned[key] = Number(value);
|
||||||
|
} else if (key === "include_sensitive") {
|
||||||
|
cleaned[key] = value === "on";
|
||||||
|
} else {
|
||||||
|
cleaned[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!("include_sensitive" in cleaned)) cleaned.include_sensitive = false;
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitDataQuery(format = null) {
|
||||||
|
const payload = cleanPayload(formData(qs("#dataForm")));
|
||||||
|
state.lastDataPayload = payload;
|
||||||
|
renderSourceHint(payload.source);
|
||||||
|
if (format) {
|
||||||
|
const result = await api("/admin/data/export", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ ...payload, export_format: format }),
|
||||||
|
});
|
||||||
|
toast(`Export #${result.id} готов`);
|
||||||
|
await loadExports();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await api("/admin/data/query", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
renderTable(qs("#dataResult"), data.rows, [], payload.source);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAudit(params = {}) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value) query.set(key, value);
|
||||||
|
});
|
||||||
|
const rows = await api(`/admin/audit-log?${query.toString()}`);
|
||||||
|
renderTable(qs("#auditTable"), rows, ["id", "actor_user_id", "actor_role", "action", "target_type", "target_id", "created_at"], "audit_logs");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadExports() {
|
||||||
|
const data = await api("/admin/exports");
|
||||||
|
renderTable(qs("#exportsTable"), data.rows, ["id", "source", "export_format", "status", "row_count", "reason", "expires_at", "created_at"], "imports_exports");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadActiveSection() {
|
||||||
|
if (state.active === "dashboard") return loadDashboard();
|
||||||
|
if (state.active === "notifications") return loadNotifications();
|
||||||
|
if (state.active === "users") return loadUsers();
|
||||||
|
if (state.active === "sto") return loadSto();
|
||||||
|
if (state.active === "sto-applications") return loadApplications();
|
||||||
|
if (state.active === "vehicles") return loadSourceTable("vehicles", "#vehiclesTable", ["id", "owner_id", "name", "make", "model", "year", "vin", "plate_number", "current_odometer", "created_at"]);
|
||||||
|
if (state.active === "appointments") return loadSourceTable("appointments", "#appointmentsTable", ["id", "service_center_id", "vehicle_id", "owner_user_id", "service_type", "status", "requested_start_at", "created_at"]);
|
||||||
|
if (state.active === "work-orders") return loadSourceTable("work_orders", "#workOrdersTable", ["id", "service_center_id", "vehicle_id", "owner_user_id", "status", "final_total", "currency", "completed_at"]);
|
||||||
|
if (state.active === "audit") return loadAudit();
|
||||||
|
if (state.active === "exports") return loadExports();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindTabButtons() {
|
||||||
|
document.querySelectorAll("[data-admin-tab]").forEach((button) => {
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
setActive(button.dataset.adminTab);
|
||||||
|
try {
|
||||||
|
await loadActiveSection();
|
||||||
|
} catch (error) {
|
||||||
|
toast(error.message || "Ошибка", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindForms() {
|
||||||
|
qs("#refreshBtn")?.addEventListener("click", () => loadActiveSection().catch((error) => toast(error.message, "error")));
|
||||||
|
qs("#readAllBtn")?.addEventListener("click", async () => {
|
||||||
|
await api("/admin/notifications/read-all", { method: "POST" });
|
||||||
|
await loadNotifications();
|
||||||
|
});
|
||||||
|
qs("#retryNotificationsBtn")?.addEventListener("click", async () => {
|
||||||
|
const result = await api("/admin/notifications/retry", { method: "POST" });
|
||||||
|
toast(`Retry: service ${result.service_delivered}, admin ${result.admin_delivered}`);
|
||||||
|
await loadNotifications();
|
||||||
|
});
|
||||||
|
document.querySelector("[data-list-filter='users']")?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await loadUsers(formData(event.currentTarget).search || "");
|
||||||
|
});
|
||||||
|
document.querySelector("[data-list-filter='sto']")?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await loadSto(formData(event.currentTarget));
|
||||||
|
});
|
||||||
|
qs("#dataForm")?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await submitDataQuery().catch((error) => toast(error.message, "error"));
|
||||||
|
});
|
||||||
|
qs("#exportJsonBtn")?.addEventListener("click", () => submitDataQuery("json").catch((error) => toast(error.message, "error")));
|
||||||
|
qs("#exportCsvBtn")?.addEventListener("click", () => submitDataQuery("csv").catch((error) => toast(error.message, "error")));
|
||||||
|
qs("#auditForm")?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await loadAudit(cleanPayload(formData(event.currentTarget)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initSources() {
|
||||||
|
const data = await api("/admin/data/sources");
|
||||||
|
state.sources = data.sources || [];
|
||||||
|
state.sourcesByName = Object.fromEntries(state.sources.map((source) => [source.name, source]));
|
||||||
|
state.sorts = data.sorts || [];
|
||||||
|
qs("#sourceSelect").innerHTML = state.sources
|
||||||
|
.filter((source) => source.available && source.allowed)
|
||||||
|
.map((source) => `<option value="${source.name}">${source.name}</option>`)
|
||||||
|
.join("");
|
||||||
|
qs("#sortSelect").innerHTML = state.sorts
|
||||||
|
.map((sort) => `<option value="${sort}">${sort}</option>`)
|
||||||
|
.join("");
|
||||||
|
qs("#sourceSelect")?.addEventListener("change", (event) => renderSourceHint(event.target.value));
|
||||||
|
renderSourceHint(qs("#sourceSelect")?.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
qs("#adminRoleBadge").textContent = CarPassPage.state.user?.platform_role || "admin";
|
||||||
|
qs("#adminMeta").textContent = `User #${CarPassPage.state.user?.id || "-"} · Telegram ${CarPassPage.state.user?.telegram_id || "-"}`;
|
||||||
|
await initSources();
|
||||||
|
bindTabButtons();
|
||||||
|
bindForms();
|
||||||
|
const urlSection = new URLSearchParams(window.location.search).get("section");
|
||||||
|
setActive(urlSection || "dashboard");
|
||||||
|
await loadActiveSection();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init };
|
||||||
|
})();
|
||||||
|
|
||||||
|
CarPassPage.boot(AdminPage.init);
|
||||||
@@ -4,6 +4,8 @@ tg?.expand();
|
|||||||
|
|
||||||
const textNodes = new WeakMap();
|
const textNodes = new WeakMap();
|
||||||
const attrOriginals = new WeakMap();
|
const attrOriginals = new WeakMap();
|
||||||
|
let translationObserver = null;
|
||||||
|
let translationTimer = null;
|
||||||
|
|
||||||
const i18n = {
|
const i18n = {
|
||||||
en: {
|
en: {
|
||||||
@@ -83,6 +85,14 @@ const i18n = {
|
|||||||
"Марка": "Make",
|
"Марка": "Make",
|
||||||
"Модель": "Model",
|
"Модель": "Model",
|
||||||
"Добавить авто": "Add vehicle",
|
"Добавить авто": "Add vehicle",
|
||||||
|
"Добавить запись": "Add entry",
|
||||||
|
"Расход": "Expense",
|
||||||
|
"дата, пробег, литры, цена": "date, odometer, liters, price",
|
||||||
|
"работа, стоимость, следующий срок": "work, cost, next due",
|
||||||
|
"страховка, штраф, парковка, прочее": "insurance, fine, parking, other",
|
||||||
|
"фото или файл": "photo or file",
|
||||||
|
"Дополнительно": "More options",
|
||||||
|
"Напоминание о следующем ТО": "Next maintenance reminder",
|
||||||
"За весь срок": "All time",
|
"За весь срок": "All time",
|
||||||
"За месяц": "This month",
|
"За месяц": "This month",
|
||||||
"За день": "Per day",
|
"За день": "Per day",
|
||||||
@@ -215,6 +225,14 @@ const i18n = {
|
|||||||
"Марка": "브랜드",
|
"Марка": "브랜드",
|
||||||
"Модель": "모델",
|
"Модель": "모델",
|
||||||
"Добавить авто": "차량 추가",
|
"Добавить авто": "차량 추가",
|
||||||
|
"Добавить запись": "기록 추가",
|
||||||
|
"Расход": "지출",
|
||||||
|
"дата, пробег, литры, цена": "날짜, 주행거리, 리터, 가격",
|
||||||
|
"работа, стоимость, следующий срок": "작업, 비용, 다음 예정",
|
||||||
|
"страховка, штраф, парковка, прочее": "보험, 벌금, 주차, 기타",
|
||||||
|
"фото или файл": "사진 또는 파일",
|
||||||
|
"Дополнительно": "추가 옵션",
|
||||||
|
"Напоминание о следующем ТО": "다음 정비 알림",
|
||||||
"За весь срок": "전체",
|
"За весь срок": "전체",
|
||||||
"За месяц": "월",
|
"За месяц": "월",
|
||||||
"За день": "일 평균",
|
"За день": "일 평균",
|
||||||
@@ -304,6 +322,15 @@ function applyTranslations(root = document.body) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function observeTranslations(root = document.body) {
|
||||||
|
if (translationObserver || !root) return;
|
||||||
|
translationObserver = new MutationObserver(() => {
|
||||||
|
window.clearTimeout(translationTimer);
|
||||||
|
translationTimer = window.setTimeout(() => applyTranslations(root), 40);
|
||||||
|
});
|
||||||
|
translationObserver.observe(root, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
user: null,
|
user: null,
|
||||||
@@ -313,6 +340,9 @@ const state = {
|
|||||||
selectedCarId: null,
|
selectedCarId: null,
|
||||||
latestFuel: [],
|
latestFuel: [],
|
||||||
latestService: [],
|
latestService: [],
|
||||||
|
latestFuelAllTime: [],
|
||||||
|
latestServiceAllTime: [],
|
||||||
|
selectedCarHighlights: null,
|
||||||
latestExpenses: [],
|
latestExpenses: [],
|
||||||
latestStats: null,
|
latestStats: null,
|
||||||
allStats: null,
|
allStats: null,
|
||||||
@@ -329,7 +359,6 @@ const state = {
|
|||||||
connectedServices: [],
|
connectedServices: [],
|
||||||
adminPendingServices: [],
|
adminPendingServices: [],
|
||||||
vehicleScore: null,
|
vehicleScore: null,
|
||||||
vehicleTimeline: [],
|
|
||||||
achievements: [],
|
achievements: [],
|
||||||
receiptFile: null,
|
receiptFile: null,
|
||||||
serviceWorkerRegistration: null,
|
serviceWorkerRegistration: null,
|
||||||
@@ -580,6 +609,7 @@ async function ensureUser() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ init_data: tg.initData }),
|
body: JSON.stringify({ init_data: tg.initData }),
|
||||||
});
|
});
|
||||||
|
localStorage.setItem("carpassLocale", state.user.locale || "ru");
|
||||||
hideAuthOverlay();
|
hideAuthOverlay();
|
||||||
updateRoleVisibility();
|
updateRoleVisibility();
|
||||||
return;
|
return;
|
||||||
@@ -588,6 +618,7 @@ async function ensureUser() {
|
|||||||
const devId = localStorage.getItem("driversDevTelegramId") || "1";
|
const devId = localStorage.getItem("driversDevTelegramId") || "1";
|
||||||
localStorage.setItem("driversDevTelegramId", devId);
|
localStorage.setItem("driversDevTelegramId", devId);
|
||||||
state.user = await api("/users/me");
|
state.user = await api("/users/me");
|
||||||
|
localStorage.setItem("carpassLocale", state.user.locale || "ru");
|
||||||
hideAuthOverlay();
|
hideAuthOverlay();
|
||||||
updateRoleVisibility();
|
updateRoleVisibility();
|
||||||
return;
|
return;
|
||||||
@@ -596,6 +627,36 @@ async function ensureUser() {
|
|||||||
throw new Error("Требуется вход через Telegram");
|
throw new Error("Требуется вход через Telegram");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function installLocaleSwitch() {
|
||||||
|
const topActions = document.querySelector(".topbar .top-actions");
|
||||||
|
if (!topActions || document.querySelector("#globalLocaleSelect")) return;
|
||||||
|
const select = document.createElement("select");
|
||||||
|
select.id = "globalLocaleSelect";
|
||||||
|
select.className = "locale-switch";
|
||||||
|
select.setAttribute("aria-label", "Язык");
|
||||||
|
select.innerHTML = `
|
||||||
|
<option value="ru">RU</option>
|
||||||
|
<option value="en">EN</option>
|
||||||
|
<option value="ko">KO</option>
|
||||||
|
`;
|
||||||
|
select.value = state.user?.locale || "ru";
|
||||||
|
select.addEventListener("change", async () => {
|
||||||
|
await runAction(select, "Сохраняю...", async () => {
|
||||||
|
state.user = await api(`/users/${state.user.id}/preferences`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ locale: select.value, currency: state.user.currency }),
|
||||||
|
});
|
||||||
|
localStorage.setItem("carpassLocale", state.user.locale || "ru");
|
||||||
|
document.querySelector("#localeSelect").value = state.user.locale || "ru";
|
||||||
|
applyTranslations();
|
||||||
|
renderCars();
|
||||||
|
renderStats(state.latestStats);
|
||||||
|
toast("Сохранено");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
topActions.prepend(select);
|
||||||
|
}
|
||||||
|
|
||||||
function hideAuthOverlay() {
|
function hideAuthOverlay() {
|
||||||
document.querySelector("#authOverlay")?.classList.add("hidden");
|
document.querySelector("#authOverlay")?.classList.add("hidden");
|
||||||
document.body.classList.remove("auth-required");
|
document.body.classList.remove("auth-required");
|
||||||
@@ -901,10 +962,11 @@ function renderCars() {
|
|||||||
.map(
|
.map(
|
||||||
(car) => `
|
(car) => `
|
||||||
<button class="car-item ${car.id === state.selectedCarId ? "active" : ""}" data-car="${car.id}">
|
<button class="car-item ${car.id === state.selectedCarId ? "active" : ""}" data-car="${car.id}">
|
||||||
<span class="car-badge">${(car.make || car.name).slice(0, 2).toUpperCase()}</span>
|
<span class="car-badge">${escapeHtml((car.make || car.name).slice(0, 2).toUpperCase())}</span>
|
||||||
<span class="car-copy">
|
<span class="car-copy">
|
||||||
<strong>${car.name}</strong>
|
<strong>${escapeHtml(car.name)}</strong>
|
||||||
<small>${[car.make, car.model, car.trim, car.year, car.fuel_type].filter(Boolean).join(" ") || t("Без деталей")}</small>
|
<small>${escapeHtml([car.make, car.model, car.trim, car.year, car.fuel_type].filter(Boolean).join(" ") || t("Без деталей"))}</small>
|
||||||
|
${car.id === state.selectedCarId ? renderSelectedCarHighlights(car) : ""}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
`,
|
`,
|
||||||
@@ -917,6 +979,84 @@ function renderCars() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderSelectedCarHighlights(car) {
|
||||||
|
const highlights = state.selectedCarHighlights?.carId === car.id ? state.selectedCarHighlights : buildCarHighlights(car, [], []);
|
||||||
|
const rows = [
|
||||||
|
["Одометр", highlights.odometer],
|
||||||
|
["Заправка", highlights.lastFuel],
|
||||||
|
["Масло", highlights.lastOil],
|
||||||
|
["До масла", highlights.oilRemaining],
|
||||||
|
];
|
||||||
|
return `
|
||||||
|
<span class="car-key-params">
|
||||||
|
${rows
|
||||||
|
.map(
|
||||||
|
([label, value]) => `
|
||||||
|
<span class="car-key-param">
|
||||||
|
<span>${label}</span>
|
||||||
|
<strong>${escapeHtml(value)}</strong>
|
||||||
|
</span>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCarHighlights(car, fuelEntries, serviceEntries) {
|
||||||
|
const currentOdometer = Number(car?.current_odometer || 0);
|
||||||
|
const lastFuel = fuelEntries[0] || null;
|
||||||
|
const lastOil = serviceEntries.find(isOilService) || null;
|
||||||
|
const oilDueOdometer = oilDueKm(car, lastOil);
|
||||||
|
const oilRemainingKm = oilDueOdometer != null && car?.current_odometer != null
|
||||||
|
? oilDueOdometer - currentOdometer
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
carId: car?.id,
|
||||||
|
odometer: car?.current_odometer != null ? `${formatKm(car.current_odometer)}` : "-",
|
||||||
|
lastFuel: lastFuel ? `${formatShortDate(lastFuel.entry_date)} · ${formatLiters(lastFuel.liters)}` : "-",
|
||||||
|
lastOil: lastOil ? formatShortDate(lastOil.entry_date) : "-",
|
||||||
|
oilRemaining: formatOilRemaining(oilRemainingKm),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOilService(item) {
|
||||||
|
const text = [item?.title, item?.category, item?.notes, item?.service_type].filter(Boolean).join(" ").toLowerCase();
|
||||||
|
return /масл|oil|engine_oil/.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function oilDueKm(car, latestOil) {
|
||||||
|
if (latestOil?.next_due_odometer != null) return Number(latestOil.next_due_odometer);
|
||||||
|
const interval = Number(car?.oil_change_interval_km || 0);
|
||||||
|
if (!interval) return null;
|
||||||
|
if (latestOil?.odometer != null) return Number(latestOil.odometer) + interval;
|
||||||
|
if (car?.current_odometer != null) return Number(car.current_odometer) + interval;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatKm(value) {
|
||||||
|
if (value == null || value === "") return "-";
|
||||||
|
return `${Number(value).toLocaleString("ru-RU")} км`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLiters(value) {
|
||||||
|
if (value == null || value === "") return "-";
|
||||||
|
return `${Number(value).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} л`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatShortDate(value) {
|
||||||
|
if (!value) return "-";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return String(value).slice(0, 10);
|
||||||
|
return date.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOilRemaining(value) {
|
||||||
|
if (value == null || Number.isNaN(value)) return "-";
|
||||||
|
if (value < 0) return `просрочено ${formatKm(Math.abs(value))}`;
|
||||||
|
return formatKm(value);
|
||||||
|
}
|
||||||
|
|
||||||
function setInputValue(form, name, value) {
|
function setInputValue(form, name, value) {
|
||||||
if (!form?.elements[name]) return;
|
if (!form?.elements[name]) return;
|
||||||
const input = form.elements[name];
|
const input = form.elements[name];
|
||||||
@@ -1902,11 +2042,60 @@ function updateScore() {
|
|||||||
ring.style.background = `conic-gradient(#5ee0bd ${score * 3.6}deg, rgba(255,255,255,0.12) 0deg)`;
|
ring.style.background = `conic-gradient(#5ee0bd ${score * 3.6}deg, rgba(255,255,255,0.12) 0deg)`;
|
||||||
}
|
}
|
||||||
document.querySelector("#scoreHint").textContent = car
|
document.querySelector("#scoreHint").textContent = car
|
||||||
? "Качество паспорта растет от подтвержденных данных, сервисной истории и точного пробега."
|
? "Короткая сводка по полноте данных и готовности к обслуживанию."
|
||||||
: t("Добавь авто и первую запись, чтобы видеть точные отчеты");
|
: t("Добавь авто и первую запись, чтобы видеть точные отчеты");
|
||||||
|
renderPassportVehicleSummary(car);
|
||||||
renderScoreActions(state.vehicleScore?.missing_items || []);
|
renderScoreActions(state.vehicleScore?.missing_items || []);
|
||||||
renderAchievements();
|
}
|
||||||
renderVehicleTimeline();
|
|
||||||
|
function renderPassportVehicleSummary(car) {
|
||||||
|
const root = document.querySelector("#passportVehicleSummary");
|
||||||
|
if (!root) return;
|
||||||
|
if (!car) {
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="passport-vehicle-empty">
|
||||||
|
<strong>Выберите автомобиль</strong>
|
||||||
|
<span>Здесь появятся номер, VIN, пробег, заправка и ближайшее ТО.</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const highlights = state.selectedCarHighlights?.carId === car.id ? state.selectedCarHighlights : buildCarHighlights(car, [], []);
|
||||||
|
const identity = [car.make, car.model, car.trim, car.year].filter(Boolean).join(" ") || "Паспорт без деталей";
|
||||||
|
const plate = car.license_plate_display || car.plate_number || "номер не указан";
|
||||||
|
const vin = car.vin || "VIN не указан";
|
||||||
|
const oilSpec = [car.engine_oil_type, car.engine_oil_volume_l ? `${Number(car.engine_oil_volume_l).toLocaleString("ru-RU")} л` : ""]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
const fuelAndOil = [car.fuel_type || "топливо не указано", oilSpec || "масло не указано"].join(" · ");
|
||||||
|
const rows = [
|
||||||
|
["Одометр", highlights.odometer],
|
||||||
|
["Последняя заправка", highlights.lastFuel],
|
||||||
|
["Замена масла", highlights.lastOil],
|
||||||
|
["До следующей", highlights.oilRemaining],
|
||||||
|
];
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="passport-vehicle-main">
|
||||||
|
<div>
|
||||||
|
<strong>${escapeHtml(car.name)}</strong>
|
||||||
|
<span>${escapeHtml(identity)}</span>
|
||||||
|
</div>
|
||||||
|
<a class="passport-edit-link" href="/car_profile.html?car_id=${car.id}">Паспорт</a>
|
||||||
|
</div>
|
||||||
|
<div class="passport-vehicle-id">
|
||||||
|
<span>${escapeHtml(plate)}</span>
|
||||||
|
<span>${escapeHtml(vin)}</span>
|
||||||
|
<span>${escapeHtml(fuelAndOil)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="passport-vehicle-facts">
|
||||||
|
${rows.map(([label, value]) => `
|
||||||
|
<div>
|
||||||
|
<span>${label}</span>
|
||||||
|
<strong>${escapeHtml(value)}</strong>
|
||||||
|
</div>
|
||||||
|
`).join("")}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scoreLabel(quality, score) {
|
function scoreLabel(quality, score) {
|
||||||
@@ -1938,9 +2127,9 @@ function healthLabel(status) {
|
|||||||
function renderScoreActions(items) {
|
function renderScoreActions(items) {
|
||||||
const root = document.querySelector("#scoreActions");
|
const root = document.querySelector("#scoreActions");
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
const visible = items.slice(0, 3);
|
const visible = items.slice(0, 1);
|
||||||
if (!visible.length) {
|
if (!visible.length) {
|
||||||
root.innerHTML = `<div class="passport-note">Паспорт выглядит надежно. Следующий рост даст подтвержденная сервисная история.</div>`;
|
root.innerHTML = `<div class="passport-note">Паспорт готов: ключевые данные, история и ТО собраны достаточно для пилота.</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
root.innerHTML = visible
|
root.innerHTML = visible
|
||||||
@@ -2339,6 +2528,7 @@ async function applyInitialRoute() {
|
|||||||
|
|
||||||
async function selectCar(carId) {
|
async function selectCar(carId) {
|
||||||
state.selectedCarId = carId;
|
state.selectedCarId = carId;
|
||||||
|
state.selectedCarHighlights = null;
|
||||||
renderCars();
|
renderCars();
|
||||||
fillCarProfileForm();
|
fillCarProfileForm();
|
||||||
await loadSelectedCar();
|
await loadSelectedCar();
|
||||||
@@ -2348,17 +2538,19 @@ async function loadSelectedCar() {
|
|||||||
if (!state.selectedCarId) {
|
if (!state.selectedCarId) {
|
||||||
state.latestFuel = [];
|
state.latestFuel = [];
|
||||||
state.latestService = [];
|
state.latestService = [];
|
||||||
|
state.latestFuelAllTime = [];
|
||||||
|
state.latestServiceAllTime = [];
|
||||||
|
state.selectedCarHighlights = null;
|
||||||
state.latestExpenses = [];
|
state.latestExpenses = [];
|
||||||
state.latestStats = null;
|
state.latestStats = null;
|
||||||
state.allStats = null;
|
state.allStats = null;
|
||||||
state.analytics = null;
|
state.analytics = null;
|
||||||
state.vehicleScore = null;
|
state.vehicleScore = null;
|
||||||
state.vehicleTimeline = [];
|
|
||||||
state.achievements = [];
|
state.achievements = [];
|
||||||
renderStats(null);
|
renderStats(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const [stats, allStats, fuel, service, expenses, analytics, vehicleScore] = await Promise.all([
|
const [stats, allStats, fuel, service, expenses, analytics, vehicleScore, allFuel, allService] = await Promise.all([
|
||||||
api(`/cars/${state.selectedCarId}/stats${periodQuery()}`),
|
api(`/cars/${state.selectedCarId}/stats${periodQuery()}`),
|
||||||
api(`/cars/${state.selectedCarId}/stats${allPeriodQuery()}`),
|
api(`/cars/${state.selectedCarId}/stats${allPeriodQuery()}`),
|
||||||
api(`/cars/${state.selectedCarId}/fuel${periodQuery()}`),
|
api(`/cars/${state.selectedCarId}/fuel${periodQuery()}`),
|
||||||
@@ -2366,20 +2558,20 @@ async function loadSelectedCar() {
|
|||||||
api(`/cars/${state.selectedCarId}/expenses${periodQuery()}`),
|
api(`/cars/${state.selectedCarId}/expenses${periodQuery()}`),
|
||||||
api(`/cars/${state.selectedCarId}/analytics`),
|
api(`/cars/${state.selectedCarId}/analytics`),
|
||||||
api(`/my/vehicles/${state.selectedCarId}/score`),
|
api(`/my/vehicles/${state.selectedCarId}/score`),
|
||||||
]);
|
api(`/cars/${state.selectedCarId}/fuel?limit=1`),
|
||||||
const [timeline, achievements] = await Promise.all([
|
api(`/cars/${state.selectedCarId}/service?limit=100`),
|
||||||
api(`/my/vehicles/${state.selectedCarId}/timeline?limit=30`),
|
|
||||||
api("/me/achievements"),
|
|
||||||
]);
|
]);
|
||||||
state.latestStats = stats;
|
state.latestStats = stats;
|
||||||
state.allStats = allStats;
|
state.allStats = allStats;
|
||||||
state.latestFuel = fuel;
|
state.latestFuel = fuel;
|
||||||
state.latestService = service;
|
state.latestService = service;
|
||||||
|
state.latestFuelAllTime = allFuel;
|
||||||
|
state.latestServiceAllTime = allService;
|
||||||
state.latestExpenses = expenses;
|
state.latestExpenses = expenses;
|
||||||
state.analytics = analytics;
|
state.analytics = analytics;
|
||||||
state.vehicleScore = vehicleScore;
|
state.vehicleScore = vehicleScore;
|
||||||
state.vehicleTimeline = timeline;
|
state.selectedCarHighlights = buildCarHighlights(selectedCar(), allFuel, allService);
|
||||||
state.achievements = achievements;
|
renderCars();
|
||||||
renderStats(stats);
|
renderStats(stats);
|
||||||
drawCharts(fuel, service, stats);
|
drawCharts(fuel, service, stats);
|
||||||
}
|
}
|
||||||
@@ -2538,6 +2730,9 @@ document.querySelector("#settingsForm").addEventListener("submit", async (event)
|
|||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({ locale: data.locale, currency: data.currency }),
|
body: JSON.stringify({ locale: data.locale, currency: data.currency }),
|
||||||
});
|
});
|
||||||
|
localStorage.setItem("carpassLocale", state.user.locale || "ru");
|
||||||
|
const globalLocale = document.querySelector("#globalLocaleSelect");
|
||||||
|
if (globalLocale) globalLocale.value = state.user.locale || "ru";
|
||||||
applyTranslations();
|
applyTranslations();
|
||||||
initCarCatalog();
|
initCarCatalog();
|
||||||
await loadSelectedCar();
|
await loadSelectedCar();
|
||||||
@@ -2567,6 +2762,7 @@ document.querySelector("#fuelForm").addEventListener("submit", async (event) =>
|
|||||||
});
|
});
|
||||||
form.reset();
|
form.reset();
|
||||||
form.entry_date.value = today();
|
form.entry_date.value = today();
|
||||||
|
form.is_full_tank.checked = true;
|
||||||
await loadSelectedCar();
|
await loadSelectedCar();
|
||||||
toast("Сохранено");
|
toast("Сохранено");
|
||||||
haptic("success");
|
haptic("success");
|
||||||
@@ -2669,6 +2865,20 @@ function mountEntryForms() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fillEntryDefaults(sectionId) {
|
||||||
|
const car = selectedCar();
|
||||||
|
const odometer = car?.current_odometer || "";
|
||||||
|
const sections = sectionId ? [document.querySelector(`#${sectionId}`)] : [...document.querySelectorAll(".drawer-section")];
|
||||||
|
sections.filter(Boolean).forEach((section) => {
|
||||||
|
section.querySelectorAll('input[name="entry_date"]').forEach((input) => {
|
||||||
|
if (!input.value) input.value = today();
|
||||||
|
});
|
||||||
|
section.querySelectorAll('input[name="odometer"]').forEach((input) => {
|
||||||
|
if (!input.value && odometer) input.value = odometer;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function openDrawerSection(sectionId, options = {}) {
|
async function openDrawerSection(sectionId, options = {}) {
|
||||||
if (!canOpenDrawerSection(sectionId)) {
|
if (!canOpenDrawerSection(sectionId)) {
|
||||||
toast("Этот раздел недоступен для вашей роли", "error");
|
toast("Этот раздел недоступен для вашей роли", "error");
|
||||||
@@ -2684,6 +2894,7 @@ async function openDrawerSection(sectionId, options = {}) {
|
|||||||
button.classList.toggle("active", button.dataset.menuSection === sectionId);
|
button.classList.toggle("active", button.dataset.menuSection === sectionId);
|
||||||
});
|
});
|
||||||
mountEntryForms();
|
mountEntryForms();
|
||||||
|
fillEntryDefaults(sectionId);
|
||||||
if (sectionId === "carProfileSection") fillCarProfileForm();
|
if (sectionId === "carProfileSection") fillCarProfileForm();
|
||||||
if (sectionId === "settingsSection") {
|
if (sectionId === "settingsSection") {
|
||||||
document.querySelector("#localeSelect").value = state.user?.locale || "ru";
|
document.querySelector("#localeSelect").value = state.user?.locale || "ru";
|
||||||
@@ -2758,7 +2969,18 @@ document.querySelector("#addCarQuickBtn").addEventListener("click", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.querySelector("#addRecordPrimaryBtn").addEventListener("click", () => {
|
document.querySelector("#addRecordPrimaryBtn").addEventListener("click", () => {
|
||||||
openDrawerSection("expensesSection");
|
openDrawerSection("quickAddSection");
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-quick-entry]").forEach((button) => {
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
haptic();
|
||||||
|
if (button.dataset.quickEntry === "scan") {
|
||||||
|
openScanModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await openDrawerSection(button.dataset.quickEntry);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelectorAll("[data-menu-section]").forEach((button) => {
|
document.querySelectorAll("[data-menu-section]").forEach((button) => {
|
||||||
@@ -2910,6 +3132,8 @@ initPwa();
|
|||||||
Promise.all([loadAuthConfig()])
|
Promise.all([loadAuthConfig()])
|
||||||
.then(() => Promise.all([ensureUser(), loadCatalog()]))
|
.then(() => Promise.all([ensureUser(), loadCatalog()]))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
installLocaleSwitch();
|
||||||
|
observeTranslations();
|
||||||
document.querySelector("#localeSelect").value = state.user?.locale || "ru";
|
document.querySelector("#localeSelect").value = state.user?.locale || "ru";
|
||||||
document.querySelector("#currencySelect").value = state.user?.currency || "RUB";
|
document.querySelector("#currencySelect").value = state.user?.currency || "RUB";
|
||||||
document.querySelector("#expenseForm").currency.value = state.user?.currency || "RUB";
|
document.querySelector("#expenseForm").currency.value = state.user?.currency || "RUB";
|
||||||
|
|||||||
@@ -113,6 +113,16 @@ document.querySelector("#filterForm").addEventListener("submit", async (event) =
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-booking-service]").forEach((button) => {
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
document.querySelector("#serviceTypeSelect").value = button.dataset.bookingService;
|
||||||
|
document.querySelectorAll("[data-booking-service]").forEach((item) => {
|
||||||
|
item.classList.toggle("active", item === button);
|
||||||
|
});
|
||||||
|
await loadSlots().catch((error) => page.toast(error.message || "Не удалось обновить окна", "error"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
document.querySelector("#bookingForm").addEventListener("submit", async (event) => {
|
document.querySelector("#bookingForm").addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const center = selectedCenter();
|
const center = selectedCenter();
|
||||||
|
|||||||
@@ -2,6 +2,238 @@ const tg = window.Telegram?.WebApp;
|
|||||||
tg?.ready();
|
tg?.ready();
|
||||||
tg?.expand();
|
tg?.expand();
|
||||||
|
|
||||||
|
const CarPassI18n = (() => {
|
||||||
|
const textNodes = new WeakMap();
|
||||||
|
const attrOriginals = new WeakMap();
|
||||||
|
let observer = null;
|
||||||
|
let timer = null;
|
||||||
|
const dictionaries = {
|
||||||
|
en: {
|
||||||
|
"Гараж": "Garage",
|
||||||
|
"Автомобили": "Vehicles",
|
||||||
|
"Автомобиль": "Vehicle",
|
||||||
|
"Авто": "Vehicles",
|
||||||
|
"Заправка": "Fuel",
|
||||||
|
"Сервис": "Service",
|
||||||
|
"Расход": "Expense",
|
||||||
|
"Расходы": "Expenses",
|
||||||
|
"ТО и ремонт": "Maintenance and repair",
|
||||||
|
"Дата": "Date",
|
||||||
|
"Одометр, км": "Odometer, km",
|
||||||
|
"Одометр": "Odometer",
|
||||||
|
"Литры": "Liters",
|
||||||
|
"Цена за литр": "Price per liter",
|
||||||
|
"АЗС": "Fuel station",
|
||||||
|
"Полный бак": "Full tank",
|
||||||
|
"Стоимость": "Cost",
|
||||||
|
"Валюта": "Currency",
|
||||||
|
"Категория": "Category",
|
||||||
|
"Название": "Title",
|
||||||
|
"Комментарий": "Comment",
|
||||||
|
"Сохранить": "Save",
|
||||||
|
"Сохранить запись": "Save entry",
|
||||||
|
"Сохранить расход": "Save expense",
|
||||||
|
"Сохранить заправку": "Save fuel entry",
|
||||||
|
"Сохранить настройки": "Save settings",
|
||||||
|
"Создать запись": "Create booking",
|
||||||
|
"Запись в СТО": "Book service",
|
||||||
|
"СТО": "Service centers",
|
||||||
|
"Сервисы": "Services",
|
||||||
|
"Каталог": "Catalog",
|
||||||
|
"Заявка": "Request",
|
||||||
|
"Выберите сервис": "Choose service",
|
||||||
|
"Город": "City",
|
||||||
|
"Специализация": "Specialization",
|
||||||
|
"Найти": "Search",
|
||||||
|
"Что нужно сделать": "What needs to be done",
|
||||||
|
"Услуга": "Service",
|
||||||
|
"Длительность": "Duration",
|
||||||
|
"Свободное окно": "Available slot",
|
||||||
|
"Окно записи": "Booking slot",
|
||||||
|
"Отправить заявку": "Send request",
|
||||||
|
"Проверить карточку авто": "Check vehicle card",
|
||||||
|
"Меню": "Menu",
|
||||||
|
"Проверить вход": "Check login",
|
||||||
|
"Открыть в Telegram": "Open in Telegram",
|
||||||
|
"Обновить": "Refresh",
|
||||||
|
"Настройки": "Settings",
|
||||||
|
"Язык": "Language",
|
||||||
|
"Панель СТО": "Service workplace",
|
||||||
|
"Записи клиентов": "Client bookings",
|
||||||
|
"Заказ-наряды": "Work orders",
|
||||||
|
"Сотрудники": "Staff",
|
||||||
|
"Пригласить": "Invite",
|
||||||
|
"Заказ-наряд": "Work order",
|
||||||
|
"Работы": "Labor",
|
||||||
|
"Запчасти и жидкости": "Parts and fluids",
|
||||||
|
"Проверьте смету": "Review estimate",
|
||||||
|
"Согласовать": "Approve",
|
||||||
|
"Отклонить": "Reject",
|
||||||
|
"Админ-панель": "Admin panel",
|
||||||
|
"Операционный обзор": "Operational overview",
|
||||||
|
"Последние события": "Latest events",
|
||||||
|
"Быстрые переходы": "Quick links",
|
||||||
|
"Заявки СТО": "Service applications",
|
||||||
|
"Записи": "Bookings",
|
||||||
|
"Фильтр": "Filter",
|
||||||
|
"Фильтры": "Filters",
|
||||||
|
"Запросить": "Query",
|
||||||
|
"Показать": "Show",
|
||||||
|
"Импорт и экспорт": "Import and export",
|
||||||
|
"Скачать JSON": "Download JSON",
|
||||||
|
"Проверить файл": "Preview file",
|
||||||
|
"Импортировать": "Import",
|
||||||
|
"Паспорт автомобиля": "Vehicle passport",
|
||||||
|
"Выберите автомобиль": "Choose vehicle",
|
||||||
|
"Параметры авто": "Vehicle settings",
|
||||||
|
"Сохранить паспорт": "Save passport",
|
||||||
|
"Удалить автомобиль": "Delete vehicle",
|
||||||
|
"Готов к работе": "Ready",
|
||||||
|
"Сохраняю...": "Saving...",
|
||||||
|
"Сохранено": "Saved",
|
||||||
|
"Ошибка": "Error",
|
||||||
|
"Нет данных": "No data",
|
||||||
|
"Нет доступа": "No access",
|
||||||
|
},
|
||||||
|
ko: {
|
||||||
|
"Гараж": "차고",
|
||||||
|
"Автомобили": "차량",
|
||||||
|
"Автомобиль": "차량",
|
||||||
|
"Авто": "차량",
|
||||||
|
"Заправка": "주유",
|
||||||
|
"Сервис": "정비",
|
||||||
|
"Расход": "지출",
|
||||||
|
"Расходы": "지출",
|
||||||
|
"ТО и ремонт": "정비 및 수리",
|
||||||
|
"Дата": "날짜",
|
||||||
|
"Одометр, км": "주행거리, km",
|
||||||
|
"Одометр": "주행거리",
|
||||||
|
"Литры": "리터",
|
||||||
|
"Цена за литр": "리터당 가격",
|
||||||
|
"АЗС": "주유소",
|
||||||
|
"Полный бак": "가득 주유",
|
||||||
|
"Стоимость": "비용",
|
||||||
|
"Валюта": "통화",
|
||||||
|
"Категория": "카테고리",
|
||||||
|
"Название": "제목",
|
||||||
|
"Комментарий": "메모",
|
||||||
|
"Сохранить": "저장",
|
||||||
|
"Сохранить запись": "기록 저장",
|
||||||
|
"Сохранить расход": "지출 저장",
|
||||||
|
"Сохранить заправку": "주유 저장",
|
||||||
|
"Сохранить настройки": "설정 저장",
|
||||||
|
"Создать запись": "예약 생성",
|
||||||
|
"Запись в СТО": "정비소 예약",
|
||||||
|
"СТО": "정비소",
|
||||||
|
"Сервисы": "서비스",
|
||||||
|
"Каталог": "목록",
|
||||||
|
"Заявка": "요청",
|
||||||
|
"Выберите сервис": "정비소 선택",
|
||||||
|
"Город": "도시",
|
||||||
|
"Специализация": "전문 분야",
|
||||||
|
"Найти": "검색",
|
||||||
|
"Что нужно сделать": "필요한 작업",
|
||||||
|
"Услуга": "서비스",
|
||||||
|
"Длительность": "소요 시간",
|
||||||
|
"Свободное окно": "예약 가능 시간",
|
||||||
|
"Окно записи": "예약 시간",
|
||||||
|
"Отправить заявку": "요청 보내기",
|
||||||
|
"Проверить карточку авто": "차량 카드 확인",
|
||||||
|
"Меню": "메뉴",
|
||||||
|
"Проверить вход": "로그인 확인",
|
||||||
|
"Открыть в Telegram": "텔레그램에서 열기",
|
||||||
|
"Обновить": "새로고침",
|
||||||
|
"Настройки": "설정",
|
||||||
|
"Язык": "언어",
|
||||||
|
"Панель СТО": "정비소 작업실",
|
||||||
|
"Записи клиентов": "고객 예약",
|
||||||
|
"Заказ-наряды": "작업지시서",
|
||||||
|
"Сотрудники": "직원",
|
||||||
|
"Пригласить": "초대",
|
||||||
|
"Заказ-наряд": "작업지시서",
|
||||||
|
"Работы": "공임",
|
||||||
|
"Запчасти и жидкости": "부품 및 오일",
|
||||||
|
"Проверьте смету": "견적 확인",
|
||||||
|
"Согласовать": "승인",
|
||||||
|
"Отклонить": "거절",
|
||||||
|
"Админ-панель": "관리자 패널",
|
||||||
|
"Операционный обзор": "운영 요약",
|
||||||
|
"Последние события": "최근 이벤트",
|
||||||
|
"Быстрые переходы": "빠른 이동",
|
||||||
|
"Заявки СТО": "정비소 신청",
|
||||||
|
"Записи": "예약",
|
||||||
|
"Фильтр": "필터",
|
||||||
|
"Фильтры": "필터",
|
||||||
|
"Запросить": "조회",
|
||||||
|
"Показать": "보기",
|
||||||
|
"Импорт и экспорт": "가져오기/내보내기",
|
||||||
|
"Скачать JSON": "JSON 다운로드",
|
||||||
|
"Проверить файл": "파일 확인",
|
||||||
|
"Импортировать": "가져오기",
|
||||||
|
"Паспорт автомобиля": "차량 패스포트",
|
||||||
|
"Выберите автомобиль": "차량 선택",
|
||||||
|
"Параметры авто": "차량 설정",
|
||||||
|
"Сохранить паспорт": "패스포트 저장",
|
||||||
|
"Удалить автомобиль": "차량 삭제",
|
||||||
|
"Готов к работе": "준비 완료",
|
||||||
|
"Сохраняю...": "저장 중...",
|
||||||
|
"Сохранено": "저장됨",
|
||||||
|
"Ошибка": "오류",
|
||||||
|
"Нет данных": "데이터 없음",
|
||||||
|
"Нет доступа": "접근 불가",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function t(text, locale = currentLocale()) {
|
||||||
|
return dictionaries[locale]?.[text] || text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentLocale() {
|
||||||
|
return window.CarPassPage?.state?.user?.locale || localStorage.getItem("carpassLocale") || "ru";
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply(root = document.body, locale = currentLocale()) {
|
||||||
|
document.documentElement.lang = locale;
|
||||||
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||||
|
acceptNode(node) {
|
||||||
|
const parent = node.parentElement;
|
||||||
|
if (!parent || ["SCRIPT", "STYLE", "TEXTAREA", "INPUT", "SELECT"].includes(parent.tagName)) return NodeFilter.FILTER_REJECT;
|
||||||
|
return node.nodeValue.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
while (walker.nextNode()) {
|
||||||
|
const node = walker.currentNode;
|
||||||
|
if (!textNodes.has(node)) textNodes.set(node, node.nodeValue.trim());
|
||||||
|
const original = textNodes.get(node);
|
||||||
|
node.nodeValue = node.nodeValue.replace(node.nodeValue.trim(), t(original, locale));
|
||||||
|
}
|
||||||
|
root.querySelectorAll?.("[placeholder], [aria-label], [title]").forEach((element) => {
|
||||||
|
["placeholder", "aria-label", "title"].forEach((attr) => {
|
||||||
|
const value = element.getAttribute(attr);
|
||||||
|
if (!value) return;
|
||||||
|
let originals = attrOriginals.get(element);
|
||||||
|
if (!originals) {
|
||||||
|
originals = {};
|
||||||
|
attrOriginals.set(element, originals);
|
||||||
|
}
|
||||||
|
originals[attr] ||= value;
|
||||||
|
element.setAttribute(attr, t(originals[attr], locale));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function observe(root = document.body) {
|
||||||
|
if (observer || !root) return;
|
||||||
|
observer = new MutationObserver(() => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
timer = window.setTimeout(() => apply(root), 40);
|
||||||
|
});
|
||||||
|
observer.observe(root, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { apply, t, currentLocale, observe };
|
||||||
|
})();
|
||||||
|
|
||||||
const CarPassPage = (() => {
|
const CarPassPage = (() => {
|
||||||
const state = { user: null, authConfig: null };
|
const state = { user: null, authConfig: null };
|
||||||
|
|
||||||
@@ -51,13 +283,14 @@ const CarPassPage = (() => {
|
|||||||
}
|
}
|
||||||
document.body.classList.remove("auth-required");
|
document.body.classList.remove("auth-required");
|
||||||
document.querySelector("#authOverlay")?.classList.add("hidden");
|
document.querySelector("#authOverlay")?.classList.add("hidden");
|
||||||
|
localStorage.setItem("carpassLocale", state.user.locale || "ru");
|
||||||
return state.user;
|
return state.user;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toast(message, tone = "success") {
|
function toast(message, tone = "success") {
|
||||||
const node = document.querySelector("#toast");
|
const node = document.querySelector("#toast");
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
node.textContent = message;
|
node.textContent = t(message);
|
||||||
node.className = `toast ${tone}`;
|
node.className = `toast ${tone}`;
|
||||||
window.clearTimeout(toast.timer);
|
window.clearTimeout(toast.timer);
|
||||||
toast.timer = window.setTimeout(() => node.classList.add("hidden"), 2600);
|
toast.timer = window.setTimeout(() => node.classList.add("hidden"), 2600);
|
||||||
@@ -69,7 +302,7 @@ const CarPassPage = (() => {
|
|||||||
button.dataset.label = button.textContent;
|
button.dataset.label = button.textContent;
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
button.classList.add("is-busy");
|
button.classList.add("is-busy");
|
||||||
button.innerHTML = `<span class="spinner"></span><span>${label}</span>`;
|
button.innerHTML = `<span class="spinner"></span><span>${t(label)}</span>`;
|
||||||
} else {
|
} else {
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
button.classList.remove("is-busy");
|
button.classList.remove("is-busy");
|
||||||
@@ -115,18 +348,73 @@ const CarPassPage = (() => {
|
|||||||
if (!value) return "-";
|
if (!value) return "-";
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
if (Number.isNaN(date.getTime())) return String(value).slice(0, 16).replace("T", " ");
|
if (Number.isNaN(date.getTime())) return String(value).slice(0, 16).replace("T", " ");
|
||||||
return date.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
|
const locale = { ru: "ru-RU", en: "en-US", ko: "ko-KR" }[state.user?.locale] || "ru-RU";
|
||||||
|
return date.toLocaleString(locale, { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function today() {
|
function today() {
|
||||||
return new Date().toISOString().slice(0, 10);
|
return new Date().toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function t(text) {
|
||||||
|
return CarPassI18n.t(text, state.user?.locale || "ru");
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTranslations(root = document.body) {
|
||||||
|
CarPassI18n.apply(root, state.user?.locale || "ru");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateLocale(locale) {
|
||||||
|
if (!state.user || state.user.locale === locale) return;
|
||||||
|
state.user = await api(`/users/${state.user.id}/preferences`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ locale }),
|
||||||
|
});
|
||||||
|
localStorage.setItem("carpassLocale", locale);
|
||||||
|
applyTranslations();
|
||||||
|
}
|
||||||
|
|
||||||
|
function installLocaleSwitch() {
|
||||||
|
const topbar = document.querySelector(".topbar");
|
||||||
|
if (!topbar || document.querySelector("#globalLocaleSelect")) return;
|
||||||
|
let host = topbar.querySelector(".top-actions");
|
||||||
|
if (!host) {
|
||||||
|
host = document.createElement("div");
|
||||||
|
host.className = "top-actions";
|
||||||
|
topbar.appendChild(host);
|
||||||
|
}
|
||||||
|
const select = document.createElement("select");
|
||||||
|
select.id = "globalLocaleSelect";
|
||||||
|
select.className = "locale-switch";
|
||||||
|
select.setAttribute("aria-label", "Язык");
|
||||||
|
select.innerHTML = `
|
||||||
|
<option value="ru">RU</option>
|
||||||
|
<option value="en">EN</option>
|
||||||
|
<option value="ko">KO</option>
|
||||||
|
`;
|
||||||
|
select.value = state.user?.locale || "ru";
|
||||||
|
select.addEventListener("change", async () => {
|
||||||
|
try {
|
||||||
|
await updateLocale(select.value);
|
||||||
|
toast("Сохранено");
|
||||||
|
} catch (error) {
|
||||||
|
toast(error.message || "Ошибка", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const primarySelect = host.querySelector("select:not(.locale-switch)");
|
||||||
|
if (primarySelect) primarySelect.insertAdjacentElement("afterend", select);
|
||||||
|
else host.prepend(select);
|
||||||
|
}
|
||||||
|
|
||||||
async function boot(init) {
|
async function boot(init) {
|
||||||
try {
|
try {
|
||||||
await loadAuthConfig();
|
await loadAuthConfig();
|
||||||
await ensureUser();
|
await ensureUser();
|
||||||
|
installLocaleSwitch();
|
||||||
|
applyTranslations();
|
||||||
|
CarPassI18n.observe();
|
||||||
await init();
|
await init();
|
||||||
|
applyTranslations();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.message === "Требуется вход через Telegram") return;
|
if (error.message === "Требуется вход через Telegram") return;
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -148,5 +436,9 @@ const CarPassPage = (() => {
|
|||||||
csvList,
|
csvList,
|
||||||
formatDateTime,
|
formatDateTime,
|
||||||
today,
|
today,
|
||||||
|
t,
|
||||||
|
applyTranslations,
|
||||||
|
installLocaleSwitch,
|
||||||
|
updateLocale,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -70,8 +70,13 @@ body.auth-required .shell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.passport-head small,
|
.passport-head small,
|
||||||
|
.passport-metric small,
|
||||||
.passport-metric span,
|
.passport-metric span,
|
||||||
.passport-action span,
|
.passport-action span,
|
||||||
|
.passport-vehicle-empty span,
|
||||||
|
.passport-vehicle-main span,
|
||||||
|
.passport-vehicle-id,
|
||||||
|
.passport-vehicle-facts span,
|
||||||
.achievement-card span,
|
.achievement-card span,
|
||||||
.timeline-item small,
|
.timeline-item small,
|
||||||
.timeline-empty,
|
.timeline-empty,
|
||||||
@@ -134,6 +139,103 @@ body.auth-required .shell {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.passport-metric-combo small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passport-vehicle-summary {
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(255, 255, 255, 0.085), rgba(255, 255, 255, 0.035));
|
||||||
|
}
|
||||||
|
|
||||||
|
.passport-vehicle-empty,
|
||||||
|
.passport-vehicle-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passport-vehicle-empty {
|
||||||
|
align-items: start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passport-vehicle-main strong,
|
||||||
|
.passport-vehicle-empty strong {
|
||||||
|
display: block;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passport-vehicle-main span {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passport-edit-link {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid rgba(94, 224, 189, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #b9f7e7;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
background: rgba(94, 224, 189, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.passport-vehicle-id {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passport-vehicle-id span {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 4px 7px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passport-vehicle-facts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passport-vehicle-facts div {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(12, 20, 22, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.passport-vehicle-facts span {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passport-vehicle-facts strong {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.25;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.passport-grid,
|
.passport-grid,
|
||||||
.passport-actions,
|
.passport-actions,
|
||||||
.achievement-strip {
|
.achievement-strip {
|
||||||
@@ -816,6 +918,36 @@ select:disabled {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.car-key-params {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.car-key-param {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 7px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.car-key-param span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.car-key-param strong {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.25;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.car-item.active {
|
.car-item.active {
|
||||||
border-color: rgba(22, 128, 106, 0.48);
|
border-color: rgba(22, 128, 106, 0.48);
|
||||||
background: #e5f4ef;
|
background: #e5f4ef;
|
||||||
@@ -1937,6 +2069,227 @@ select {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-page {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, #ffffff 0, #edf5f2 220px),
|
||||||
|
var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell {
|
||||||
|
width: min(1320px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-hero {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tabs {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 8;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 0 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
background: rgba(238, 243, 241, 0.92);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tabs button,
|
||||||
|
.admin-link-grid button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tabs button.active {
|
||||||
|
border-color: rgba(22, 128, 106, 0.48);
|
||||||
|
background: #dff1eb;
|
||||||
|
color: #0e5d4b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panel {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats {
|
||||||
|
grid-template-columns: repeat(6, minmax(130px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.25fr) minmax(260px, 0.75fr);
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-grid h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-link-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-filter {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
align-items: end;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-filter button {
|
||||||
|
min-width: 112px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-wrap {
|
||||||
|
width: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 10px 30px rgba(17, 36, 30, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 720px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th,
|
||||||
|
.admin-table td {
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: #f5faf7;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table tbody tr {
|
||||||
|
transition: background 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table tbody tr:hover {
|
||||||
|
background: #f7fbf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-actions-head,
|
||||||
|
.admin-action-cell {
|
||||||
|
position: sticky;
|
||||||
|
right: 0;
|
||||||
|
min-width: 128px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: -10px 0 18px rgba(255, 255, 255, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table tbody tr:hover .admin-action-cell {
|
||||||
|
background: #f7fbf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-action-cell {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-btn {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 22px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border: 1px solid rgba(22, 128, 106, 0.18);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #eef7f3;
|
||||||
|
color: #0e5d4b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-data-form {
|
||||||
|
align-items: end;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-check {
|
||||||
|
min-height: 42px;
|
||||||
|
align-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-form-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-source-hint {
|
||||||
|
margin: -2px 0 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgba(22, 128, 106, 0.16);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f2faf6;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
color: var(--danger);
|
||||||
|
background: #fff4f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.admin-stats {
|
||||||
|
display: flex;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats .stat {
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-grid,
|
||||||
|
.admin-link-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tabs {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.work-order-total strong {
|
.work-order-total strong {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: clamp(24px, 4vw, 34px);
|
font-size: clamp(24px, 4vw, 34px);
|
||||||
@@ -2159,11 +2512,25 @@ select {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.car-key-param strong {
|
||||||
|
white-space: normal;
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
}
|
||||||
|
|
||||||
.passport-head {
|
.passport-head {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.passport-vehicle-main {
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passport-vehicle-facts {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.score-ring {
|
.score-ring {
|
||||||
width: 72px;
|
width: 72px;
|
||||||
height: 72px;
|
height: 72px;
|
||||||
@@ -2212,7 +2579,7 @@ select {
|
|||||||
|
|
||||||
.sto-page .top-actions {
|
.sto-page .top-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 44px;
|
grid-template-columns: minmax(0, 1fr) 58px 38px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.staff-form button {
|
.staff-form button {
|
||||||
@@ -2230,3 +2597,265 @@ select {
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Compact UX pass */
|
||||||
|
.locale-switch {
|
||||||
|
width: 66px;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
padding-block: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar h1 {
|
||||||
|
font-size: clamp(23px, 4vw, 34px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
.ghost-btn {
|
||||||
|
min-height: 38px;
|
||||||
|
padding-inline: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 38px;
|
||||||
|
min-height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
min-height: 38px;
|
||||||
|
padding-inline: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
min-height: 92px;
|
||||||
|
padding: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card strong {
|
||||||
|
font-size: clamp(19px, 3.2vw, 27px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-add-btn {
|
||||||
|
min-height: 46px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel,
|
||||||
|
.workspace,
|
||||||
|
.chart-card {
|
||||||
|
padding: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passport-panel {
|
||||||
|
gap: 8px;
|
||||||
|
padding: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passport-head h2,
|
||||||
|
h2 {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats,
|
||||||
|
.hero-grid,
|
||||||
|
.layout,
|
||||||
|
.charts,
|
||||||
|
.flow-layout,
|
||||||
|
.sto-grid {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat,
|
||||||
|
.stat-card {
|
||||||
|
min-height: 76px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat strong,
|
||||||
|
.stat-card strong {
|
||||||
|
font-size: clamp(18px, 2.1vw, 23px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-form,
|
||||||
|
.entry-form,
|
||||||
|
.flow-form {
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-form {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-panel {
|
||||||
|
width: min(560px, 100%);
|
||||||
|
gap: 9px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-menu {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(145px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
max-height: min(28vh, 230px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-group {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-row {
|
||||||
|
min-height: 40px;
|
||||||
|
padding-inline: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-entry-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-entry-grid button {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
min-height: 82px;
|
||||||
|
align-content: center;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
box-shadow: 0 8px 20px rgba(27, 38, 34, 0.06);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-entry-grid button span {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-entry-grid button small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advanced-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fbfdfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advanced-fields summary {
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advanced-fields summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advanced-fields summary::after {
|
||||||
|
content: "+";
|
||||||
|
float: right;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.advanced-fields[open] summary::after {
|
||||||
|
content: "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-inner-form {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 10px 10px;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-filter {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-filter label,
|
||||||
|
.compact-filter button {
|
||||||
|
margin: 0 10px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-service-pills {
|
||||||
|
margin-top: -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-service-pills button.active {
|
||||||
|
border-color: rgba(18, 115, 95, 0.45);
|
||||||
|
background: #e7f4ef;
|
||||||
|
color: #0e604f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-hero {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-hero h2 {
|
||||||
|
font-size: clamp(20px, 2.4vw, 28px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-block {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th,
|
||||||
|
.admin-table td {
|
||||||
|
padding: 7px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.top-actions {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-switch {
|
||||||
|
width: 58px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-entry-grid,
|
||||||
|
.compact-inner-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-menu {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
max-height: min(32vh, 260px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card,
|
||||||
|
.stat {
|
||||||
|
min-height: 74px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user