2 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
20 changed files with 1289 additions and 70 deletions

View File

@@ -24,12 +24,19 @@ Admin Control Center дает администраторам закрытого
- новый пользователь; - новый пользователь;
- первое авто пользователя; - первое авто пользователя;
- первая запись пользователя;
- новая заявка СТО; - новая заявка СТО;
- обновление документов или повторная отправка заявки СТО;
- изменение статуса заявки СТО; - изменение статуса заявки СТО;
- одобрение, блокировка и разблокировка СТО; - одобрение, блокировка и разблокировка СТО;
- новая запись в СТО, отмена записи СТО;
- создание, завершение и отклонение заказ-наряда;
- запрос и решение коррекции заказ-наряда;
- низкая оценка СТО;
- OCR/upload/rate-limit события;
- security/system события через общий admin notification service. - security/system события через общий admin notification service.
Idempotency key защищает от дублей. Idempotency key защищает от дублей. Telegram-сообщение содержит кнопку открытия соответствующего раздела админки.
Env: Env:
@@ -113,6 +120,7 @@ Data Explorer работает только по whitelist источников
- `notifications` - `notifications`
- `admin_notifications` - `admin_notifications`
- `audit_logs` - `audit_logs`
- `ocr_results`
- `imports_exports` - `imports_exports`
Поддержаны фильтры по дате, статусу, пользователю, Telegram ID, авто, СТО, городу, роли, категории, сумме, ошибкам и текстовому поиску. Каждый запрос ограничен `limit` до 500 строк и пишет audit log. Поддержаны фильтры по дате, статусу, пользователю, Telegram ID, авто, СТО, городу, роли, категории, сумме, ошибкам и текстовому поиску. Каждый запрос ограничен `limit` до 500 строк и пишет audit log.

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

View File

