diff --git a/ADMIN.md b/ADMIN.md index 1279123..3c57f43 100644 --- a/ADMIN.md +++ b/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. diff --git a/alembic/versions/202605190001_ocr_results.py b/alembic/versions/202605190001_ocr_results.py new file mode 100644 index 0000000..18ee656 --- /dev/null +++ b/alembic/versions/202605190001_ocr_results.py @@ -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") diff --git a/app/api/admin.py b/app/api/admin.py index 250282d..e74b1d9 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -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, diff --git a/app/api/cars.py b/app/api/cars.py index f4a423b..18547d3 100644 --- a/app/api/cars.py +++ b/app/api/cars.py @@ -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 diff --git a/app/api/entries.py b/app/api/entries.py index c1d53ee..4fb948f 100644 --- a/app/api/entries.py +++ b/app/api/entries.py @@ -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 diff --git a/app/api/ocr.py b/app/api/ocr.py index 16cd1b1..269c16e 100644 --- a/app/api/ocr.py +++ b/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], diff --git a/app/api/service_centers.py b/app/api/service_centers.py index 898290d..f5deb5d 100644 --- a/app/api/service_centers.py +++ b/app/api/service_centers.py @@ -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 diff --git a/app/api/sto_booking.py b/app/api/sto_booking.py index 7243a28..1ad934d 100644 --- a/app/api/sto_booking.py +++ b/app/api/sto_booking.py @@ -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) diff --git a/app/api/work_orders.py b/app/api/work_orders.py index e3f140f..17dd189 100644 --- a/app/api/work_orders.py +++ b/app/api/work_orders.py @@ -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, diff --git a/app/models/car.py b/app/models/car.py index 7e09f9b..53d3710 100644 --- a/app/models/car.py +++ b/app/models/car.py @@ -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" diff --git a/app/services/admin_notifications.py b/app/services/admin_notifications.py index 9c2b4f5..4baf20e 100644 --- a/app/services/admin_notifications.py +++ b/app/services/admin_notifications.py @@ -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() diff --git a/tests/test_admin_control_center.py b/tests/test_admin_control_center.py index 0e5f66b..b09caee 100644 --- a/tests/test_admin_control_center.py +++ b/tests/test_admin_control_center.py @@ -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 diff --git a/tests/test_production_flows.py b/tests/test_production_flows.py index dfba02b..51af8eb 100644 --- a/tests/test_production_flows.py +++ b/tests/test_production_flows.py @@ -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']}", diff --git a/tests/test_sto_booking.py b/tests/test_sto_booking.py index a31618f..fdd9ef8 100644 --- a/tests/test_sto_booking.py +++ b/tests/test_sto_booking.py @@ -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"]