Files
mechanic-pwa/docs/superpowers/plans/2026-05-16-premium-mechanic-phase1.md
T

106 KiB
Raw Blame History

Premium Mechanic PWA — Phase 1 MVP Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Working PWA for parking mechanics to perform vehicle inspections with photos, simple point-markers, and "before/after" comparison on return-inspections — independent of vendor's app.

Architecture: React+Vite PWA on mechanic.pptaxi.ru talks to FastAPI module mechanic (new sub-app inside existing TaxiDashboard backend on 100.64.0.12). PostgreSQL stores inspection meta, MinIO bucket pp-inspections stores photos (uploaded via pre-signed POST, served via pre-signed GET). No annotation/polygon editor in Phase 1 — only point markers on photos and zone-clicks on a SVG vehicle scheme.

Tech Stack:

  • Backend: Python 3.11+, FastAPI, SQLAlchemy 2.x, Alembic, asyncpg, aiobotocore, Pillow, Celery
  • Frontend: React 18, TypeScript, Vite, Tailwind, shadcn/ui, TanStack Query, Zustand, React Router 7, html5-qrcode, vite-plugin-pwa
  • Storage: MinIO (s3.pptaxi.ru, bucket pp-inspections)
  • Deploy: Caddy on 100.64.0.12, sub-domain mechanic.pptaxi.ru

Spec: PremiumDriverApp/docs/superpowers/specs/2026-05-16-premium-mechanic-design.md


File Structure

Backend (extends existing TaxiDashboard FastAPI app)

