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

3586 lines
106 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<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**
```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/<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:
```python
"""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**
```bash
alembic upgrade head
```
Expected: `Running upgrade <prev> -> <new>, 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 `<head>`:
```html
<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:
```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<AuthState>()(
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(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
<Toaster richColors position="top-center" />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>
);
```
- [ ] **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 <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`:
```tsx
export default function Login() { return <div className="p-8">Login (stub)</div>; }
```
`src/pages/Home.tsx`:
```tsx
export default function Home() { return <div className="p-8">Home (stub)</div>; }
```
`src/pages/VehicleCard.tsx`:
```tsx
export default function VehicleCard() { return <div className="p-8">VehicleCard (stub)</div>; }
```
`src/pages/InspectionEditor.tsx`:
```tsx
export default function InspectionEditor() { return <div className="p-8">InspectionEditor (stub)</div>; }
```
`src/pages/InspectionReview.tsx`:
```tsx
export default function InspectionReview() { return <div className="p-8">InspectionReview (stub)</div>; }
```
- [ ] **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<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**
```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 (
<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**
```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<VehicleSummary[]> {
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 (
<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**
```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<VehicleDetail> {
return api.get(`vehicles/${id}`).json<VehicleDetail>();
}
```
- [ ] **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 <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**
```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 `<input type="file" capture>` 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<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**
```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<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**
```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<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**
```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
<?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**
```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<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**
```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<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**
```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 <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**
```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 <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**
```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/<new-id>/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/<new-id>/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=<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.