complete admin notifications data explorer
Some checks failed
ci / test (push) Has been cancelled

This commit is contained in:
VPN SaaS Dev
2026-05-19 19:02:16 +09:00
parent 58ff6ff614
commit 99bc9aa6a1
14 changed files with 528 additions and 5 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"]