# 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: ```bash 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//` - [ ] **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: DB session: User model path: Vehicle model path: MinIO client init: ``` 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** ```bash 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`: ```caddy 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: ```bash 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)** ```bash 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`: ```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** ```bash # 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** ```bash 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** ```bash 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** ```bash cd /opt/sites/taxi-dashboard/backend alembic revision -m "mechanic_init" ``` Expected: created file `alembic/versions/_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: ```python """mechanic_init Revision ID: Revises: 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 = "" down_revision = "" 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 `` and `` with values Alembic assigned in Step 1. - [ ] **Step 3: Apply migration to dev DB** ```bash alembic upgrade head ``` Expected: `Running upgrade -> , mechanic_init`. - [ ] **Step 4: Verify schema in psql** ```bash 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** ```bash alembic downgrade -1 alembic upgrade head ``` Expected: both succeed without errors. - [ ] **Step 6: Commit** ```bash 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`: ```python """Premium Mechanic — PWA backend module for vehicle inspections.""" from .router import router __all__ = ["router"] ``` `backend/app/mechanic/router.py`: ```python 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: ```python # 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** ```bash 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** ```bash 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`: ```python 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** ```bash 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`: ```python 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** ```bash pytest backend/tests/test_mechanic/test_models.py -v ``` Expected: 3 passed. - [ ] **Step 5: Commit** ```bash 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`: ```python 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)** ```bash 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`: ```python """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** ```bash 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`: ```python 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** ```bash 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: ```python 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** ```bash pytest backend/tests/test_mechanic/test_me.py -v ``` Expected: 2 passed. - [ ] **Step 5: Commit** ```bash git add backend/app/mechanic/router.py backend/tests/test_mechanic/test_me.py git commit -m "feat(mechanic): GET /me endpoint" ``` --- ### Task C2: `GET /vehicles` — list with search **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`: ```python 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** ```bash pytest backend/tests/test_mechanic/test_vehicles.py -v ``` - [ ] **Step 3: Implement service** Create `backend/app/mechanic/service.py`: ```python 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: ```python 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** ```bash 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** ```python @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** ```bash pytest backend/tests/test_mechanic/test_vehicles.py::test_vehicle_detail -v ``` - [ ] **Step 3: Add service method** Append to `service.py`: ```python 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`: ```python @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** ```bash 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** ```python 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** ```bash pytest backend/tests/test_mechanic/test_inspections.py -v ``` - [ ] **Step 3: Implement service** Append to `service.py`: ```python 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`: ```python 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** ```bash 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** ```python @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`: ```python 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** ```bash 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** ```python @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`: ```python 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** ```bash 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** ```python 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`: ```python 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** ```bash 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** ```python 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`: ```python 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`: ```python 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** ```bash 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** ```python @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`: ```python 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** ```bash 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** ```python @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`: ```python 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** ```bash 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** ```python 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`: ```python 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: ```python 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** ```bash 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** ```python 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`: ```python 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** ```bash 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** ```python @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`: ```python @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** ```bash 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** ```bash 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** ```bash 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** ```bash npx tailwindcss init -p ``` Edit `tailwind.config.js`: ```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`: ```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** ```bash npm run dev ``` Visit `http://localhost:5173`. Expected: Vite welcome page with Inter font. - [ ] **Step 5: Commit** ```bash 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** ```bash cd mechanic-pwa/frontend npx shadcn@latest init -d ``` Use defaults: style=default, color=slate (cream-friendly). - [ ] **Step 2: Add starter components** ```bash npx shadcn@latest add button card input label sonner ``` - [ ] **Step 3: Commit** ```bash 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`: ```json { "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`: ```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 ``: ```html ``` - [ ] **Step 4: Add 192/512 PNG icons (placeholder)** For now any 192×192 and 512×512 PNG is fine. Generate via: ```bash # 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** ```bash npm run build npm run preview ``` Open in browser → DevTools → Application → Manifest → should show `Premium Механик` with both icon sizes. - [ ] **Step 6: Commit** ```bash 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`: ```ts import { create } from "zustand"; import { persist } from "zustand/middleware"; interface AuthState { token: string | null; setToken: (t: string | null) => void; } export const useAuth = create()( persist( (set) => ({ token: null, setToken: (t) => set({ token: t }), }), { name: "mechanic-auth" } ) ); ``` - [ ] **Step 2: Write API client** `src/api/client.ts`: ```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** ```bash 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`** ```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( ); ``` - [ ] **Step 2: Write `App.tsx` with route skeleton** ```tsx 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 ; return <>{children}; } export default function App() { return ( } /> } /> } /> } /> } /> } /> ); } ``` - [ ] **Step 3: Stub page components** Create placeholder files for each page: `src/pages/Login.tsx`: ```tsx export default function Login() { return
Login (stub)
; } ``` `src/pages/Home.tsx`: ```tsx export default function Home() { return
Home (stub)
; } ``` `src/pages/VehicleCard.tsx`: ```tsx export default function VehicleCard() { return
VehicleCard (stub)
; } ``` `src/pages/InspectionEditor.tsx`: ```tsx export default function InspectionEditor() { return
InspectionEditor (stub)
; } ``` `src/pages/InspectionReview.tsx`: ```tsx export default function InspectionReview() { return
InspectionReview (stub)
; } ``` - [ ] **Step 4: Run dev, verify navigation** ```bash npm run dev ``` Open `http://localhost:5173` — should redirect to `/login`. Open `/inspections/123` — also redirects to login. - [ ] **Step 5: Commit** ```bash 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`: ```ts import { api } from "./client"; export interface LoginInput { email: string; pin: string; } export interface LoginOutput { token: string; } export async function login(input: LoginInput): Promise { return api.post("auth/login", { json: input }).json(); } ``` > 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** ```tsx 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 (

