This commit is contained in:
10
ADMIN.md
10
ADMIN.md
@@ -24,12 +24,19 @@ Admin Control Center дает администраторам закрытого
|
||||
|
||||
- новый пользователь;
|
||||
- первое авто пользователя;
|
||||
- первая запись пользователя;
|
||||
- новая заявка СТО;
|
||||
- обновление документов или повторная отправка заявки СТО;
|
||||
- изменение статуса заявки СТО;
|
||||
- одобрение, блокировка и разблокировка СТО;
|
||||
- новая запись в СТО, отмена записи СТО;
|
||||
- создание, завершение и отклонение заказ-наряда;
|
||||
- запрос и решение коррекции заказ-наряда;
|
||||
- низкая оценка СТО;
|
||||
- OCR/upload/rate-limit события;
|
||||
- security/system события через общий admin notification service.
|
||||
|
||||
Idempotency key защищает от дублей.
|
||||
Idempotency key защищает от дублей. Telegram-сообщение содержит кнопку открытия соответствующего раздела админки.
|
||||
|
||||
Env:
|
||||
|
||||
@@ -113,6 +120,7 @@ Data Explorer работает только по whitelist источников
|
||||
- `notifications`
|
||||
- `admin_notifications`
|
||||
- `audit_logs`
|
||||
- `ocr_results`
|
||||
- `imports_exports`
|
||||
|
||||
Поддержаны фильтры по дате, статусу, пользователю, Telegram ID, авто, СТО, городу, роли, категории, сумме, ошибкам и текстовому поиску. Каждый запрос ограничен `limit` до 500 строк и пишет audit log.
|
||||
|
||||
52
alembic/versions/202605190001_ocr_results.py
Normal file
52
alembic/versions/202605190001_ocr_results.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""persist ocr preview results for admin explorer
|
||||
|
||||
Revision ID: 202605190001
|
||||
Revises: 202605170001
|
||||
Create Date: 2026-05-19 00:00:00.000000
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "202605190001"
|
||||
down_revision: str | None = "202605170001"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"ocr_results",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("vehicle_id", sa.Integer(), nullable=True),
|
||||
sa.Column("scope", sa.String(length=80), nullable=False),
|
||||
sa.Column("filename", sa.String(length=255), nullable=True),
|
||||
sa.Column("content_type", sa.String(length=120), nullable=True),
|
||||
sa.Column("status", sa.String(length=24), server_default="preview", nullable=False),
|
||||
sa.Column("provider", sa.String(length=80), nullable=True),
|
||||
sa.Column("confidence", sa.Numeric(5, 4), nullable=True),
|
||||
sa.Column("recognized_text", sa.Text(), nullable=True),
|
||||
sa.Column("candidates_json", sa.JSON(), nullable=True),
|
||||
sa.Column("error", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["vehicle_id"], ["cars.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_ocr_results_created_at", "ocr_results", ["created_at"])
|
||||
op.create_index("ix_ocr_results_scope", "ocr_results", ["scope"])
|
||||
op.create_index("ix_ocr_results_status", "ocr_results", ["status"])
|
||||
op.create_index("ix_ocr_results_user_id", "ocr_results", ["user_id"])
|
||||
op.create_index("ix_ocr_results_vehicle_id", "ocr_results", ["vehicle_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_ocr_results_vehicle_id", table_name="ocr_results")
|
||||
op.drop_index("ix_ocr_results_user_id", table_name="ocr_results")
|
||||
op.drop_index("ix_ocr_results_status", table_name="ocr_results")
|
||||
op.drop_index("ix_ocr_results_scope", table_name="ocr_results")
|
||||
op.drop_index("ix_ocr_results_created_at", table_name="ocr_results")
|
||||
op.drop_table("ocr_results")
|
||||
@@ -20,6 +20,7 @@ from app.models.car import (
|
||||
AuditLog,
|
||||
Car,
|
||||
CarServiceLink,
|
||||
OCRResult,
|
||||
ServiceAppointment,
|
||||
ServiceCenter,
|
||||
ServiceCenterReview,
|
||||
@@ -251,7 +252,15 @@ DATA_SOURCES: dict[str, dict[str, Any]] = {
|
||||
"filters": {"user_id": "actor_user_id", "role": "actor_role"},
|
||||
"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": {
|
||||
"model": AdminExportJob,
|
||||
"roles": DATA_EXPORT_ROLES,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_telegram_user
|
||||
@@ -7,6 +7,7 @@ from app.db.session import get_session
|
||||
from app.models.car import Car, VehicleAccess
|
||||
from app.models.user import User
|
||||
from app.schemas.car import CarCreate, CarRead, CarUpdate
|
||||
from app.services.admin_notifications import create_admin_notification
|
||||
from app.services.odometer import add_odometer_history, validate_odometer_change
|
||||
from app.services.vehicle_identity import normalize_license_plate, validate_vin
|
||||
|
||||
@@ -42,6 +43,28 @@ async def create_car(
|
||||
source_record_id=None,
|
||||
changed_by=current_user.id,
|
||||
)
|
||||
vehicle_count = int(
|
||||
(await session.execute(select(func.count(Car.id)).where(Car.owner_id == current_user.id))).scalar_one()
|
||||
or 0
|
||||
)
|
||||
if vehicle_count == 1:
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="vehicle_created",
|
||||
title="Пользователь впервые добавил авто",
|
||||
body="\n".join(
|
||||
[
|
||||
f"User ID: {current_user.id}",
|
||||
f"Telegram ID: {current_user.telegram_id}",
|
||||
f"Авто: {car.name}",
|
||||
f"Пробег: {car.current_odometer or '-'}",
|
||||
]
|
||||
),
|
||||
entity_type="vehicle",
|
||||
entity_id=car.id,
|
||||
idempotency_key=f"vehicle_created:{current_user.id}",
|
||||
metadata={"user_id": current_user.id, "vehicle_id": car.id},
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(car)
|
||||
return car
|
||||
|
||||
@@ -3,7 +3,7 @@ from io import BytesIO
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_telegram_user
|
||||
@@ -25,6 +25,7 @@ from app.schemas.expense import (
|
||||
ServiceEntryRead,
|
||||
ServiceEntryUpdate,
|
||||
)
|
||||
from app.services.admin_notifications import create_admin_notification
|
||||
from app.services.calculations import dataframe_from_query, get_ownership_stats, predict_odometer
|
||||
from app.services.odometer import (
|
||||
apply_odometer_from_record,
|
||||
@@ -53,6 +54,59 @@ async def ensure_entry_owner(
|
||||
return entry
|
||||
|
||||
|
||||
async def maybe_notify_first_record(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
user: User,
|
||||
car: Car,
|
||||
record_type: str,
|
||||
record_id: int,
|
||||
) -> None:
|
||||
fuel_count = int(
|
||||
(
|
||||
await session.execute(
|
||||
select(func.count(FuelEntry.id)).join(Car, FuelEntry.car_id == Car.id).where(Car.owner_id == user.id)
|
||||
)
|
||||
).scalar_one()
|
||||
or 0
|
||||
)
|
||||
service_count = int(
|
||||
(
|
||||
await session.execute(
|
||||
select(func.count(ServiceEntry.id)).join(Car, ServiceEntry.car_id == Car.id).where(Car.owner_id == user.id)
|
||||
)
|
||||
).scalar_one()
|
||||
or 0
|
||||
)
|
||||
expense_count = int(
|
||||
(
|
||||
await session.execute(
|
||||
select(func.count(ExpenseEntry.id)).join(Car, ExpenseEntry.car_id == Car.id).where(Car.owner_id == user.id)
|
||||
)
|
||||
).scalar_one()
|
||||
or 0
|
||||
)
|
||||
if fuel_count + service_count + expense_count != 1:
|
||||
return
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="first_record_created",
|
||||
title="Пользователь впервые создал запись",
|
||||
body="\n".join(
|
||||
[
|
||||
f"User ID: {user.id}",
|
||||
f"Telegram ID: {user.telegram_id}",
|
||||
f"Авто: {car.name}",
|
||||
f"Тип записи: {record_type}",
|
||||
]
|
||||
),
|
||||
entity_type="vehicle",
|
||||
entity_id=car.id,
|
||||
idempotency_key=f"first_record_created:{user.id}",
|
||||
metadata={"user_id": user.id, "vehicle_id": car.id, "record_type": record_type, "record_id": record_id},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/fuel", response_model=FuelEntryRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_fuel_entry(
|
||||
payload: FuelEntryCreate,
|
||||
@@ -78,6 +132,7 @@ async def create_fuel_entry(
|
||||
changed_by=current_user.id,
|
||||
confirm_lower_odometer=payload.confirm_lower_odometer,
|
||||
)
|
||||
await maybe_notify_first_record(session, user=current_user, car=car, record_type="fuel", record_id=entry.id)
|
||||
await session.commit()
|
||||
await session.refresh(entry)
|
||||
return entry
|
||||
@@ -174,6 +229,7 @@ async def create_service_entry(
|
||||
changed_by=current_user.id,
|
||||
confirm_lower_odometer=payload.confirm_lower_odometer,
|
||||
)
|
||||
await maybe_notify_first_record(session, user=current_user, car=car, record_type="service", record_id=entry.id)
|
||||
await session.commit()
|
||||
await session.refresh(entry)
|
||||
return entry
|
||||
@@ -266,6 +322,7 @@ async def create_expense_entry(
|
||||
changed_by=current_user.id,
|
||||
confirm_lower_odometer=payload.confirm_lower_odometer,
|
||||
)
|
||||
await maybe_notify_first_record(session, user=current_user, car=car, record_type="expense", record_id=entry.id)
|
||||
await session.commit()
|
||||
await session.refresh(entry)
|
||||
return entry
|
||||
|
||||
128
app/api/ocr.py
128
app/api/ocr.py
@@ -9,9 +9,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_telegram_user
|
||||
from app.db.session import get_session
|
||||
from app.models.car import OCRResult
|
||||
from app.models.user import User
|
||||
from app.services.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.uploads import SAFE_IMAGE_TYPES, SAFE_TEXT_TYPES, validate_upload
|
||||
|
||||
@@ -42,6 +43,51 @@ class OCRResultRead(BaseModel):
|
||||
provider: str = "heuristic"
|
||||
|
||||
|
||||
def ocr_candidates_json(result: OcrResult | None) -> list[dict] | None:
|
||||
if result is None:
|
||||
return None
|
||||
return [
|
||||
{"type": candidate.type, "value": candidate.value, "confidence": candidate.confidence}
|
||||
for candidate in result.candidates
|
||||
]
|
||||
|
||||
|
||||
def ocr_confidence(result: OcrResult | None) -> Decimal | None:
|
||||
if result is None or not result.candidates:
|
||||
return None
|
||||
return Decimal(str(round(max(candidate.confidence for candidate in result.candidates), 4)))
|
||||
|
||||
|
||||
async def save_ocr_result(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
current_user: User,
|
||||
scope: str,
|
||||
filename: str | None,
|
||||
content_type: str | None,
|
||||
status: str,
|
||||
result: OcrResult | None = None,
|
||||
recognized_text: str | None = None,
|
||||
provider: str | None = None,
|
||||
error: str | None = None,
|
||||
) -> OCRResult:
|
||||
record = OCRResult(
|
||||
user_id=current_user.id,
|
||||
scope=scope,
|
||||
filename=filename,
|
||||
content_type=content_type,
|
||||
status=status,
|
||||
provider=result.provider if result is not None else provider,
|
||||
confidence=ocr_confidence(result),
|
||||
recognized_text=result.recognized_text if result is not None else recognized_text,
|
||||
candidates_json=ocr_candidates_json(result),
|
||||
error=error,
|
||||
)
|
||||
session.add(record)
|
||||
await session.flush()
|
||||
return record
|
||||
|
||||
|
||||
async def validate_ocr_upload(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
@@ -59,6 +105,15 @@ async def validate_ocr_upload(
|
||||
allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES,
|
||||
)
|
||||
except HTTPException as exc:
|
||||
await save_ocr_result(
|
||||
session,
|
||||
current_user=current_user,
|
||||
scope="upload_validation",
|
||||
filename=filename,
|
||||
content_type=content_type,
|
||||
status="blocked",
|
||||
error=str(exc.detail),
|
||||
)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="upload_blocked",
|
||||
@@ -93,6 +148,15 @@ async def recognize_with_alert(
|
||||
try:
|
||||
return await get_ocr_provider().recognize(content, filename)
|
||||
except Exception as exc: # noqa: BLE001 - OCR must fail gracefully and alert admins
|
||||
await save_ocr_result(
|
||||
session,
|
||||
current_user=current_user,
|
||||
scope=scope,
|
||||
filename=filename,
|
||||
content_type=None,
|
||||
status="failed",
|
||||
error=type(exc).__name__,
|
||||
)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="ocr_failed",
|
||||
@@ -134,10 +198,31 @@ async def parse_text_receipt(
|
||||
scope="parse_text_receipt",
|
||||
)
|
||||
if not result or not result.recognized_text:
|
||||
if result is not None:
|
||||
await save_ocr_result(
|
||||
session,
|
||||
current_user=current_user,
|
||||
scope="parse_text_receipt",
|
||||
filename=file.filename,
|
||||
content_type=file.content_type,
|
||||
status="preview",
|
||||
result=result,
|
||||
)
|
||||
await session.commit()
|
||||
return ReceiptSuggestion(
|
||||
confidence=0,
|
||||
message="Не удалось уверенно распознать чек. Открылся ручной ввод: проверьте дату, сумму, литры и цену.",
|
||||
)
|
||||
await save_ocr_result(
|
||||
session,
|
||||
current_user=current_user,
|
||||
scope="parse_text_receipt",
|
||||
filename=file.filename,
|
||||
content_type=file.content_type,
|
||||
status="preview",
|
||||
result=result,
|
||||
)
|
||||
await session.commit()
|
||||
return parse_receipt_text(result.recognized_text)
|
||||
text = " ".join(
|
||||
[
|
||||
@@ -145,6 +230,17 @@ async def parse_text_receipt(
|
||||
content.decode("utf-8", errors="ignore"),
|
||||
]
|
||||
)
|
||||
await save_ocr_result(
|
||||
session,
|
||||
current_user=current_user,
|
||||
scope="parse_text_receipt",
|
||||
filename=file.filename,
|
||||
content_type=file.content_type,
|
||||
status="preview",
|
||||
recognized_text=text,
|
||||
provider="text",
|
||||
)
|
||||
await session.commit()
|
||||
return parse_receipt_text(text)
|
||||
|
||||
|
||||
@@ -223,6 +319,16 @@ async def recognize_license_plate(
|
||||
)
|
||||
if result is None:
|
||||
return OCRResultRead(recognized_text="", candidates=[], provider="error")
|
||||
await save_ocr_result(
|
||||
session,
|
||||
current_user=current_user,
|
||||
scope="license_plate",
|
||||
filename=file.filename,
|
||||
content_type=file.content_type,
|
||||
status="preview",
|
||||
result=result,
|
||||
)
|
||||
await session.commit()
|
||||
return OCRResultRead(
|
||||
recognized_text=result.recognized_text,
|
||||
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "license_plate"],
|
||||
@@ -255,6 +361,16 @@ async def recognize_vin(
|
||||
)
|
||||
if result is None:
|
||||
return OCRResultRead(recognized_text="", candidates=[], provider="error")
|
||||
await save_ocr_result(
|
||||
session,
|
||||
current_user=current_user,
|
||||
scope="vin",
|
||||
filename=file.filename,
|
||||
content_type=file.content_type,
|
||||
status="preview",
|
||||
result=result,
|
||||
)
|
||||
await session.commit()
|
||||
return OCRResultRead(
|
||||
recognized_text=result.recognized_text,
|
||||
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "vin"],
|
||||
@@ -287,6 +403,16 @@ async def recognize_service_document(
|
||||
)
|
||||
if result is None:
|
||||
return OCRResultRead(recognized_text="", candidates=[], provider="error")
|
||||
await save_ocr_result(
|
||||
session,
|
||||
current_user=current_user,
|
||||
scope="service_document",
|
||||
filename=file.filename,
|
||||
content_type=file.content_type,
|
||||
status="preview",
|
||||
result=result,
|
||||
)
|
||||
await session.commit()
|
||||
return OCRResultRead(
|
||||
recognized_text=result.recognized_text,
|
||||
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates],
|
||||
|
||||
@@ -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 create_admin_notification(
|
||||
session,
|
||||
event_type="sto_application_updated",
|
||||
title="СТО обновило заявку",
|
||||
body="\n".join(
|
||||
[
|
||||
f"Название: {center.display_name or center.name}",
|
||||
f"Город: {center.city or '-'}",
|
||||
f"Статус: {center.verification_status}",
|
||||
]
|
||||
),
|
||||
entity_type="service_center",
|
||||
entity_id=center.id,
|
||||
idempotency_key=f"sto_application_updated:{center.id}:{int(datetime.now(UTC).timestamp() // 60)}",
|
||||
metadata={"city": center.city, "owner_user_id": current_user.id},
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(center)
|
||||
return center
|
||||
@@ -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 session.flush()
|
||||
await refresh_service_rating(session, service_center_id)
|
||||
if review.rating <= 2:
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="sto_low_review",
|
||||
title="Низкая оценка СТО",
|
||||
body=f"СТО ID: {service_center_id}\nОценка: {review.rating}\nОтзыв: {review.text or '-'}",
|
||||
entity_type="service_center",
|
||||
entity_id=service_center_id,
|
||||
severity="warning",
|
||||
idempotency_key=f"sto_low_review:{review.id}:{review.rating}",
|
||||
metadata={"review_id": review.id, "rating": review.rating, "user_id": current_user.id},
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(review)
|
||||
return review
|
||||
|
||||
@@ -36,6 +36,7 @@ from app.schemas.sto_booking import (
|
||||
ServiceCenterHolidayRead,
|
||||
STODashboardRead,
|
||||
)
|
||||
from app.services.admin_notifications import create_admin_notification
|
||||
from app.services.rate_limit import check_rate_limit
|
||||
from app.services.sto_booking import (
|
||||
calculate_available_slots,
|
||||
@@ -238,6 +239,28 @@ async def create_appointment(
|
||||
body=f"{appointment.service_name}: {appointment.requested_start_at:%Y-%m-%d %H:%M}",
|
||||
appointment_id=appointment.id,
|
||||
)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="appointment_created",
|
||||
title="Новая запись в СТО",
|
||||
body="\n".join(
|
||||
[
|
||||
f"СТО ID: {appointment.service_center_id}",
|
||||
f"User ID: {current_user.id}",
|
||||
f"Авто ID: {appointment.vehicle_id}",
|
||||
f"Услуга: {appointment.service_name}",
|
||||
f"Время: {appointment.requested_start_at:%Y-%m-%d %H:%M}",
|
||||
]
|
||||
),
|
||||
entity_type="appointment",
|
||||
entity_id=appointment.id,
|
||||
idempotency_key=f"appointment_created:{appointment.id}",
|
||||
metadata={
|
||||
"service_center_id": appointment.service_center_id,
|
||||
"vehicle_id": appointment.vehicle_id,
|
||||
"owner_id": appointment.owner_id,
|
||||
},
|
||||
)
|
||||
await log_audit(
|
||||
session,
|
||||
actor=current_user,
|
||||
@@ -554,6 +577,17 @@ async def reject_appointment(
|
||||
title="СТО отклонило запись",
|
||||
body=payload.comment,
|
||||
)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="appointment_cancelled",
|
||||
title="СТО отклонило запись",
|
||||
body=payload.comment,
|
||||
entity_type="appointment",
|
||||
entity_id=appointment.id,
|
||||
severity="warning",
|
||||
idempotency_key=f"appointment_rejected_by_sto:{appointment.id}",
|
||||
metadata={"service_center_id": appointment.service_center_id, "owner_id": appointment.owner_id},
|
||||
)
|
||||
await log_audit(session, actor=current_user, action="appointment.reject", target_type="service_appointment", target_id=appointment_id)
|
||||
await session.commit()
|
||||
await session.refresh(appointment)
|
||||
@@ -579,6 +613,17 @@ async def delete_appointment_by_sto(
|
||||
body=f"{appointment.service_name}: {appointment.requested_start_at:%Y-%m-%d %H:%M}",
|
||||
idempotency_key=f"appointment:{appointment.id}:deleted_by_sto",
|
||||
)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="appointment_cancelled",
|
||||
title="СТО удалило запись",
|
||||
body=f"{appointment.service_name}: {appointment.requested_start_at:%Y-%m-%d %H:%M}",
|
||||
entity_type="appointment",
|
||||
entity_id=appointment.id,
|
||||
severity="warning",
|
||||
idempotency_key=f"appointment_deleted_by_sto:{appointment.id}",
|
||||
metadata={"service_center_id": appointment.service_center_id, "owner_id": appointment.owner_id},
|
||||
)
|
||||
await log_audit(
|
||||
session,
|
||||
actor=current_user,
|
||||
@@ -677,6 +722,21 @@ async def create_work_order_from_appointment(
|
||||
body=visit.work_order_number,
|
||||
idempotency_key=f"work_order:{visit.id}:created",
|
||||
)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="work_order_created",
|
||||
title="Создан заказ-наряд",
|
||||
body=f"{visit.work_order_number or visit.id}: СТО {visit.service_center_id}, авто {visit.vehicle_id}",
|
||||
entity_type="work_order",
|
||||
entity_id=visit.id,
|
||||
idempotency_key=f"work_order_created:{visit.id}",
|
||||
metadata={
|
||||
"appointment_id": appointment.id,
|
||||
"service_center_id": visit.service_center_id,
|
||||
"vehicle_id": visit.vehicle_id,
|
||||
"owner_id": visit.owner_id,
|
||||
},
|
||||
)
|
||||
await log_audit(session, actor=current_user, action="appointment.create_work_order", target_type="service_appointment", target_id=appointment_id, metadata={"service_visit_id": visit.id})
|
||||
await session.commit()
|
||||
await session.refresh(visit)
|
||||
|
||||
@@ -39,6 +39,7 @@ from app.schemas.service_center import (
|
||||
WorkOrderStatusHistoryRead,
|
||||
WorkOrderUpdate,
|
||||
)
|
||||
from app.services.admin_notifications import create_admin_notification
|
||||
from app.services.sto_booking import create_service_notification
|
||||
from app.services.work_orders import (
|
||||
add_labor_item,
|
||||
@@ -461,6 +462,17 @@ async def reject_work_order(
|
||||
visit.owner_comment = payload.comment
|
||||
visit.owner_resolved_at = datetime.now(UTC)
|
||||
await add_status_history(session, visit, to_status="rejected_by_owner", actor=current_user, comment=payload.comment)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="work_order_rejected_by_owner",
|
||||
title="Владелец отклонил смету",
|
||||
body=payload.comment,
|
||||
entity_type="work_order",
|
||||
entity_id=visit.id,
|
||||
severity="warning",
|
||||
idempotency_key=f"work_order_rejected_by_owner:{visit.id}",
|
||||
metadata={"service_center_id": visit.service_center_id, "vehicle_id": visit.vehicle_id, "owner_id": current_user.id},
|
||||
)
|
||||
await log_audit(session, actor=current_user, action="work_order.reject", target_type="service_visit", target_id=visit.id)
|
||||
await session.commit()
|
||||
await session.refresh(visit)
|
||||
@@ -502,6 +514,21 @@ async def complete_work_order(
|
||||
actor=current_user,
|
||||
confirm_lower_odometer=payload.confirm_lower_odometer,
|
||||
)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="work_order_completed",
|
||||
title="Заказ-наряд завершён",
|
||||
body=f"{visit.work_order_number or visit.id}: {visit.final_total} {visit.currency}",
|
||||
entity_type="work_order",
|
||||
entity_id=visit.id,
|
||||
idempotency_key=f"work_order_completed:{visit.id}",
|
||||
metadata={
|
||||
"service_center_id": visit.service_center_id,
|
||||
"vehicle_id": visit.vehicle_id,
|
||||
"owner_id": visit.owner_id,
|
||||
"final_total": str(visit.final_total),
|
||||
},
|
||||
)
|
||||
await log_audit(session, actor=current_user, action="work_order.complete", target_type="service_visit", target_id=visit.id)
|
||||
await session.commit()
|
||||
await session.refresh(visit)
|
||||
@@ -599,6 +626,21 @@ async def create_work_order_correction(
|
||||
web_app_url=work_order_webapp_url(visit.id),
|
||||
button_text="Открыть заказ-наряд",
|
||||
)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="work_order_correction_requested",
|
||||
title="Запрошена коррекция заказ-наряда",
|
||||
body=payload.reason,
|
||||
entity_type="work_order",
|
||||
entity_id=visit.id,
|
||||
severity="warning",
|
||||
idempotency_key=f"work_order_correction_requested:{visit.id}:{visit.version or 1}:{payload.reason[:80]}",
|
||||
metadata={
|
||||
"service_center_id": visit.service_center_id,
|
||||
"vehicle_id": visit.vehicle_id,
|
||||
"owner_approval_required": payload.owner_approval_required,
|
||||
},
|
||||
)
|
||||
await log_audit(
|
||||
session,
|
||||
actor=current_user,
|
||||
@@ -626,6 +668,16 @@ async def approve_work_order_correction(
|
||||
raise HTTPException(status_code=409, detail="Correction is already resolved")
|
||||
correction.status = "approved"
|
||||
correction.resolved_at = datetime.now(UTC)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="work_order_correction_resolved",
|
||||
title="Коррекция заказ-наряда согласована",
|
||||
body=payload.comment,
|
||||
entity_type="work_order",
|
||||
entity_id=visit.id,
|
||||
idempotency_key=f"work_order_correction_approved:{correction.id}",
|
||||
metadata={"correction_id": correction.id, "status": "approved"},
|
||||
)
|
||||
await log_audit(
|
||||
session,
|
||||
actor=current_user,
|
||||
@@ -653,6 +705,17 @@ async def reject_work_order_correction(
|
||||
raise HTTPException(status_code=409, detail="Correction is already resolved")
|
||||
correction.status = "rejected"
|
||||
correction.resolved_at = datetime.now(UTC)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="work_order_correction_resolved",
|
||||
title="Коррекция заказ-наряда отклонена",
|
||||
body=payload.comment,
|
||||
entity_type="work_order",
|
||||
entity_id=visit.id,
|
||||
severity="warning",
|
||||
idempotency_key=f"work_order_correction_rejected:{correction.id}",
|
||||
metadata={"correction_id": correction.id, "status": "rejected"},
|
||||
)
|
||||
await log_audit(
|
||||
session,
|
||||
actor=current_user,
|
||||
|
||||
@@ -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):
|
||||
__tablename__ = "service_work_items"
|
||||
|
||||
|
||||
@@ -20,6 +20,14 @@ ADMIN_EVENT_FLAGS = {
|
||||
"sto_application_updated": "admin_notify_sto_applications",
|
||||
"sto_approved": "admin_notify_sto_applications",
|
||||
"sto_suspended": "admin_notify_sto_applications",
|
||||
"sto_low_review": "admin_notify_sto_applications",
|
||||
"appointment_created": "admin_notify_sto_applications",
|
||||
"appointment_cancelled": "admin_notify_sto_applications",
|
||||
"work_order_created": "admin_notify_sto_applications",
|
||||
"work_order_completed": "admin_notify_sto_applications",
|
||||
"work_order_rejected_by_owner": "admin_notify_sto_applications",
|
||||
"work_order_correction_requested": "admin_notify_sto_applications",
|
||||
"work_order_correction_resolved": "admin_notify_sto_applications",
|
||||
"security_event": "admin_notify_security_events",
|
||||
"rate_limit_exceeded": "admin_notify_security_events",
|
||||
"upload_blocked": "admin_notify_security_events",
|
||||
@@ -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}"
|
||||
if entity_type == "user" and entity_id:
|
||||
return f"{base}/admin.html?section=users&entity_id={entity_id}"
|
||||
if entity_type == "vehicle" and entity_id:
|
||||
return f"{base}/admin.html?section=vehicles&entity_id={entity_id}"
|
||||
if entity_type == "appointment" and entity_id:
|
||||
return f"{base}/admin.html?section=appointments&entity_id={entity_id}"
|
||||
if entity_type == "work_order" and entity_id:
|
||||
return f"{base}/admin.html?section=work-orders&entity_id={entity_id}"
|
||||
if entity_type == "ocr_result" and entity_id:
|
||||
return f"{base}/admin.html?section=data&source=ocr_results&entity_id={entity_id}"
|
||||
return f"{base}/admin.html"
|
||||
|
||||
|
||||
@@ -119,6 +135,9 @@ async def send_admin_telegram_notification(notification: AdminNotification) -> N
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": True,
|
||||
"reply_markup": {
|
||||
"inline_keyboard": [[{"text": "Открыть в админке", "url": link}]]
|
||||
},
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -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
|
||||
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)
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ocr_preview_is_available_in_admin_data_explorer(
|
||||
client, auth_headers, admin_auth_headers, internal_headers
|
||||
) -> None:
|
||||
await ensure_admin(client, internal_headers)
|
||||
|
||||
response = await client.post(
|
||||
"/api/ocr/vin",
|
||||
headers=auth_headers,
|
||||
files={"file": ("vin.txt", b"VIN KMHCT41BAHU123456", "text/plain")},
|
||||
)
|
||||
query = await client.post(
|
||||
"/api/admin/data/query",
|
||||
headers=admin_auth_headers,
|
||||
json={"source": "ocr_results", "category": "vin", "limit": 25},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert query.status_code == 200
|
||||
assert query.json()["rows"][0]["scope"] == "vin"
|
||||
assert query.json()["rows"][0]["status"] == "preview"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_creates_admin_notification(
|
||||
client, auth_headers, admin_auth_headers, internal_headers
|
||||
|
||||
@@ -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_volume_l"] == "4.00"
|
||||
assert stats.json()["total_cost"] == "130.00"
|
||||
admin_notifications = await client.get("/api/admin/notifications?limit=100", headers=admin_auth_headers)
|
||||
admin_events = {item["event_type"] for item in admin_notifications.json()["rows"]}
|
||||
assert {"work_order_completed", "work_order_correction_requested", "work_order_correction_resolved"} <= admin_events
|
||||
|
||||
cannot_edit = await client.patch(
|
||||
f"/api/work-orders/{work_order['id']}",
|
||||
|
||||
@@ -204,6 +204,9 @@ async def test_customer_booking_lifecycle_capacity_calendar_work_order_and_notif
|
||||
)
|
||||
assert work_order.status_code == 201
|
||||
assert work_order.json()["vehicle_id"] == vehicle["id"]
|
||||
admin_notifications = await client.get("/api/admin/notifications?limit=100", headers=admin_auth_headers)
|
||||
admin_events = {item["event_type"] for item in admin_notifications.json()["rows"]}
|
||||
assert {"appointment_created", "work_order_created"} <= admin_events
|
||||
|
||||
my_appointments = await client.get("/api/appointments/my", headers=other_auth_headers)
|
||||
assert my_appointments.json()[0]["linked_work_order_id"] == work_order.json()["id"]
|
||||
|
||||
Reference in New Issue
Block a user