16 Commits

Author SHA1 Message Date
VPN SaaS Dev
99bc9aa6a1 complete admin notifications data explorer
Some checks failed
ci / test (push) Has been cancelled
2026-05-19 19:02:16 +09:00
VPN SaaS Dev
58ff6ff614 compact UI and add localization switch
Some checks failed
ci / test (push) Has been cancelled
2026-05-19 05:05:24 +09:00
5e5582664a Merge pull request 'pilot-hardening-notifications-deploy' (#1) from pilot-hardening-notifications-deploy into main
Some checks failed
ci / test (push) Has been cancelled
Reviewed-on: https://git.smartsoltech.kr/trevor/drivers_bot/pulls/1
2026-05-18 12:23:11 +00:00
VPN SaaS Dev
8982299e71 add admin data mutations and load check
Some checks failed
ci / test (pull_request) Has been cancelled
2026-05-18 18:37:19 +09:00
VPN SaaS Dev
59bc6ebd4f fix rsync deploy progress reports 2026-05-18 18:23:18 +09:00
VPN SaaS Dev
22b9b40d78 harden deploy reports and admin alerts 2026-05-18 18:17:53 +09:00
VPN SaaS Dev
2d5695fdce cover admin notifications and data explorer 2026-05-17 21:16:30 +09:00
VPN SaaS Dev
0f6d6e31e1 add admin control center ui and bot commands 2026-05-17 21:16:28 +09:00
VPN SaaS Dev
fa703acce1 admin notifications and data explorer backend 2026-05-17 21:16:22 +09:00
VPN SaaS Dev
f4be38f9b9 show vehicle facts in passport
Some checks failed
ci / test (push) Has been cancelled
2026-05-16 22:16:47 +09:00
VPN SaaS Dev
8ab296b675 compact vehicle card passport
Some checks failed
ci / test (push) Has been cancelled
2026-05-16 22:04:00 +09:00
VPN SaaS Dev
c98432ca7d docker-deploy-port-config
Some checks failed
ci / test (push) Has been cancelled
2026-05-16 21:30:19 +09:00
VPN SaaS Dev
9fe172702f docker-deploy-smoke
Some checks failed
ci / test (push) Has been cancelled
2026-05-16 19:35:07 +09:00
VPN SaaS Dev
8efac3a844 frontend-roles-ux 2026-05-16 19:35:04 +09:00
VPN SaaS Dev
b03b63a5cc work-order-hardening 2026-05-16 19:35:01 +09:00
VPN SaaS Dev
4ee83690f6 audit/entity-map 2026-05-16 19:34:56 +09:00
44 changed files with 5507 additions and 181 deletions

View File

@@ -20,7 +20,10 @@ SECRET_KEY=change-this-long-random-secret
REDIS_URL=redis://redis:6379/0
OCR_PROVIDER=tesseract
OCR_LANGUAGES=eng+rus+kor
LLM_BASE_URL=
LLM_MODEL=
ADMIN_TELEGRAM_IDS=
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
View 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 или сетевых сбоях.

View File

@@ -19,7 +19,7 @@ Edit `.env` and set real secrets:
- `INTERNAL_API_TOKEN`
- `SECRET_KEY`
- `REDIS_URL` if Redis is external
- `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` when browser push is enabled
- `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` only when browser push beta is enabled
- `ADMIN_TELEGRAM_IDS`
Production must use public HTTPS URLs and `ALLOW_DEV_AUTH=false`.
@@ -33,7 +33,15 @@ python -m scripts.bootstrap_admin
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.
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.
## Git-Based Update
@@ -51,7 +59,7 @@ The script runs:
- Docker build/up
- `alembic upgrade head`
- Python smoke compile
- `/ready` health check
- `/health`, `/ready` and `/metrics` smoke checks
Do not use rsync as the primary deploy mechanism.
@@ -75,12 +83,24 @@ Create a compressed custom-format dump before risky deploys:
BACKUP_DIR=/opt/carpass/backups ./scripts/backup_db.sh
```
Compatibility wrapper:
```bash
BACKUP_DIR=/opt/carpass/backups ./scripts/backup.sh
```
Restore only during a maintenance window:
```bash
./scripts/restore_db.sh /opt/carpass/backups/carpass-drivers-YYYYMMDDTHHMMSSZ.dump
```
Compatibility wrapper:
```bash
./scripts/restore.sh /opt/carpass/backups/carpass-drivers-YYYYMMDDTHHMMSSZ.dump
```
For volume-level recovery, back up the Docker named volumes `pgdata` and `redisdata` according to the host backup policy.
## Logs

View File

@@ -63,6 +63,14 @@ CarPass создает рекомендации обслуживания из д
Уведомления имеют статусы `pending`, `processing`, `sent`, `failed`, `retrying`, `abandoned`, `read`, счетчик повторов и idempotency key, чтобы не плодить дубли.
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, номер, пробег и технические параметры, проходят подтверждение владельца.

View File

@@ -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")

View 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")

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
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.user import User
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.vehicle_identity import normalize_license_plate, validate_vin
@@ -42,6 +43,28 @@ async def create_car(
source_record_id=None,
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.refresh(car)
return car

View File

@@ -8,6 +8,7 @@ from app.core.config import settings
from app.db.session import get_session
from app.models.car import AuditLog, Car, ServiceCenter, ServiceEmployee, VehicleAccess
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
@@ -36,6 +37,24 @@ async def get_or_create_telegram_user(
if user is None:
user = User(**{key: value for key, value in payload.items() if value is not None})
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:
for field, value in payload.items():
if value is not None:

View File

@@ -3,7 +3,7 @@ from io import BytesIO
import matplotlib.pyplot as plt
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_telegram_user
@@ -25,6 +25,7 @@ from app.schemas.expense import (
ServiceEntryRead,
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.odometer import (
apply_odometer_from_record,
@@ -53,6 +54,59 @@ async def ensure_entry_owner(
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)
async def create_fuel_entry(
payload: FuelEntryCreate,
@@ -78,6 +132,7 @@ async def create_fuel_entry(
changed_by=current_user.id,
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.refresh(entry)
return entry
@@ -174,6 +229,7 @@ async def create_service_entry(
changed_by=current_user.id,
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.refresh(entry)
return entry
@@ -266,6 +322,7 @@ async def create_expense_entry(
changed_by=current_user.id,
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.refresh(entry)
return entry

View File

@@ -27,6 +27,7 @@ from app.schemas.service_center import (
VehicleUpdate,
)
from app.schemas.user import UserRead
from app.services.admin_notifications import create_admin_notification
from app.services.odometer import (
add_odometer_history,
recalculate_current_odometer,
@@ -381,6 +382,20 @@ async def create_vehicle(
changed_by=current_user.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.refresh(car)
return car

View File

@@ -1,15 +1,18 @@
import re
import time
from datetime import date
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 sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_telegram_user
from app.db.session import get_session
from app.models.car import OCRResult
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.uploads import SAFE_IMAGE_TYPES, SAFE_TEXT_TYPES, validate_upload
@@ -40,6 +43,135 @@ class OCRResultRead(BaseModel):
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)
async def parse_text_receipt(
request: Request,
@@ -49,21 +181,48 @@ async def parse_text_receipt(
) -> ReceiptSuggestion:
await check_rate_limit(scope="ocr", limit=10, window_seconds=60, request=request, user=current_user, session=session)
content = await file.read()
validate_upload(
content=content,
await validate_ocr_upload(
session=session,
current_user=current_user,
filename=file.filename,
content_type=file.content_type,
max_bytes=MAX_OCR_FILE_BYTES,
allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES,
content=content,
)
content_type = (file.content_type or "").lower()
if content_type.startswith("image/") or content_type == "application/pdf":
result = await get_ocr_provider().recognize(content, file.filename)
if not result.recognized_text:
result = await recognize_with_alert(
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(
confidence=0,
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)
text = " ".join(
[
@@ -71,6 +230,17 @@ async def parse_text_receipt(
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)
@@ -133,8 +303,32 @@ async def recognize_license_plate(
) -> OCRResultRead:
await check_rate_limit(scope="ocr_license_plate", limit=8, window_seconds=60, request=request, user=current_user, session=session)
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)
result = await get_ocr_provider().recognize(content, file.filename)
await validate_ocr_upload(
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(
recognized_text=result.recognized_text,
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "license_plate"],
@@ -151,8 +345,32 @@ async def recognize_vin(
) -> OCRResultRead:
await check_rate_limit(scope="ocr_vin", limit=8, window_seconds=60, request=request, user=current_user, session=session)
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)
result = await get_ocr_provider().recognize(content, file.filename)
await validate_ocr_upload(
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(
recognized_text=result.recognized_text,
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "vin"],
@@ -169,8 +387,32 @@ async def recognize_service_document(
) -> OCRResultRead:
await check_rate_limit(scope="ocr_service_document", limit=8, window_seconds=60, request=request, user=current_user, session=session)
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)
result = await get_ocr_provider().recognize(content, file.filename)
await validate_ocr_upload(
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(
recognized_text=result.recognized_text,
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates],

View File

@@ -48,6 +48,7 @@ from app.schemas.service_center import (
VehicleSearchRequest,
VehicleSearchResult,
)
from app.services.admin_notifications import create_admin_notification
from app.services.notifications import notify_platform_moderators
from app.services.odometer import validate_odometer_change
from app.services.rate_limit import check_rate_limit
@@ -147,6 +148,25 @@ async def create_service_center(
target_type="service_center",
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.refresh(center)
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 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.refresh(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 session.flush()
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.refresh(review)
return review

View File

@@ -36,6 +36,7 @@ from app.schemas.sto_booking import (
ServiceCenterHolidayRead,
STODashboardRead,
)
from app.services.admin_notifications import create_admin_notification
from app.services.rate_limit import check_rate_limit
from app.services.sto_booking import (
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}",
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(
session,
actor=current_user,
@@ -554,6 +577,17 @@ async def reject_appointment(
title="СТО отклонило запись",
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 session.commit()
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}",
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(
session,
actor=current_user,
@@ -677,6 +722,21 @@ async def create_work_order_from_appointment(
body=visit.work_order_number,
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 session.commit()
await session.refresh(visit)

View File

@@ -39,6 +39,7 @@ from app.schemas.service_center import (
WorkOrderStatusHistoryRead,
WorkOrderUpdate,
)
from app.services.admin_notifications import create_admin_notification
from app.services.sto_booking import create_service_notification
from app.services.work_orders import (
add_labor_item,
@@ -88,6 +89,13 @@ async def get_work_order_with_items(session: AsyncSession, work_order_id: int) -
return visit
async def get_work_order_correction(session: AsyncSession, correction_id: int) -> WorkOrderCorrection:
correction = await session.get(WorkOrderCorrection, correction_id)
if correction is None:
raise HTTPException(status_code=404, detail="Work order correction not found")
return correction
async def ensure_work_order_sto_access(
session: AsyncSession, visit: ServiceVisit, user: User, allowed_roles: set[str] | None = None
) -> None:
@@ -454,6 +462,17 @@ async def reject_work_order(
visit.owner_comment = payload.comment
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 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 session.commit()
await session.refresh(visit)
@@ -495,6 +514,21 @@ async def complete_work_order(
actor=current_user,
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 session.commit()
await session.refresh(visit)
@@ -539,6 +573,26 @@ async def request_vehicle_profile_details(
return visit
@router.get("/{work_order_id}/corrections", response_model=list[WorkOrderCorrectionRead])
async def list_work_order_corrections(
work_order_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[WorkOrderCorrection]:
visit = await get_work_order(session, work_order_id)
vehicle = await session.get(Car, visit.vehicle_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
if vehicle.owner_id != current_user.id:
await ensure_work_order_sto_access(session, visit, current_user)
result = await session.execute(
select(WorkOrderCorrection)
.where(WorkOrderCorrection.service_visit_id == visit.id)
.order_by(WorkOrderCorrection.created_at.desc(), WorkOrderCorrection.id.desc())
)
return list(result.scalars())
@router.post("/{work_order_id}/corrections", response_model=WorkOrderCorrectionRead, status_code=status.HTTP_201_CREATED)
async def create_work_order_correction(
work_order_id: int,
@@ -559,6 +613,34 @@ async def create_work_order_correction(
created_version=visit.version or 1,
)
session.add(correction)
vehicle = await session.get(Car, visit.vehicle_id)
if payload.owner_approval_required and vehicle is not None:
await create_service_notification(
session,
recipient_user_id=vehicle.owner_id,
service_center_id=visit.service_center_id,
notification_type="work_order.correction_waiting_owner_approval",
title="СТО просит согласовать правку заказ-наряда",
body=payload.reason,
idempotency_key=f"work_order:{visit.id}:correction:{visit.version or 1}:{payload.reason[:80]}",
web_app_url=work_order_webapp_url(visit.id),
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(
session,
actor=current_user,
@@ -572,6 +654,81 @@ async def create_work_order_correction(
return correction
@router.post("/corrections/{correction_id}/approve", response_model=WorkOrderCorrectionRead)
async def approve_work_order_correction(
correction_id: int,
payload: WorkOrderDecision,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> WorkOrderCorrection:
correction = await get_work_order_correction(session, correction_id)
visit = await get_work_order(session, correction.service_visit_id)
await ensure_work_order_owner_access(session, visit, current_user)
if correction.status != "pending":
raise HTTPException(status_code=409, detail="Correction is already resolved")
correction.status = "approved"
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(
session,
actor=current_user,
action="work_order.correction.approve",
target_type="work_order_correction",
target_id=correction.id,
metadata={"comment": payload.comment},
)
await session.commit()
await session.refresh(correction)
return correction
@router.post("/corrections/{correction_id}/reject", response_model=WorkOrderCorrectionRead)
async def reject_work_order_correction(
correction_id: int,
payload: WorkOrderDecision,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> WorkOrderCorrection:
correction = await get_work_order_correction(session, correction_id)
visit = await get_work_order(session, correction.service_visit_id)
await ensure_work_order_owner_access(session, visit, current_user)
if correction.status != "pending":
raise HTTPException(status_code=409, detail="Correction is already resolved")
correction.status = "rejected"
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(
session,
actor=current_user,
action="work_order.correction.reject",
target_type="work_order_correction",
target_id=correction.id,
metadata={"comment": payload.comment},
)
await session.commit()
await session.refresh(correction)
return correction
@router.get("/{work_order_id}/status-history", response_model=list[WorkOrderStatusHistoryRead])
async def work_order_status_history(
work_order_id: int,

View File

@@ -22,10 +22,13 @@ class Settings(BaseSettings):
allow_dev_auth: bool = False
ocr_provider: str = "tesseract"
ocr_languages: str = "eng+rus+kor"
llm_base_url: str = ""
llm_model: str = ""
admin_telegram_ids: 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")

View File

@@ -25,7 +25,8 @@ from app.api import (
work_orders,
)
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
@@ -49,8 +50,29 @@ async def production_headers_and_metrics(request: Request, call_next):
start = monotonic()
try:
response = await call_next(request)
except Exception:
except Exception as exc:
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
duration = monotonic() - start
REQUEST_COUNT += 1

View File

@@ -432,6 +432,47 @@ class ServiceNotification(Base):
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):
__tablename__ = "service_work_items"
@@ -625,3 +666,19 @@ class AuditLog(Base):
user_agent: Mapped[str | None] = mapped_column(String(256))
metadata_json: Mapped[dict | None] = mapped_column(JSON)
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)

View 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

View File

@@ -41,7 +41,13 @@ async def check_rate_limit(
if settings.redis_url:
allowed = await check_redis_rate_limit(scope, identifiers, limit, window_seconds)
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)
return
@@ -52,7 +58,13 @@ async def check_rate_limit(
while bucket and now - bucket[0] > window_seconds:
bucket.popleft()
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)
for identifier in identifiers:
_buckets[(scope, identifier)].append(now)
@@ -107,18 +119,82 @@ async def log_rate_limit_event(
*,
scope: str,
identifier: str,
user: User | None = None,
request: Request | None = None,
) -> None:
if session is None:
return
from app.models.car import AuditLog
client_host = request.client.host if request and request.client else None
user_agent = request.headers.get("user-agent") if request else None
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(
actor_user_id=None,
actor_role="system",
actor_user_id=user.id if user else None,
actor_role=user.platform_role if user else "system",
action="rate_limit.exceeded",
target_type=scope,
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()

View File

@@ -8,6 +8,21 @@ from app.core.config import settings
class ApiClient:
def __init__(self) -> None:
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]:
headers = {"X-Internal-API-Token": settings.internal_api_token}
@@ -24,8 +39,7 @@ class ApiClient:
json: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
) -> Any:
async with httpx.AsyncClient(base_url=self.base_url, timeout=15) as client:
response = await client.request(
response = await self.client.request(
method,
path,
json=json,
@@ -44,22 +58,19 @@ class ApiClient:
"first_name": telegram_user.first_name,
"last_name": telegram_user.last_name,
}
async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client:
response = await client.post("/api/users", json=payload, headers=self.headers())
response = await self.client.post("/api/users", json=payload, headers=self.headers())
response.raise_for_status()
return response.json()
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 client.get(
response = await self.client.get(
"/api/cars", params={"owner_id": owner_id}, headers=self.headers(telegram_id)
)
response.raise_for_status()
return response.json()
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 client.post(
response = await self.client.post(
"/api/cars",
json={"owner_id": owner_id, "name": name},
headers=self.headers(telegram_id),
@@ -68,8 +79,7 @@ class ApiClient:
return response.json()
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 client.get(f"/api/cars/{car_id}/stats", headers=self.headers(telegram_id))
response = await self.client.get(f"/api/cars/{car_id}/stats", headers=self.headers(telegram_id))
response.raise_for_status()
return response.json()
@@ -126,6 +136,15 @@ class ApiClient:
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)
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(
self,
telegram_id: int,

View File

@@ -25,7 +25,7 @@ dp = Dispatcher()
api = ApiClient()
APPROVED_SERVICE_STATUSES = {"approved", "verified"}
STO_WORKPLACE_ROLES = {"owner", "mechanic"}
STO_WORKPLACE_ROLES = {"owner", "manager", "receptionist", "mechanic"}
def webapp_url(path: str = "") -> str:
@@ -433,6 +433,7 @@ async def register_sto(message: Message, command: CommandObject) -> None:
@dp.message(Command("admin_sto_pending"))
@dp.message(Command("admin_pending_sto"))
async def admin_sto_pending(message: Message) -> None:
await upsert(message)
try:
@@ -459,6 +460,77 @@ async def admin_sto_pending(message: Message) -> None:
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:
args = (command.args or "").split(maxsplit=1)
if not args:
@@ -577,7 +649,14 @@ async def admin_callback(callback: CallbackQuery) -> None:
@dp.message(F.text == "Помощь")
@dp.message(Command("help"))
async def help_message(message: Message) -> None:
user = await api.upsert_user(message.from_user)
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_bookings или /sto_workplace — панель подтвержденного СТО;\n"
"• /accept_sto_invite <token> — принять приглашение сотрудника;\n"
@@ -601,6 +680,7 @@ async def help_message(message: Message) -> None:
"• /sto — каталог проверенных СТО;\n"
"• /appointments — мои записи в СТО;\n"
f"{sto_workplace_help}"
f"{admin_help}"
"\n"
"Владелец: добавь авто, выбери проверенное СТО, создай запись, согласуй заказ-наряд и смотри завершенные работы в истории автомобиля.\n"
f"{sto_business_help}"
@@ -702,7 +782,11 @@ async def main() -> None:
raise RuntimeError("INTERNAL_API_TOKEN is empty")
settings.validate_webapp_url_for_telegram()
bot = Bot(settings.bot_token)
try:
await dp.start_polling(bot)
finally:
await api.close()
await bot.session.close()
if __name__ == "__main__":

View File

@@ -54,14 +54,12 @@ services:
ALLOW_DEV_AUTH: ${ALLOW_DEV_AUTH:-false}
OCR_PROVIDER: ${OCR_PROVIDER:-tesseract}
OCR_LANGUAGES: ${OCR_LANGUAGES:-eng+rus+kor}
LLM_BASE_URL: ${LLM_BASE_URL:-}
LLM_MODEL: ${LLM_MODEL:-}
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
SECRET_KEY: ${SECRET_KEY:-}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
ports:
- "127.0.0.1:8000:8000"
- "127.0.0.1:${APP_PORT:-8000}:8000"
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/ready', timeout=3).read()\""]
interval: 10s
@@ -91,8 +89,6 @@ services:
APP_ENV: ${APP_ENV:-development}
OCR_PROVIDER: ${OCR_PROVIDER:-tesseract}
OCR_LANGUAGES: ${OCR_LANGUAGES:-eng+rus+kor}
LLM_BASE_URL: ${LLM_BASE_URL:-}
LLM_MODEL: ${LLM_MODEL:-}
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
SECRET_KEY: ${SECRET_KEY:-}
depends_on:

View File

@@ -0,0 +1,47 @@
# CarPass Production Pilot Entity Map
This map captures the closed-pilot domain model as implemented in the codebase.
## Owner Domain
- `User` owns `Car` records and authenticates through Telegram.
- `Car` is the vehicle/passport entity. It owns `FuelEntry`, `ServiceEntry`, `ExpenseEntry`, `OdometerHistory`, `CarServiceLink`, `ServiceAppointment`, `ServiceVisit`, and `MaintenanceRecommendation`.
- `FuelEntry`, `ServiceEntry`, and `ExpenseEntry` update ownership analytics and can update the vehicle odometer through `OdometerHistory`.
- `OwnershipAnalytics` is computed on demand from entries, expenses, depreciation settings, and loan settings.
- `VehicleAccess` grants non-owner user access to a vehicle for selected backend flows.
## STO Domain
- `ServiceCenter` is the STO profile and tenant boundary.
- `ServiceCenterVerification` stores moderation applications and review decisions.
- `ServiceEmployee` links users to a service center with one of `owner`, `manager`, `receptionist`, or `mechanic`.
- `CarServiceLink` is the owner-approved connection between a vehicle and an STO.
- `ServiceCenterBookingSettings` and `ServiceCenterHoliday` define the appointment calendar.
- `ServiceAppointment` is the customer booking request and can become one `ServiceVisit`.
- `ServiceCenterReview` and `ServiceCenterReviewComment` store public feedback.
## Work Order Domain
- `ServiceVisit` is the work order. It links `ServiceCenter`, `Car`, owner user, employee, appointment, labor items, product items, status history, and corrections.
- `ServiceWorkItem` stores labor/repair work and next-service intervals.
- `ServiceProductItem` stores parts, fluids, materials, SKU and quantity.
- `WorkOrderCatalogItem` is the STO price/catalog item source.
- `InventoryTransaction` records consumed products on work-order completion.
- `WorkOrderStatusHistory` audits status transitions.
- `WorkOrderCorrection` records post-completion correction requests and owner decisions.
- Completion creates immutable owner records: `ServiceEntry`, `ExpenseEntry`, `OdometerHistory`, `MaintenanceRecommendation` when applicable, `ServiceNotification`, and `AuditLog`.
## Trust, Notifications, And Exchange
- `MaintenanceRecommendation` is connected to a vehicle and optionally a service center/appointment.
- `ServiceNotification` is the internal notification queue with best-effort Telegram delivery and idempotency keys.
- `AuditLog` records sensitive and operational actions.
- `Achievement`, `UserAchievement`, `VehicleScore`, `ServiceCenterScore`, and `EngagementEvent` power passport quality, trust, and timeline features.
- Import/export is synchronous API work, not a persisted `ImportExportJob`; schema is `carpass.exchange.v1`.
- OCR returns preview candidates through API responses; no persisted `OCRResult` table exists and OCR never writes vehicle data directly.
## Pilot Gaps To Keep Explicit
- Browser Web Push stores subscriptions and has a service worker listener, but server-side Web Push delivery is beta and not part of the pilot-critical notification path.
- Inventory exists as consumption transactions and catalog products, not as full stock-level management.
- Correction requests record decisions; they do not mutate completed work-order snapshots automatically.

4
scripts/backup.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
exec "$(dirname "$0")/backup_db.sh" "$@"

View File

@@ -44,6 +44,7 @@ for attempt in {1..30}; do
if curl -fsS "$HEALTH_URL" >/tmp/carpass-ready.json; then
cat /tmp/carpass-ready.json
echo
BASE_URL="${BASE_URL:-${HEALTH_URL%/ready}}" ./scripts/smoke_test.sh
$COMPOSE ps
exit 0
fi

81
scripts/load_check.py Normal file
View 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()))

4
scripts/restore.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
exec "$(dirname "$0")/restore_db.sh" "$@"

96
scripts/rsync_deploy.sh Executable file
View 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
View 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())

22
scripts/smoke_test.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${BASE_URL:-http://127.0.0.1:8000}"
echo "Checking health..."
curl -fsS "$BASE_URL/health"
echo
echo "Checking readiness..."
curl -fsS "$BASE_URL/ready"
echo
echo "Checking metrics..."
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."

View File

@@ -40,6 +40,8 @@ def configure_settings() -> None:
settings.internal_api_token = TEST_INTERNAL_TOKEN
settings.app_env = "test"
settings.allow_dev_auth = False
settings.admin_telegram_ids = ""
settings.admin_notification_chat_id = ""
yield

View 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())

View File

@@ -50,6 +50,19 @@ async def test_employee_invite_activation_revoked_and_expired(
dashboard = await client.get(f"/api/sto/dashboard?service_center_id={center['id']}", headers=other_auth_headers)
assert dashboard.status_code == 200
other_center = await create_verified_center(
client,
auth_headers,
admin_auth_headers,
internal_headers,
"Other Tenant Service",
)
cross_tenant_dashboard = await client.get(
f"/api/sto/dashboard?service_center_id={other_center['id']}",
headers=other_auth_headers,
)
assert cross_tenant_dashboard.status_code == 403
revoked_invite = (
await client.post(
f"/api/service-centers/{center['id']}/employees/invite",
@@ -193,6 +206,22 @@ async def test_work_order_completion_creates_vehicle_records_and_updates_costs(
)
assert correction.status_code == 201
assert correction.json()["created_version"] == completed.json()["version"]
corrections = await client.get(f"/api/work-orders/{work_order['id']}/corrections", headers=other_auth_headers)
assert corrections.status_code == 200
assert corrections.json()[0]["id"] == correction.json()["id"]
approved_correction = await client.post(
f"/api/work-orders/corrections/{correction.json()['id']}/approve",
headers=other_auth_headers,
json={"comment": "Correction accepted"},
)
assert approved_correction.status_code == 200
assert approved_correction.json()["status"] == "approved"
repeated_correction_decision = await client.post(
f"/api/work-orders/corrections/{correction.json()['id']}/reject",
headers=other_auth_headers,
json={"comment": "Too late"},
)
assert repeated_correction_decision.status_code == 409
service_history = await client.get(
f"/api/my/vehicles/{vehicle['id']}/service-history",
@@ -214,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_volume_l"] == "4.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(
f"/api/work-orders/{work_order['id']}",

View File

@@ -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.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)
assert my_appointments.json()[0]["linked_work_order_id"] == work_order.json()["id"]

245
web/admin.html Normal file
View 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>

View File

@@ -53,6 +53,8 @@
</div>
</div>
<form id="filterForm" class="grid-form drawer-form compact-form">
<details class="advanced-fields compact-filter">
<summary>Фильтры</summary>
<label>
Город
<input name="city" placeholder="Seoul" />
@@ -62,6 +64,7 @@
<input name="specialization" placeholder="BMW, масло, тормоза" />
</label>
<button type="submit">Найти</button>
</details>
</form>
<div id="serviceList" class="stack-list"></div>
</aside>
@@ -89,6 +92,12 @@
<option value="other">Другое</option>
</select>
</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>
Длительность
<select name="estimated_duration_minutes" id="durationSelect">

View File

@@ -78,23 +78,19 @@
<span>/100</span>
</div>
</div>
<div class="passport-vehicle-summary" id="passportVehicleSummary"></div>
<div class="passport-grid">
<div class="passport-metric">
<span>Profile quality</span>
<span>Качество данных</span>
<strong id="scoreTitle">Старт</strong>
</div>
<div class="passport-metric">
<span>Verified history</span>
<div class="passport-metric passport-metric-combo">
<span>История и ТО</span>
<strong id="verifiedHistoryStatus">Self-reported</strong>
</div>
<div class="passport-metric">
<span>Maintenance health</span>
<strong id="maintenanceStatus">Unknown</strong>
<small id="maintenanceStatus">Unknown</small>
</div>
</div>
<div class="passport-actions" id="scoreActions"></div>
<div class="achievement-strip" id="achievementList"></div>
<div class="vehicle-timeline" id="vehicleTimeline"></div>
</section>
<div class="status-bar" id="statusBar" aria-live="polite">Готов к работе</div>
@@ -224,6 +220,9 @@
Исполнитель
<input name="vendor" placeholder="СТО / магазин" />
</label>
<details class="advanced-fields wide">
<summary>Напоминание о следующем ТО</summary>
<div class="grid-form drawer-form compact-inner-form">
<label>
Следующая дата
<input name="next_due_date" type="date" />
@@ -232,6 +231,8 @@
Следующий пробег
<input name="next_due_odometer" type="number" min="0" />
</label>
</div>
</details>
<button type="submit">Сохранить запись</button>
</form>
</section>
@@ -296,6 +297,28 @@
</div>
<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">
<h2>Автомобили</h2>
<div id="drawerCars" class="cars drawer-cars"></div>
@@ -354,6 +377,9 @@
Поставщик / место
<input name="vendor" />
</label>
<details class="advanced-fields wide">
<summary>Дополнительно</summary>
<div class="grid-form drawer-form compact-inner-form">
<label>
Одометр
<input name="odometer" type="number" min="0" />
@@ -397,6 +423,8 @@
<input name="is_recurring" type="checkbox" />
Регулярный расход
</label>
</div>
</details>
<button type="submit">Сохранить расход</button>
</form>
</section>

479
web/static/admin.js Normal file
View 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);

View File

@@ -4,6 +4,8 @@ tg?.expand();
const textNodes = new WeakMap();
const attrOriginals = new WeakMap();
let translationObserver = null;
let translationTimer = null;
const i18n = {
en: {
@@ -83,6 +85,14 @@ const i18n = {
"Марка": "Make",
"Модель": "Model",
"Добавить авто": "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",
"За месяц": "This month",
"За день": "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 = {
user: null,
@@ -313,6 +340,9 @@ const state = {
selectedCarId: null,
latestFuel: [],
latestService: [],
latestFuelAllTime: [],
latestServiceAllTime: [],
selectedCarHighlights: null,
latestExpenses: [],
latestStats: null,
allStats: null,
@@ -329,7 +359,6 @@ const state = {
connectedServices: [],
adminPendingServices: [],
vehicleScore: null,
vehicleTimeline: [],
achievements: [],
receiptFile: null,
serviceWorkerRegistration: null,
@@ -580,6 +609,7 @@ async function ensureUser() {
method: "POST",
body: JSON.stringify({ init_data: tg.initData }),
});
localStorage.setItem("carpassLocale", state.user.locale || "ru");
hideAuthOverlay();
updateRoleVisibility();
return;
@@ -588,6 +618,7 @@ async function ensureUser() {
const devId = localStorage.getItem("driversDevTelegramId") || "1";
localStorage.setItem("driversDevTelegramId", devId);
state.user = await api("/users/me");
localStorage.setItem("carpassLocale", state.user.locale || "ru");
hideAuthOverlay();
updateRoleVisibility();
return;
@@ -596,13 +627,43 @@ async function ensureUser() {
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() {
document.querySelector("#authOverlay")?.classList.add("hidden");
document.body.classList.remove("auth-required");
}
const APPROVED_SERVICE_STATUSES = new Set(["approved", "verified"]);
const STO_WORKPLACE_ROLES = new Set(["owner", "mechanic"]);
const STO_WORKPLACE_ROLES = new Set(["owner", "manager", "receptionist", "mechanic"]);
const STO_CALENDAR_ROLES = new Set(["owner", "manager", "receptionist"]);
function isPlatformAdmin() {
@@ -901,10 +962,11 @@ function renderCars() {
.map(
(car) => `
<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">
<strong>${car.name}</strong>
<small>${[car.make, car.model, car.trim, car.year, car.fuel_type].filter(Boolean).join(" ") || t("Без деталей")}</small>
<strong>${escapeHtml(car.name)}</strong>
<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>
</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) {
if (!form?.elements[name]) return;
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)`;
}
document.querySelector("#scoreHint").textContent = car
? "Качество паспорта растет от подтвержденных данных, сервисной истории и точного пробега."
? "Короткая сводка по полноте данных и готовности к обслуживанию."
: t("Добавь авто и первую запись, чтобы видеть точные отчеты");
renderPassportVehicleSummary(car);
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) {
@@ -1938,9 +2127,9 @@ function healthLabel(status) {
function renderScoreActions(items) {
const root = document.querySelector("#scoreActions");
if (!root) return;
const visible = items.slice(0, 3);
const visible = items.slice(0, 1);
if (!visible.length) {
root.innerHTML = `<div class="passport-note">Паспорт выглядит надежно. Следующий рост даст подтвержденная сервисная история.</div>`;
root.innerHTML = `<div class="passport-note">Паспорт готов: ключевые данные, история и ТО собраны достаточно для пилота.</div>`;
return;
}
root.innerHTML = visible
@@ -2339,6 +2528,7 @@ async function applyInitialRoute() {
async function selectCar(carId) {
state.selectedCarId = carId;
state.selectedCarHighlights = null;
renderCars();
fillCarProfileForm();
await loadSelectedCar();
@@ -2348,17 +2538,19 @@ async function loadSelectedCar() {
if (!state.selectedCarId) {
state.latestFuel = [];
state.latestService = [];
state.latestFuelAllTime = [];
state.latestServiceAllTime = [];
state.selectedCarHighlights = null;
state.latestExpenses = [];
state.latestStats = null;
state.allStats = null;
state.analytics = null;
state.vehicleScore = null;
state.vehicleTimeline = [];
state.achievements = [];
renderStats(null);
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${allPeriodQuery()}`),
api(`/cars/${state.selectedCarId}/fuel${periodQuery()}`),
@@ -2366,20 +2558,20 @@ async function loadSelectedCar() {
api(`/cars/${state.selectedCarId}/expenses${periodQuery()}`),
api(`/cars/${state.selectedCarId}/analytics`),
api(`/my/vehicles/${state.selectedCarId}/score`),
]);
const [timeline, achievements] = await Promise.all([
api(`/my/vehicles/${state.selectedCarId}/timeline?limit=30`),
api("/me/achievements"),
api(`/cars/${state.selectedCarId}/fuel?limit=1`),
api(`/cars/${state.selectedCarId}/service?limit=100`),
]);
state.latestStats = stats;
state.allStats = allStats;
state.latestFuel = fuel;
state.latestService = service;
state.latestFuelAllTime = allFuel;
state.latestServiceAllTime = allService;
state.latestExpenses = expenses;
state.analytics = analytics;
state.vehicleScore = vehicleScore;
state.vehicleTimeline = timeline;
state.achievements = achievements;
state.selectedCarHighlights = buildCarHighlights(selectedCar(), allFuel, allService);
renderCars();
renderStats(stats);
drawCharts(fuel, service, stats);
}
@@ -2538,6 +2730,9 @@ document.querySelector("#settingsForm").addEventListener("submit", async (event)
method: "PATCH",
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();
initCarCatalog();
await loadSelectedCar();
@@ -2567,6 +2762,7 @@ document.querySelector("#fuelForm").addEventListener("submit", async (event) =>
});
form.reset();
form.entry_date.value = today();
form.is_full_tank.checked = true;
await loadSelectedCar();
toast("Сохранено");
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 = {}) {
if (!canOpenDrawerSection(sectionId)) {
toast("Этот раздел недоступен для вашей роли", "error");
@@ -2684,6 +2894,7 @@ async function openDrawerSection(sectionId, options = {}) {
button.classList.toggle("active", button.dataset.menuSection === sectionId);
});
mountEntryForms();
fillEntryDefaults(sectionId);
if (sectionId === "carProfileSection") fillCarProfileForm();
if (sectionId === "settingsSection") {
document.querySelector("#localeSelect").value = state.user?.locale || "ru";
@@ -2758,7 +2969,18 @@ document.querySelector("#addCarQuickBtn").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) => {
@@ -2910,6 +3132,8 @@ initPwa();
Promise.all([loadAuthConfig()])
.then(() => Promise.all([ensureUser(), loadCatalog()]))
.then(() => {
installLocaleSwitch();
observeTranslations();
document.querySelector("#localeSelect").value = state.user?.locale || "ru";
document.querySelector("#currencySelect").value = state.user?.currency || "RUB";
document.querySelector("#expenseForm").currency.value = state.user?.currency || "RUB";

View File

@@ -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) => {
event.preventDefault();
const center = selectedCenter();

View File

@@ -2,6 +2,238 @@ const tg = window.Telegram?.WebApp;
tg?.ready();
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 state = { user: null, authConfig: null };
@@ -51,13 +283,14 @@ const CarPassPage = (() => {
}
document.body.classList.remove("auth-required");
document.querySelector("#authOverlay")?.classList.add("hidden");
localStorage.setItem("carpassLocale", state.user.locale || "ru");
return state.user;
}
function toast(message, tone = "success") {
const node = document.querySelector("#toast");
if (!node) return;
node.textContent = message;
node.textContent = t(message);
node.className = `toast ${tone}`;
window.clearTimeout(toast.timer);
toast.timer = window.setTimeout(() => node.classList.add("hidden"), 2600);
@@ -69,7 +302,7 @@ const CarPassPage = (() => {
button.dataset.label = button.textContent;
button.disabled = true;
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 {
button.disabled = false;
button.classList.remove("is-busy");
@@ -115,18 +348,73 @@ const CarPassPage = (() => {
if (!value) return "-";
const date = new Date(value);
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() {
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) {
try {
await loadAuthConfig();
await ensureUser();
installLocaleSwitch();
applyTranslations();
CarPassI18n.observe();
await init();
applyTranslations();
} catch (error) {
if (error.message === "Требуется вход через Telegram") return;
console.error(error);
@@ -148,5 +436,9 @@ const CarPassPage = (() => {
csvList,
formatDateTime,
today,
t,
applyTranslations,
installLocaleSwitch,
updateLocale,
};
})();

View File

@@ -3,7 +3,7 @@ tg?.ready();
tg?.expand();
const APPROVED_SERVICE_STATUSES = new Set(["approved", "verified"]);
const STO_WORKPLACE_ROLES = new Set(["owner", "mechanic"]);
const STO_WORKPLACE_ROLES = new Set(["owner", "manager", "receptionist", "mechanic"]);
const state = {
user: null,
@@ -250,7 +250,8 @@ function renderDashboard(dashboard) {
}
function renderAppointments() {
const canManage = (activeCenter()?.employee_role || "owner") === "owner";
const role = activeCenter()?.employee_role || "owner";
const canManage = ["owner", "manager", "receptionist"].includes(role);
document.querySelector("#appointmentsList").innerHTML = state.appointments.length
? state.appointments.map((item) => `
<div class="stack-item work-order-card">

View File

@@ -70,8 +70,13 @@ body.auth-required .shell {
}
.passport-head small,
.passport-metric small,
.passport-metric span,
.passport-action span,
.passport-vehicle-empty span,
.passport-vehicle-main span,
.passport-vehicle-id,
.passport-vehicle-facts span,
.achievement-card span,
.timeline-item small,
.timeline-empty,
@@ -134,6 +139,103 @@ body.auth-required .shell {
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-actions,
.achievement-strip {
@@ -816,6 +918,36 @@ select:disabled {
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 {
border-color: rgba(22, 128, 106, 0.48);
background: #e5f4ef;
@@ -1937,6 +2069,227 @@ select {
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 {
color: #fff;
font-size: clamp(24px, 4vw, 34px);
@@ -2159,11 +2512,25 @@ select {
white-space: nowrap;
}
.car-key-param strong {
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.passport-head {
display: grid;
grid-template-columns: 1fr auto;
}
.passport-vehicle-main {
align-items: start;
}
.passport-vehicle-facts {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.score-ring {
width: 72px;
height: 72px;
@@ -2212,7 +2579,7 @@ select {
.sto-page .top-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) 44px;
grid-template-columns: minmax(0, 1fr) 58px 38px;
}
.staff-form button {
@@ -2230,3 +2597,265 @@ select {
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;
}
}