Premium Механик

{ e.preventDefault(); m.mutate({ email, pin }); }} className="space-y-3" >
setEmail(e.target.value)} required autoFocus />
setPin(e.target.value)} required />
); } ``` - [ ] **Step 3: Manual test** `npm run dev` → визит `/login` → ввод тестовых креденшалов → toast + redirect на `/`. - [ ] **Step 4: Commit** ```bash git add mechanic-pwa/frontend/src/pages/Login.tsx mechanic-pwa/frontend/src/api/auth.ts git commit -m "feat(mechanic-pwa): Login page" ``` --- ### Task H2: Home page — vehicle search **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`: ```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 { const params = q ? { q } : {}; const res = await api.get("vehicles", { searchParams: params }).json<{ items: VehicleSummary[] }>(); return res.items; } ``` - [ ] **Step 2: Implement Home page** ```tsx 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 (

Машины

setQ(e.target.value)} className="mb-4" /> {isLoading ? (
Загружаю…
) : (
{(data ?? []).map((v) => ( navigate(`/vehicles/${v.id}`)} >
{v.license_plate}
{[v.make, v.model, v.year].filter(Boolean).join(" ")}
{v.last_inspection_at && (
Последний осмотр: {new Date(v.last_inspection_at).toLocaleDateString("ru-RU")}
)}
))}
)}
); } ``` - [ ] **Step 3: Commit** ```bash 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`** ```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 { return api.get(`vehicles/${id}`).json(); } ``` - [ ] **Step 2: Create `inspections.ts`** ```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** ```tsx 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
Загружаю…
; return (
{v.license_plate}
{[v.make, v.model, v.year].filter(Boolean).join(" ")}
{v.vin &&
VIN: {v.vin}
}

Прошлые осмотры

{v.recent_inspections.length === 0 && (
Осмотров ещё не было.
)} {v.recent_inspections.map((i) => ( navigate(`/inspections/${i.id}`)}>
{i.type} {" — "} {new Date(i.started_at).toLocaleString("ru-RU")}
{i.status}
))}
); } ``` - [ ] **Step 4: Commit** ```bash 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 `` for max compat)** ```tsx 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(null); const [preview, setPreview] = useState(null); return (
{ const file = e.target.files?.[0]; if (!file) return; setPreview(URL.createObjectURL(file)); onCapture(file); }} /> {preview ? (
) : ( )}
); } ``` - [ ] **Step 2: Commit** ```bash 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** ```ts import { api } from "./client"; export interface PresignedUpload { photo_id: number; presigned_url: string; fields: Record; storage_key: string; expires_at: string; } export async function requestUploadUrl( inspectionId: number, args: { side: string; slot_index: number; content_type: string; size: number }, ): Promise { return api .post(`inspections/${inspectionId}/photos/upload-url`, { json: args }) .json(); } export async function uploadToS3(p: PresignedUpload, file: File): Promise { 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 { 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** ```bash 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** ```tsx 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(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 (
{label}
{photoId && ( {label} )} {uploading &&
Загружаю…
}
); } ``` - [ ] **Step 2: Commit** ```bash 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 ``` - [ ] **Step 2: Implement VehicleScheme component** ```tsx 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(null); return (
{ 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) => (
))} {hoverZone && (
{humanize(hoverZone)}
)}
); } function humanize(zoneId: string): string { const map: Record = { "zone-front": "Передний бампер", "zone-rear": "Задний бампер", "zone-left": "Левый бок", "zone-right": "Правый бок", "zone-hood": "Капот", "zone-trunk": "Багажник", "zone-roof": "Крыша", }; return map[zoneId] || zoneId; } ``` - [ ] **Step 3: Commit** ```bash 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`: ```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 { return api.get(`inspections/${id}`).json(); } export async function patchInspection(id: number, body: { status?: string; notes?: string; }): Promise { return api.patch(`inspections/${id}`, { json: body }).json(); } export async function createMarker(inspectionId: number, body: { photo_id?: number; side?: string; x?: number; y?: number; damage_type?: string; severity?: string; description?: string; }): Promise { return api.post(`inspections/${inspectionId}/markers`, { json: body }) .json(); } ``` - [ ] **Step 2: Implement InspectionEditor** ```tsx 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
Загружаю…
; const photosBySide = new Map(); 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 (

Осмотр #{ins.id} ({ins.type})

Схема — кликни на зону, чтобы поставить метку

addZoneMarker.mutate(zone)} />