Exact paths depend on existing layout. Task A1 below reconciles. Assumed paths reference backend/app/ as the package root.

  • Create: backend/app/mechanic/__init__.py
  • Create: backend/app/mechanic/router.py (FastAPI APIRouter, /api/v1/mechanic/*)
  • Create: backend/app/mechanic/models.py (Inspection, InspectionPhoto, DamageMarker)
  • Create: backend/app/mechanic/schemas.py (Pydantic)
  • Create: backend/app/mechanic/service.py (business logic: start_inspection, copy_markers, etc.)
  • Create: backend/app/mechanic/storage.py (aiobotocore wrapper for MinIO)
  • Create: backend/app/mechanic/deps.py (auth + permission deps)
  • Create: backend/app/mechanic/tasks.py (Celery: thumbnails)
  • Create: backend/alembic/versions/2026_05_16_1200_mechanic_init.py (migration)
  • Modify: backend/app/main.py — register mechanic_router
  • Create: backend/tests/test_mechanic/test_inspections_api.py
  • Create: backend/tests/test_mechanic/test_storage.py

Frontend (new project)

  • Create: mechanic-pwa/frontend/package.json
  • Create: mechanic-pwa/frontend/vite.config.ts
  • Create: mechanic-pwa/frontend/tsconfig.json
  • Create: mechanic-pwa/frontend/tailwind.config.ts
  • Create: mechanic-pwa/frontend/index.html
  • Create: mechanic-pwa/frontend/public/manifest.webmanifest
  • Create: mechanic-pwa/frontend/src/main.tsx
  • Create: mechanic-pwa/frontend/src/App.tsx
  • Create: mechanic-pwa/frontend/src/api/client.ts
  • Create: mechanic-pwa/frontend/src/api/auth.ts
  • Create: mechanic-pwa/frontend/src/api/vehicles.ts
  • Create: mechanic-pwa/frontend/src/api/inspections.ts
  • Create: mechanic-pwa/frontend/src/api/photos.ts
  • Create: mechanic-pwa/frontend/src/store/auth.ts
  • Create: mechanic-pwa/frontend/src/store/inspectionDraft.ts
  • Create: mechanic-pwa/frontend/src/pages/Login.tsx
  • Create: mechanic-pwa/frontend/src/pages/Home.tsx
  • Create: mechanic-pwa/frontend/src/pages/VehicleCard.tsx
  • Create: mechanic-pwa/frontend/src/pages/InspectionEditor.tsx
  • Create: mechanic-pwa/frontend/src/pages/InspectionReview.tsx
  • Create: mechanic-pwa/frontend/src/components/camera/CameraCapture.tsx
  • Create: mechanic-pwa/frontend/src/components/camera/PhotoSlot.tsx
  • Create: mechanic-pwa/frontend/src/components/marker/PointMarkerOverlay.tsx
  • Create: mechanic-pwa/frontend/src/components/marker/MarkerModal.tsx
  • Create: mechanic-pwa/frontend/src/components/vehicle-scheme/VehicleScheme.tsx
  • Create: mechanic-pwa/frontend/src/components/vehicle-scheme/HotSpot.tsx
  • Create: mechanic-pwa/frontend/src/components/vehicle-scheme/schemes/sedan-top.svg
  • Create: mechanic-pwa/frontend/src/lib/coords.ts

Ops

  • Create: mechanic-pwa/ops/Caddyfile.fragment (Caddy config for mechanic.pptaxi.ru)
  • Create: mechanic-pwa/ops/minio-cors.json (CORS policy for bucket)

Conventions

  • TDD: backend tests use pytest+httpx async client; write failing test FIRST, then implement, then verify pass
  • Commits: small, atomic, after each task
  • Branching: feature branch feat/mechanic-mvp off main (or current dev branch)
  • No placeholders in code: every step is fully executable

Phase A — Infrastructure & Database (1 day)

Task A1: Inspect existing TaxiDashboard backend structure

Files: read-only

  • Step 1: SSH onto VDS, identify backend layout

Run on host machine:

ssh root@100.64.0.12
cd /opt/sites/taxi-dashboard
ls -la
find . -maxdepth 4 -type d -name "app" -o -name "alembic" 2>/dev/null
cat backend/app/main.py 2>/dev/null | head -40   # or wherever main.py lives

Expected: identify exact package layout, e.g.:

  • /opt/sites/taxi-dashboard/backend/app/ is FastAPI root

  • /opt/sites/taxi-dashboard/backend/alembic/ is migrations dir

  • Existing modules under backend/app/<modulename>/

  • Step 2: Document findings

Write down (in mechanic-pwa/README.md or in a comment block at top of router.py later):

Backend root: /opt/sites/taxi-dashboard/backend/app
Alembic dir: /opt/sites/taxi-dashboard/backend/alembic
Auth: <existing dep, name>
DB session: <existing dep, name>
User model path: <existing>
Vehicle model path: <existing>
MinIO client init: <existing>

These will be referenced by remaining backend tasks. If paths differ from this plan's assumptions, replace backend/app/... consistently throughout subsequent tasks.

  • Step 3: Commit notes
git add mechanic-pwa/README.md
git commit -m "docs(mechanic): record existing backend layout"

Task A2: Caddyfile for mechanic.pptaxi.ru

Files:

  • Create: mechanic-pwa/ops/Caddyfile.fragment

  • Step 1: Write Caddy fragment

Create mechanic-pwa/ops/Caddyfile.fragment:

mechanic.pptaxi.ru {
    encode gzip

    # API → existing TaxiDashboard backend
    handle /api/* {
        reverse_proxy 127.0.0.1:8000
    }

    # PWA static
    handle {
        root * /opt/sites/mechanic-pwa/dist
        try_files {path} /index.html
        file_server
    }

    log {
        output file /var/log/caddy/mechanic.pptaxi.ru.log
    }
}

(Replace 127.0.0.1:8000 if the TaxiDashboard backend listens on a different port — check Task A1 notes.)

  • Step 2: Test config syntax locally (or on VDS)

Run on VDS:

caddy validate --config /etc/caddy/Caddyfile

Expected: Valid configuration (after wiring fragment into main Caddyfile or copying into appropriate include).

  • Step 3: Commit (don't reload yet — wait for deploy phase)
git add mechanic-pwa/ops/Caddyfile.fragment
git commit -m "ops(mechanic): add Caddy fragment for mechanic.pptaxi.ru"

Task A3: MinIO CORS policy on bucket pp-inspections

Files:

  • Create: mechanic-pwa/ops/minio-cors.json

  • Step 1: Write CORS policy

Create mechanic-pwa/ops/minio-cors.json:

{
  "CORSRules": [
    {
      "AllowedOrigins": [
        "https://mechanic.pptaxi.ru",
        "https://crm.pptaxi.ru",
        "http://localhost:5173"
      ],
      "AllowedMethods": ["GET", "PUT", "POST", "HEAD"],
      "AllowedHeaders": ["*"],
      "ExposeHeaders": ["ETag", "x-amz-meta-*"],
      "MaxAgeSeconds": 3600
    }
  ]
}
  • Step 2: Apply via mc (MinIO client) on VDS
# On VDS 100.64.0.12
source /opt/sites/taxi-dashboard/minio.env
mc alias set local https://s3.pptaxi.ru "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"
mc anonymous set-json /path/to/mechanic-pwa/ops/minio-cors.json local/pp-inspections
# (For mc < 2024: `mc admin policy set-json` — adjust to installed version)

Expected: Successfully configured cors policy for local/pp-inspections``.

  • Step 3: Verify with curl preflight
curl -i -X OPTIONS \
  -H "Origin: https://mechanic.pptaxi.ru" \
  -H "Access-Control-Request-Method: PUT" \
  https://s3.pptaxi.ru/pp-inspections

Expected: HTTP/2 200 and Access-Control-Allow-Origin: https://mechanic.pptaxi.ru.

  • Step 4: Commit
git add mechanic-pwa/ops/minio-cors.json
git commit -m "ops(mechanic): MinIO CORS policy for pp-inspections bucket"

Task A4: PostgreSQL migration — three new tables

Files:

  • Create: backend/alembic/versions/2026_05_16_1200_mechanic_init.py

  • Step 1: Generate empty migration

cd /opt/sites/taxi-dashboard/backend
alembic revision -m "mechanic_init"

Expected: created file alembic/versions/<hash>_mechanic_init.py.

Rename or note the file (use date-prefix convention to match this plan: 2026_05_16_1200_mechanic_init.py).

  • Step 2: Write upgrade() and downgrade()

Replace migration content with:

"""mechanic_init

Revision ID: <as generated>
Revises: <prev>
Create Date: 2026-05-16 12:00:00
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB


# revision identifiers, used by Alembic.
revision = "<as generated>"
down_revision = "<previous head>"
branch_labels = None
depends_on = None


def upgrade() -> None:
    op.create_table(
        "inspections",
        sa.Column("id", sa.BigInteger, primary_key=True),
        sa.Column("vehicle_id", sa.BigInteger, sa.ForeignKey("vehicles.id"), nullable=False),
        sa.Column("type", sa.String(32), nullable=False),
        sa.Column("performed_by", sa.Integer, sa.ForeignKey("users.id"), nullable=False),
        sa.Column("driver_id", sa.Integer, sa.ForeignKey("users.id"), nullable=True),
        sa.Column("started_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
        sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
        sa.Column("status", sa.String(16), nullable=False, server_default="in_progress"),
        sa.Column("notes", sa.Text, nullable=True),
        sa.Column("prev_inspection_id", sa.BigInteger, sa.ForeignKey("inspections.id"), nullable=True),
        sa.Column("meta", JSONB, server_default=sa.text("'{}'::jsonb")),
    )
    op.create_index("ix_inspections_vehicle_started", "inspections", ["vehicle_id", sa.text("started_at DESC")])

    op.create_table(
        "inspection_photos",
        sa.Column("id", sa.BigInteger, primary_key=True),
        sa.Column("inspection_id", sa.BigInteger, sa.ForeignKey("inspections.id", ondelete="CASCADE"), nullable=False),
        sa.Column("side", sa.String(16), nullable=False),
        sa.Column("slot_index", sa.Integer, server_default="0"),
        sa.Column("storage_key", sa.Text, nullable=False),
        sa.Column("annotated_key", sa.Text, nullable=True),
        sa.Column("thumb_key", sa.Text, nullable=True),
        sa.Column("width", sa.Integer, nullable=True),
        sa.Column("height", sa.Integer, nullable=True),
        sa.Column("bytes", sa.BigInteger, nullable=True),
        sa.Column("status", sa.String(16), nullable=False, server_default="pending"),  # pending|ready
        sa.Column("taken_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
        sa.Column("display_order", sa.Integer, server_default="0"),
    )

    op.create_table(
        "damage_markers",
        sa.Column("id", sa.BigInteger, primary_key=True),
        sa.Column("inspection_id", sa.BigInteger, sa.ForeignKey("inspections.id", ondelete="CASCADE"), nullable=False),
        sa.Column("photo_id", sa.BigInteger, sa.ForeignKey("inspection_photos.id"), nullable=True),
        sa.Column("side", sa.String(16), nullable=True),
        sa.Column("x", sa.Numeric(6, 4), nullable=True),
        sa.Column("y", sa.Numeric(6, 4), nullable=True),
        sa.Column("polygon", JSONB, nullable=True),
        sa.Column("damage_type", sa.String(32), nullable=True),
        sa.Column("severity", sa.String(16), nullable=True),
        sa.Column("description", sa.Text, nullable=True),
        sa.Column("carried_over_from_id", sa.BigInteger, sa.ForeignKey("damage_markers.id"), nullable=True),
        sa.Column("resolved", sa.Boolean, server_default=sa.false()),
        sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
    )
    op.create_index("ix_damage_markers_inspection", "damage_markers", ["inspection_id"])


def downgrade() -> None:
    op.drop_index("ix_damage_markers_inspection", table_name="damage_markers")
    op.drop_table("damage_markers")
    op.drop_table("inspection_photos")
    op.drop_index("ix_inspections_vehicle_started", table_name="inspections")
    op.drop_table("inspections")

Replace <as generated> and <previous head> with values Alembic assigned in Step 1.

  • Step 3: Apply migration to dev DB
alembic upgrade head

Expected: Running upgrade <prev> -> <new>, mechanic_init.

  • Step 4: Verify schema in psql
psql $DATABASE_URL -c "\d inspections" -c "\d inspection_photos" -c "\d damage_markers"

Expected: three tables with described columns.

  • Step 5: Test downgrade then re-upgrade
alembic downgrade -1
alembic upgrade head

Expected: both succeed without errors.

  • Step 6: Commit
git add backend/alembic/versions/2026_05_16_1200_mechanic_init.py
git commit -m "feat(mechanic): add migration for inspections, photos, markers tables"

Phase B — Backend Module Scaffold (1 day)

Task B1: Create empty mechanic module package

Files:

  • Create: backend/app/mechanic/__init__.py

  • Create: backend/app/mechanic/router.py

  • Step 1: Create package with empty router

backend/app/mechanic/__init__.py:

"""Premium Mechanic — PWA backend module for vehicle inspections."""

from .router import router

__all__ = ["router"]

backend/app/mechanic/router.py:

from fastapi import APIRouter

router = APIRouter(prefix="/api/v1/mechanic", tags=["mechanic"])


@router.get("/healthz")
async def healthz() -> dict[str, str]:
    return {"status": "ok"}
  • Step 2: Register in main app

Find backend/app/main.py (or wherever FastAPI() and existing routers live).

Add import + include:

# At top with other imports
from app.mechanic import router as mechanic_router

# Where other routers are included
app.include_router(mechanic_router)
  • Step 3: Start app, hit healthz
uvicorn app.main:app --reload --port 8000
# in another terminal
curl http://127.0.0.1:8000/api/v1/mechanic/healthz

Expected: {"status":"ok"}.

  • Step 4: Commit
git add backend/app/mechanic/ backend/app/main.py
git commit -m "feat(mechanic): scaffold module with healthz endpoint"

Task B2: SQLAlchemy models

Files:

  • Create: backend/app/mechanic/models.py

  • Step 1: Write failing test for model imports

Create backend/tests/test_mechanic/__init__.py (empty) and backend/tests/test_mechanic/test_models.py:

from app.mechanic.models import Inspection, InspectionPhoto, DamageMarker


def test_inspection_columns():
    cols = {c.name for c in Inspection.__table__.columns}
    expected = {
        "id", "vehicle_id", "type", "performed_by", "driver_id",
        "started_at", "finished_at", "status", "notes",
        "prev_inspection_id", "meta",
    }
    assert expected.issubset(cols), f"missing: {expected - cols}"


def test_inspection_photo_columns():
    cols = {c.name for c in InspectionPhoto.__table__.columns}
    expected = {
        "id", "inspection_id", "side", "slot_index", "storage_key",
        "annotated_key", "thumb_key", "width", "height", "bytes",
        "status", "taken_at", "display_order",
    }
    assert expected.issubset(cols)


def test_damage_marker_columns():
    cols = {c.name for c in DamageMarker.__table__.columns}
    expected = {
        "id", "inspection_id", "photo_id", "side", "x", "y", "polygon",
        "damage_type", "severity", "description",
        "carried_over_from_id", "resolved", "created_at",
    }
    assert expected.issubset(cols)
  • Step 2: Run, verify fails
pytest backend/tests/test_mechanic/test_models.py -v

Expected: ModuleNotFoundError: No module named 'app.mechanic.models'.

  • Step 3: Implement models

backend/app/mechanic/models.py:

from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import Any, Optional

from sqlalchemy import (
    BigInteger, Boolean, DateTime, ForeignKey, Integer, Numeric,
    String, Text, func, false,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship

# Use the same Base as the rest of the project (e.g. from app.db.base)
from app.db.base import Base  # adjust import if path differs


class Inspection(Base):
    __tablename__ = "inspections"

    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    vehicle_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("vehicles.id"), nullable=False, index=True)
    type: Mapped[str] = mapped_column(String(32), nullable=False)
    performed_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
    driver_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
    started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
    finished_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
    status: Mapped[str] = mapped_column(String(16), nullable=False, server_default="in_progress")
    notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
    prev_inspection_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("inspections.id"), nullable=True)
    meta: Mapped[dict[str, Any]] = mapped_column(JSONB, server_default="{}")

    photos: Mapped[list["InspectionPhoto"]] = relationship(back_populates="inspection", cascade="all, delete-orphan")
    markers: Mapped[list["DamageMarker"]] = relationship(back_populates="inspection", cascade="all, delete-orphan")
    prev: Mapped[Optional["Inspection"]] = relationship(remote_side=[id], foreign_keys=[prev_inspection_id])


class InspectionPhoto(Base):
    __tablename__ = "inspection_photos"

    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    inspection_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("inspections.id", ondelete="CASCADE"), nullable=False, index=True)
    side: Mapped[str] = mapped_column(String(16), nullable=False)
    slot_index: Mapped[int] = mapped_column(Integer, server_default="0")
    storage_key: Mapped[str] = mapped_column(Text, nullable=False)
    annotated_key: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
    thumb_key: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
    width: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
    height: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
    bytes: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True)
    status: Mapped[str] = mapped_column(String(16), nullable=False, server_default="pending")
    taken_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
    display_order: Mapped[int] = mapped_column(Integer, server_default="0")

    inspection: Mapped[Inspection] = relationship(back_populates="photos")
    markers: Mapped[list["DamageMarker"]] = relationship(back_populates="photo")


class DamageMarker(Base):
    __tablename__ = "damage_markers"

    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    inspection_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("inspections.id", ondelete="CASCADE"), nullable=False, index=True)
    photo_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("inspection_photos.id"), nullable=True)
    side: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
    x: Mapped[Optional[Decimal]] = mapped_column(Numeric(6, 4), nullable=True)
    y: Mapped[Optional[Decimal]] = mapped_column(Numeric(6, 4), nullable=True)
    polygon: Mapped[Optional[list[dict[str, float]]]] = mapped_column(JSONB, nullable=True)
    damage_type: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
    severity: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
    description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
    carried_over_from_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("damage_markers.id"), nullable=True)
    resolved: Mapped[bool] = mapped_column(Boolean, server_default=false())
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

    inspection: Mapped[Inspection] = relationship(back_populates="markers")
    photo: Mapped[Optional[InspectionPhoto]] = relationship(back_populates="markers")
    carried_over_from: Mapped[Optional["DamageMarker"]] = relationship(remote_side=[id], foreign_keys=[carried_over_from_id])

Note: from app.db.base import Base — adjust import to wherever the project's declarative Base lives (Task A1 finding).

  • Step 4: Run test, verify passes
pytest backend/tests/test_mechanic/test_models.py -v

Expected: 3 passed.

  • Step 5: Commit
git add backend/app/mechanic/models.py backend/tests/test_mechanic/
git commit -m "feat(mechanic): SQLAlchemy models for inspections + photos + markers"

Task B3: Pydantic schemas

Files:

  • Create: backend/app/mechanic/schemas.py

  • Step 1: Write the file

backend/app/mechanic/schemas.py:

from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import Any, Literal, Optional

from pydantic import BaseModel, ConfigDict, Field


InspectionType = Literal["handover", "return", "periodic", "ad-hoc"]
InspectionStatus = Literal["in_progress", "completed", "cancelled"]
PhotoStatus = Literal["pending", "ready"]
PhotoSide = Literal[
    "front", "rear", "left", "right",
    "interior", "vin", "odometer",
    "wheel-fl", "wheel-fr", "wheel-rl", "wheel-rr",
    "free",
]
DamageType = Literal["scratch", "dent", "paint", "crack", "missing", "rust", "glass"]
Severity = Literal["cosmetic", "minor", "moderate", "severe"]


class _Base(BaseModel):
    model_config = ConfigDict(from_attributes=True)


class VehicleSummary(_Base):
    id: int
    license_plate: str
    vin: Optional[str] = None
    make: Optional[str] = None
    model: Optional[str] = None
    year: Optional[int] = None
    last_inspection_at: Optional[datetime] = None


class InspectionCreate(BaseModel):
    vehicle_id: int
    type: InspectionType
    driver_id: Optional[int] = None


class InspectionPatch(BaseModel):
    status: Optional[InspectionStatus] = None
    notes: Optional[str] = None
    finished_at: Optional[datetime] = None


class InspectionPhotoOut(_Base):
    id: int
    side: PhotoSide
    slot_index: int
    storage_key: str
    thumb_key: Optional[str]
    width: Optional[int]
    height: Optional[int]
    status: PhotoStatus
    taken_at: datetime


class MarkerCreate(BaseModel):
    photo_id: Optional[int] = None
    side: Optional[str] = None
    x: Optional[Decimal] = Field(default=None, ge=0, le=1)
    y: Optional[Decimal] = Field(default=None, ge=0, le=1)
    polygon: Optional[list[dict[str, float]]] = None
    damage_type: Optional[DamageType] = None
    severity: Optional[Severity] = None
    description: Optional[str] = None


class MarkerPatch(BaseModel):
    damage_type: Optional[DamageType] = None
    severity: Optional[Severity] = None
    description: Optional[str] = None
    polygon: Optional[list[dict[str, float]]] = None
    resolved: Optional[bool] = None


class MarkerOut(_Base):
    id: int
    inspection_id: int
    photo_id: Optional[int]
    side: Optional[str]
    x: Optional[Decimal]
    y: Optional[Decimal]
    polygon: Optional[list[dict[str, float]]]
    damage_type: Optional[str]
    severity: Optional[str]
    description: Optional[str]
    carried_over_from_id: Optional[int]
    resolved: bool
    created_at: datetime


class InspectionDetail(_Base):
    id: int
    vehicle_id: int
    type: InspectionType
    performed_by: int
    driver_id: Optional[int]
    started_at: datetime
    finished_at: Optional[datetime]
    status: InspectionStatus
    notes: Optional[str]
    prev_inspection_id: Optional[int]
    meta: dict[str, Any] = {}
    photos: list[InspectionPhotoOut] = []
    markers: list[MarkerOut] = []


class PresignedUploadRequest(BaseModel):
    side: PhotoSide
    slot_index: int = 0
    content_type: str
    size: int = Field(ge=1, le=20 * 1024 * 1024)  # max 20 MB


class PresignedUploadResponse(BaseModel):
    photo_id: int
    presigned_url: str
    fields: dict[str, str]
    storage_key: str
    expires_at: datetime


class PhotoConfirmRequest(BaseModel):
    width: Optional[int] = None
    height: Optional[int] = None
    actual_size: Optional[int] = None


class MeOut(_Base):
    id: int
    name: str
    email: Optional[str]
    permissions: list[str] = []
  • Step 2: Commit (no test for schemas alone — covered by endpoint tests)
git add backend/app/mechanic/schemas.py
git commit -m "feat(mechanic): Pydantic schemas"

Task B4: Auth + DB session dependencies

Files:

  • Create: backend/app/mechanic/deps.py

  • Step 1: Write deps

backend/app/mechanic/deps.py:

"""Dependency-injection helpers — reuse existing TaxiDashboard auth + session.

Adjust imports based on Task A1 findings:
- get_db: returns async DB session
- get_current_user: validates JWT cookie, returns User model
"""
from fastapi import Depends, HTTPException, status

from app.auth.deps import get_current_user  # adjust path
from app.db.deps import get_db              # adjust path
from app.users.models import User           # adjust path


def require_mechanic(user: User = Depends(get_current_user)) -> User:
    """Permission guard: only mechanics (dept='Тех. служба') or admins."""
    # Replace `dept` / `is_admin` with actual field names from existing User model
    if getattr(user, "is_admin", False):
        return user
    dept = getattr(user, "department", None) or getattr(user, "dept", None)
    if dept and dept.lower() in ("тех. служба", "тех служба", "техслужба", "mechanic"):
        return user
    raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="mechanic role required")


__all__ = ["get_db", "get_current_user", "require_mechanic"]
  • Step 2: Commit
git add backend/app/mechanic/deps.py
git commit -m "feat(mechanic): auth deps wrapper"

Phase C — Backend Read API (1-2 days)

Task C1: GET /me endpoint

Files:

  • Modify: backend/app/mechanic/router.py

  • Create: backend/tests/test_mechanic/test_me.py

  • Step 1: Write failing test

backend/tests/test_mechanic/test_me.py:

import pytest
from httpx import AsyncClient


@pytest.mark.asyncio
async def test_me_returns_current_mechanic(client: AsyncClient, mechanic_user_token: str):
    r = await client.get("/api/v1/mechanic/me",
                         headers={"Authorization": f"Bearer {mechanic_user_token}"})
    assert r.status_code == 200
    body = r.json()
    assert "id" in body and "name" in body
    assert isinstance(body.get("permissions", []), list)


@pytest.mark.asyncio
async def test_me_unauthorized_without_token(client: AsyncClient):
    r = await client.get("/api/v1/mechanic/me")
    assert r.status_code in (401, 403)

(Assumes existing client and mechanic_user_token fixtures in backend/tests/conftest.py. If not present — extract from existing tests of other modules; do NOT recreate, reuse.)

  • Step 2: Verify fails
pytest backend/tests/test_mechanic/test_me.py -v

Expected: 404 (route not found) or import error.

  • Step 3: Add endpoint to router

In backend/app/mechanic/router.py, append:

from fastapi import Depends
from app.mechanic.deps import require_mechanic
from app.mechanic.schemas import MeOut


@router.get("/me", response_model=MeOut)
async def me(user=Depends(require_mechanic)):
    perms = []
    if getattr(user, "is_admin", False):
        perms.append("admin")
    perms.append("mechanic:inspect")
    return MeOut(
        id=user.id,
        name=getattr(user, "full_name", None) or getattr(user, "name", "") or user.email or f"user#{user.id}",
        email=getattr(user, "email", None),
        permissions=perms,
    )
  • Step 4: Verify passes
pytest backend/tests/test_mechanic/test_me.py -v

Expected: 2 passed.

  • Step 5: Commit
git add backend/app/mechanic/router.py backend/tests/test_mechanic/test_me.py
git commit -m "feat(mechanic): GET /me endpoint"

Files:

  • Modify: backend/app/mechanic/router.py

  • Create: backend/app/mechanic/service.py

  • Create: backend/tests/test_mechanic/test_vehicles.py

  • Step 1: Write failing test

backend/tests/test_mechanic/test_vehicles.py:

import pytest
from httpx import AsyncClient


@pytest.mark.asyncio
async def test_vehicles_list_returns_items(client, mechanic_user_token, sample_vehicles):
    r = await client.get("/api/v1/mechanic/vehicles?limit=10",
                         headers={"Authorization": f"Bearer {mechanic_user_token}"})
    assert r.status_code == 200
    data = r.json()
    assert "items" in data
    assert len(data["items"]) >= 1
    item = data["items"][0]
    assert "id" in item and "license_plate" in item


@pytest.mark.asyncio
async def test_vehicles_search_by_plate(client, mechanic_user_token, sample_vehicles):
    plate = sample_vehicles[0].license_plate[:3]
    r = await client.get(f"/api/v1/mechanic/vehicles?q={plate}",
                         headers={"Authorization": f"Bearer {mechanic_user_token}"})
    assert r.status_code == 200
    assert any(plate in v["license_plate"] for v in r.json()["items"])

The sample_vehicles fixture must exist or be added to conftest.py (creates ≥3 vehicles in DB before test, cleans up after). If not present — add it.

  • Step 2: Verify fails
pytest backend/tests/test_mechanic/test_vehicles.py -v
  • Step 3: Implement service

Create backend/app/mechanic/service.py:

from __future__ import annotations
from typing import Optional

from sqlalchemy import select, or_, func
from sqlalchemy.ext.asyncio import AsyncSession

from app.vehicles.models import Vehicle  # adjust import to existing path
from app.mechanic.models import Inspection


async def search_vehicles(
    session: AsyncSession,
    q: Optional[str] = None,
    limit: int = 20,
) -> list[Vehicle]:
    stmt = select(Vehicle).limit(limit)
    if q:
        like = f"%{q}%"
        stmt = stmt.where(
            or_(
                Vehicle.license_plate.ilike(like),
                Vehicle.vin.ilike(like) if hasattr(Vehicle, "vin") else False,
            )
        )
    stmt = stmt.order_by(Vehicle.license_plate.asc())
    result = await session.execute(stmt)
    return list(result.scalars().all())


async def last_inspection_at(session: AsyncSession, vehicle_id: int):
    stmt = select(func.max(Inspection.finished_at)).where(
        Inspection.vehicle_id == vehicle_id,
        Inspection.status == "completed",
    )
    return (await session.execute(stmt)).scalar_one_or_none()
  • Step 4: Add endpoint to router

In backend/app/mechanic/router.py, append:

from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession

from app.mechanic.deps import get_db
from app.mechanic import service
from app.mechanic.schemas import VehicleSummary


@router.get("/vehicles", response_model=dict)
async def list_vehicles(
    q: Optional[str] = None,
    limit: int = 20,
    session: AsyncSession = Depends(get_db),
    _user=Depends(require_mechanic),
):
    vehicles = await service.search_vehicles(session, q=q, limit=limit)
    out = []
    for v in vehicles:
        out.append(VehicleSummary(
            id=v.id,
            license_plate=v.license_plate,
            vin=getattr(v, "vin", None),
            make=getattr(v, "make", None),
            model=getattr(v, "model", None),
            year=getattr(v, "year", None),
            last_inspection_at=await service.last_inspection_at(session, v.id),
        ))
    return {"items": [item.model_dump(mode="json") for item in out]}
  • Step 5: Verify passes + commit
pytest backend/tests/test_mechanic/test_vehicles.py -v
git add backend/app/mechanic/ backend/tests/test_mechanic/test_vehicles.py
git commit -m "feat(mechanic): GET /vehicles search endpoint"

Task C3: GET /vehicles/{id} — detail

Files:

  • Modify: backend/app/mechanic/router.py

  • Modify: backend/app/mechanic/service.py

  • Append to: backend/tests/test_mechanic/test_vehicles.py

  • Step 1: Append test

@pytest.mark.asyncio
async def test_vehicle_detail(client, mechanic_user_token, sample_vehicles):
    vid = sample_vehicles[0].id
    r = await client.get(f"/api/v1/mechanic/vehicles/{vid}",
                         headers={"Authorization": f"Bearer {mechanic_user_token}"})
    assert r.status_code == 200
    body = r.json()
    assert body["id"] == vid
    assert "license_plate" in body
    assert "recent_inspections" in body


@pytest.mark.asyncio
async def test_vehicle_detail_404(client, mechanic_user_token):
    r = await client.get("/api/v1/mechanic/vehicles/999999999",
                         headers={"Authorization": f"Bearer {mechanic_user_token}"})
    assert r.status_code == 404
  • Step 2: Verify fails
pytest backend/tests/test_mechanic/test_vehicles.py::test_vehicle_detail -v
  • Step 3: Add service method

Append to service.py:

from app.mechanic.models import InspectionPhoto

async def get_vehicle_or_404(session: AsyncSession, vehicle_id: int) -> Vehicle:
    obj = await session.get(Vehicle, vehicle_id)
    if not obj:
        from fastapi import HTTPException
        raise HTTPException(status_code=404, detail="vehicle not found")
    return obj


async def recent_inspections(session: AsyncSession, vehicle_id: int, limit: int = 5) -> list[Inspection]:
    stmt = (
        select(Inspection)
        .where(Inspection.vehicle_id == vehicle_id)
        .order_by(Inspection.started_at.desc())
        .limit(limit)
    )
    return list((await session.execute(stmt)).scalars().all())
  • Step 4: Add endpoint

Append to router.py:

@router.get("/vehicles/{vehicle_id}")
async def vehicle_detail(
    vehicle_id: int,
    session: AsyncSession = Depends(get_db),
    _user=Depends(require_mechanic),
):
    v = await service.get_vehicle_or_404(session, vehicle_id)
    insps = await service.recent_inspections(session, vehicle_id, limit=10)
    return {
        "id": v.id,
        "license_plate": v.license_plate,
        "vin": getattr(v, "vin", None),
        "make": getattr(v, "make", None),
        "model": getattr(v, "model", None),
        "year": getattr(v, "year", None),
        "recent_inspections": [
            {
                "id": i.id, "type": i.type, "status": i.status,
                "started_at": i.started_at.isoformat(),
                "finished_at": i.finished_at.isoformat() if i.finished_at else None,
            }
            for i in insps
        ],
    }
  • Step 5: Verify + commit
pytest backend/tests/test_mechanic/test_vehicles.py -v
git add backend/app/mechanic/ backend/tests/test_mechanic/test_vehicles.py
git commit -m "feat(mechanic): GET /vehicles/{id} detail endpoint"

Phase D — Backend Inspection API (1-2 days)

Task D1: POST /inspections — create new

Files:

  • Modify: backend/app/mechanic/router.py

  • Modify: backend/app/mechanic/service.py

  • Create: backend/tests/test_mechanic/test_inspections.py

  • Step 1: Write failing test

import pytest
from httpx import AsyncClient


@pytest.mark.asyncio
async def test_create_inspection_handover(client, mechanic_user_token, sample_vehicles):
    vid = sample_vehicles[0].id
    r = await client.post("/api/v1/mechanic/inspections",
                          headers={"Authorization": f"Bearer {mechanic_user_token}"},
                          json={"vehicle_id": vid, "type": "handover"})
    assert r.status_code == 201
    body = r.json()
    assert body["vehicle_id"] == vid
    assert body["type"] == "handover"
    assert body["status"] == "in_progress"
    assert "id" in body


@pytest.mark.asyncio
async def test_create_inspection_return_copies_markers(
    client, mechanic_user_token, sample_vehicle_with_prev_inspection
):
    """If prev inspection has markers, new return-inspection inherits them."""
    vid, prev_marker_count = sample_vehicle_with_prev_inspection
    r = await client.post("/api/v1/mechanic/inspections",
                          headers={"Authorization": f"Bearer {mechanic_user_token}"},
                          json={"vehicle_id": vid, "type": "return"})
    assert r.status_code == 201
    new_id = r.json()["id"]

    r2 = await client.get(f"/api/v1/mechanic/inspections/{new_id}",
                          headers={"Authorization": f"Bearer {mechanic_user_token}"})
    detail = r2.json()
    assert len(detail["markers"]) == prev_marker_count
    assert all(m["carried_over_from_id"] for m in detail["markers"])
  • Step 2: Verify fails
pytest backend/tests/test_mechanic/test_inspections.py -v
  • Step 3: Implement service

Append to service.py:

from app.mechanic.models import DamageMarker


async def latest_completed_inspection(session: AsyncSession, vehicle_id: int) -> Optional[Inspection]:
    stmt = (
        select(Inspection)
        .where(Inspection.vehicle_id == vehicle_id, Inspection.status == "completed")
        .order_by(Inspection.finished_at.desc())
        .limit(1)
    )
    return (await session.execute(stmt)).scalar_one_or_none()


async def start_inspection(
    session: AsyncSession,
    *,
    vehicle_id: int,
    type: str,
    performed_by: int,
    driver_id: Optional[int] = None,
) -> Inspection:
    prev = None
    if type == "return":
        prev = await latest_completed_inspection(session, vehicle_id)
    insp = Inspection(
        vehicle_id=vehicle_id,
        type=type,
        performed_by=performed_by,
        driver_id=driver_id,
        prev_inspection_id=prev.id if prev else None,
    )
    session.add(insp)
    await session.flush()  # assigns id

    if prev:
        # Copy markers from prev as carried-over
        prev_markers = (await session.execute(
            select(DamageMarker).where(DamageMarker.inspection_id == prev.id)
        )).scalars().all()
        for m in prev_markers:
            session.add(DamageMarker(
                inspection_id=insp.id,
                photo_id=None,  # original photo belongs to prev inspection
                side=m.side,
                x=m.x, y=m.y, polygon=m.polygon,
                damage_type=m.damage_type, severity=m.severity,
                description=m.description,
                carried_over_from_id=m.id,
                resolved=False,
            ))
    await session.commit()
    await session.refresh(insp)
    return insp
  • Step 4: Add endpoint

Append to router.py:

from fastapi import status as http_status
from app.mechanic.schemas import InspectionCreate, InspectionDetail


@router.post("/inspections", status_code=http_status.HTTP_201_CREATED, response_model=InspectionDetail)
async def create_inspection(
    payload: InspectionCreate,
    session: AsyncSession = Depends(get_db),
    user=Depends(require_mechanic),
):
    # Ensure vehicle exists
    await service.get_vehicle_or_404(session, payload.vehicle_id)
    insp = await service.start_inspection(
        session,
        vehicle_id=payload.vehicle_id,
        type=payload.type,
        performed_by=user.id,
        driver_id=payload.driver_id,
    )
    return insp
  • Step 5: Verify + commit
pytest backend/tests/test_mechanic/test_inspections.py::test_create_inspection_handover -v
git add backend/app/mechanic/ backend/tests/test_mechanic/test_inspections.py
git commit -m "feat(mechanic): POST /inspections endpoint"

(Second test depends on Task D2.)


Task D2: GET /inspections/{id} — full detail with photos & markers

Files:

  • Modify: backend/app/mechanic/router.py

  • Append to: backend/tests/test_mechanic/test_inspections.py

  • Step 1: Append test

@pytest.mark.asyncio
async def test_get_inspection_detail_includes_photos_and_markers(
    client, mechanic_user_token, sample_inspection_with_photo_and_marker
):
    insp_id = sample_inspection_with_photo_and_marker
    r = await client.get(f"/api/v1/mechanic/inspections/{insp_id}",
                         headers={"Authorization": f"Bearer {mechanic_user_token}"})
    assert r.status_code == 200
    body = r.json()
    assert body["id"] == insp_id
    assert len(body["photos"]) >= 1
    assert len(body["markers"]) >= 1
  • Step 2: Verify fails

  • Step 3: Add endpoint

Append to router.py:

from sqlalchemy.orm import selectinload
from app.mechanic.models import Inspection


@router.get("/inspections/{inspection_id}", response_model=InspectionDetail)
async def get_inspection(
    inspection_id: int,
    session: AsyncSession = Depends(get_db),
    _user=Depends(require_mechanic),
):
    stmt = (
        select(Inspection)
        .where(Inspection.id == inspection_id)
        .options(selectinload(Inspection.photos), selectinload(Inspection.markers))
    )
    insp = (await session.execute(stmt)).scalar_one_or_none()
    if not insp:
        from fastapi import HTTPException
        raise HTTPException(404, "inspection not found")
    return insp
  • Step 4: Verify + commit
pytest backend/tests/test_mechanic/test_inspections.py -v
git add backend/app/mechanic/router.py backend/tests/test_mechanic/test_inspections.py
git commit -m "feat(mechanic): GET /inspections/{id} detail endpoint"

Now also test_create_inspection_return_copies_markers should pass.


Task D3: PATCH /inspections/{id} — update status/notes

Files:

  • Modify: backend/app/mechanic/router.py

  • Append to: backend/tests/test_mechanic/test_inspections.py

  • Step 1: Append test

@pytest.mark.asyncio
async def test_finalize_inspection(client, mechanic_user_token, sample_inspection):
    insp_id = sample_inspection.id
    r = await client.patch(
        f"/api/v1/mechanic/inspections/{insp_id}",
        headers={"Authorization": f"Bearer {mechanic_user_token}"},
        json={"status": "completed", "notes": "All good."},
    )
    assert r.status_code == 200
    assert r.json()["status"] == "completed"
  • Step 2: Verify fails

  • Step 3: Implement

Append to router.py:

from datetime import datetime, timezone
from app.mechanic.schemas import InspectionPatch


@router.patch("/inspections/{inspection_id}", response_model=InspectionDetail)
async def patch_inspection(
    inspection_id: int,
    payload: InspectionPatch,
    session: AsyncSession = Depends(get_db),
    _user=Depends(require_mechanic),
):
    insp = await session.get(Inspection, inspection_id)
    if not insp:
        from fastapi import HTTPException
        raise HTTPException(404, "inspection not found")
    data = payload.model_dump(exclude_unset=True)
    if data.get("status") == "completed" and "finished_at" not in data:
        data["finished_at"] = datetime.now(timezone.utc)
    for k, v in data.items():
        setattr(insp, k, v)
    await session.commit()
    await session.refresh(insp)
    return insp
  • Step 4: Verify + commit
pytest backend/tests/test_mechanic/test_inspections.py -v
git add backend/app/mechanic/router.py backend/tests/test_mechanic/test_inspections.py
git commit -m "feat(mechanic): PATCH /inspections/{id}"

Phase E — Backend Photo Storage (2 days)

Task E1: MinIO async client wrapper

Files:

  • Create: backend/app/mechanic/storage.py

  • Create: backend/tests/test_mechanic/test_storage.py

  • Step 1: Write failing test

import pytest
from app.mechanic.storage import build_storage_key, get_minio_client


def test_build_storage_key_inspection_original():
    key = build_storage_key(inspection_id=42, photo_id=7, variant="original", ext="jpg")
    assert key == "inspections/42/original/7.jpg"


def test_build_storage_key_thumb():
    key = build_storage_key(inspection_id=42, photo_id=7, variant="thumb", ext="jpg")
    assert key == "inspections/42/thumb/7.jpg"


@pytest.mark.asyncio
async def test_get_minio_client_returns_session(monkeypatch):
    monkeypatch.setenv("MINIO_ENDPOINT", "https://s3.pptaxi.ru")
    monkeypatch.setenv("MINIO_ACCESS_KEY", "key")
    monkeypatch.setenv("MINIO_SECRET_KEY", "secret")
    async with get_minio_client() as client:
        # client should expose generate_presigned_post via aiobotocore (smoke check)
        assert hasattr(client, "generate_presigned_post")
  • Step 2: Implement

backend/app/mechanic/storage.py:

from __future__ import annotations
import os
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone

from aiobotocore.session import get_session
from botocore.client import Config

BUCKET = os.environ.get("MINIO_BUCKET", "pp-inspections")
ENDPOINT = os.environ.get("MINIO_ENDPOINT", "https://s3.pptaxi.ru")
ACCESS_KEY = os.environ.get("MINIO_ACCESS_KEY", "")
SECRET_KEY = os.environ.get("MINIO_SECRET_KEY", "")
REGION = os.environ.get("MINIO_REGION", "us-east-1")
PRESIGN_TTL = int(os.environ.get("MINIO_PRESIGN_TTL", "3600"))  # 1 hour


def build_storage_key(*, inspection_id: int, photo_id: int, variant: str, ext: str) -> str:
    assert variant in {"original", "annotated", "thumb"}
    ext = ext.lstrip(".").lower()
    return f"inspections/{inspection_id}/{variant}/{photo_id}.{ext}"


@asynccontextmanager
async def get_minio_client():
    session = get_session()
    async with session.create_client(
        "s3",
        endpoint_url=ENDPOINT,
        aws_access_key_id=ACCESS_KEY,
        aws_secret_access_key=SECRET_KEY,
        region_name=REGION,
        config=Config(signature_version="s3v4"),
    ) as client:
        yield client


async def presigned_upload(
    *,
    storage_key: str,
    content_type: str,
    max_bytes: int = 20 * 1024 * 1024,
) -> tuple[dict, datetime]:
    """Return ({'url': ..., 'fields': {...}}, expires_at)."""
    async with get_minio_client() as client:
        resp = await client.generate_presigned_post(
            Bucket=BUCKET,
            Key=storage_key,
            Fields={"Content-Type": content_type},
            Conditions=[
                {"Content-Type": content_type},
                ["content-length-range", 1, max_bytes],
            ],
            ExpiresIn=PRESIGN_TTL,
        )
    expires_at = datetime.now(timezone.utc) + timedelta(seconds=PRESIGN_TTL)
    return resp, expires_at


async def presigned_get(storage_key: str) -> str:
    async with get_minio_client() as client:
        return await client.generate_presigned_url(
            "get_object",
            Params={"Bucket": BUCKET, "Key": storage_key},
            ExpiresIn=PRESIGN_TTL,
        )


async def head_object(storage_key: str) -> dict | None:
    async with get_minio_client() as client:
        try:
            return await client.head_object(Bucket=BUCKET, Key=storage_key)
        except Exception:
            return None
  • Step 3: Verify + commit
pytest backend/tests/test_mechanic/test_storage.py -v
git add backend/app/mechanic/storage.py backend/tests/test_mechanic/test_storage.py
git commit -m "feat(mechanic): MinIO storage wrapper with presigned upload/get"

Task E2: POST /inspections/{id}/photos/upload-url — presigned upload URL

Files:

  • Modify: backend/app/mechanic/router.py

  • Modify: backend/app/mechanic/service.py

  • Create: backend/tests/test_mechanic/test_photos.py

  • Step 1: Write failing test

import pytest


@pytest.mark.asyncio
async def test_request_upload_url(client, mechanic_user_token, sample_inspection):
    insp_id = sample_inspection.id
    r = await client.post(
        f"/api/v1/mechanic/inspections/{insp_id}/photos/upload-url",
        headers={"Authorization": f"Bearer {mechanic_user_token}"},
        json={"side": "front", "slot_index": 0,
              "content_type": "image/jpeg", "size": 1024 * 500},
    )
    assert r.status_code == 200
    body = r.json()
    assert "photo_id" in body
    assert body["presigned_url"].startswith("http")
    assert "fields" in body
    assert "key" in body["fields"] or "Key" in body["fields"]


@pytest.mark.asyncio
async def test_request_upload_url_rejects_oversize(client, mechanic_user_token, sample_inspection):
    r = await client.post(
        f"/api/v1/mechanic/inspections/{sample_inspection.id}/photos/upload-url",
        headers={"Authorization": f"Bearer {mechanic_user_token}"},
        json={"side": "front", "content_type": "image/jpeg", "size": 50 * 1024 * 1024},
    )
    assert r.status_code in (400, 422)
  • Step 2: Verify fails

  • Step 3: Implement service method

Append to service.py:

from app.mechanic.models import InspectionPhoto
from app.mechanic.storage import presigned_upload, build_storage_key


async def create_photo_placeholder(
    session: AsyncSession,
    *,
    inspection_id: int,
    side: str,
    slot_index: int,
    content_type: str,
    size: int,
):
    # Create the DB row first to obtain photo_id
    ext = "jpg" if content_type in ("image/jpeg", "image/jpg") else content_type.split("/")[-1]
    photo = InspectionPhoto(
        inspection_id=inspection_id,
        side=side,
        slot_index=slot_index,
        storage_key="pending",
        status="pending",
        bytes=size,
    )
    session.add(photo)
    await session.flush()  # gets photo.id
    key = build_storage_key(
        inspection_id=inspection_id, photo_id=photo.id,
        variant="original", ext=ext,
    )
    photo.storage_key = key
    await session.commit()
    await session.refresh(photo)
    presign, expires_at = await presigned_upload(
        storage_key=key, content_type=content_type, max_bytes=size,
    )
    return photo, presign, expires_at
  • Step 4: Add endpoint

Append to router.py:

from app.mechanic.schemas import PresignedUploadRequest, PresignedUploadResponse


@router.post(
    "/inspections/{inspection_id}/photos/upload-url",
    response_model=PresignedUploadResponse,
)
async def request_photo_upload_url(
    inspection_id: int,
    payload: PresignedUploadRequest,
    session: AsyncSession = Depends(get_db),
    _user=Depends(require_mechanic),
):
    insp = await session.get(Inspection, inspection_id)
    if not insp:
        from fastapi import HTTPException
        raise HTTPException(404, "inspection not found")
    photo, presign, expires_at = await service.create_photo_placeholder(
        session,
        inspection_id=inspection_id,
        side=payload.side, slot_index=payload.slot_index,
        content_type=payload.content_type, size=payload.size,
    )
    return PresignedUploadResponse(
        photo_id=photo.id,
        presigned_url=presign["url"],
        fields=presign["fields"],
        storage_key=photo.storage_key,
        expires_at=expires_at,
    )
  • Step 5: Verify + commit
pytest backend/tests/test_mechanic/test_photos.py -v
git add backend/app/mechanic/ backend/tests/test_mechanic/test_photos.py
git commit -m "feat(mechanic): POST /photos/upload-url (presigned)"

Task E3: POST /photos/{id}/confirm — mark as ready

Files:

  • Modify: backend/app/mechanic/router.py

  • Append to: backend/tests/test_mechanic/test_photos.py

  • Step 1: Append test

@pytest.mark.asyncio
async def test_confirm_photo_after_upload(client, mechanic_user_token, uploaded_photo_pending):
    photo_id = uploaded_photo_pending.id  # fixture that simulates upload in MinIO
    r = await client.post(
        f"/api/v1/mechanic/photos/{photo_id}/confirm",
        headers={"Authorization": f"Bearer {mechanic_user_token}"},
        json={"width": 1920, "height": 1080, "actual_size": 524288},
    )
    assert r.status_code == 200
    assert r.json()["status"] == "ready"

The fixture uploaded_photo_pending creates a pending InspectionPhoto row and (for the test) PUTs a small object into MinIO under its storage_key via the same client (or stubs head_object to return non-None).

  • Step 2: Verify fails

  • Step 3: Implement

Append to router.py:

from app.mechanic.schemas import PhotoConfirmRequest
from app.mechanic.models import InspectionPhoto
from app.mechanic.storage import head_object


@router.post("/photos/{photo_id}/confirm")
async def confirm_photo(
    photo_id: int,
    payload: PhotoConfirmRequest,
    session: AsyncSession = Depends(get_db),
    _user=Depends(require_mechanic),
):
    photo = await session.get(InspectionPhoto, photo_id)
    if not photo:
        from fastapi import HTTPException
        raise HTTPException(404, "photo not found")
    # Verify object actually exists in MinIO
    meta = await head_object(photo.storage_key)
    if not meta:
        from fastapi import HTTPException
        raise HTTPException(409, "object not uploaded yet")
    photo.status = "ready"
    if payload.width:
        photo.width = payload.width
    if payload.height:
        photo.height = payload.height
    if payload.actual_size:
        photo.bytes = payload.actual_size
    await session.commit()
    await session.refresh(photo)
    # Enqueue celery task for thumbnail (next task) — for now, fire-and-forget
    # from app.mechanic.tasks import make_thumbnail
    # make_thumbnail.delay(photo.id)
    return {
        "id": photo.id, "storage_key": photo.storage_key, "thumb_key": photo.thumb_key,
        "status": photo.status,
    }
  • Step 4: Verify + commit
pytest backend/tests/test_mechanic/test_photos.py -v
git add backend/app/mechanic/router.py backend/tests/test_mechanic/test_photos.py
git commit -m "feat(mechanic): POST /photos/{id}/confirm"

Task E4: GET /photos/{id} — 302 redirect to presigned URL

Files:

  • Modify: backend/app/mechanic/router.py

  • Append to: backend/tests/test_mechanic/test_photos.py

  • Step 1: Append test

@pytest.mark.asyncio
async def test_get_photo_returns_redirect(client, mechanic_user_token, uploaded_photo_ready):
    r = await client.get(
        f"/api/v1/mechanic/photos/{uploaded_photo_ready.id}",
        headers={"Authorization": f"Bearer {mechanic_user_token}"},
        follow_redirects=False,
    )
    assert r.status_code == 302
    assert r.headers["location"].startswith("https://s3.pptaxi.ru/pp-inspections/")
  • Step 2: Verify fails

  • Step 3: Implement

Append to router.py:

from fastapi.responses import RedirectResponse
from app.mechanic.storage import presigned_get


@router.get("/photos/{photo_id}")
async def get_photo(
    photo_id: int,
    variant: str = "original",  # 'original' | 'thumb' | 'annotated'
    session: AsyncSession = Depends(get_db),
    _user=Depends(require_mechanic),
):
    photo = await session.get(InspectionPhoto, photo_id)
    if not photo:
        from fastapi import HTTPException
        raise HTTPException(404, "photo not found")
    key_map = {
        "original": photo.storage_key,
        "thumb": photo.thumb_key,
        "annotated": photo.annotated_key,
    }
    key = key_map.get(variant) or photo.storage_key
    url = await presigned_get(key)
    return RedirectResponse(url, status_code=302)
  • Step 4: Verify + commit
pytest backend/tests/test_mechanic/test_photos.py -v
git add backend/app/mechanic/router.py backend/tests/test_mechanic/test_photos.py
git commit -m "feat(mechanic): GET /photos/{id} returns 302 to presigned URL"

Task E5: Celery task for thumbnail generation

Files:

  • Create: backend/app/mechanic/tasks.py

  • Create: backend/tests/test_mechanic/test_tasks.py

  • Step 1: Write failing test

import pytest
from PIL import Image
import io

from app.mechanic.tasks import _make_thumbnail_sync


def test_make_thumbnail_sync_downsizes():
    # build 4000×3000 sample
    img = Image.new("RGB", (4000, 3000), color=(128, 64, 32))
    buf = io.BytesIO()
    img.save(buf, format="JPEG", quality=80)
    src_bytes = buf.getvalue()

    thumb_bytes = _make_thumbnail_sync(src_bytes, max_side=512)

    out = Image.open(io.BytesIO(thumb_bytes))
    assert max(out.size) == 512
  • Step 2: Verify fails

  • Step 3: Implement

backend/app/mechanic/tasks.py:

from __future__ import annotations
import io
from PIL import Image, ImageOps

# If the project uses Celery and has a configured app, use that. Otherwise this
# task can be invoked synchronously after upload-confirm.
try:
    from app.celery_app import celery_app  # adjust import per project
    HAS_CELERY = True
except ImportError:
    celery_app = None
    HAS_CELERY = False


def _make_thumbnail_sync(src: bytes, max_side: int = 512) -> bytes:
    img = Image.open(io.BytesIO(src))
    img = ImageOps.exif_transpose(img)
    img.thumbnail((max_side, max_side))
    out = io.BytesIO()
    img.convert("RGB").save(out, format="JPEG", quality=85)
    return out.getvalue()


async def make_thumbnail_async(photo_id: int) -> None:
    """Download original, make thumbnail, upload to thumb_key, update DB."""
    from app.mechanic.storage import get_minio_client, build_storage_key, BUCKET
    from app.mechanic.models import InspectionPhoto
    from app.db.deps import async_session_factory  # adjust path

    async with async_session_factory() as session:
        photo = await session.get(InspectionPhoto, photo_id)
        if not photo or photo.status != "ready":
            return

        async with get_minio_client() as client:
            resp = await client.get_object(Bucket=BUCKET, Key=photo.storage_key)
            async with resp["Body"] as stream:
                src_bytes = await stream.read()

        thumb_bytes = _make_thumbnail_sync(src_bytes, max_side=512)
        thumb_key = build_storage_key(
            inspection_id=photo.inspection_id, photo_id=photo.id,
            variant="thumb", ext="jpg",
        )
        async with get_minio_client() as client:
            await client.put_object(
                Bucket=BUCKET, Key=thumb_key,
                Body=thumb_bytes, ContentType="image/jpeg",
            )
        photo.thumb_key = thumb_key
        await session.commit()


if HAS_CELERY:
    @celery_app.task(name="mechanic.make_thumbnail")
    def make_thumbnail_task(photo_id: int):
        import asyncio
        asyncio.run(make_thumbnail_async(photo_id))
  • Step 4: Wire into confirm endpoint

In router.py, in confirm_photo(), replace the commented # Enqueue celery task block:

    from app.mechanic.tasks import make_thumbnail_async, HAS_CELERY
    if HAS_CELERY:
        from app.mechanic.tasks import make_thumbnail_task
        make_thumbnail_task.delay(photo.id)
    else:
        # dev/test: do it inline async (best-effort)
        import asyncio
        asyncio.create_task(make_thumbnail_async(photo.id))
  • Step 5: Verify + commit
pytest backend/tests/test_mechanic/test_tasks.py -v
git add backend/app/mechanic/tasks.py backend/app/mechanic/router.py backend/tests/test_mechanic/test_tasks.py
git commit -m "feat(mechanic): thumbnail generation task"

Phase F — Backend Markers API (0.5 day)

Task F1: POST /inspections/{id}/markers

Files:

  • Modify: backend/app/mechanic/router.py

  • Create: backend/tests/test_mechanic/test_markers.py

  • Step 1: Write failing test

import pytest


@pytest.mark.asyncio
async def test_create_marker_point_on_photo(
    client, mechanic_user_token, sample_inspection_with_photo
):
    insp_id, photo_id = sample_inspection_with_photo
    r = await client.post(
        f"/api/v1/mechanic/inspections/{insp_id}/markers",
        headers={"Authorization": f"Bearer {mechanic_user_token}"},
        json={
            "photo_id": photo_id, "side": "front",
            "x": 0.35, "y": 0.62,
            "damage_type": "scratch", "severity": "minor",
            "description": "На бампере",
        },
    )
    assert r.status_code == 201
    body = r.json()
    assert body["damage_type"] == "scratch"
    assert body["photo_id"] == photo_id
  • Step 2: Verify fails

  • Step 3: Implement

Append to router.py:

from app.mechanic.schemas import MarkerCreate, MarkerOut, MarkerPatch
from app.mechanic.models import DamageMarker


@router.post(
    "/inspections/{inspection_id}/markers",
    status_code=http_status.HTTP_201_CREATED,
    response_model=MarkerOut,
)
async def create_marker(
    inspection_id: int,
    payload: MarkerCreate,
    session: AsyncSession = Depends(get_db),
    _user=Depends(require_mechanic),
):
    if not await session.get(Inspection, inspection_id):
        from fastapi import HTTPException
        raise HTTPException(404, "inspection not found")
    m = DamageMarker(
        inspection_id=inspection_id,
        photo_id=payload.photo_id,
        side=payload.side,
        x=payload.x, y=payload.y, polygon=payload.polygon,
        damage_type=payload.damage_type,
        severity=payload.severity,
        description=payload.description,
    )
    session.add(m)
    await session.commit()
    await session.refresh(m)
    return m
  • Step 4: Verify + commit
pytest backend/tests/test_mechanic/test_markers.py -v
git add backend/app/mechanic/router.py backend/tests/test_mechanic/test_markers.py
git commit -m "feat(mechanic): POST markers endpoint"

Task F2: PATCH /markers/{id} + DELETE /markers/{id}

Files:

  • Modify: backend/app/mechanic/router.py

  • Append to: backend/tests/test_mechanic/test_markers.py

  • Step 1: Append tests

@pytest.mark.asyncio
async def test_patch_marker_severity(client, mechanic_user_token, sample_marker):
    r = await client.patch(
        f"/api/v1/mechanic/markers/{sample_marker.id}",
        headers={"Authorization": f"Bearer {mechanic_user_token}"},
        json={"severity": "moderate", "resolved": True},
    )
    assert r.status_code == 200
    body = r.json()
    assert body["severity"] == "moderate"
    assert body["resolved"] is True


@pytest.mark.asyncio
async def test_delete_marker(client, mechanic_user_token, sample_marker):
    r = await client.delete(
        f"/api/v1/mechanic/markers/{sample_marker.id}",
        headers={"Authorization": f"Bearer {mechanic_user_token}"},
    )
    assert r.status_code == 204
  • Step 2: Verify fails

  • Step 3: Implement

Append to router.py:

@router.patch("/markers/{marker_id}", response_model=MarkerOut)
async def patch_marker(
    marker_id: int,
    payload: MarkerPatch,
    session: AsyncSession = Depends(get_db),
    _user=Depends(require_mechanic),
):
    m = await session.get(DamageMarker, marker_id)
    if not m:
        from fastapi import HTTPException
        raise HTTPException(404, "marker not found")
    for k, v in payload.model_dump(exclude_unset=True).items():
        setattr(m, k, v)
    await session.commit()
    await session.refresh(m)
    return m


@router.delete("/markers/{marker_id}", status_code=http_status.HTTP_204_NO_CONTENT)
async def delete_marker(
    marker_id: int,
    session: AsyncSession = Depends(get_db),
    _user=Depends(require_mechanic),
):
    m = await session.get(DamageMarker, marker_id)
    if not m:
        from fastapi import HTTPException
        raise HTTPException(404, "marker not found")
    await session.delete(m)
    await session.commit()
    return None
  • Step 4: Verify + commit
pytest backend/tests/test_mechanic/test_markers.py -v
git add backend/app/mechanic/router.py backend/tests/test_mechanic/test_markers.py
git commit -m "feat(mechanic): PATCH and DELETE markers"

Phase G — Frontend Scaffold (1-2 days)

Task G1: Init Vite + React + TS project

Files:

  • Create: entire mechanic-pwa/frontend/ directory

  • Step 1: Run scaffolder

cd c:/NewProject/PremiumDriverApp/mechanic-pwa
npm create vite@latest frontend -- --template react-ts
cd frontend
npm install

Expected: scaffold created, dev server starts on npm run dev.

  • Step 2: Install runtime deps
npm install react-router-dom@^7 @tanstack/react-query@^5 zustand@^5 \
            ky@^1 zod@^3 react-hook-form@^7 \
            html5-qrcode@^2 vite-plugin-pwa@^0.20
npm install -D @types/node tailwindcss postcss autoprefixer
  • Step 3: Init Tailwind
npx tailwindcss init -p

Edit tailwind.config.js:

/** @type {import('tailwindcss').Config} */
export default {
  content: ["./index.html", "./src/**/*.{ts,tsx}"],
  theme: {
    extend: {
      fontFamily: {
        sans: ["Inter", "system-ui", "sans-serif"],
      },
    },
  },
  plugins: [],
};

Edit src/index.css:

@tailwind base;
@tailwind components;
@tailwind utilities;

@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');

:root {
  font-family: 'Inter', system-ui, sans-serif;
}
  • Step 4: Smoke run
npm run dev

Visit http://localhost:5173. Expected: Vite welcome page with Inter font.

  • Step 5: Commit
git add mechanic-pwa/frontend
git commit -m "feat(mechanic-pwa): scaffold Vite + React + TS + Tailwind"

Task G2: shadcn/ui setup

Files:

  • Modify: mechanic-pwa/frontend/components.json, src/lib/utils.ts, etc.

  • Step 1: Init shadcn

cd mechanic-pwa/frontend
npx shadcn@latest init -d

Use defaults: style=default, color=slate (cream-friendly).

  • Step 2: Add starter components
npx shadcn@latest add button card input label sonner
  • Step 3: Commit
git add mechanic-pwa/frontend
git commit -m "feat(mechanic-pwa): add shadcn/ui with button/card/input/label/sonner"

Task G3: PWA manifest + Service Worker

Files:

  • Create: mechanic-pwa/frontend/public/manifest.webmanifest

  • Create: mechanic-pwa/frontend/public/icons/icon-192.png (placeholder, plain crest)

  • Create: mechanic-pwa/frontend/public/icons/icon-512.png

  • Modify: mechanic-pwa/frontend/vite.config.ts

  • Modify: mechanic-pwa/frontend/index.html

  • Step 1: Write manifest

public/manifest.webmanifest:

{
  "name": "Premium Механик",
  "short_name": "Mechanic",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#f5f1ea",
  "theme_color": "#2a2a2a",
  "icons": [
    { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
  ]
}
  • Step 2: Configure vite-plugin-pwa

vite.config.ts:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa";
import path from "node:path";

export default defineConfig({
  plugins: [
    react(),
    VitePWA({
      registerType: "autoUpdate",
      includeAssets: ["icons/*.png"],
      manifest: false,  // we ship our own
      workbox: {
        globPatterns: ["**/*.{js,css,html,svg,png,woff2}"],
        runtimeCaching: [
          {
            urlPattern: /\/api\//,
            handler: "NetworkOnly",
          },
        ],
      },
    }),
  ],
  resolve: {
    alias: { "@": path.resolve(__dirname, "src") },
  },
  server: { port: 5173 },
});
  • Step 3: Link manifest in index.html

Add inside <head>:

<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#2a2a2a" />
  • Step 4: Add 192/512 PNG icons (placeholder)

For now any 192×192 and 512×512 PNG is fine. Generate via:

# Use any tool; quick option with imagemagick / online generator
# placeholder solid-colored squares
convert -size 192x192 xc:'#f5f1ea' -gravity center -pointsize 72 -fill '#2a2a2a' \
        -annotate 0 'PM' public/icons/icon-192.png
convert -size 512x512 xc:'#f5f1ea' -gravity center -pointsize 192 -fill '#2a2a2a' \
        -annotate 0 'PM' public/icons/icon-512.png
  • Step 5: Build + smoke check
npm run build
npm run preview

Open in browser → DevTools → Application → Manifest → should show Premium Механик with both icon sizes.

  • Step 6: Commit
git add mechanic-pwa/frontend
git commit -m "feat(mechanic-pwa): PWA manifest + Service Worker"

Task G4: API client with auth interceptor

Files:

  • Create: mechanic-pwa/frontend/src/api/client.ts

  • Create: mechanic-pwa/frontend/src/store/auth.ts

  • Step 1: Write auth store

src/store/auth.ts:

import { create } from "zustand";
import { persist } from "zustand/middleware";

interface AuthState {
  token: string | null;
  setToken: (t: string | null) => void;
}

export const useAuth = create<AuthState>()(
  persist(
    (set) => ({
      token: null,
      setToken: (t) => set({ token: t }),
    }),
    { name: "mechanic-auth" }
  )
);
  • Step 2: Write API client

src/api/client.ts:

import ky, { type KyInstance } from "ky";
import { useAuth } from "@/store/auth";

const API_BASE = import.meta.env.VITE_API_BASE ?? "/api/v1/mechanic";

export const api: KyInstance = ky.create({
  prefixUrl: API_BASE,
  timeout: 30_000,
  hooks: {
    beforeRequest: [
      (request) => {
        const token = useAuth.getState().token;
        if (token) {
          request.headers.set("Authorization", `Bearer ${token}`);
        }
      },
    ],
    beforeError: [
      (error) => {
        if (error.response?.status === 401) {
          useAuth.getState().setToken(null);
        }
        return error;
      },
    ],
  },
});
  • Step 3: Commit
git add mechanic-pwa/frontend/src/api mechanic-pwa/frontend/src/store
git commit -m "feat(mechanic-pwa): api client + auth store"

Task G5: Routing skeleton

Files:

  • Modify: mechanic-pwa/frontend/src/main.tsx

  • Modify: mechanic-pwa/frontend/src/App.tsx

  • Step 1: Write main.tsx

import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "sonner";
import App from "./App";
import "./index.css";

const queryClient = new QueryClient({
  defaultOptions: { queries: { retry: 1, staleTime: 30_000 } },
});

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <BrowserRouter>
        <App />
        <Toaster richColors position="top-center" />
      </BrowserRouter>
    </QueryClientProvider>
  </React.StrictMode>
);
  • Step 2: Write App.tsx with route skeleton
import { Routes, Route, Navigate } from "react-router-dom";
import { useAuth } from "@/store/auth";
import Login from "@/pages/Login";
import Home from "@/pages/Home";
import VehicleCard from "@/pages/VehicleCard";
import InspectionEditor from "@/pages/InspectionEditor";
import InspectionReview from "@/pages/InspectionReview";

function RequireAuth({ children }: { children: React.ReactNode }) {
  const token = useAuth((s) => s.token);
  if (!token) return <Navigate to="/login" replace />;
  return <>{children}</>;
}

export default function App() {
  return (
    <Routes>
      <Route path="/login" element={<Login />} />
      <Route
        path="/"
        element={<RequireAuth><Home /></RequireAuth>}
      />
      <Route
        path="/vehicles/:id"
        element={<RequireAuth><VehicleCard /></RequireAuth>}
      />
      <Route
        path="/inspections/:id/edit"
        element={<RequireAuth><InspectionEditor /></RequireAuth>}
      />
      <Route
        path="/inspections/:id"
        element={<RequireAuth><InspectionReview /></RequireAuth>}
      />
      <Route path="*" element={<Navigate to="/" replace />} />
    </Routes>
  );
}
  • Step 3: Stub page components

Create placeholder files for each page:

src/pages/Login.tsx:

export default function Login() { return <div className="p-8">Login (stub)</div>; }

src/pages/Home.tsx:

export default function Home() { return <div className="p-8">Home (stub)</div>; }

src/pages/VehicleCard.tsx:

export default function VehicleCard() { return <div className="p-8">VehicleCard (stub)</div>; }

src/pages/InspectionEditor.tsx:

export default function InspectionEditor() { return <div className="p-8">InspectionEditor (stub)</div>; }

src/pages/InspectionReview.tsx:

export default function InspectionReview() { return <div className="p-8">InspectionReview (stub)</div>; }
  • Step 4: Run dev, verify navigation
npm run dev

Open http://localhost:5173 — should redirect to /login. Open /inspections/123 — also redirects to login.

  • Step 5: Commit
git add mechanic-pwa/frontend/src
git commit -m "feat(mechanic-pwa): routing skeleton with stub pages"

Phase H — Frontend Pages (3-5 days)

Task H1: Login page

Files:

  • Modify: mechanic-pwa/frontend/src/pages/Login.tsx

  • Modify: mechanic-pwa/frontend/src/api/auth.ts (create)

  • Step 1: Write API function

src/api/auth.ts:

import { api } from "./client";

export interface LoginInput { email: string; pin: string; }
export interface LoginOutput { token: string; }

export async function login(input: LoginInput): Promise<LoginOutput> {
  return api.post("auth/login", { json: input }).json<LoginOutput>();
}

Adjust to existing TaxiDashboard auth endpoint (may already exist under a different path; if so — import that instead of duplicating).

  • Step 2: Implement Login page
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { login } from "@/api/auth";
import { useAuth } from "@/store/auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

export default function Login() {
  const navigate = useNavigate();
  const setToken = useAuth((s) => s.setToken);
  const [email, setEmail] = useState("");
  const [pin, setPin] = useState("");

  const m = useMutation({
    mutationFn: login,
    onSuccess: (data) => {
      setToken(data.token);
      toast.success("Вход выполнен");
      navigate("/", { replace: true });
    },
    onError: () => toast.error("Не удалось войти"),
  });

  return (
    <div className="min-h-screen flex items-center justify-center bg-[#f5f1ea]">
      <div className="w-full max-w-sm bg-white rounded-2xl shadow-sm p-6 space-y-4">
        <h1 className="text-2xl font-semibold">Premium Механик</h1>
        <form
          onSubmit={(e) => {
            e.preventDefault();
            m.mutate({ email, pin });
          }}
          className="space-y-3"
        >
          <div className="space-y-1">
            <Label htmlFor="email">Почта</Label>
            <Input id="email" type="email" value={email}
                   onChange={(e) => setEmail(e.target.value)} required autoFocus />
          </div>
          <div className="space-y-1">
            <Label htmlFor="pin">PIN</Label>
            <Input id="pin" type="password" inputMode="numeric"
                   value={pin} onChange={(e) => setPin(e.target.value)} required />
          </div>
          <Button type="submit" disabled={m.isPending} className="w-full">
            {m.isPending ? "Входим…" : "Войти"}
          </Button>
        </form>
      </div>
    </div>
  );
}
  • Step 3: Manual test

npm run dev → визит /login → ввод тестовых креденшалов → toast + redirect на /.

  • Step 4: Commit
git add mechanic-pwa/frontend/src/pages/Login.tsx mechanic-pwa/frontend/src/api/auth.ts
git commit -m "feat(mechanic-pwa): Login page"

Files:

  • Modify: mechanic-pwa/frontend/src/pages/Home.tsx

  • Create: mechanic-pwa/frontend/src/api/vehicles.ts

  • Step 1: Write API module

src/api/vehicles.ts:

import { api } from "./client";

export interface VehicleSummary {
  id: number;
  license_plate: string;
  vin?: string;
  make?: string;
  model?: string;
  year?: number;
  last_inspection_at?: string;
}

export async function listVehicles(q?: string): Promise<VehicleSummary[]> {
  const params = q ? { q } : {};
  const res = await api.get("vehicles", { searchParams: params }).json<{ items: VehicleSummary[] }>();
  return res.items;
}
  • Step 2: Implement Home page
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { listVehicles } from "@/api/vehicles";
import { Input } from "@/components/ui/input";
import { Card } from "@/components/ui/card";

export default function Home() {
  const [q, setQ] = useState("");
  const navigate = useNavigate();
  const { data, isLoading } = useQuery({
    queryKey: ["vehicles", q],
    queryFn: () => listVehicles(q || undefined),
  });

  return (
    <div className="min-h-screen bg-[#f5f1ea] p-4">
      <h1 className="text-2xl font-semibold mb-4">Машины</h1>
      <Input
        placeholder="Поиск по номеру или VIN"
        value={q}
        onChange={(e) => setQ(e.target.value)}
        className="mb-4"
      />
      {isLoading ? (
        <div>Загружаю…</div>
      ) : (
        <div className="space-y-2">
          {(data ?? []).map((v) => (
            <Card
              key={v.id}
              className="p-4 cursor-pointer hover:shadow"
              onClick={() => navigate(`/vehicles/${v.id}`)}
            >
              <div className="font-semibold text-lg">{v.license_plate}</div>
              <div className="text-sm text-gray-500">
                {[v.make, v.model, v.year].filter(Boolean).join(" ")}
              </div>
              {v.last_inspection_at && (
                <div className="text-xs text-gray-400 mt-1">
                  Последний осмотр: {new Date(v.last_inspection_at).toLocaleDateString("ru-RU")}
                </div>
              )}
            </Card>
          ))}
        </div>
      )}
    </div>
  );
}
  • Step 3: Commit
git add mechanic-pwa/frontend/src
git commit -m "feat(mechanic-pwa): Home page with vehicle search"

Task H3: VehicleCard page

Files:

  • Modify: mechanic-pwa/frontend/src/pages/VehicleCard.tsx

  • Modify: mechanic-pwa/frontend/src/api/vehicles.ts

  • Create: mechanic-pwa/frontend/src/api/inspections.ts

  • Step 1: Append to vehicles.ts

export interface VehicleDetail {
  id: number; license_plate: string; vin?: string;
  make?: string; model?: string; year?: number;
  recent_inspections: {
    id: number; type: string; status: string;
    started_at: string; finished_at?: string;
  }[];
}

export async function getVehicle(id: number): Promise<VehicleDetail> {
  return api.get(`vehicles/${id}`).json<VehicleDetail>();
}
  • Step 2: Create inspections.ts
import { api } from "./client";

export type InspectionType = "handover" | "return" | "periodic" | "ad-hoc";

export interface InspectionSummary {
  id: number; type: InspectionType; status: string;
  started_at: string; finished_at?: string;
}

export async function createInspection(input: {
  vehicle_id: number; type: InspectionType; driver_id?: number;
}): Promise<{ id: number }> {
  return api.post("inspections", { json: input }).json();
}
  • Step 3: Implement VehicleCard page
import { useParams, useNavigate } from "react-router-dom";
import { useQuery, useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { getVehicle } from "@/api/vehicles";
import { createInspection, type InspectionType } from "@/api/inspections";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";

export default function VehicleCard() {
  const { id } = useParams<{ id: string }>();
  const navigate = useNavigate();
  const vehicleId = Number(id);

  const { data: v, isLoading } = useQuery({
    queryKey: ["vehicle", vehicleId],
    queryFn: () => getVehicle(vehicleId),
  });

  const startInspection = useMutation({
    mutationFn: (type: InspectionType) =>
      createInspection({ vehicle_id: vehicleId, type }),
    onSuccess: ({ id: newId }) => {
      navigate(`/inspections/${newId}/edit`);
    },
    onError: () => toast.error("Не удалось создать осмотр"),
  });

  if (isLoading || !v) return <div className="p-8">Загружаю…</div>;

  return (
    <div className="min-h-screen bg-[#f5f1ea] p-4 space-y-4">
      <button onClick={() => navigate("/")} className="text-sm text-gray-500">
         К списку
      </button>
      <Card className="p-4">
        <div className="text-xl font-semibold">{v.license_plate}</div>
        <div className="text-sm text-gray-600">
          {[v.make, v.model, v.year].filter(Boolean).join(" ")}
        </div>
        {v.vin && <div className="text-xs text-gray-400 mt-1">VIN: {v.vin}</div>}
      </Card>

      <div className="grid grid-cols-2 gap-2">
        <Button onClick={() => startInspection.mutate("handover")}>Передача</Button>
        <Button onClick={() => startInspection.mutate("return")}>Приёмка</Button>
        <Button onClick={() => startInspection.mutate("periodic")} variant="outline">Плановый</Button>
        <Button onClick={() => startInspection.mutate("ad-hoc")} variant="outline">Свободный</Button>
      </div>

      <h2 className="text-lg font-semibold mt-6">Прошлые осмотры</h2>
      <div className="space-y-2">
        {v.recent_inspections.length === 0 && (
          <div className="text-sm text-gray-500">Осмотров ещё не было.</div>
        )}
        {v.recent_inspections.map((i) => (
          <Card key={i.id} className="p-3 cursor-pointer"
                onClick={() => navigate(`/inspections/${i.id}`)}>
            <div className="text-sm">
              <span className="font-medium">{i.type}</span>
              {" — "}
              {new Date(i.started_at).toLocaleString("ru-RU")}
            </div>
            <div className="text-xs text-gray-500">{i.status}</div>
          </Card>
        ))}
      </div>
    </div>
  );
}
  • Step 4: Commit
git add mechanic-pwa/frontend/src
git commit -m "feat(mechanic-pwa): VehicleCard page with start-inspection buttons"

Phase I — Frontend Inspection Flow (4-6 days)

Task I1: CameraCapture component

Files:

  • Create: mechanic-pwa/frontend/src/components/camera/CameraCapture.tsx

  • Step 1: Implement (use <input type="file" capture> for max compat)

import { useRef, useState } from "react";
import { Button } from "@/components/ui/button";

interface Props {
  onCapture: (file: File) => void;
  buttonLabel?: string;
}

export function CameraCapture({ onCapture, buttonLabel = "Сделать фото" }: Props) {
  const inputRef = useRef<HTMLInputElement>(null);
  const [preview, setPreview] = useState<string | null>(null);

  return (
    <div className="space-y-2">
      <input
        ref={inputRef}
        type="file"
        accept="image/*"
        capture="environment"
        className="hidden"
        onChange={(e) => {
          const file = e.target.files?.[0];
          if (!file) return;
          setPreview(URL.createObjectURL(file));
          onCapture(file);
        }}
      />
      {preview ? (
        <div>
          <img src={preview} alt="" className="w-full rounded-lg" />
          <Button variant="outline" className="mt-2 w-full"
                  onClick={() => inputRef.current?.click()}>
            Переснять
          </Button>
        </div>
      ) : (
        <Button onClick={() => inputRef.current?.click()} className="w-full">
          {buttonLabel}
        </Button>
      )}
    </div>
  );
}
  • Step 2: Commit
git add mechanic-pwa/frontend/src/components/camera
git commit -m "feat(mechanic-pwa): CameraCapture component"

Task I2: Photo upload helper (presigned flow)

Files:

  • Create: mechanic-pwa/frontend/src/api/photos.ts

  • Step 1: Implement

import { api } from "./client";

export interface PresignedUpload {
  photo_id: number;
  presigned_url: string;
  fields: Record<string, string>;
  storage_key: string;
  expires_at: string;
}

export async function requestUploadUrl(
  inspectionId: number,
  args: { side: string; slot_index: number; content_type: string; size: number },
): Promise<PresignedUpload> {
  return api
    .post(`inspections/${inspectionId}/photos/upload-url`, { json: args })
    .json<PresignedUpload>();
}

export async function uploadToS3(p: PresignedUpload, file: File): Promise<void> {
  const fd = new FormData();
  for (const [k, v] of Object.entries(p.fields)) fd.append(k, v);
  fd.append("file", file);
  const r = await fetch(p.presigned_url, { method: "POST", body: fd });
  if (!r.ok) throw new Error(`S3 upload failed: ${r.status}`);
}

export async function confirmPhoto(
  photoId: number,
  meta: { width?: number; height?: number; actual_size?: number },
): Promise<{ id: number; status: string }> {
  return api.post(`photos/${photoId}/confirm`, { json: meta }).json();
}

export async function uploadPhoto(
  inspectionId: number,
  side: string,
  slotIndex: number,
  file: File,
): Promise<number> {
  const presign = await requestUploadUrl(inspectionId, {
    side, slot_index: slotIndex,
    content_type: file.type || "image/jpeg",
    size: file.size,
  });
  await uploadToS3(presign, file);

  // Get image dimensions
  const dim = await new Promise<{ w: number; h: number }>((resolve) => {
    const img = new Image();
    img.onload = () => resolve({ w: img.naturalWidth, h: img.naturalHeight });
    img.src = URL.createObjectURL(file);
  });

  await confirmPhoto(presign.photo_id, {
    width: dim.w, height: dim.h, actual_size: file.size,
  });
  return presign.photo_id;
}
  • Step 2: Commit
git add mechanic-pwa/frontend/src/api/photos.ts
git commit -m "feat(mechanic-pwa): photo upload helper (presigned POST)"

Task I3: PhotoSlot component

Files:

  • Create: mechanic-pwa/frontend/src/components/camera/PhotoSlot.tsx

  • Step 1: Implement

import { useState } from "react";
import { CameraCapture } from "./CameraCapture";
import { uploadPhoto } from "@/api/photos";
import { toast } from "sonner";

interface Props {
  inspectionId: number;
  side: string;
  slotIndex: number;
  label: string;
  initialPhotoId?: number;
  onUploaded?: (photoId: number) => void;
}

export function PhotoSlot({ inspectionId, side, slotIndex, label, initialPhotoId, onUploaded }: Props) {
  const [photoId, setPhotoId] = useState<number | undefined>(initialPhotoId);
  const [uploading, setUploading] = useState(false);

  async function handleCapture(file: File) {
    setUploading(true);
    try {
      const id = await uploadPhoto(inspectionId, side, slotIndex, file);
      setPhotoId(id);
      onUploaded?.(id);
      toast.success(`${label} загружено`);
    } catch (e) {
      toast.error("Ошибка загрузки");
    } finally {
      setUploading(false);
    }
  }

  return (
    <div className="border rounded-lg p-3 bg-white">
      <div className="text-sm font-medium mb-2">{label}</div>
      {photoId && (
        <img
          src={`/api/v1/mechanic/photos/${photoId}?variant=thumb`}
          alt={label}
          className="w-full rounded mb-2"
        />
      )}
      <CameraCapture
        onCapture={handleCapture}
        buttonLabel={photoId ? "Заменить" : "Снять"}
      />
      {uploading && <div className="text-xs text-gray-500 mt-1">Загружаю…</div>}
    </div>
  );
}
  • Step 2: Commit
git add mechanic-pwa/frontend/src/components/camera
git commit -m "feat(mechanic-pwa): PhotoSlot component"

Task I4: Vehicle SVG scheme + HotSpot

Files:

  • Create: mechanic-pwa/frontend/src/components/vehicle-scheme/schemes/sedan-top.svg

  • Create: mechanic-pwa/frontend/src/components/vehicle-scheme/VehicleScheme.tsx

  • Create: mechanic-pwa/frontend/src/components/vehicle-scheme/HotSpot.tsx

  • Step 1: Build minimalist top-view SVG of sedan

Create sedan-top.svg (simplified vector outline; can be replaced later with cleaner artwork):

<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 400">
  <!-- Body outline -->
  <rect x="40" y="20" width="120" height="360" rx="40" ry="40"
        fill="none" stroke="#444" stroke-width="2"/>
  <!-- Windshield -->
  <rect x="55" y="60" width="90" height="60" rx="10" fill="#dde6ec"/>
  <!-- Rear window -->
  <rect x="55" y="280" width="90" height="50" rx="10" fill="#dde6ec"/>
  <!-- Roof -->
  <rect x="55" y="130" width="90" height="140" fill="#f3eee4" stroke="#888"/>
  <!-- Zone labels (id-marked hotspot rectangles, transparent) -->
  <rect id="zone-front" x="40" y="20" width="120" height="40" fill="transparent"/>
  <rect id="zone-rear" x="40" y="340" width="120" height="40" fill="transparent"/>
  <rect id="zone-left" x="40" y="60" width="20" height="280" fill="transparent"/>
  <rect id="zone-right" x="140" y="60" width="20" height="280" fill="transparent"/>
  <rect id="zone-hood" x="55" y="20" width="90" height="40" fill="transparent"/>
  <rect id="zone-trunk" x="55" y="340" width="90" height="40" fill="transparent"/>
  <rect id="zone-roof" x="55" y="130" width="90" height="140" fill="transparent"/>
</svg>
  • Step 2: Implement VehicleScheme component
import { useState } from "react";
import sedanSrc from "./schemes/sedan-top.svg?raw";

interface MarkerDot { x: number; y: number; severity: string; }

interface Props {
  markers?: MarkerDot[];
  onZoneClick?: (zoneId: string) => void;
}

const ZONES = [
  "zone-front", "zone-rear", "zone-left", "zone-right",
  "zone-hood", "zone-trunk", "zone-roof",
];

export function VehicleScheme({ markers = [], onZoneClick }: Props) {
  const [hoverZone, setHoverZone] = useState<string | null>(null);

  return (
    <div className="relative w-full max-w-xs mx-auto">
      <div
        className="vehicle-scheme"
        dangerouslySetInnerHTML={{ __html: sedanSrc }}
        onClick={(e) => {
          const target = (e.target as SVGElement);
          const id = target.getAttribute("id");
          if (id && ZONES.includes(id)) onZoneClick?.(id);
        }}
        onMouseMove={(e) => {
          const target = (e.target as SVGElement);
          const id = target.getAttribute("id");
          setHoverZone(id && ZONES.includes(id) ? id : null);
        }}
      />
      {/* Render markers as red dots on top of SVG (normalized coords) */}
      {markers.map((m, i) => (
        <div
          key={i}
          className="absolute w-3 h-3 rounded-full bg-red-500/80 border border-white"
          style={{ left: `${m.x * 100}%`, top: `${m.y * 100}%`, transform: "translate(-50%, -50%)" }}
        />
      ))}
      {hoverZone && (
        <div className="text-xs text-center mt-1 text-gray-600">
          {humanize(hoverZone)}
        </div>
      )}
    </div>
  );
}

function humanize(zoneId: string): string {
  const map: Record<string, string> = {
    "zone-front": "Передний бампер",
    "zone-rear": "Задний бампер",
    "zone-left": "Левый бок",
    "zone-right": "Правый бок",
    "zone-hood": "Капот",
    "zone-trunk": "Багажник",
    "zone-roof": "Крыша",
  };
  return map[zoneId] || zoneId;
}
  • Step 3: Commit
git add mechanic-pwa/frontend/src/components/vehicle-scheme
git commit -m "feat(mechanic-pwa): VehicleScheme SVG component"

Task I5: InspectionEditor page

Files:

  • Modify: mechanic-pwa/frontend/src/pages/InspectionEditor.tsx

  • Append to: mechanic-pwa/frontend/src/api/inspections.ts

  • Step 1: API extensions

Append to inspections.ts:

export interface MarkerSummary {
  id: number; photo_id?: number; side?: string;
  x?: number; y?: number;
  damage_type?: string; severity?: string; description?: string;
  carried_over_from_id?: number; resolved: boolean;
}

export interface PhotoSummary {
  id: number; side: string; slot_index: number;
  storage_key: string; thumb_key?: string;
  width?: number; height?: number; status: string;
  taken_at: string;
}

export interface InspectionDetail {
  id: number; vehicle_id: number; type: string;
  status: string; started_at: string; finished_at?: string;
  notes?: string; prev_inspection_id?: number;
  photos: PhotoSummary[];
  markers: MarkerSummary[];
}

export async function getInspection(id: number): Promise<InspectionDetail> {
  return api.get(`inspections/${id}`).json<InspectionDetail>();
}

export async function patchInspection(id: number, body: {
  status?: string; notes?: string;
}): Promise<InspectionDetail> {
  return api.patch(`inspections/${id}`, { json: body }).json<InspectionDetail>();
}

export async function createMarker(inspectionId: number, body: {
  photo_id?: number; side?: string;
  x?: number; y?: number;
  damage_type?: string; severity?: string; description?: string;
}): Promise<MarkerSummary> {
  return api.post(`inspections/${inspectionId}/markers`, { json: body })
    .json<MarkerSummary>();
}
  • Step 2: Implement InspectionEditor
import { useParams, useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { getInspection, patchInspection, createMarker } from "@/api/inspections";
import { PhotoSlot } from "@/components/camera/PhotoSlot";
import { VehicleScheme } from "@/components/vehicle-scheme/VehicleScheme";
import { Button } from "@/components/ui/button";

const SLOTS: { side: string; label: string }[] = [
  { side: "front", label: "Перед" },
  { side: "rear", label: "Зад" },
  { side: "left", label: "Левый бок" },
  { side: "right", label: "Правый бок" },
  { side: "vin", label: "VIN" },
  { side: "odometer", label: "Одометр" },
  { side: "interior", label: "Салон" },
  { side: "free", label: "Свободное" },
];

export default function InspectionEditor() {
  const { id } = useParams<{ id: string }>();
  const insId = Number(id);
  const navigate = useNavigate();
  const qc = useQueryClient();

  const { data: ins, isLoading } = useQuery({
    queryKey: ["inspection", insId],
    queryFn: () => getInspection(insId),
  });

  const finalize = useMutation({
    mutationFn: () => patchInspection(insId, { status: "completed" }),
    onSuccess: () => {
      toast.success("Осмотр завершён");
      navigate(`/inspections/${insId}`);
    },
  });

  const addZoneMarker = useMutation({
    mutationFn: (zone: string) =>
      createMarker(insId, { side: zone, description: "Метка со схемы", damage_type: "scratch", severity: "cosmetic" }),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ["inspection", insId] });
      toast.success("Метка добавлена");
    },
  });

  if (isLoading || !ins) return <div className="p-8">Загружаю…</div>;

  const photosBySide = new Map<string, typeof ins.photos[0]>();
  for (const p of ins.photos) photosBySide.set(`${p.side}-${p.slot_index}`, p);

  const dotsForScheme = ins.markers
    .filter((m) => m.side?.startsWith("zone-") && m.x != null && m.y != null)
    .map((m) => ({ x: Number(m.x), y: Number(m.y), severity: m.severity || "minor" }));

  return (
    <div className="min-h-screen bg-[#f5f1ea] p-4 space-y-4">
      <button onClick={() => navigate(-1)} className="text-sm text-gray-500"> Назад</button>

      <h1 className="text-xl font-semibold">Осмотр #{ins.id} ({ins.type})</h1>

      <section>
        <h2 className="text-lg font-medium mb-2">Схема  кликни на зону, чтобы поставить метку</h2>
        <VehicleScheme
          markers={dotsForScheme}
          onZoneClick={(zone) => addZoneMarker.mutate(zone)}
        />
      </section>

      <section>
        <h2 className="text-lg font-medium mb-2">Фото</h2>
        <div className="grid grid-cols-2 gap-2">
          {SLOTS.map((s) => (
            <PhotoSlot
              key={`${s.side}-0`}
              inspectionId={insId}
              side={s.side}
              slotIndex={0}
              label={s.label}
              initialPhotoId={photosBySide.get(`${s.side}-0`)?.id}
              onUploaded={() => qc.invalidateQueries({ queryKey: ["inspection", insId] })}
            />
          ))}
        </div>
      </section>

      {ins.markers.length > 0 && (
        <section>
          <h2 className="text-lg font-medium mb-2">Метки ({ins.markers.length})</h2>
          <ul className="space-y-1 text-sm">
            {ins.markers.map((m) => (
              <li key={m.id} className="bg-white p-2 rounded">
                {m.side}  {m.damage_type || "n/a"}  {m.severity || "n/a"}
                {m.carried_over_from_id && <span className="text-xs text-gray-500"> (из прошлого)</span>}
              </li>
            ))}
          </ul>
        </section>
      )}

      <Button className="w-full" onClick={() => finalize.mutate()}>
        Завершить осмотр
      </Button>
    </div>
  );
}
  • Step 3: Manual smoke test