@@ -20,6 +20,7 @@ from app.models.car import (
AuditLog, AuditLog,
Car, Car,
CarServiceLink, CarServiceLink,
OCRResult,
ServiceAppointment, ServiceAppointment,
ServiceCenter, ServiceCenter,
ServiceCenterReview, ServiceCenterReview,
@@ -251,7 +252,15 @@ DATA_SOURCES: dict[str, dict[str, Any]] = {
"filters": {"user_id": "actor_user_id", "role": "actor_role"}, "filters": {"user_id": "actor_user_id", "role": "actor_role"},
"columns": ["id", "actor_user_id", "actor_role", "action", "target_type", "target_id", "ip", "created_at"], "columns": ["id", "actor_user_id", "actor_role", "action", "target_type", "target_id", "ip", "created_at"],
}, },
"ocr_results": {"model": None, "roles": ADMIN_ROLES, "columns": []}, "ocr_results": {
"model": OCRResult,
"roles": ADMIN_ROLES,
"search": ["filename", "recognized_text", "error"],
"filters": {"user_id": "user_id", "vehicle_id": "vehicle_id", "status": "status", "category": "scope"},
"columns": ["id", "user_id", "vehicle_id", "scope", "filename", "status", "provider", "confidence", "created_at", "error"],
"editable": ["status"],
"delete": {"type": "hard"},
},
"imports_exports": { "imports_exports": {
"model": AdminExportJob, "model": AdminExportJob,
"roles": DATA_EXPORT_ROLES, "roles": DATA_EXPORT_ROLES,

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

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

@@ -9,9 +9,10 @@ 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.admin_notifications import create_admin_notification from app.services.admin_notifications import create_admin_notification
from app.services.ocr_provider import get_ocr_provider 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
@@ -42,6 +43,51 @@ 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( async def validate_ocr_upload(
*, *,
session: AsyncSession, session: AsyncSession,
@@ -59,6 +105,15 @@ async def validate_ocr_upload(
allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES, allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES,
) )
except HTTPException as exc: 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( await create_admin_notification(
session, session,
event_type="upload_blocked", event_type="upload_blocked",
@@ -93,6 +148,15 @@ async def recognize_with_alert(
try: try:
return await get_ocr_provider().recognize(content, filename) return await get_ocr_provider().recognize(content, filename)
except Exception as exc: # noqa: BLE001 - OCR must fail gracefully and alert admins 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( await create_admin_notification(
session, session,
event_type="ocr_failed", event_type="ocr_failed",
@@ -134,10 +198,31 @@ async def parse_text_receipt(
scope="parse_text_receipt", scope="parse_text_receipt",
) )
if not result or not result.recognized_text: 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(
[ [
@@ -145,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)
@@ -223,6 +319,16 @@ async def recognize_license_plate(
) )
if result is None: if result is None:
return OCRResultRead(recognized_text="", candidates=[], provider="error") 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"],
@@ -255,6 +361,16 @@ async def recognize_vin(
) )
if result is None: if result is None:
return OCRResultRead(recognized_text="", candidates=[], provider="error") 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"],
@@ -287,6 +403,16 @@ async def recognize_service_document(
) )
if result is None: if result is None:
return OCRResultRead(recognized_text="", candidates=[], provider="error") 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

@@ -209,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
@@ -847,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

@@ -455,6 +455,24 @@ class AdminNotification(Base):
) )
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"

View File

@@ -20,6 +20,14 @@ ADMIN_EVENT_FLAGS = {
"sto_application_updated": "admin_notify_sto_applications", "sto_application_updated": "admin_notify_sto_applications",
"sto_approved": "admin_notify_sto_applications", "sto_approved": "admin_notify_sto_applications",
"sto_suspended": "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", "security_event": "admin_notify_security_events",
"rate_limit_exceeded": "admin_notify_security_events", "rate_limit_exceeded": "admin_notify_security_events",
"upload_blocked": "admin_notify_security_events", "upload_blocked": "admin_notify_security_events",
@@ -47,6 +55,14 @@ def admin_notification_url(entity_type: str | None = None, entity_id: str | int
return f"{base}/admin.html?section=sto-applications&entity_id={entity_id}" return f"{base}/admin.html?section=sto-applications&entity_id={entity_id}"
if entity_type == "user" and entity_id: if entity_type == "user" and entity_id:
return f"{base}/admin.html?section=users&entity_id={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" return f"{base}/admin.html"
@@ -119,6 +135,9 @@ async def send_admin_telegram_notification(notification: AdminNotification) -> N
"text": text, "text": text,
"parse_mode": "HTML", "parse_mode": "HTML",
"disable_web_page_preview": True, "disable_web_page_preview": True,
"reply_markup": {
"inline_keyboard": [[{"text": "Открыть в админке", "url": link}]]
},
}, },
) )
response.raise_for_status() response.raise_for_status()

View File

@@ -123,6 +123,37 @@ async def test_new_sto_application_creates_admin_notification(
) )
@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 @pytest.mark.asyncio
async def test_admin_dashboard_requires_admin_role(client, auth_headers, admin_auth_headers, internal_headers) -> None: 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) forbidden = await client.get("/api/admin/dashboard", headers=auth_headers)
@@ -389,6 +420,29 @@ async def test_blocked_ocr_upload_creates_admin_notification(
assert any(item["event_type"] == "upload_blocked" for item in notifications.json()["rows"]) 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 @pytest.mark.asyncio
async def test_rate_limit_creates_admin_notification( async def test_rate_limit_creates_admin_notification(
client, auth_headers, admin_auth_headers, internal_headers client, auth_headers, admin_auth_headers, internal_headers

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"]

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

@@ -220,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" />
@@ -228,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>
@@ -292,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>
@@ -350,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" />
@@ -393,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>

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,
@@ -582,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;
@@ -590,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;
@@ -598,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");
@@ -2671,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();
@@ -2700,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");
@@ -2802,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");
@@ -2817,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";
@@ -2891,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) => {
@@ -3043,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

@@ -2579,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 {
@@ -2597,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;
}
}