Фото

{SLOTS.map((s) => ( qc.invalidateQueries({ queryKey: ["inspection", insId] })} /> ))}
{ins.markers.length > 0 && (

Метки ({ins.markers.length})

    {ins.markers.map((m) => (
  • {m.side} — {m.damage_type || "n/a"} — {m.severity || "n/a"} {m.carried_over_from_id && (из прошлого)}
  • ))}
)}
); } ``` - [ ] **Step 3: Manual smoke test** `npm run dev` → пройти flow login → home → vehicle → start inspection → попасть в editor → попробовать тапать на зоны схемы (markers создаются), снимать фото (uploads пройдут на dev S3 если CORS настроен). - [ ] **Step 4: Commit** ```bash 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** ```tsx 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
Загружаю…
; return (

Осмотр #{ins.id}

Тип: {ins.type}
Статус: {ins.status}
Начат: {new Date(ins.started_at).toLocaleString("ru-RU")}
{ins.finished_at &&
Завершён: {new Date(ins.finished_at).toLocaleString("ru-RU")}
} {ins.notes &&
Заметки: {ins.notes}
}

Фото ({ins.photos.length})

{ins.photos.map((p) => ( {p.side}
{p.side}
))}

Метки ({ins.markers.length})

    {ins.markers.map((m) => (
  • {m.side} — {m.damage_type || "—"} — {m.severity || "—"} {m.description &&
    {m.description}
    } {m.resolved && ✓ устранено} {m.carried_over_from_id && (перенесено)}
  • ))}
); } ``` - [ ] **Step 2: Commit** ```bash 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** ```bash cd /opt/sites/taxi-dashboard/backend # or wherever uvicorn app.main:app --reload --port 8000 ``` - [ ] **Step 2: Start frontend dev** ```bash 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** ```bash ssh root@100.64.0.12 cd /opt/sites/taxi-dashboard git pull ``` - [ ] **Step 2: Install new deps, run migration** ```bash source .venv/bin/activate pip install -r requirements.txt # if aiobotocore/Pillow were added there alembic upgrade head ``` - [ ] **Step 3: Restart backend service** ```bash 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. ```bash 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** ```bash cd c:/NewProject/PremiumDriverApp/mechanic-pwa/frontend npm run build ``` Expected: `dist/` directory with optimized assets. - [ ] **Step 2: Rsync to VDS** ```bash rsync -avz --delete dist/ root@100.64.0.12:/opt/sites/mechanic-pwa/dist/ ``` - [ ] **Step 3: Wire Caddy fragment** On VDS: ```bash 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** ```bash 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** ```bash 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** ```bash mc ls local/pp-inspections/inspections//original/ ``` Expected: at least one `.jpg` file present. - [ ] **Step 3: Verify thumbnail (after celery task runs)** Wait ~10 seconds, then: ```bash mc ls local/pp-inspections/inspections//thumb/ ``` Expected: matching `.jpg` thumbnail. - [ ] **Step 4: Verify DB rows** ```bash 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=;" psql $DATABASE_URL -c "SELECT * FROM damage_markers WHERE inspection_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.