npm run dev → пройти flow login → home → vehicle → start inspection → попасть в editor → попробовать тапать на зоны схемы (markers создаются), снимать фото (uploads пройдут на dev S3 если CORS настроен).

  • Step 4: Commit
git add mechanic-pwa/frontend/src
git commit -m "feat(mechanic-pwa): InspectionEditor with scheme + photo slots + zone markers"

Task I6: InspectionReview page (read-only обзор завершённого осмотра)

Files:

  • Modify: mechanic-pwa/frontend/src/pages/InspectionReview.tsx

  • Step 1: Implement

import { useParams, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { getInspection } from "@/api/inspections";
import { Card } from "@/components/ui/card";

export default function InspectionReview() {
  const { id } = useParams<{ id: string }>();
  const insId = Number(id);
  const navigate = useNavigate();
  const { data: ins, isLoading } = useQuery({
    queryKey: ["inspection", insId],
    queryFn: () => getInspection(insId),
  });

  if (isLoading || !ins) return <div className="p-8">Загружаю…</div>;

  return (
    <div className="min-h-screen bg-[#f5f1ea] p-4 space-y-4">
      <button onClick={() => navigate(-1)} className="text-sm text-gray-500"> Назад</button>

      <h1 className="text-xl font-semibold">Осмотр #{ins.id}</h1>
      <Card className="p-4 text-sm space-y-1">
        <div><b>Тип:</b> {ins.type}</div>
        <div><b>Статус:</b> {ins.status}</div>
        <div><b>Начат:</b> {new Date(ins.started_at).toLocaleString("ru-RU")}</div>
        {ins.finished_at && <div><b>Завершён:</b> {new Date(ins.finished_at).toLocaleString("ru-RU")}</div>}
        {ins.notes && <div><b>Заметки:</b> {ins.notes}</div>}
      </Card>

      <h2 className="text-lg font-medium">Фото ({ins.photos.length})</h2>
      <div className="grid grid-cols-2 gap-2">
        {ins.photos.map((p) => (
          <a
            key={p.id}
            href={`/api/v1/mechanic/photos/${p.id}`}
            target="_blank" rel="noreferrer"
          >
            <img
              src={`/api/v1/mechanic/photos/${p.id}?variant=thumb`}
              alt={p.side}
              className="w-full rounded"
            />
            <div className="text-xs text-gray-500">{p.side}</div>
          </a>
        ))}
      </div>

      <h2 className="text-lg font-medium">Метки ({ins.markers.length})</h2>
      <ul className="space-y-1 text-sm">
        {ins.markers.map((m) => (
          <li key={m.id} className="bg-white p-2 rounded">
            {m.side}  {m.damage_type || "—"}  {m.severity || "—"}
            {m.description && <div className="text-xs text-gray-500 mt-1">{m.description}</div>}
            {m.resolved && <span className="text-xs text-green-600 ml-2"> устранено</span>}
            {m.carried_over_from_id && <span className="text-xs text-gray-400 ml-2">(перенесено)</span>}
          </li>
        ))}
      </ul>
    </div>
  );
}
  • Step 2: Commit
git add mechanic-pwa/frontend/src/pages/InspectionReview.tsx
git commit -m "feat(mechanic-pwa): InspectionReview page"

Phase J — Integration & Deploy (1-2 days)

Task J1: End-to-end manual test in dev

  • Step 1: Start backend with mechanic module
cd /opt/sites/taxi-dashboard/backend  # or wherever
uvicorn app.main:app --reload --port 8000
  • Step 2: Start frontend dev
cd c:/NewProject/PremiumDriverApp/mechanic-pwa/frontend
VITE_API_BASE=http://localhost:8000/api/v1/mechanic npm run dev
  • Step 3: Full flow walkthrough

In browser, http://localhost:5173:

  1. Login with a mechanic-role test account
  2. Search for a vehicle (use any from vehicles table)
  3. Open vehicle card → "Передача"
  4. In editor: click 2 zones on the scheme — markers appear; snap 2 photos
  5. Click "Завершить осмотр"
  6. Review page shows photos + markers

Expected: full flow without errors. If photo upload fails — check CORS on MinIO (Task A3) and that the access keys in minio.env are correct.

  • Step 4: Take screenshots, save to mechanic-pwa/docs/

  • Step 5: Note any issues found, file as follow-up tasks


Task J2: Backend production deploy

  • Step 1: Pull code on VDS
ssh root@100.64.0.12
cd /opt/sites/taxi-dashboard
git pull
  • Step 2: Install new deps, run migration
source .venv/bin/activate
pip install -r requirements.txt  # if aiobotocore/Pillow were added there
alembic upgrade head
  • Step 3: Restart backend service
systemctl restart taxi-dashboard-backend.service  # adjust unit name
journalctl -u taxi-dashboard-backend -f --since "1 min ago"

Expected: started without errors, healthz responds.

curl https://crm.pptaxi.ru/api/v1/mechanic/healthz

Expected: {"status":"ok"}.

  • Step 4: Commit deployment notes if needed

Task J3: Frontend production deploy

  • Step 1: Build
cd c:/NewProject/PremiumDriverApp/mechanic-pwa/frontend
npm run build

Expected: dist/ directory with optimized assets.

  • Step 2: Rsync to VDS
rsync -avz --delete dist/ root@100.64.0.12:/opt/sites/mechanic-pwa/dist/
  • Step 3: Wire Caddy fragment

On VDS:

cp /opt/sites/mechanic-pwa/repo/mechanic-pwa/ops/Caddyfile.fragment /etc/caddy/sites-enabled/mechanic.pptaxi.ru
caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy

(Adjust path of fragment based on actual repo location.)

  • Step 4: Smoke test via curl
curl -I https://mechanic.pptaxi.ru
curl https://mechanic.pptaxi.ru/manifest.webmanifest

Expected: 200 OK; manifest JSON returns.

  • Step 5: Open in mobile browser

On a phone, go to https://mechanic.pptaxi.ru. Verify:

  • Page loads

  • "Add to Home Screen" appears in browser menu

  • After adding, opens full-screen as PWA

  • Step 6: Commit

git add mechanic-pwa
git commit -m "deploy(mechanic): first production deploy of MVP"

Task J4: Production smoke test

  • Step 1: Run a real inspection on production

Use a test mechanic account; create an inspection on a test vehicle; snap a real photo (small); add markers; finalize.

  • Step 2: Verify photo in MinIO
mc ls local/pp-inspections/inspections/<new-id>/original/

Expected: at least one .jpg file present.

  • Step 3: Verify thumbnail (after celery task runs)

Wait ~10 seconds, then:

mc ls local/pp-inspections/inspections/<new-id>/thumb/

Expected: matching .jpg thumbnail.

  • Step 4: Verify DB rows
psql $DATABASE_URL -c "SELECT id, vehicle_id, type, status FROM inspections ORDER BY id DESC LIMIT 5;"
psql $DATABASE_URL -c "SELECT id, side, status FROM inspection_photos WHERE inspection_id=<new-id>;"
psql $DATABASE_URL -c "SELECT * FROM damage_markers WHERE inspection_id=<new-id>;"
  • Step 5: Sign off

If all of the above pass — MVP is live. File any UX issues found as follow-up GitHub issues.


Acceptance Criteria for Phase 1 MVP

  • Mechanic can log in on mechanic.pptaxi.ru from a mobile browser
  • Can find a vehicle by license plate
  • Can open a vehicle card and see recent inspections
  • Can start a handover/return/periodic/ad-hoc inspection
  • On return, previously-recorded markers are auto-carried over with "carried_over_from_id" links
  • Can take ≥4 photos in inspection slots, uploaded directly to MinIO via pre-signed POST
  • Can click zones on the SVG scheme to add point markers
  • Can finalize inspection (status=completed, finished_at filled)
  • Can view a read-only review of a finalized inspection with all photos + markers
  • PWA is installable to home screen on both Android and iOS

What's NOT in Phase 1 (deferred to Phase 2+)

  • Polygon/freehand annotation editor over photos (Konva-based)
  • Offline-first mode with IndexedDB queue and SW background sync
  • Capacitor-wrapped native APK/IPA
  • Push notifications
  • VendorBridge data import from api.ttcontrol.naughtysoft.ru
  • Admin dashboard with statistics
  • QR-сканер штрих-кодов на машинах

Self-Review Notes

  • Spec coverage: §1-12 from 2026-05-16-premium-mechanic-design.md — все ключевые требования покрыты: модель данных (Phase A4 / Phase B2), API endpoints (Phases C-F), UI flow (Phase G-I), MinIO storage (Phase E), deployment (Phase J). Полигональный editor отложен в Phase 2 как явно согласовано.
  • No placeholders: каждый Step имеет либо bash-команду, либо полный код. Где ссылки на «adjust import to existing path» (paths модели User, Vehicle, Base, async_session_factory) — Task A1 даёт engineer'у инструкцию выяснить точные пути перед началом implementation.
  • Type consistency: Pydantic схемы (InspectionType, PhotoSide, DamageType, Severity) — последовательно использованы между backend и frontend. SQLAlchemy column names в migration (Task A4) совпадают с column declarations в models.py (Task B2). API response shapes в openapi (router) совпадают с TypeScript interfaces во frontend api/*.ts.