12 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
39 changed files with 5290 additions and 167 deletions

View File

@@ -22,3 +22,8 @@ OCR_PROVIDER=tesseract
OCR_LANGUAGES=eng+rus+kor OCR_LANGUAGES=eng+rus+kor
ADMIN_TELEGRAM_IDS= ADMIN_TELEGRAM_IDS=
ADMIN_BOOTSTRAP_TOKEN= ADMIN_BOOTSTRAP_TOKEN=
ADMIN_NOTIFICATION_CHAT_ID=
ADMIN_NOTIFY_NEW_USERS=true
ADMIN_NOTIFY_STO_APPLICATIONS=true
ADMIN_NOTIFY_SECURITY_EVENTS=true
ADMIN_NOTIFY_SYSTEM_ERRORS=true

201
ADMIN.md Normal file
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

@@ -33,6 +33,13 @@ python -m scripts.bootstrap_admin
curl -fsS http://127.0.0.1:8000/ready curl -fsS http://127.0.0.1:8000/ready
``` ```
If port `8000` is already used on the host, set `APP_PORT` in `.env` and point the reverse proxy to that local port:
```bash
APP_PORT=8010
curl -fsS http://127.0.0.1:8010/ready
```
The default compose stack includes Postgres, Redis, API and bot services with health checks, restart policies and log rotation. The default compose stack includes Postgres, Redis, API and bot services with health checks, restart policies and log rotation.
Telegram notifications are the primary pilot notification channel. Browser push currently stores subscriptions and is treated as beta until server-side Web Push delivery is enabled. Telegram notifications are the primary pilot notification channel. Browser push currently stores subscriptions and is treated as beta until server-side Web Push delivery is enabled.

View File

@@ -65,6 +65,12 @@ CarPass создает рекомендации обслуживания из д
Telegram-уведомления являются основным каналом закрытого пилота. Browser push уже умеет сохранять подписки в Mini App и принимать push-события в service worker, но серверная Web Push-доставка помечена как beta и не считается критическим каналом пилота. Telegram-уведомления являются основным каналом закрытого пилота. Browser push уже умеет сохранять подписки в Mini App и принимать push-события в service worker, но серверная Web Push-доставка помечена как beta и не считается критическим каналом пилота.
## Администрирование
Admin Control Center доступен по `/admin.html` и через команды бота `/admin`, `/admin_stats`, `/admin_users`, `/admin_sto`, `/admin_pending_sto`, `/admin_alerts`.
Админка включает dashboard сервиса, admin notifications, очередь заявок СТО, пользователей, автомобили, записи, заказ-наряды, audit log, экспорт и безопасный Data Explorer без произвольного SQL. Подробности по ролям, privacy, env и API описаны в [ADMIN.md](ADMIN.md).
## Безопасность данных ## Безопасность данных
CarPass не раскрывает историю автомобиля по одному VIN или госномеру. СТО видит только разрешенный владельцем объем данных: базовую карточку, историю обслуживания или полный доступ. Любые чувствительные изменения, включая VIN, номер, пробег и технические параметры, проходят подтверждение владельца. CarPass не раскрывает историю автомобиля по одному VIN или госномеру. СТО видит только разрешенный владельцем объем данных: базовую карточку, историю обслуживания или полный доступ. Любые чувствительные изменения, включая VIN, номер, пробег и технические параметры, проходят подтверждение владельца.

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 fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_telegram_user from app.api.deps import get_current_telegram_user
@@ -7,6 +7,7 @@ from app.db.session import get_session
from app.models.car import Car, VehicleAccess from app.models.car import Car, VehicleAccess
from app.models.user import User from app.models.user import User
from app.schemas.car import CarCreate, CarRead, CarUpdate from app.schemas.car import CarCreate, CarRead, CarUpdate
from app.services.admin_notifications import create_admin_notification
from app.services.odometer import add_odometer_history, validate_odometer_change from app.services.odometer import add_odometer_history, validate_odometer_change
from app.services.vehicle_identity import normalize_license_plate, validate_vin from app.services.vehicle_identity import normalize_license_plate, validate_vin
@@ -42,6 +43,28 @@ async def create_car(
source_record_id=None, source_record_id=None,
changed_by=current_user.id, changed_by=current_user.id,
) )
vehicle_count = int(
(await session.execute(select(func.count(Car.id)).where(Car.owner_id == current_user.id))).scalar_one()
or 0
)
if vehicle_count == 1:
await create_admin_notification(
session,
event_type="vehicle_created",
title="Пользователь впервые добавил авто",
body="\n".join(
[
f"User ID: {current_user.id}",
f"Telegram ID: {current_user.telegram_id}",
f"Авто: {car.name}",
f"Пробег: {car.current_odometer or '-'}",
]
),
entity_type="vehicle",
entity_id=car.id,
idempotency_key=f"vehicle_created:{current_user.id}",
metadata={"user_id": current_user.id, "vehicle_id": car.id},
)
await session.commit() await session.commit()
await session.refresh(car) await session.refresh(car)
return car return car

View File

@@ -8,6 +8,7 @@ from app.core.config import settings
from app.db.session import get_session from app.db.session import get_session
from app.models.car import AuditLog, Car, ServiceCenter, ServiceEmployee, VehicleAccess from app.models.car import AuditLog, Car, ServiceCenter, ServiceEmployee, VehicleAccess
from app.models.user import User from app.models.user import User
from app.services.admin_notifications import create_admin_notification
from app.services.telegram_auth import verify_webapp_init_data from app.services.telegram_auth import verify_webapp_init_data
@@ -36,6 +37,24 @@ async def get_or_create_telegram_user(
if user is None: if user is None:
user = User(**{key: value for key, value in payload.items() if value is not None}) user = User(**{key: value for key, value in payload.items() if value is not None})
session.add(user) session.add(user)
await session.flush()
await create_admin_notification(
session,
event_type="user_registered",
title="Новый пользователь",
body="\n".join(
item
for item in [
f"Имя: {' '.join(part for part in [first_name, last_name] if part) or '-'}",
f"Telegram ID: {telegram_id}",
f"Username: @{username}" if username else "Username: -",
]
),
entity_type="user",
entity_id=user.id,
idempotency_key=f"user_registered:{telegram_id}",
metadata={"telegram_id": telegram_id, "username": username},
)
else: else:
for field, value in payload.items(): for field, value in payload.items():
if value is not None: if value is not None:

View File

@@ -3,7 +3,7 @@ from io import BytesIO
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_telegram_user from app.api.deps import get_current_telegram_user
@@ -25,6 +25,7 @@ from app.schemas.expense import (
ServiceEntryRead, ServiceEntryRead,
ServiceEntryUpdate, ServiceEntryUpdate,
) )
from app.services.admin_notifications import create_admin_notification
from app.services.calculations import dataframe_from_query, get_ownership_stats, predict_odometer from app.services.calculations import dataframe_from_query, get_ownership_stats, predict_odometer
from app.services.odometer import ( from app.services.odometer import (
apply_odometer_from_record, apply_odometer_from_record,
@@ -53,6 +54,59 @@ async def ensure_entry_owner(
return entry return entry
async def maybe_notify_first_record(
session: AsyncSession,
*,
user: User,
car: Car,
record_type: str,
record_id: int,
) -> None:
fuel_count = int(
(
await session.execute(
select(func.count(FuelEntry.id)).join(Car, FuelEntry.car_id == Car.id).where(Car.owner_id == user.id)
)
).scalar_one()
or 0
)
service_count = int(
(
await session.execute(
select(func.count(ServiceEntry.id)).join(Car, ServiceEntry.car_id == Car.id).where(Car.owner_id == user.id)
)
).scalar_one()
or 0
)
expense_count = int(
(
await session.execute(
select(func.count(ExpenseEntry.id)).join(Car, ExpenseEntry.car_id == Car.id).where(Car.owner_id == user.id)
)
).scalar_one()
or 0
)
if fuel_count + service_count + expense_count != 1:
return
await create_admin_notification(
session,
event_type="first_record_created",
title="Пользователь впервые создал запись",
body="\n".join(
[
f"User ID: {user.id}",
f"Telegram ID: {user.telegram_id}",
f"Авто: {car.name}",
f"Тип записи: {record_type}",
]
),
entity_type="vehicle",
entity_id=car.id,
idempotency_key=f"first_record_created:{user.id}",
metadata={"user_id": user.id, "vehicle_id": car.id, "record_type": record_type, "record_id": record_id},
)
@router.post("/fuel", response_model=FuelEntryRead, status_code=status.HTTP_201_CREATED) @router.post("/fuel", response_model=FuelEntryRead, status_code=status.HTTP_201_CREATED)
async def create_fuel_entry( async def create_fuel_entry(
payload: FuelEntryCreate, payload: FuelEntryCreate,
@@ -78,6 +132,7 @@ async def create_fuel_entry(
changed_by=current_user.id, changed_by=current_user.id,
confirm_lower_odometer=payload.confirm_lower_odometer, confirm_lower_odometer=payload.confirm_lower_odometer,
) )
await maybe_notify_first_record(session, user=current_user, car=car, record_type="fuel", record_id=entry.id)
await session.commit() await session.commit()
await session.refresh(entry) await session.refresh(entry)
return entry return entry
@@ -174,6 +229,7 @@ async def create_service_entry(
changed_by=current_user.id, changed_by=current_user.id,
confirm_lower_odometer=payload.confirm_lower_odometer, confirm_lower_odometer=payload.confirm_lower_odometer,
) )
await maybe_notify_first_record(session, user=current_user, car=car, record_type="service", record_id=entry.id)
await session.commit() await session.commit()
await session.refresh(entry) await session.refresh(entry)
return entry return entry
@@ -266,6 +322,7 @@ async def create_expense_entry(
changed_by=current_user.id, changed_by=current_user.id,
confirm_lower_odometer=payload.confirm_lower_odometer, confirm_lower_odometer=payload.confirm_lower_odometer,
) )
await maybe_notify_first_record(session, user=current_user, car=car, record_type="expense", record_id=entry.id)
await session.commit() await session.commit()
await session.refresh(entry) await session.refresh(entry)
return entry return entry

View File

@@ -27,6 +27,7 @@ from app.schemas.service_center import (
VehicleUpdate, VehicleUpdate,
) )
from app.schemas.user import UserRead from app.schemas.user import UserRead
from app.services.admin_notifications import create_admin_notification
from app.services.odometer import ( from app.services.odometer import (
add_odometer_history, add_odometer_history,
recalculate_current_odometer, recalculate_current_odometer,
@@ -381,6 +382,20 @@ async def create_vehicle(
changed_by=current_user.id, changed_by=current_user.id,
) )
await log_audit(session, actor=current_user, action="vehicle.create", target_type="vehicle", target_id=car.id) await log_audit(session, actor=current_user, action="vehicle.create", target_type="vehicle", target_id=car.id)
vehicle_count = (
await session.execute(select(Car.id).where(Car.owner_id == current_user.id).limit(2))
).scalars().all()
if len(vehicle_count) == 1:
await create_admin_notification(
session,
event_type="vehicle_created",
title="Пользователь впервые добавил авто",
body=f"{current_user.first_name or current_user.username or current_user.telegram_id}: {car.name}",
entity_type="vehicle",
entity_id=car.id,
idempotency_key=f"first_vehicle:{current_user.id}",
metadata={"user_id": current_user.id, "vehicle_id": car.id},
)
await session.commit() await session.commit()
await session.refresh(car) await session.refresh(car)
return car return car

View File

@@ -1,15 +1,18 @@
import re import re
import time
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from fastapi import APIRouter, Depends, File, Request, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_telegram_user from app.api.deps import get_current_telegram_user
from app.db.session import get_session from app.db.session import get_session
from app.models.car import OCRResult
from app.models.user import User from app.models.user import User
from app.services.ocr_provider import get_ocr_provider from app.services.admin_notifications import create_admin_notification
from app.services.ocr_provider import OcrResult, get_ocr_provider
from app.services.rate_limit import check_rate_limit from app.services.rate_limit import check_rate_limit
from app.services.uploads import SAFE_IMAGE_TYPES, SAFE_TEXT_TYPES, validate_upload from app.services.uploads import SAFE_IMAGE_TYPES, SAFE_TEXT_TYPES, validate_upload
@@ -40,6 +43,135 @@ class OCRResultRead(BaseModel):
provider: str = "heuristic" provider: str = "heuristic"
def ocr_candidates_json(result: OcrResult | None) -> list[dict] | None:
if result is None:
return None
return [
{"type": candidate.type, "value": candidate.value, "confidence": candidate.confidence}
for candidate in result.candidates
]
def ocr_confidence(result: OcrResult | None) -> Decimal | None:
if result is None or not result.candidates:
return None
return Decimal(str(round(max(candidate.confidence for candidate in result.candidates), 4)))
async def save_ocr_result(
session: AsyncSession,
*,
current_user: User,
scope: str,
filename: str | None,
content_type: str | None,
status: str,
result: OcrResult | None = None,
recognized_text: str | None = None,
provider: str | None = None,
error: str | None = None,
) -> OCRResult:
record = OCRResult(
user_id=current_user.id,
scope=scope,
filename=filename,
content_type=content_type,
status=status,
provider=result.provider if result is not None else provider,
confidence=ocr_confidence(result),
recognized_text=result.recognized_text if result is not None else recognized_text,
candidates_json=ocr_candidates_json(result),
error=error,
)
session.add(record)
await session.flush()
return record
async def validate_ocr_upload(
*,
session: AsyncSession,
current_user: User,
content: bytes,
filename: str | None,
content_type: str | None,
) -> str:
try:
return validate_upload(
content=content,
filename=filename,
content_type=content_type,
max_bytes=MAX_OCR_FILE_BYTES,
allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES,
)
except HTTPException as exc:
await save_ocr_result(
session,
current_user=current_user,
scope="upload_validation",
filename=filename,
content_type=content_type,
status="blocked",
error=str(exc.detail),
)
await create_admin_notification(
session,
event_type="upload_blocked",
title="Upload blocked",
body=f"OCR upload blocked: {filename or '-'}\nReason: {exc.detail}",
entity_type="user",
entity_id=current_user.id,
severity="warning",
idempotency_key=(
f"upload_blocked:{current_user.id}:{filename or 'upload'}:{exc.status_code}:"
f"{int(time.time() // 60)}"
),
metadata={
"filename": filename,
"content_type": content_type,
"status_code": exc.status_code,
"detail": exc.detail,
},
)
await session.commit()
raise
async def recognize_with_alert(
*,
session: AsyncSession,
current_user: User,
content: bytes,
filename: str | None,
scope: str,
):
try:
return await get_ocr_provider().recognize(content, filename)
except Exception as exc: # noqa: BLE001 - OCR must fail gracefully and alert admins
await save_ocr_result(
session,
current_user=current_user,
scope=scope,
filename=filename,
content_type=None,
status="failed",
error=type(exc).__name__,
)
await create_admin_notification(
session,
event_type="ocr_failed",
title="OCR provider failed",
body=f"Scope: {scope}\nFile: {filename or '-'}\nError: {type(exc).__name__}",
entity_type="user",
entity_id=current_user.id,
severity="error",
idempotency_key=f"ocr_failed:{scope}:{current_user.id}:{int(time.time() // 60)}",
metadata={"scope": scope, "filename": filename, "error_type": type(exc).__name__},
)
await session.commit()
return None
@router.post("/parse-text-receipt", response_model=ReceiptSuggestion) @router.post("/parse-text-receipt", response_model=ReceiptSuggestion)
async def parse_text_receipt( async def parse_text_receipt(
request: Request, request: Request,
@@ -49,21 +181,48 @@ async def parse_text_receipt(
) -> ReceiptSuggestion: ) -> ReceiptSuggestion:
await check_rate_limit(scope="ocr", limit=10, window_seconds=60, request=request, user=current_user, session=session) await check_rate_limit(scope="ocr", limit=10, window_seconds=60, request=request, user=current_user, session=session)
content = await file.read() content = await file.read()
validate_upload( await validate_ocr_upload(
content=content, session=session,
current_user=current_user,
filename=file.filename, filename=file.filename,
content_type=file.content_type, content_type=file.content_type,
max_bytes=MAX_OCR_FILE_BYTES, content=content,
allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES,
) )
content_type = (file.content_type or "").lower() content_type = (file.content_type or "").lower()
if content_type.startswith("image/") or content_type == "application/pdf": if content_type.startswith("image/") or content_type == "application/pdf":
result = await get_ocr_provider().recognize(content, file.filename) result = await recognize_with_alert(
if not result.recognized_text: session=session,
current_user=current_user,
content=content,
filename=file.filename,
scope="parse_text_receipt",
)
if not result or not result.recognized_text:
if result is not None:
await save_ocr_result(
session,
current_user=current_user,
scope="parse_text_receipt",
filename=file.filename,
content_type=file.content_type,
status="preview",
result=result,
)
await session.commit()
return ReceiptSuggestion( return ReceiptSuggestion(
confidence=0, confidence=0,
message="Не удалось уверенно распознать чек. Открылся ручной ввод: проверьте дату, сумму, литры и цену.", message="Не удалось уверенно распознать чек. Открылся ручной ввод: проверьте дату, сумму, литры и цену.",
) )
await save_ocr_result(
session,
current_user=current_user,
scope="parse_text_receipt",
filename=file.filename,
content_type=file.content_type,
status="preview",
result=result,
)
await session.commit()
return parse_receipt_text(result.recognized_text) return parse_receipt_text(result.recognized_text)
text = " ".join( text = " ".join(
[ [
@@ -71,6 +230,17 @@ async def parse_text_receipt(
content.decode("utf-8", errors="ignore"), content.decode("utf-8", errors="ignore"),
] ]
) )
await save_ocr_result(
session,
current_user=current_user,
scope="parse_text_receipt",
filename=file.filename,
content_type=file.content_type,
status="preview",
recognized_text=text,
provider="text",
)
await session.commit()
return parse_receipt_text(text) return parse_receipt_text(text)
@@ -133,8 +303,32 @@ async def recognize_license_plate(
) -> OCRResultRead: ) -> OCRResultRead:
await check_rate_limit(scope="ocr_license_plate", limit=8, window_seconds=60, request=request, user=current_user, session=session) await check_rate_limit(scope="ocr_license_plate", limit=8, window_seconds=60, request=request, user=current_user, session=session)
content = await file.read() content = await file.read()
validate_upload(content=content, filename=file.filename, content_type=file.content_type, max_bytes=MAX_OCR_FILE_BYTES, allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES) await validate_ocr_upload(
result = await get_ocr_provider().recognize(content, file.filename) session=session,
current_user=current_user,
content=content,
filename=file.filename,
content_type=file.content_type,
)
result = await recognize_with_alert(
session=session,
current_user=current_user,
content=content,
filename=file.filename,
scope="license_plate",
)
if result is None:
return OCRResultRead(recognized_text="", candidates=[], provider="error")
await save_ocr_result(
session,
current_user=current_user,
scope="license_plate",
filename=file.filename,
content_type=file.content_type,
status="preview",
result=result,
)
await session.commit()
return OCRResultRead( return OCRResultRead(
recognized_text=result.recognized_text, recognized_text=result.recognized_text,
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "license_plate"], candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "license_plate"],
@@ -151,8 +345,32 @@ async def recognize_vin(
) -> OCRResultRead: ) -> OCRResultRead:
await check_rate_limit(scope="ocr_vin", limit=8, window_seconds=60, request=request, user=current_user, session=session) await check_rate_limit(scope="ocr_vin", limit=8, window_seconds=60, request=request, user=current_user, session=session)
content = await file.read() content = await file.read()
validate_upload(content=content, filename=file.filename, content_type=file.content_type, max_bytes=MAX_OCR_FILE_BYTES, allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES) await validate_ocr_upload(
result = await get_ocr_provider().recognize(content, file.filename) session=session,
current_user=current_user,
content=content,
filename=file.filename,
content_type=file.content_type,
)
result = await recognize_with_alert(
session=session,
current_user=current_user,
content=content,
filename=file.filename,
scope="vin",
)
if result is None:
return OCRResultRead(recognized_text="", candidates=[], provider="error")
await save_ocr_result(
session,
current_user=current_user,
scope="vin",
filename=file.filename,
content_type=file.content_type,
status="preview",
result=result,
)
await session.commit()
return OCRResultRead( return OCRResultRead(
recognized_text=result.recognized_text, recognized_text=result.recognized_text,
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "vin"], candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "vin"],
@@ -169,8 +387,32 @@ async def recognize_service_document(
) -> OCRResultRead: ) -> OCRResultRead:
await check_rate_limit(scope="ocr_service_document", limit=8, window_seconds=60, request=request, user=current_user, session=session) await check_rate_limit(scope="ocr_service_document", limit=8, window_seconds=60, request=request, user=current_user, session=session)
content = await file.read() content = await file.read()
validate_upload(content=content, filename=file.filename, content_type=file.content_type, max_bytes=MAX_OCR_FILE_BYTES, allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES) await validate_ocr_upload(
result = await get_ocr_provider().recognize(content, file.filename) session=session,
current_user=current_user,
content=content,
filename=file.filename,
content_type=file.content_type,
)
result = await recognize_with_alert(
session=session,
current_user=current_user,
content=content,
filename=file.filename,
scope="service_document",
)
if result is None:
return OCRResultRead(recognized_text="", candidates=[], provider="error")
await save_ocr_result(
session,
current_user=current_user,
scope="service_document",
filename=file.filename,
content_type=file.content_type,
status="preview",
result=result,
)
await session.commit()
return OCRResultRead( return OCRResultRead(
recognized_text=result.recognized_text, recognized_text=result.recognized_text,
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates], candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates],

View File

@@ -48,6 +48,7 @@ from app.schemas.service_center import (
VehicleSearchRequest, VehicleSearchRequest,
VehicleSearchResult, VehicleSearchResult,
) )
from app.services.admin_notifications import create_admin_notification
from app.services.notifications import notify_platform_moderators from app.services.notifications import notify_platform_moderators
from app.services.odometer import validate_odometer_change from app.services.odometer import validate_odometer_change
from app.services.rate_limit import check_rate_limit from app.services.rate_limit import check_rate_limit
@@ -147,6 +148,25 @@ async def create_service_center(
target_type="service_center", target_type="service_center",
target_id=center.id, target_id=center.id,
) )
await create_admin_notification(
session,
event_type="sto_application_created",
title="Новая заявка СТО",
body="\n".join(
item
for item in [
f"Название: {center.display_name or center.name}",
f"Город: {center.city or '-'}",
f"Телефон: {center.phone or center.contact_phone or '-'}",
f"Документы: {len(center.document_photo_urls or [])}",
"Статус: pending",
]
),
entity_type="service_center",
entity_id=center.id,
idempotency_key=f"sto_application_created:{center.id}",
metadata={"city": center.city, "owner_user_id": current_user.id},
)
await session.commit() await session.commit()
await session.refresh(center) await session.refresh(center)
await notify_platform_moderators( await notify_platform_moderators(
@@ -189,6 +209,22 @@ async def update_service_center_application(
) )
) )
await log_audit(session, actor=current_user, action="service_center.update", target_type="service_center", target_id=center.id) await log_audit(session, actor=current_user, action="service_center.update", target_type="service_center", target_id=center.id)
await create_admin_notification(
session,
event_type="sto_application_updated",
title="СТО обновило заявку",
body="\n".join(
[
f"Название: {center.display_name or center.name}",
f"Город: {center.city or '-'}",
f"Статус: {center.verification_status}",
]
),
entity_type="service_center",
entity_id=center.id,
idempotency_key=f"sto_application_updated:{center.id}:{int(datetime.now(UTC).timestamp() // 60)}",
metadata={"city": center.city, "owner_user_id": current_user.id},
)
await session.commit() await session.commit()
await session.refresh(center) await session.refresh(center)
return center return center
@@ -827,6 +863,18 @@ async def create_service_center_review(
await log_audit(session, actor=current_user, action="service_review.upsert", target_type="service_center", target_id=service_center_id) await log_audit(session, actor=current_user, action="service_review.upsert", target_type="service_center", target_id=service_center_id)
await session.flush() await session.flush()
await refresh_service_rating(session, service_center_id) await refresh_service_rating(session, service_center_id)
if review.rating <= 2:
await create_admin_notification(
session,
event_type="sto_low_review",
title="Низкая оценка СТО",
body=f"СТО ID: {service_center_id}\nОценка: {review.rating}\nОтзыв: {review.text or '-'}",
entity_type="service_center",
entity_id=service_center_id,
severity="warning",
idempotency_key=f"sto_low_review:{review.id}:{review.rating}",
metadata={"review_id": review.id, "rating": review.rating, "user_id": current_user.id},
)
await session.commit() await session.commit()
await session.refresh(review) await session.refresh(review)
return review return review

View File

@@ -36,6 +36,7 @@ from app.schemas.sto_booking import (
ServiceCenterHolidayRead, ServiceCenterHolidayRead,
STODashboardRead, STODashboardRead,
) )
from app.services.admin_notifications import create_admin_notification
from app.services.rate_limit import check_rate_limit from app.services.rate_limit import check_rate_limit
from app.services.sto_booking import ( from app.services.sto_booking import (
calculate_available_slots, calculate_available_slots,
@@ -238,6 +239,28 @@ async def create_appointment(
body=f"{appointment.service_name}: {appointment.requested_start_at:%Y-%m-%d %H:%M}", body=f"{appointment.service_name}: {appointment.requested_start_at:%Y-%m-%d %H:%M}",
appointment_id=appointment.id, appointment_id=appointment.id,
) )
await create_admin_notification(
session,
event_type="appointment_created",
title="Новая запись в СТО",
body="\n".join(
[
f"СТО ID: {appointment.service_center_id}",
f"User ID: {current_user.id}",
f"Авто ID: {appointment.vehicle_id}",
f"Услуга: {appointment.service_name}",
f"Время: {appointment.requested_start_at:%Y-%m-%d %H:%M}",
]
),
entity_type="appointment",
entity_id=appointment.id,
idempotency_key=f"appointment_created:{appointment.id}",
metadata={
"service_center_id": appointment.service_center_id,
"vehicle_id": appointment.vehicle_id,
"owner_id": appointment.owner_id,
},
)
await log_audit( await log_audit(
session, session,
actor=current_user, actor=current_user,
@@ -554,6 +577,17 @@ async def reject_appointment(
title="СТО отклонило запись", title="СТО отклонило запись",
body=payload.comment, body=payload.comment,
) )
await create_admin_notification(
session,
event_type="appointment_cancelled",
title="СТО отклонило запись",
body=payload.comment,
entity_type="appointment",
entity_id=appointment.id,
severity="warning",
idempotency_key=f"appointment_rejected_by_sto:{appointment.id}",
metadata={"service_center_id": appointment.service_center_id, "owner_id": appointment.owner_id},
)
await log_audit(session, actor=current_user, action="appointment.reject", target_type="service_appointment", target_id=appointment_id) await log_audit(session, actor=current_user, action="appointment.reject", target_type="service_appointment", target_id=appointment_id)
await session.commit() await session.commit()
await session.refresh(appointment) await session.refresh(appointment)
@@ -579,6 +613,17 @@ async def delete_appointment_by_sto(
body=f"{appointment.service_name}: {appointment.requested_start_at:%Y-%m-%d %H:%M}", body=f"{appointment.service_name}: {appointment.requested_start_at:%Y-%m-%d %H:%M}",
idempotency_key=f"appointment:{appointment.id}:deleted_by_sto", idempotency_key=f"appointment:{appointment.id}:deleted_by_sto",
) )
await create_admin_notification(
session,
event_type="appointment_cancelled",
title="СТО удалило запись",
body=f"{appointment.service_name}: {appointment.requested_start_at:%Y-%m-%d %H:%M}",
entity_type="appointment",
entity_id=appointment.id,
severity="warning",
idempotency_key=f"appointment_deleted_by_sto:{appointment.id}",
metadata={"service_center_id": appointment.service_center_id, "owner_id": appointment.owner_id},
)
await log_audit( await log_audit(
session, session,
actor=current_user, actor=current_user,
@@ -677,6 +722,21 @@ async def create_work_order_from_appointment(
body=visit.work_order_number, body=visit.work_order_number,
idempotency_key=f"work_order:{visit.id}:created", idempotency_key=f"work_order:{visit.id}:created",
) )
await create_admin_notification(
session,
event_type="work_order_created",
title="Создан заказ-наряд",
body=f"{visit.work_order_number or visit.id}: СТО {visit.service_center_id}, авто {visit.vehicle_id}",
entity_type="work_order",
entity_id=visit.id,
idempotency_key=f"work_order_created:{visit.id}",
metadata={
"appointment_id": appointment.id,
"service_center_id": visit.service_center_id,
"vehicle_id": visit.vehicle_id,
"owner_id": visit.owner_id,
},
)
await log_audit(session, actor=current_user, action="appointment.create_work_order", target_type="service_appointment", target_id=appointment_id, metadata={"service_visit_id": visit.id}) await log_audit(session, actor=current_user, action="appointment.create_work_order", target_type="service_appointment", target_id=appointment_id, metadata={"service_visit_id": visit.id})
await session.commit() await session.commit()
await session.refresh(visit) await session.refresh(visit)

View File

@@ -39,6 +39,7 @@ from app.schemas.service_center import (
WorkOrderStatusHistoryRead, WorkOrderStatusHistoryRead,
WorkOrderUpdate, WorkOrderUpdate,
) )
from app.services.admin_notifications import create_admin_notification
from app.services.sto_booking import create_service_notification from app.services.sto_booking import create_service_notification
from app.services.work_orders import ( from app.services.work_orders import (
add_labor_item, add_labor_item,
@@ -461,6 +462,17 @@ async def reject_work_order(
visit.owner_comment = payload.comment visit.owner_comment = payload.comment
visit.owner_resolved_at = datetime.now(UTC) visit.owner_resolved_at = datetime.now(UTC)
await add_status_history(session, visit, to_status="rejected_by_owner", actor=current_user, comment=payload.comment) await add_status_history(session, visit, to_status="rejected_by_owner", actor=current_user, comment=payload.comment)
await create_admin_notification(
session,
event_type="work_order_rejected_by_owner",
title="Владелец отклонил смету",
body=payload.comment,
entity_type="work_order",
entity_id=visit.id,
severity="warning",
idempotency_key=f"work_order_rejected_by_owner:{visit.id}",
metadata={"service_center_id": visit.service_center_id, "vehicle_id": visit.vehicle_id, "owner_id": current_user.id},
)
await log_audit(session, actor=current_user, action="work_order.reject", target_type="service_visit", target_id=visit.id) await log_audit(session, actor=current_user, action="work_order.reject", target_type="service_visit", target_id=visit.id)
await session.commit() await session.commit()
await session.refresh(visit) await session.refresh(visit)
@@ -502,6 +514,21 @@ async def complete_work_order(
actor=current_user, actor=current_user,
confirm_lower_odometer=payload.confirm_lower_odometer, confirm_lower_odometer=payload.confirm_lower_odometer,
) )
await create_admin_notification(
session,
event_type="work_order_completed",
title="Заказ-наряд завершён",
body=f"{visit.work_order_number or visit.id}: {visit.final_total} {visit.currency}",
entity_type="work_order",
entity_id=visit.id,
idempotency_key=f"work_order_completed:{visit.id}",
metadata={
"service_center_id": visit.service_center_id,
"vehicle_id": visit.vehicle_id,
"owner_id": visit.owner_id,
"final_total": str(visit.final_total),
},
)
await log_audit(session, actor=current_user, action="work_order.complete", target_type="service_visit", target_id=visit.id) await log_audit(session, actor=current_user, action="work_order.complete", target_type="service_visit", target_id=visit.id)
await session.commit() await session.commit()
await session.refresh(visit) await session.refresh(visit)
@@ -599,6 +626,21 @@ async def create_work_order_correction(
web_app_url=work_order_webapp_url(visit.id), web_app_url=work_order_webapp_url(visit.id),
button_text="Открыть заказ-наряд", button_text="Открыть заказ-наряд",
) )
await create_admin_notification(
session,
event_type="work_order_correction_requested",
title="Запрошена коррекция заказ-наряда",
body=payload.reason,
entity_type="work_order",
entity_id=visit.id,
severity="warning",
idempotency_key=f"work_order_correction_requested:{visit.id}:{visit.version or 1}:{payload.reason[:80]}",
metadata={
"service_center_id": visit.service_center_id,
"vehicle_id": visit.vehicle_id,
"owner_approval_required": payload.owner_approval_required,
},
)
await log_audit( await log_audit(
session, session,
actor=current_user, actor=current_user,
@@ -626,6 +668,16 @@ async def approve_work_order_correction(
raise HTTPException(status_code=409, detail="Correction is already resolved") raise HTTPException(status_code=409, detail="Correction is already resolved")
correction.status = "approved" correction.status = "approved"
correction.resolved_at = datetime.now(UTC) correction.resolved_at = datetime.now(UTC)
await create_admin_notification(
session,
event_type="work_order_correction_resolved",
title="Коррекция заказ-наряда согласована",
body=payload.comment,
entity_type="work_order",
entity_id=visit.id,
idempotency_key=f"work_order_correction_approved:{correction.id}",
metadata={"correction_id": correction.id, "status": "approved"},
)
await log_audit( await log_audit(
session, session,
actor=current_user, actor=current_user,
@@ -653,6 +705,17 @@ async def reject_work_order_correction(
raise HTTPException(status_code=409, detail="Correction is already resolved") raise HTTPException(status_code=409, detail="Correction is already resolved")
correction.status = "rejected" correction.status = "rejected"
correction.resolved_at = datetime.now(UTC) correction.resolved_at = datetime.now(UTC)
await create_admin_notification(
session,
event_type="work_order_correction_resolved",
title="Коррекция заказ-наряда отклонена",
body=payload.comment,
entity_type="work_order",
entity_id=visit.id,
severity="warning",
idempotency_key=f"work_order_correction_rejected:{correction.id}",
metadata={"correction_id": correction.id, "status": "rejected"},
)
await log_audit( await log_audit(
session, session,
actor=current_user, actor=current_user,

View File

@@ -24,6 +24,11 @@ class Settings(BaseSettings):
ocr_languages: str = "eng+rus+kor" ocr_languages: str = "eng+rus+kor"
admin_telegram_ids: str = "" admin_telegram_ids: str = ""
admin_bootstrap_token: str = "" admin_bootstrap_token: str = ""
admin_notification_chat_id: str = ""
admin_notify_new_users: bool = True
admin_notify_sto_applications: bool = True
admin_notify_security_events: bool = True
admin_notify_system_errors: bool = True
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")

View File

@@ -25,7 +25,8 @@ from app.api import (
work_orders, work_orders,
) )
from app.core.config import settings from app.core.config import settings
from app.db.session import get_session from app.db.session import async_session_factory, get_session
from app.services.admin_notifications import create_admin_notification
from app.services.rate_limit import get_redis_client from app.services.rate_limit import get_redis_client
@@ -49,8 +50,29 @@ async def production_headers_and_metrics(request: Request, call_next):
start = monotonic() start = monotonic()
try: try:
response = await call_next(request) response = await call_next(request)
except Exception: except Exception as exc:
REQUEST_ERRORS += 1 REQUEST_ERRORS += 1
try:
async with async_session_factory() as session:
await create_admin_notification(
session,
event_type="system_error",
title="Unhandled API error",
body=f"{request.method} {request.url.path}\nError: {type(exc).__name__}",
entity_type="system",
entity_id=request.url.path,
severity="error",
idempotency_key=f"system_error:{request.url.path}:{type(exc).__name__}:{int(start // 60)}",
metadata={
"path": request.url.path,
"method": request.method,
"request_id": request_id,
"error_type": type(exc).__name__,
},
)
await session.commit()
except Exception:
pass
raise raise
duration = monotonic() - start duration = monotonic() - start
REQUEST_COUNT += 1 REQUEST_COUNT += 1

View File

@@ -432,6 +432,47 @@ class ServiceNotification(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
class AdminNotification(Base):
__tablename__ = "admin_notifications"
id: Mapped[int] = mapped_column(primary_key=True)
event_type: Mapped[str] = mapped_column(String(80), index=True)
severity: Mapped[str] = mapped_column(String(24), default="info", server_default="info", index=True)
title: Mapped[str] = mapped_column(String(180))
body: Mapped[str | None] = mapped_column(Text)
entity_type: Mapped[str | None] = mapped_column(String(80), index=True)
entity_id: Mapped[str | None] = mapped_column(String(80), index=True)
status: Mapped[str] = mapped_column(String(24), default="unread", server_default="unread", index=True)
idempotency_key: Mapped[str] = mapped_column(String(180), unique=True, index=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON)
telegram_status: Mapped[str] = mapped_column(String(24), default="pending", server_default="pending", index=True)
telegram_error: Mapped[str | None] = mapped_column(Text)
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
dismissed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class OCRResult(Base):
__tablename__ = "ocr_results"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
vehicle_id: Mapped[int | None] = mapped_column(ForeignKey("cars.id", ondelete="SET NULL"), index=True)
scope: Mapped[str] = mapped_column(String(80), index=True)
filename: Mapped[str | None] = mapped_column(String(255))
content_type: Mapped[str | None] = mapped_column(String(120))
status: Mapped[str] = mapped_column(String(24), default="preview", server_default="preview", index=True)
provider: Mapped[str | None] = mapped_column(String(80))
confidence: Mapped[Decimal | None] = mapped_column(Numeric(5, 4))
recognized_text: Mapped[str | None] = mapped_column(Text)
candidates_json: Mapped[list | None] = mapped_column(JSON)
error: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
class ServiceWorkItem(Base): class ServiceWorkItem(Base):
__tablename__ = "service_work_items" __tablename__ = "service_work_items"
@@ -625,3 +666,19 @@ class AuditLog(Base):
user_agent: Mapped[str | None] = mapped_column(String(256)) user_agent: Mapped[str | None] = mapped_column(String(256))
metadata_json: Mapped[dict | None] = mapped_column(JSON) metadata_json: Mapped[dict | None] = mapped_column(JSON)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
class AdminExportJob(Base):
__tablename__ = "admin_export_jobs"
id: Mapped[int] = mapped_column(primary_key=True)
requested_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
source: Mapped[str] = mapped_column(String(80), index=True)
export_format: Mapped[str] = mapped_column(String(16), default="json", server_default="json")
status: Mapped[str] = mapped_column(String(24), default="ready", server_default="ready", index=True)
reason: Mapped[str | None] = mapped_column(Text)
filters_json: Mapped[dict | None] = mapped_column(JSON)
result_text: Mapped[str | None] = mapped_column(Text)
row_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)

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: if settings.redis_url:
allowed = await check_redis_rate_limit(scope, identifiers, limit, window_seconds) allowed = await check_redis_rate_limit(scope, identifiers, limit, window_seconds)
if not allowed: if not allowed:
await log_rate_limit_event(session, scope=scope, identifier="redis") await log_rate_limit_event(
session,
scope=scope,
identifier="redis",
user=user,
request=request,
)
raise_rate_limit(scope, window_seconds) raise_rate_limit(scope, window_seconds)
return return
@@ -52,7 +58,13 @@ async def check_rate_limit(
while bucket and now - bucket[0] > window_seconds: while bucket and now - bucket[0] > window_seconds:
bucket.popleft() bucket.popleft()
if len(bucket) >= limit: if len(bucket) >= limit:
await log_rate_limit_event(session, scope=scope, identifier=str(identifier)) await log_rate_limit_event(
session,
scope=scope,
identifier=str(identifier),
user=user,
request=request,
)
raise_rate_limit(scope, window_seconds) raise_rate_limit(scope, window_seconds)
for identifier in identifiers: for identifier in identifiers:
_buckets[(scope, identifier)].append(now) _buckets[(scope, identifier)].append(now)
@@ -107,18 +119,82 @@ async def log_rate_limit_event(
*, *,
scope: str, scope: str,
identifier: str, identifier: str,
user: User | None = None,
request: Request | None = None,
) -> None: ) -> None:
if session is None: client_host = request.client.host if request and request.client else None
return user_agent = request.headers.get("user-agent") if request else None
from app.models.car import AuditLog metadata = {
"scope": scope,
"identifier": identifier,
"telegram_id": user.telegram_id if user else None,
"user_id": user.id if user else None,
"ip": client_host,
}
session.add( if session is None:
from app.db.session import async_session_factory
async with async_session_factory() as event_session:
await persist_rate_limit_event(
event_session,
scope=scope,
identifier=identifier,
user=user,
client_host=client_host,
user_agent=user_agent,
metadata=metadata,
)
return
await persist_rate_limit_event(
session,
scope=scope,
identifier=identifier,
user=user,
client_host=client_host,
user_agent=user_agent,
metadata=metadata,
)
async def persist_rate_limit_event(
event_session: AsyncSession,
*,
scope: str,
identifier: str,
user: User | None,
client_host: str | None,
user_agent: str | None,
metadata: dict,
) -> None:
from app.models.car import AuditLog
from app.services.admin_notifications import create_admin_notification
try:
event_session.add(
AuditLog( AuditLog(
actor_user_id=None, actor_user_id=user.id if user else None,
actor_role="system", actor_role=user.platform_role if user else "system",
action="rate_limit.exceeded", action="rate_limit.exceeded",
target_type=scope, target_type=scope,
target_id=identifier[:80], target_id=identifier[:80],
metadata_json={"scope": scope, "identifier": identifier}, metadata_json=metadata,
ip=client_host,
user_agent=user_agent[:256] if user_agent else None,
) )
) )
await create_admin_notification(
event_session,
event_type="rate_limit_exceeded",
title="Rate limit exceeded",
body=f"Scope: {scope}\nIdentifier: {identifier}",
entity_type="user" if user else "system",
entity_id=user.id if user else scope,
severity="warning",
idempotency_key=f"rate_limit:{scope}:{identifier}:{int(time.time() // max(60, 1))}",
metadata=metadata,
)
await event_session.commit()
except Exception:
await event_session.rollback()

View File

@@ -8,6 +8,21 @@ from app.core.config import settings
class ApiClient: class ApiClient:
def __init__(self) -> None: def __init__(self) -> None:
self.base_url = settings.api_base_url.rstrip("/") self.base_url = settings.api_base_url.rstrip("/")
self._client: httpx.AsyncClient | None = None
@property
def client(self) -> httpx.AsyncClient:
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
base_url=self.base_url,
timeout=httpx.Timeout(15.0, connect=5.0),
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
)
return self._client
async def close(self) -> None:
if self._client is not None:
await self._client.aclose()
def headers(self, telegram_id: int | None = None) -> dict[str, str]: def headers(self, telegram_id: int | None = None) -> dict[str, str]:
headers = {"X-Internal-API-Token": settings.internal_api_token} headers = {"X-Internal-API-Token": settings.internal_api_token}
@@ -24,8 +39,7 @@ class ApiClient:
json: dict[str, Any] | None = None, json: dict[str, Any] | None = None,
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
) -> Any: ) -> Any:
async with httpx.AsyncClient(base_url=self.base_url, timeout=15) as client: response = await self.client.request(
response = await client.request(
method, method,
path, path,
json=json, json=json,
@@ -44,22 +58,19 @@ class ApiClient:
"first_name": telegram_user.first_name, "first_name": telegram_user.first_name,
"last_name": telegram_user.last_name, "last_name": telegram_user.last_name,
} }
async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client: response = await self.client.post("/api/users", json=payload, headers=self.headers())
response = await client.post("/api/users", json=payload, headers=self.headers())
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
async def list_cars(self, owner_id: int, telegram_id: int) -> list[dict[str, Any]]: async def list_cars(self, owner_id: int, telegram_id: int) -> list[dict[str, Any]]:
async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client: response = await self.client.get(
response = await client.get(
"/api/cars", params={"owner_id": owner_id}, headers=self.headers(telegram_id) "/api/cars", params={"owner_id": owner_id}, headers=self.headers(telegram_id)
) )
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
async def create_car(self, owner_id: int, name: str, telegram_id: int) -> dict[str, Any]: async def create_car(self, owner_id: int, name: str, telegram_id: int) -> dict[str, Any]:
async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client: response = await self.client.post(
response = await client.post(
"/api/cars", "/api/cars",
json={"owner_id": owner_id, "name": name}, json={"owner_id": owner_id, "name": name},
headers=self.headers(telegram_id), headers=self.headers(telegram_id),
@@ -68,8 +79,7 @@ class ApiClient:
return response.json() return response.json()
async def stats(self, car_id: int, telegram_id: int) -> dict[str, Any]: async def stats(self, car_id: int, telegram_id: int) -> dict[str, Any]:
async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client: response = await self.client.get(f"/api/cars/{car_id}/stats", headers=self.headers(telegram_id))
response = await client.get(f"/api/cars/{car_id}/stats", headers=self.headers(telegram_id))
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
@@ -126,6 +136,15 @@ class ApiClient:
async def pending_service_centers(self, telegram_id: int) -> list[dict[str, Any]]: async def pending_service_centers(self, telegram_id: int) -> list[dict[str, Any]]:
return await self.request("GET", "/api/admin/service-centers/pending", telegram_id=telegram_id) return await self.request("GET", "/api/admin/service-centers/pending", telegram_id=telegram_id)
async def admin_dashboard(self, telegram_id: int) -> dict[str, Any]:
return await self.request("GET", "/api/admin/dashboard", telegram_id=telegram_id)
async def admin_users(self, telegram_id: int) -> dict[str, Any]:
return await self.request("GET", "/api/admin/users", telegram_id=telegram_id, params={"limit": 10})
async def admin_alerts(self, telegram_id: int) -> dict[str, Any]:
return await self.request("GET", "/api/admin/notifications", telegram_id=telegram_id, params={"limit": 10})
async def moderate_service_center( async def moderate_service_center(
self, self,
telegram_id: int, telegram_id: int,

View File

@@ -433,6 +433,7 @@ async def register_sto(message: Message, command: CommandObject) -> None:
@dp.message(Command("admin_sto_pending")) @dp.message(Command("admin_sto_pending"))
@dp.message(Command("admin_pending_sto"))
async def admin_sto_pending(message: Message) -> None: async def admin_sto_pending(message: Message) -> None:
await upsert(message) await upsert(message)
try: try:
@@ -459,6 +460,77 @@ async def admin_sto_pending(message: Message) -> None:
await message.answer(text, reply_markup=admin_card_keyboard(center["id"])) await message.answer(text, reply_markup=admin_card_keyboard(center["id"]))
@dp.message(Command("admin"))
async def admin_home(message: Message) -> None:
await upsert(message)
try:
await api.admin_dashboard(message.from_user.id)
except httpx.HTTPStatusError as error:
await message.answer(f"Админка недоступна: {error.response.text}")
return
await message.answer(
"Admin Control Center: уведомления, пользователи, СТО, заявки, Data Explorer и Audit Log.",
reply_markup=webapp_inline_keyboard("Открыть админку", "admin.html"),
)
@dp.message(Command("admin_stats"))
async def admin_stats(message: Message) -> None:
await upsert(message)
try:
dashboard = await api.admin_dashboard(message.from_user.id)
except httpx.HTTPStatusError as error:
await message.answer(f"Нет доступа к admin stats: {error.response.text}")
return
await message.answer(
"\n".join(
[
"Admin stats",
f"Users today: {dashboard['users_today']}",
f"Users total: {dashboard['users_total']}",
f"STO pending: {dashboard['pending_sto_applications']}",
f"Appointments today: {dashboard['appointments_today']}",
f"Work orders active: {dashboard['active_work_orders']}",
f"Errors/security: {dashboard['system_errors']} / {dashboard['security_events']}",
]
),
reply_markup=webapp_inline_keyboard("Admin dashboard", "admin.html"),
)
@dp.message(Command("admin_users"))
async def admin_users(message: Message) -> None:
await upsert(message)
try:
data = await api.admin_users(message.from_user.id)
except httpx.HTTPStatusError as error:
await message.answer(f"Нет доступа к admin users: {error.response.text}")
return
lines = ["Последние пользователи:"]
for row in data.get("rows", [])[:10]:
lines.append(f"#{row.get('id')} {row.get('username') or '-'} · {row.get('platform_role')} · {row.get('created_at')}")
await message.answer("\n".join(lines), reply_markup=webapp_inline_keyboard("Users", "admin.html?section=users"))
@dp.message(Command("admin_sto"))
async def admin_sto(message: Message) -> None:
await admin_sto_pending(message)
@dp.message(Command("admin_alerts"))
async def admin_alerts(message: Message) -> None:
await upsert(message)
try:
data = await api.admin_alerts(message.from_user.id)
except httpx.HTTPStatusError as error:
await message.answer(f"Нет доступа к admin alerts: {error.response.text}")
return
lines = ["Admin alerts:"]
for row in data.get("rows", [])[:10]:
lines.append(f"#{row.get('id')} {row.get('severity')} · {row.get('title')} · {row.get('status')}")
await message.answer("\n".join(lines), reply_markup=webapp_inline_keyboard("Alerts", "admin.html?section=notifications"))
async def admin_action(message: Message, command: CommandObject, action: str) -> None: async def admin_action(message: Message, command: CommandObject, action: str) -> None:
args = (command.args or "").split(maxsplit=1) args = (command.args or "").split(maxsplit=1)
if not args: if not args:
@@ -577,7 +649,14 @@ async def admin_callback(callback: CallbackQuery) -> None:
@dp.message(F.text == "Помощь") @dp.message(F.text == "Помощь")
@dp.message(Command("help")) @dp.message(Command("help"))
async def help_message(message: Message) -> None: async def help_message(message: Message) -> None:
user = await api.upsert_user(message.from_user)
centers = await sto_workplace_centers(message.from_user.id) centers = await sto_workplace_centers(message.from_user.id)
admin_help = (
"Админ: /admin — панель, /admin_stats — метрики, /admin_users — последние пользователи, "
"/admin_pending_sto — заявки СТО, /admin_alerts — события.\n"
if user.get("platform_role") in {"admin", "super_admin", "moderator", "support", "analyst"}
else ""
)
sto_workplace_help = ( sto_workplace_help = (
"• /sto_bookings или /sto_workplace — панель подтвержденного СТО;\n" "• /sto_bookings или /sto_workplace — панель подтвержденного СТО;\n"
"• /accept_sto_invite <token> — принять приглашение сотрудника;\n" "• /accept_sto_invite <token> — принять приглашение сотрудника;\n"
@@ -601,6 +680,7 @@ async def help_message(message: Message) -> None:
"• /sto — каталог проверенных СТО;\n" "• /sto — каталог проверенных СТО;\n"
"• /appointments — мои записи в СТО;\n" "• /appointments — мои записи в СТО;\n"
f"{sto_workplace_help}" f"{sto_workplace_help}"
f"{admin_help}"
"\n" "\n"
"Владелец: добавь авто, выбери проверенное СТО, создай запись, согласуй заказ-наряд и смотри завершенные работы в истории автомобиля.\n" "Владелец: добавь авто, выбери проверенное СТО, создай запись, согласуй заказ-наряд и смотри завершенные работы в истории автомобиля.\n"
f"{sto_business_help}" f"{sto_business_help}"
@@ -702,7 +782,11 @@ async def main() -> None:
raise RuntimeError("INTERNAL_API_TOKEN is empty") raise RuntimeError("INTERNAL_API_TOKEN is empty")
settings.validate_webapp_url_for_telegram() settings.validate_webapp_url_for_telegram()
bot = Bot(settings.bot_token) bot = Bot(settings.bot_token)
try:
await dp.start_polling(bot) await dp.start_polling(bot)
finally:
await api.close()
await bot.session.close()
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -59,7 +59,7 @@ services:
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-} VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-} VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
ports: ports:
- "127.0.0.1:8000:8000" - "127.0.0.1:${APP_PORT:-8000}:8000"
healthcheck: healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/ready', timeout=3).read()\""] test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/ready', timeout=3).read()\""]
interval: 10s interval: 10s

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

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

View File

@@ -13,4 +13,10 @@ echo
echo "Checking metrics..." echo "Checking metrics..."
curl -fsS "$BASE_URL/metrics" | grep -q "carpass_requests_total" curl -fsS "$BASE_URL/metrics" | grep -q "carpass_requests_total"
for path in / /sto.html /admin.html /work_order.html; do
echo "Checking static page $path..."
curl -fsSI "$BASE_URL$path" | grep -q "200 OK"
done
echo "Smoke test passed." echo "Smoke test passed."

View File

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

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

@@ -243,6 +243,9 @@ async def test_work_order_completion_creates_vehicle_records_and_updates_costs(
assert refreshed.json()["engine_oil_type"] == "5W-30" assert refreshed.json()["engine_oil_type"] == "5W-30"
assert refreshed.json()["engine_oil_volume_l"] == "4.00" assert refreshed.json()["engine_oil_volume_l"] == "4.00"
assert stats.json()["total_cost"] == "130.00" assert stats.json()["total_cost"] == "130.00"
admin_notifications = await client.get("/api/admin/notifications?limit=100", headers=admin_auth_headers)
admin_events = {item["event_type"] for item in admin_notifications.json()["rows"]}
assert {"work_order_completed", "work_order_correction_requested", "work_order_correction_resolved"} <= admin_events
cannot_edit = await client.patch( cannot_edit = await client.patch(
f"/api/work-orders/{work_order['id']}", f"/api/work-orders/{work_order['id']}",

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.status_code == 201
assert work_order.json()["vehicle_id"] == vehicle["id"] assert work_order.json()["vehicle_id"] == vehicle["id"]
admin_notifications = await client.get("/api/admin/notifications?limit=100", headers=admin_auth_headers)
admin_events = {item["event_type"] for item in admin_notifications.json()["rows"]}
assert {"appointment_created", "work_order_created"} <= admin_events
my_appointments = await client.get("/api/appointments/my", headers=other_auth_headers) my_appointments = await client.get("/api/appointments/my", headers=other_auth_headers)
assert my_appointments.json()[0]["linked_work_order_id"] == work_order.json()["id"] assert my_appointments.json()[0]["linked_work_order_id"] == work_order.json()["id"]

245
web/admin.html Normal file
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>
</div> </div>
<form id="filterForm" class="grid-form drawer-form compact-form"> <form id="filterForm" class="grid-form drawer-form compact-form">
<details class="advanced-fields compact-filter">
<summary>Фильтры</summary>
<label> <label>
Город Город
<input name="city" placeholder="Seoul" /> <input name="city" placeholder="Seoul" />
@@ -62,6 +64,7 @@
<input name="specialization" placeholder="BMW, масло, тормоза" /> <input name="specialization" placeholder="BMW, масло, тормоза" />
</label> </label>
<button type="submit">Найти</button> <button type="submit">Найти</button>
</details>
</form> </form>
<div id="serviceList" class="stack-list"></div> <div id="serviceList" class="stack-list"></div>
</aside> </aside>
@@ -89,6 +92,12 @@
<option value="other">Другое</option> <option value="other">Другое</option>
</select> </select>
</label> </label>
<div class="preset-row quick-service-pills wide" aria-label="Быстрый выбор услуги">
<button type="button" data-booking-service="oil_change">Масло</button>
<button type="button" data-booking-service="diagnostics">Диагностика</button>
<button type="button" data-booking-service="tire_service">Шины</button>
<button type="button" data-booking-service="brakes">Тормоза</button>
</div>
<label> <label>
Длительность Длительность
<select name="estimated_duration_minutes" id="durationSelect"> <select name="estimated_duration_minutes" id="durationSelect">

View File

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

479
web/static/admin.js Normal file
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 textNodes = new WeakMap();
const attrOriginals = new WeakMap(); const attrOriginals = new WeakMap();
let translationObserver = null;
let translationTimer = null;
const i18n = { const i18n = {
en: { en: {
@@ -83,6 +85,14 @@ const i18n = {
"Марка": "Make", "Марка": "Make",
"Модель": "Model", "Модель": "Model",
"Добавить авто": "Add vehicle", "Добавить авто": "Add vehicle",
"Добавить запись": "Add entry",
"Расход": "Expense",
"дата, пробег, литры, цена": "date, odometer, liters, price",
"работа, стоимость, следующий срок": "work, cost, next due",
"страховка, штраф, парковка, прочее": "insurance, fine, parking, other",
"фото или файл": "photo or file",
"Дополнительно": "More options",
"Напоминание о следующем ТО": "Next maintenance reminder",
"За весь срок": "All time", "За весь срок": "All time",
"За месяц": "This month", "За месяц": "This month",
"За день": "Per day", "За день": "Per day",
@@ -215,6 +225,14 @@ const i18n = {
"Марка": "브랜드", "Марка": "브랜드",
"Модель": "모델", "Модель": "모델",
"Добавить авто": "차량 추가", "Добавить авто": "차량 추가",
"Добавить запись": "기록 추가",
"Расход": "지출",
"дата, пробег, литры, цена": "날짜, 주행거리, 리터, 가격",
"работа, стоимость, следующий срок": "작업, 비용, 다음 예정",
"страховка, штраф, парковка, прочее": "보험, 벌금, 주차, 기타",
"фото или файл": "사진 또는 파일",
"Дополнительно": "추가 옵션",
"Напоминание о следующем ТО": "다음 정비 알림",
"За весь срок": "전체", "За весь срок": "전체",
"За месяц": "월", "За месяц": "월",
"За день": "일 평균", "За день": "일 평균",
@@ -304,6 +322,15 @@ function applyTranslations(root = document.body) {
}); });
} }
function observeTranslations(root = document.body) {
if (translationObserver || !root) return;
translationObserver = new MutationObserver(() => {
window.clearTimeout(translationTimer);
translationTimer = window.setTimeout(() => applyTranslations(root), 40);
});
translationObserver.observe(root, { childList: true, subtree: true });
}
const state = { const state = {
user: null, user: null,
@@ -313,6 +340,9 @@ const state = {
selectedCarId: null, selectedCarId: null,
latestFuel: [], latestFuel: [],
latestService: [], latestService: [],
latestFuelAllTime: [],
latestServiceAllTime: [],
selectedCarHighlights: null,
latestExpenses: [], latestExpenses: [],
latestStats: null, latestStats: null,
allStats: null, allStats: null,
@@ -329,7 +359,6 @@ const state = {
connectedServices: [], connectedServices: [],
adminPendingServices: [], adminPendingServices: [],
vehicleScore: null, vehicleScore: null,
vehicleTimeline: [],
achievements: [], achievements: [],
receiptFile: null, receiptFile: null,
serviceWorkerRegistration: null, serviceWorkerRegistration: null,
@@ -580,6 +609,7 @@ async function ensureUser() {
method: "POST", method: "POST",
body: JSON.stringify({ init_data: tg.initData }), body: JSON.stringify({ init_data: tg.initData }),
}); });
localStorage.setItem("carpassLocale", state.user.locale || "ru");
hideAuthOverlay(); hideAuthOverlay();
updateRoleVisibility(); updateRoleVisibility();
return; return;
@@ -588,6 +618,7 @@ async function ensureUser() {
const devId = localStorage.getItem("driversDevTelegramId") || "1"; const devId = localStorage.getItem("driversDevTelegramId") || "1";
localStorage.setItem("driversDevTelegramId", devId); localStorage.setItem("driversDevTelegramId", devId);
state.user = await api("/users/me"); state.user = await api("/users/me");
localStorage.setItem("carpassLocale", state.user.locale || "ru");
hideAuthOverlay(); hideAuthOverlay();
updateRoleVisibility(); updateRoleVisibility();
return; return;
@@ -596,6 +627,36 @@ async function ensureUser() {
throw new Error("Требуется вход через Telegram"); throw new Error("Требуется вход через Telegram");
} }
function installLocaleSwitch() {
const topActions = document.querySelector(".topbar .top-actions");
if (!topActions || document.querySelector("#globalLocaleSelect")) return;
const select = document.createElement("select");
select.id = "globalLocaleSelect";
select.className = "locale-switch";
select.setAttribute("aria-label", "Язык");
select.innerHTML = `
<option value="ru">RU</option>
<option value="en">EN</option>
<option value="ko">KO</option>
`;
select.value = state.user?.locale || "ru";
select.addEventListener("change", async () => {
await runAction(select, "Сохраняю...", async () => {
state.user = await api(`/users/${state.user.id}/preferences`, {
method: "PATCH",
body: JSON.stringify({ locale: select.value, currency: state.user.currency }),
});
localStorage.setItem("carpassLocale", state.user.locale || "ru");
document.querySelector("#localeSelect").value = state.user.locale || "ru";
applyTranslations();
renderCars();
renderStats(state.latestStats);
toast("Сохранено");
});
});
topActions.prepend(select);
}
function hideAuthOverlay() { function hideAuthOverlay() {
document.querySelector("#authOverlay")?.classList.add("hidden"); document.querySelector("#authOverlay")?.classList.add("hidden");
document.body.classList.remove("auth-required"); document.body.classList.remove("auth-required");
@@ -901,10 +962,11 @@ function renderCars() {
.map( .map(
(car) => ` (car) => `
<button class="car-item ${car.id === state.selectedCarId ? "active" : ""}" data-car="${car.id}"> <button class="car-item ${car.id === state.selectedCarId ? "active" : ""}" data-car="${car.id}">
<span class="car-badge">${(car.make || car.name).slice(0, 2).toUpperCase()}</span> <span class="car-badge">${escapeHtml((car.make || car.name).slice(0, 2).toUpperCase())}</span>
<span class="car-copy"> <span class="car-copy">
<strong>${car.name}</strong> <strong>${escapeHtml(car.name)}</strong>
<small>${[car.make, car.model, car.trim, car.year, car.fuel_type].filter(Boolean).join(" ") || t("Без деталей")}</small> <small>${escapeHtml([car.make, car.model, car.trim, car.year, car.fuel_type].filter(Boolean).join(" ") || t("Без деталей"))}</small>
${car.id === state.selectedCarId ? renderSelectedCarHighlights(car) : ""}
</span> </span>
</button> </button>
`, `,
@@ -917,6 +979,84 @@ function renderCars() {
}); });
} }
function renderSelectedCarHighlights(car) {
const highlights = state.selectedCarHighlights?.carId === car.id ? state.selectedCarHighlights : buildCarHighlights(car, [], []);
const rows = [
["Одометр", highlights.odometer],
["Заправка", highlights.lastFuel],
["Масло", highlights.lastOil],
["До масла", highlights.oilRemaining],
];
return `
<span class="car-key-params">
${rows
.map(
([label, value]) => `
<span class="car-key-param">
<span>${label}</span>
<strong>${escapeHtml(value)}</strong>
</span>
`,
)
.join("")}
</span>
`;
}
function buildCarHighlights(car, fuelEntries, serviceEntries) {
const currentOdometer = Number(car?.current_odometer || 0);
const lastFuel = fuelEntries[0] || null;
const lastOil = serviceEntries.find(isOilService) || null;
const oilDueOdometer = oilDueKm(car, lastOil);
const oilRemainingKm = oilDueOdometer != null && car?.current_odometer != null
? oilDueOdometer - currentOdometer
: null;
return {
carId: car?.id,
odometer: car?.current_odometer != null ? `${formatKm(car.current_odometer)}` : "-",
lastFuel: lastFuel ? `${formatShortDate(lastFuel.entry_date)} · ${formatLiters(lastFuel.liters)}` : "-",
lastOil: lastOil ? formatShortDate(lastOil.entry_date) : "-",
oilRemaining: formatOilRemaining(oilRemainingKm),
};
}
function isOilService(item) {
const text = [item?.title, item?.category, item?.notes, item?.service_type].filter(Boolean).join(" ").toLowerCase();
return /масл|oil|engine_oil/.test(text);
}
function oilDueKm(car, latestOil) {
if (latestOil?.next_due_odometer != null) return Number(latestOil.next_due_odometer);
const interval = Number(car?.oil_change_interval_km || 0);
if (!interval) return null;
if (latestOil?.odometer != null) return Number(latestOil.odometer) + interval;
if (car?.current_odometer != null) return Number(car.current_odometer) + interval;
return null;
}
function formatKm(value) {
if (value == null || value === "") return "-";
return `${Number(value).toLocaleString("ru-RU")} км`;
}
function formatLiters(value) {
if (value == null || value === "") return "-";
return `${Number(value).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} л`;
}
function formatShortDate(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value).slice(0, 10);
return date.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "2-digit" });
}
function formatOilRemaining(value) {
if (value == null || Number.isNaN(value)) return "-";
if (value < 0) return `просрочено ${formatKm(Math.abs(value))}`;
return formatKm(value);
}
function setInputValue(form, name, value) { function setInputValue(form, name, value) {
if (!form?.elements[name]) return; if (!form?.elements[name]) return;
const input = form.elements[name]; const input = form.elements[name];
@@ -1902,11 +2042,60 @@ function updateScore() {
ring.style.background = `conic-gradient(#5ee0bd ${score * 3.6}deg, rgba(255,255,255,0.12) 0deg)`; ring.style.background = `conic-gradient(#5ee0bd ${score * 3.6}deg, rgba(255,255,255,0.12) 0deg)`;
} }
document.querySelector("#scoreHint").textContent = car document.querySelector("#scoreHint").textContent = car
? "Качество паспорта растет от подтвержденных данных, сервисной истории и точного пробега." ? "Короткая сводка по полноте данных и готовности к обслуживанию."
: t("Добавь авто и первую запись, чтобы видеть точные отчеты"); : t("Добавь авто и первую запись, чтобы видеть точные отчеты");
renderPassportVehicleSummary(car);
renderScoreActions(state.vehicleScore?.missing_items || []); renderScoreActions(state.vehicleScore?.missing_items || []);
renderAchievements(); }
renderVehicleTimeline();
function renderPassportVehicleSummary(car) {
const root = document.querySelector("#passportVehicleSummary");
if (!root) return;
if (!car) {
root.innerHTML = `
<div class="passport-vehicle-empty">
<strong>Выберите автомобиль</strong>
<span>Здесь появятся номер, VIN, пробег, заправка и ближайшее ТО.</span>
</div>
`;
return;
}
const highlights = state.selectedCarHighlights?.carId === car.id ? state.selectedCarHighlights : buildCarHighlights(car, [], []);
const identity = [car.make, car.model, car.trim, car.year].filter(Boolean).join(" ") || "Паспорт без деталей";
const plate = car.license_plate_display || car.plate_number || "номер не указан";
const vin = car.vin || "VIN не указан";
const oilSpec = [car.engine_oil_type, car.engine_oil_volume_l ? `${Number(car.engine_oil_volume_l).toLocaleString("ru-RU")} л` : ""]
.filter(Boolean)
.join(" · ");
const fuelAndOil = [car.fuel_type || "топливо не указано", oilSpec || "масло не указано"].join(" · ");
const rows = [
["Одометр", highlights.odometer],
["Последняя заправка", highlights.lastFuel],
["Замена масла", highlights.lastOil],
["До следующей", highlights.oilRemaining],
];
root.innerHTML = `
<div class="passport-vehicle-main">
<div>
<strong>${escapeHtml(car.name)}</strong>
<span>${escapeHtml(identity)}</span>
</div>
<a class="passport-edit-link" href="/car_profile.html?car_id=${car.id}">Паспорт</a>
</div>
<div class="passport-vehicle-id">
<span>${escapeHtml(plate)}</span>
<span>${escapeHtml(vin)}</span>
<span>${escapeHtml(fuelAndOil)}</span>
</div>
<div class="passport-vehicle-facts">
${rows.map(([label, value]) => `
<div>
<span>${label}</span>
<strong>${escapeHtml(value)}</strong>
</div>
`).join("")}
</div>
`;
} }
function scoreLabel(quality, score) { function scoreLabel(quality, score) {
@@ -1938,9 +2127,9 @@ function healthLabel(status) {
function renderScoreActions(items) { function renderScoreActions(items) {
const root = document.querySelector("#scoreActions"); const root = document.querySelector("#scoreActions");
if (!root) return; if (!root) return;
const visible = items.slice(0, 3); const visible = items.slice(0, 1);
if (!visible.length) { if (!visible.length) {
root.innerHTML = `<div class="passport-note">Паспорт выглядит надежно. Следующий рост даст подтвержденная сервисная история.</div>`; root.innerHTML = `<div class="passport-note">Паспорт готов: ключевые данные, история и ТО собраны достаточно для пилота.</div>`;
return; return;
} }
root.innerHTML = visible root.innerHTML = visible
@@ -2339,6 +2528,7 @@ async function applyInitialRoute() {
async function selectCar(carId) { async function selectCar(carId) {
state.selectedCarId = carId; state.selectedCarId = carId;
state.selectedCarHighlights = null;
renderCars(); renderCars();
fillCarProfileForm(); fillCarProfileForm();
await loadSelectedCar(); await loadSelectedCar();
@@ -2348,17 +2538,19 @@ async function loadSelectedCar() {
if (!state.selectedCarId) { if (!state.selectedCarId) {
state.latestFuel = []; state.latestFuel = [];
state.latestService = []; state.latestService = [];
state.latestFuelAllTime = [];
state.latestServiceAllTime = [];
state.selectedCarHighlights = null;
state.latestExpenses = []; state.latestExpenses = [];
state.latestStats = null; state.latestStats = null;
state.allStats = null; state.allStats = null;
state.analytics = null; state.analytics = null;
state.vehicleScore = null; state.vehicleScore = null;
state.vehicleTimeline = [];
state.achievements = []; state.achievements = [];
renderStats(null); renderStats(null);
return; return;
} }
const [stats, allStats, fuel, service, expenses, analytics, vehicleScore] = await Promise.all([ const [stats, allStats, fuel, service, expenses, analytics, vehicleScore, allFuel, allService] = await Promise.all([
api(`/cars/${state.selectedCarId}/stats${periodQuery()}`), api(`/cars/${state.selectedCarId}/stats${periodQuery()}`),
api(`/cars/${state.selectedCarId}/stats${allPeriodQuery()}`), api(`/cars/${state.selectedCarId}/stats${allPeriodQuery()}`),
api(`/cars/${state.selectedCarId}/fuel${periodQuery()}`), api(`/cars/${state.selectedCarId}/fuel${periodQuery()}`),
@@ -2366,20 +2558,20 @@ async function loadSelectedCar() {
api(`/cars/${state.selectedCarId}/expenses${periodQuery()}`), api(`/cars/${state.selectedCarId}/expenses${periodQuery()}`),
api(`/cars/${state.selectedCarId}/analytics`), api(`/cars/${state.selectedCarId}/analytics`),
api(`/my/vehicles/${state.selectedCarId}/score`), api(`/my/vehicles/${state.selectedCarId}/score`),
]); api(`/cars/${state.selectedCarId}/fuel?limit=1`),
const [timeline, achievements] = await Promise.all([ api(`/cars/${state.selectedCarId}/service?limit=100`),
api(`/my/vehicles/${state.selectedCarId}/timeline?limit=30`),
api("/me/achievements"),
]); ]);
state.latestStats = stats; state.latestStats = stats;
state.allStats = allStats; state.allStats = allStats;
state.latestFuel = fuel; state.latestFuel = fuel;
state.latestService = service; state.latestService = service;
state.latestFuelAllTime = allFuel;
state.latestServiceAllTime = allService;
state.latestExpenses = expenses; state.latestExpenses = expenses;
state.analytics = analytics; state.analytics = analytics;
state.vehicleScore = vehicleScore; state.vehicleScore = vehicleScore;
state.vehicleTimeline = timeline; state.selectedCarHighlights = buildCarHighlights(selectedCar(), allFuel, allService);
state.achievements = achievements; renderCars();
renderStats(stats); renderStats(stats);
drawCharts(fuel, service, stats); drawCharts(fuel, service, stats);
} }
@@ -2538,6 +2730,9 @@ document.querySelector("#settingsForm").addEventListener("submit", async (event)
method: "PATCH", method: "PATCH",
body: JSON.stringify({ locale: data.locale, currency: data.currency }), body: JSON.stringify({ locale: data.locale, currency: data.currency }),
}); });
localStorage.setItem("carpassLocale", state.user.locale || "ru");
const globalLocale = document.querySelector("#globalLocaleSelect");
if (globalLocale) globalLocale.value = state.user.locale || "ru";
applyTranslations(); applyTranslations();
initCarCatalog(); initCarCatalog();
await loadSelectedCar(); await loadSelectedCar();
@@ -2567,6 +2762,7 @@ document.querySelector("#fuelForm").addEventListener("submit", async (event) =>
}); });
form.reset(); form.reset();
form.entry_date.value = today(); form.entry_date.value = today();
form.is_full_tank.checked = true;
await loadSelectedCar(); await loadSelectedCar();
toast("Сохранено"); toast("Сохранено");
haptic("success"); haptic("success");
@@ -2669,6 +2865,20 @@ function mountEntryForms() {
} }
} }
function fillEntryDefaults(sectionId) {
const car = selectedCar();
const odometer = car?.current_odometer || "";
const sections = sectionId ? [document.querySelector(`#${sectionId}`)] : [...document.querySelectorAll(".drawer-section")];
sections.filter(Boolean).forEach((section) => {
section.querySelectorAll('input[name="entry_date"]').forEach((input) => {
if (!input.value) input.value = today();
});
section.querySelectorAll('input[name="odometer"]').forEach((input) => {
if (!input.value && odometer) input.value = odometer;
});
});
}
async function openDrawerSection(sectionId, options = {}) { async function openDrawerSection(sectionId, options = {}) {
if (!canOpenDrawerSection(sectionId)) { if (!canOpenDrawerSection(sectionId)) {
toast("Этот раздел недоступен для вашей роли", "error"); toast("Этот раздел недоступен для вашей роли", "error");
@@ -2684,6 +2894,7 @@ async function openDrawerSection(sectionId, options = {}) {
button.classList.toggle("active", button.dataset.menuSection === sectionId); button.classList.toggle("active", button.dataset.menuSection === sectionId);
}); });
mountEntryForms(); mountEntryForms();
fillEntryDefaults(sectionId);
if (sectionId === "carProfileSection") fillCarProfileForm(); if (sectionId === "carProfileSection") fillCarProfileForm();
if (sectionId === "settingsSection") { if (sectionId === "settingsSection") {
document.querySelector("#localeSelect").value = state.user?.locale || "ru"; document.querySelector("#localeSelect").value = state.user?.locale || "ru";
@@ -2758,7 +2969,18 @@ document.querySelector("#addCarQuickBtn").addEventListener("click", () => {
}); });
document.querySelector("#addRecordPrimaryBtn").addEventListener("click", () => { document.querySelector("#addRecordPrimaryBtn").addEventListener("click", () => {
openDrawerSection("expensesSection"); openDrawerSection("quickAddSection");
});
document.querySelectorAll("[data-quick-entry]").forEach((button) => {
button.addEventListener("click", async () => {
haptic();
if (button.dataset.quickEntry === "scan") {
openScanModal();
return;
}
await openDrawerSection(button.dataset.quickEntry);
});
}); });
document.querySelectorAll("[data-menu-section]").forEach((button) => { document.querySelectorAll("[data-menu-section]").forEach((button) => {
@@ -2910,6 +3132,8 @@ initPwa();
Promise.all([loadAuthConfig()]) Promise.all([loadAuthConfig()])
.then(() => Promise.all([ensureUser(), loadCatalog()])) .then(() => Promise.all([ensureUser(), loadCatalog()]))
.then(() => { .then(() => {
installLocaleSwitch();
observeTranslations();
document.querySelector("#localeSelect").value = state.user?.locale || "ru"; document.querySelector("#localeSelect").value = state.user?.locale || "ru";
document.querySelector("#currencySelect").value = state.user?.currency || "RUB"; document.querySelector("#currencySelect").value = state.user?.currency || "RUB";
document.querySelector("#expenseForm").currency.value = state.user?.currency || "RUB"; document.querySelector("#expenseForm").currency.value = state.user?.currency || "RUB";

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) => { document.querySelector("#bookingForm").addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
const center = selectedCenter(); const center = selectedCenter();

View File

@@ -2,6 +2,238 @@ const tg = window.Telegram?.WebApp;
tg?.ready(); tg?.ready();
tg?.expand(); tg?.expand();
const CarPassI18n = (() => {
const textNodes = new WeakMap();
const attrOriginals = new WeakMap();
let observer = null;
let timer = null;
const dictionaries = {
en: {
"Гараж": "Garage",
"Автомобили": "Vehicles",
"Автомобиль": "Vehicle",
"Авто": "Vehicles",
"Заправка": "Fuel",
"Сервис": "Service",
"Расход": "Expense",
"Расходы": "Expenses",
"ТО и ремонт": "Maintenance and repair",
"Дата": "Date",
"Одометр, км": "Odometer, km",
"Одометр": "Odometer",
"Литры": "Liters",
"Цена за литр": "Price per liter",
"АЗС": "Fuel station",
"Полный бак": "Full tank",
"Стоимость": "Cost",
"Валюта": "Currency",
"Категория": "Category",
"Название": "Title",
"Комментарий": "Comment",
"Сохранить": "Save",
"Сохранить запись": "Save entry",
"Сохранить расход": "Save expense",
"Сохранить заправку": "Save fuel entry",
"Сохранить настройки": "Save settings",
"Создать запись": "Create booking",
"Запись в СТО": "Book service",
"СТО": "Service centers",
"Сервисы": "Services",
"Каталог": "Catalog",
"Заявка": "Request",
"Выберите сервис": "Choose service",
"Город": "City",
"Специализация": "Specialization",
"Найти": "Search",
"Что нужно сделать": "What needs to be done",
"Услуга": "Service",
"Длительность": "Duration",
"Свободное окно": "Available slot",
"Окно записи": "Booking slot",
"Отправить заявку": "Send request",
"Проверить карточку авто": "Check vehicle card",
"Меню": "Menu",
"Проверить вход": "Check login",
"Открыть в Telegram": "Open in Telegram",
"Обновить": "Refresh",
"Настройки": "Settings",
"Язык": "Language",
"Панель СТО": "Service workplace",
"Записи клиентов": "Client bookings",
"Заказ-наряды": "Work orders",
"Сотрудники": "Staff",
"Пригласить": "Invite",
"Заказ-наряд": "Work order",
"Работы": "Labor",
"Запчасти и жидкости": "Parts and fluids",
"Проверьте смету": "Review estimate",
"Согласовать": "Approve",
"Отклонить": "Reject",
"Админ-панель": "Admin panel",
"Операционный обзор": "Operational overview",
"Последние события": "Latest events",
"Быстрые переходы": "Quick links",
"Заявки СТО": "Service applications",
"Записи": "Bookings",
"Фильтр": "Filter",
"Фильтры": "Filters",
"Запросить": "Query",
"Показать": "Show",
"Импорт и экспорт": "Import and export",
"Скачать JSON": "Download JSON",
"Проверить файл": "Preview file",
"Импортировать": "Import",
"Паспорт автомобиля": "Vehicle passport",
"Выберите автомобиль": "Choose vehicle",
"Параметры авто": "Vehicle settings",
"Сохранить паспорт": "Save passport",
"Удалить автомобиль": "Delete vehicle",
"Готов к работе": "Ready",
"Сохраняю...": "Saving...",
"Сохранено": "Saved",
"Ошибка": "Error",
"Нет данных": "No data",
"Нет доступа": "No access",
},
ko: {
"Гараж": "차고",
"Автомобили": "차량",
"Автомобиль": "차량",
"Авто": "차량",
"Заправка": "주유",
"Сервис": "정비",
"Расход": "지출",
"Расходы": "지출",
"ТО и ремонт": "정비 및 수리",
"Дата": "날짜",
"Одометр, км": "주행거리, km",
"Одометр": "주행거리",
"Литры": "리터",
"Цена за литр": "리터당 가격",
"АЗС": "주유소",
"Полный бак": "가득 주유",
"Стоимость": "비용",
"Валюта": "통화",
"Категория": "카테고리",
"Название": "제목",
"Комментарий": "메모",
"Сохранить": "저장",
"Сохранить запись": "기록 저장",
"Сохранить расход": "지출 저장",
"Сохранить заправку": "주유 저장",
"Сохранить настройки": "설정 저장",
"Создать запись": "예약 생성",
"Запись в СТО": "정비소 예약",
"СТО": "정비소",
"Сервисы": "서비스",
"Каталог": "목록",
"Заявка": "요청",
"Выберите сервис": "정비소 선택",
"Город": "도시",
"Специализация": "전문 분야",
"Найти": "검색",
"Что нужно сделать": "필요한 작업",
"Услуга": "서비스",
"Длительность": "소요 시간",
"Свободное окно": "예약 가능 시간",
"Окно записи": "예약 시간",
"Отправить заявку": "요청 보내기",
"Проверить карточку авто": "차량 카드 확인",
"Меню": "메뉴",
"Проверить вход": "로그인 확인",
"Открыть в Telegram": "텔레그램에서 열기",
"Обновить": "새로고침",
"Настройки": "설정",
"Язык": "언어",
"Панель СТО": "정비소 작업실",
"Записи клиентов": "고객 예약",
"Заказ-наряды": "작업지시서",
"Сотрудники": "직원",
"Пригласить": "초대",
"Заказ-наряд": "작업지시서",
"Работы": "공임",
"Запчасти и жидкости": "부품 및 오일",
"Проверьте смету": "견적 확인",
"Согласовать": "승인",
"Отклонить": "거절",
"Админ-панель": "관리자 패널",
"Операционный обзор": "운영 요약",
"Последние события": "최근 이벤트",
"Быстрые переходы": "빠른 이동",
"Заявки СТО": "정비소 신청",
"Записи": "예약",
"Фильтр": "필터",
"Фильтры": "필터",
"Запросить": "조회",
"Показать": "보기",
"Импорт и экспорт": "가져오기/내보내기",
"Скачать JSON": "JSON 다운로드",
"Проверить файл": "파일 확인",
"Импортировать": "가져오기",
"Паспорт автомобиля": "차량 패스포트",
"Выберите автомобиль": "차량 선택",
"Параметры авто": "차량 설정",
"Сохранить паспорт": "패스포트 저장",
"Удалить автомобиль": "차량 삭제",
"Готов к работе": "준비 완료",
"Сохраняю...": "저장 중...",
"Сохранено": "저장됨",
"Ошибка": "오류",
"Нет данных": "데이터 없음",
"Нет доступа": "접근 불가",
},
};
function t(text, locale = currentLocale()) {
return dictionaries[locale]?.[text] || text;
}
function currentLocale() {
return window.CarPassPage?.state?.user?.locale || localStorage.getItem("carpassLocale") || "ru";
}
function apply(root = document.body, locale = currentLocale()) {
document.documentElement.lang = locale;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const parent = node.parentElement;
if (!parent || ["SCRIPT", "STYLE", "TEXTAREA", "INPUT", "SELECT"].includes(parent.tagName)) return NodeFilter.FILTER_REJECT;
return node.nodeValue.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
},
});
while (walker.nextNode()) {
const node = walker.currentNode;
if (!textNodes.has(node)) textNodes.set(node, node.nodeValue.trim());
const original = textNodes.get(node);
node.nodeValue = node.nodeValue.replace(node.nodeValue.trim(), t(original, locale));
}
root.querySelectorAll?.("[placeholder], [aria-label], [title]").forEach((element) => {
["placeholder", "aria-label", "title"].forEach((attr) => {
const value = element.getAttribute(attr);
if (!value) return;
let originals = attrOriginals.get(element);
if (!originals) {
originals = {};
attrOriginals.set(element, originals);
}
originals[attr] ||= value;
element.setAttribute(attr, t(originals[attr], locale));
});
});
}
function observe(root = document.body) {
if (observer || !root) return;
observer = new MutationObserver(() => {
window.clearTimeout(timer);
timer = window.setTimeout(() => apply(root), 40);
});
observer.observe(root, { childList: true, subtree: true });
}
return { apply, t, currentLocale, observe };
})();
const CarPassPage = (() => { const CarPassPage = (() => {
const state = { user: null, authConfig: null }; const state = { user: null, authConfig: null };
@@ -51,13 +283,14 @@ const CarPassPage = (() => {
} }
document.body.classList.remove("auth-required"); document.body.classList.remove("auth-required");
document.querySelector("#authOverlay")?.classList.add("hidden"); document.querySelector("#authOverlay")?.classList.add("hidden");
localStorage.setItem("carpassLocale", state.user.locale || "ru");
return state.user; return state.user;
} }
function toast(message, tone = "success") { function toast(message, tone = "success") {
const node = document.querySelector("#toast"); const node = document.querySelector("#toast");
if (!node) return; if (!node) return;
node.textContent = message; node.textContent = t(message);
node.className = `toast ${tone}`; node.className = `toast ${tone}`;
window.clearTimeout(toast.timer); window.clearTimeout(toast.timer);
toast.timer = window.setTimeout(() => node.classList.add("hidden"), 2600); toast.timer = window.setTimeout(() => node.classList.add("hidden"), 2600);
@@ -69,7 +302,7 @@ const CarPassPage = (() => {
button.dataset.label = button.textContent; button.dataset.label = button.textContent;
button.disabled = true; button.disabled = true;
button.classList.add("is-busy"); button.classList.add("is-busy");
button.innerHTML = `<span class="spinner"></span><span>${label}</span>`; button.innerHTML = `<span class="spinner"></span><span>${t(label)}</span>`;
} else { } else {
button.disabled = false; button.disabled = false;
button.classList.remove("is-busy"); button.classList.remove("is-busy");
@@ -115,18 +348,73 @@ const CarPassPage = (() => {
if (!value) return "-"; if (!value) return "-";
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value).slice(0, 16).replace("T", " "); if (Number.isNaN(date.getTime())) return String(value).slice(0, 16).replace("T", " ");
return date.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }); const locale = { ru: "ru-RU", en: "en-US", ko: "ko-KR" }[state.user?.locale] || "ru-RU";
return date.toLocaleString(locale, { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
} }
function today() { function today() {
return new Date().toISOString().slice(0, 10); return new Date().toISOString().slice(0, 10);
} }
function t(text) {
return CarPassI18n.t(text, state.user?.locale || "ru");
}
function applyTranslations(root = document.body) {
CarPassI18n.apply(root, state.user?.locale || "ru");
}
async function updateLocale(locale) {
if (!state.user || state.user.locale === locale) return;
state.user = await api(`/users/${state.user.id}/preferences`, {
method: "PATCH",
body: JSON.stringify({ locale }),
});
localStorage.setItem("carpassLocale", locale);
applyTranslations();
}
function installLocaleSwitch() {
const topbar = document.querySelector(".topbar");
if (!topbar || document.querySelector("#globalLocaleSelect")) return;
let host = topbar.querySelector(".top-actions");
if (!host) {
host = document.createElement("div");
host.className = "top-actions";
topbar.appendChild(host);
}
const select = document.createElement("select");
select.id = "globalLocaleSelect";
select.className = "locale-switch";
select.setAttribute("aria-label", "Язык");
select.innerHTML = `
<option value="ru">RU</option>
<option value="en">EN</option>
<option value="ko">KO</option>
`;
select.value = state.user?.locale || "ru";
select.addEventListener("change", async () => {
try {
await updateLocale(select.value);
toast("Сохранено");
} catch (error) {
toast(error.message || "Ошибка", "error");
}
});
const primarySelect = host.querySelector("select:not(.locale-switch)");
if (primarySelect) primarySelect.insertAdjacentElement("afterend", select);
else host.prepend(select);
}
async function boot(init) { async function boot(init) {
try { try {
await loadAuthConfig(); await loadAuthConfig();
await ensureUser(); await ensureUser();
installLocaleSwitch();
applyTranslations();
CarPassI18n.observe();
await init(); await init();
applyTranslations();
} catch (error) { } catch (error) {
if (error.message === "Требуется вход через Telegram") return; if (error.message === "Требуется вход через Telegram") return;
console.error(error); console.error(error);
@@ -148,5 +436,9 @@ const CarPassPage = (() => {
csvList, csvList,
formatDateTime, formatDateTime,
today, today,
t,
applyTranslations,
installLocaleSwitch,
updateLocale,
}; };
})(); })();

View File

@@ -70,8 +70,13 @@ body.auth-required .shell {
} }
.passport-head small, .passport-head small,
.passport-metric small,
.passport-metric span, .passport-metric span,
.passport-action span, .passport-action span,
.passport-vehicle-empty span,
.passport-vehicle-main span,
.passport-vehicle-id,
.passport-vehicle-facts span,
.achievement-card span, .achievement-card span,
.timeline-item small, .timeline-item small,
.timeline-empty, .timeline-empty,
@@ -134,6 +139,103 @@ body.auth-required .shell {
font-size: 14px; font-size: 14px;
} }
.passport-metric-combo small {
display: block;
margin-top: 2px;
}
.passport-vehicle-summary {
display: grid;
gap: 9px;
padding: 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.085), rgba(255, 255, 255, 0.035));
}
.passport-vehicle-empty,
.passport-vehicle-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.passport-vehicle-empty {
align-items: start;
flex-direction: column;
gap: 3px;
}
.passport-vehicle-main strong,
.passport-vehicle-empty strong {
display: block;
color: #fff;
font-size: 16px;
}
.passport-vehicle-main span {
display: block;
margin-top: 2px;
font-size: 12px;
}
.passport-edit-link {
flex: 0 0 auto;
padding: 7px 10px;
border: 1px solid rgba(94, 224, 189, 0.3);
border-radius: 8px;
color: #b9f7e7;
font-size: 12px;
font-weight: 700;
text-decoration: none;
background: rgba(94, 224, 189, 0.08);
}
.passport-vehicle-id {
display: flex;
flex-wrap: wrap;
gap: 6px;
font-size: 12px;
}
.passport-vehicle-id span {
min-width: 0;
max-width: 100%;
padding: 4px 7px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.06);
overflow-wrap: anywhere;
}
.passport-vehicle-facts {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 7px;
}
.passport-vehicle-facts div {
display: grid;
gap: 3px;
min-width: 0;
padding: 8px;
border-radius: 8px;
background: rgba(12, 20, 22, 0.34);
}
.passport-vehicle-facts span {
font-size: 11px;
line-height: 1.2;
}
.passport-vehicle-facts strong {
color: #fff;
font-size: 13px;
line-height: 1.25;
overflow-wrap: anywhere;
}
.passport-grid, .passport-grid,
.passport-actions, .passport-actions,
.achievement-strip { .achievement-strip {
@@ -816,6 +918,36 @@ select:disabled {
color: var(--muted); color: var(--muted);
} }
.car-key-params {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
margin-top: 9px;
}
.car-key-param {
display: grid;
gap: 2px;
min-width: 0;
padding: 7px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(255, 255, 255, 0.78);
}
.car-key-param span {
color: var(--muted);
font-size: 11px;
line-height: 1.2;
}
.car-key-param strong {
color: var(--text);
font-size: 12px;
line-height: 1.25;
overflow-wrap: anywhere;
}
.car-item.active { .car-item.active {
border-color: rgba(22, 128, 106, 0.48); border-color: rgba(22, 128, 106, 0.48);
background: #e5f4ef; background: #e5f4ef;
@@ -1937,6 +2069,227 @@ select {
font-size: 12px; font-size: 12px;
} }
.admin-page {
background:
linear-gradient(180deg, #ffffff 0, #edf5f2 220px),
var(--bg);
}
.admin-shell {
width: min(1320px, 100%);
}
.admin-hero {
margin-bottom: 10px;
}
.admin-tabs {
position: sticky;
top: 0;
z-index: 8;
display: flex;
gap: 8px;
padding: 8px 0 12px;
margin-bottom: 6px;
overflow-x: auto;
background: rgba(238, 243, 241, 0.92);
backdrop-filter: blur(10px);
}
.admin-tabs button,
.admin-link-grid button {
flex: 0 0 auto;
min-height: 38px;
padding: 0 12px;
border: 1px solid var(--line);
background: #fff;
color: var(--text);
box-shadow: none;
}
.admin-tabs button.active {
border-color: rgba(22, 128, 106, 0.48);
background: #dff1eb;
color: #0e5d4b;
}
.admin-panel {
margin-bottom: 16px;
}
.admin-stats {
grid-template-columns: repeat(6, minmax(130px, 1fr));
}
.admin-grid {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(260px, 0.75fr);
gap: 14px;
}
.admin-grid h3 {
margin: 0 0 8px;
font-size: 15px;
}
.admin-link-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.admin-filter {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 8px;
align-items: end;
margin-bottom: 12px;
}
.admin-filter button {
min-width: 112px;
}
.admin-table-wrap {
width: 100%;
overflow: auto;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
box-shadow: 0 10px 30px rgba(17, 36, 30, 0.05);
}
.admin-table {
width: 100%;
min-width: 720px;
border-collapse: collapse;
font-size: 13px;
}
.admin-table th,
.admin-table td {
padding: 9px 10px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
overflow-wrap: anywhere;
}
.admin-table th {
position: sticky;
top: 0;
z-index: 1;
background: #f5faf7;
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.admin-table tr:last-child td {
border-bottom: 0;
}
.admin-table tbody tr {
transition: background 140ms ease;
}
.admin-table tbody tr:hover {
background: #f7fbf8;
}
.admin-actions-head,
.admin-action-cell {
position: sticky;
right: 0;
min-width: 128px;
background: #fff;
box-shadow: -10px 0 18px rgba(255, 255, 255, 0.78);
}
.admin-table tbody tr:hover .admin-action-cell {
background: #f7fbf8;
}
.admin-action-cell {
display: flex;
gap: 6px;
align-items: center;
border-left: 1px solid var(--line);
}
.compact-btn {
min-height: 30px;
padding: 0 8px;
font-size: 12px;
box-shadow: none;
}
.admin-badge {
display: inline-flex;
min-height: 22px;
align-items: center;
padding: 2px 7px;
border: 1px solid rgba(22, 128, 106, 0.18);
border-radius: 8px;
background: #eef7f3;
color: #0e5d4b;
font-size: 12px;
font-weight: 700;
}
.admin-data-form {
align-items: end;
margin-bottom: 12px;
}
.admin-check {
min-height: 42px;
align-content: center;
}
.admin-form-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.admin-source-hint {
margin: -2px 0 10px;
padding: 10px 12px;
border: 1px solid rgba(22, 128, 106, 0.16);
border-radius: 8px;
background: #f2faf6;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.error-state {
color: var(--danger);
background: #fff4f2;
}
@media (max-width: 980px) {
.admin-stats {
display: flex;
overflow-x: auto;
padding-bottom: 2px;
}
.admin-stats .stat {
min-width: 150px;
}
.admin-grid,
.admin-link-grid {
grid-template-columns: 1fr;
}
.admin-tabs {
top: 0;
}
}
.work-order-total strong { .work-order-total strong {
color: #fff; color: #fff;
font-size: clamp(24px, 4vw, 34px); font-size: clamp(24px, 4vw, 34px);
@@ -2159,11 +2512,25 @@ select {
white-space: nowrap; white-space: nowrap;
} }
.car-key-param strong {
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.passport-head { .passport-head {
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
} }
.passport-vehicle-main {
align-items: start;
}
.passport-vehicle-facts {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.score-ring { .score-ring {
width: 72px; width: 72px;
height: 72px; height: 72px;
@@ -2212,7 +2579,7 @@ select {
.sto-page .top-actions { .sto-page .top-actions {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 44px; grid-template-columns: minmax(0, 1fr) 58px 38px;
} }
.staff-form button { .staff-form button {
@@ -2230,3 +2597,265 @@ select {
padding: 20px; padding: 20px;
} }
} }
/* Compact UX pass */
.locale-switch {
width: 66px;
min-height: 34px;
padding: 0 8px;
border-radius: 8px;
background: #fff;
color: var(--text);
font-size: 12px;
font-weight: 850;
}
.shell {
padding-top: 10px;
}
.topbar {
padding-block: 9px;
}
.topbar h1 {
font-size: clamp(23px, 4vw, 34px);
}
.eyebrow {
font-size: 11px;
letter-spacing: 0.03em;
}
button,
.ghost-btn {
min-height: 38px;
padding-inline: 12px;
}
.icon-btn {
width: 38px;
min-height: 38px;
}
input,
select,
textarea {
min-height: 38px;
padding-inline: 10px;
}
label {
gap: 4px;
font-size: 12px;
}
.summary-card {
min-height: 92px;
padding: 13px;
}
.summary-card strong {
font-size: clamp(19px, 3.2vw, 27px);
}
.primary-add-btn {
min-height: 46px;
margin-bottom: 12px;
}
.panel,
.workspace,
.chart-card {
padding: 13px;
}
.passport-panel {
gap: 8px;
padding: 11px;
}
.passport-head h2,
h2 {
font-size: 17px;
}
.stats,
.hero-grid,
.layout,
.charts,
.flow-layout,
.sto-grid {
gap: 10px;
}
.stat,
.stat-card {
min-height: 76px;
padding: 10px;
}
.stat strong,
.stat-card strong {
font-size: clamp(18px, 2.1vw, 23px);
}
.grid-form,
.entry-form,
.flow-form {
gap: 9px;
}
.entry-form {
padding: 12px;
}
.drawer-panel {
width: min(560px, 100%);
gap: 9px;
padding: 14px;
}
.drawer-menu {
grid-template-columns: repeat(auto-fit, minmax(145px, 1fr));
gap: 8px;
max-height: min(28vh, 230px);
}
.menu-group {
padding: 8px;
}
.menu-row {
min-height: 40px;
padding-inline: 11px;
}
.quick-entry-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 9px;
}
.quick-entry-grid button {
display: grid;
gap: 4px;
min-height: 82px;
align-content: center;
background: #fff;
color: var(--text);
border: 1px solid var(--line);
box-shadow: 0 8px 20px rgba(27, 38, 34, 0.06);
text-align: left;
}
.quick-entry-grid button span {
font-size: 15px;
}
.quick-entry-grid button small {
color: var(--muted);
font-size: 12px;
line-height: 1.25;
}
.advanced-fields {
display: grid;
grid-column: 1 / -1;
gap: 8px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fbfdfc;
}
.advanced-fields summary {
min-height: 38px;
padding: 10px 12px;
color: var(--text);
cursor: pointer;
font-size: 12px;
font-weight: 850;
list-style: none;
}
.advanced-fields summary::-webkit-details-marker {
display: none;
}
.advanced-fields summary::after {
content: "+";
float: right;
color: var(--muted);
}
.advanced-fields[open] summary::after {
content: "-";
}
.compact-inner-form {
margin: 0;
padding: 0 10px 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.compact-filter {
width: 100%;
padding: 0;
}
.compact-filter label,
.compact-filter button {
margin: 0 10px 10px;
}
.quick-service-pills {
margin-top: -4px;
}
.quick-service-pills button.active {
border-color: rgba(18, 115, 95, 0.45);
background: #e7f4ef;
color: #0e604f;
}
.flow-hero {
padding: 14px;
}
.flow-hero h2 {
font-size: clamp(20px, 2.4vw, 28px);
}
.form-block {
padding: 10px;
}
.admin-table th,
.admin-table td {
padding: 7px 9px;
}
@media (max-width: 640px) {
.top-actions {
gap: 6px;
}
.locale-switch {
width: 58px;
}
.quick-entry-grid,
.compact-inner-form {
grid-template-columns: 1fr;
}
.drawer-menu {
grid-template-columns: 1fr;
max-height: min(32vh, 260px);
}
.summary-card,
.stat {
min-height: 74px;
}
}