feat(T03): OCR-Erfassung via OpenRouter Qwen2.5-VL + OCR UI with drag-and-drop

This commit is contained in:
2026-07-17 00:45:14 +02:00
parent b128ea6b39
commit 149ac04dc1
20 changed files with 2348 additions and 182 deletions
+179
View File
@@ -0,0 +1,179 @@
"""OCR router: upload, get results, list results, apply to vehicle."""
import uuid
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, Query, UploadFile, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.dependencies import get_current_user
from app.models.user import User
from app.models.ocr_result import OCRStatus
from app.schemas.ocr import (
OCRApplyResponse,
OCRResultListResponse,
OCRResultResponse,
OCRUploadResponse,
)
from app.services import ocr_service
from app.tasks.ocr_processing import run_ocr_processing
router = APIRouter(prefix="/ocr", tags=["ocr"])
@router.post(
"/upload",
response_model=OCRUploadResponse,
status_code=status.HTTP_202_ACCEPTED,
)
async def upload_scan(
background_tasks: BackgroundTasks,
file: UploadFile = File(..., description="Image file to OCR"),
vehicle_id: str | None = Form(None, description="Optional vehicle ID to link"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Upload an image file for OCR processing.
Returns 202 with ocr_result_id. Processing happens asynchronously.
"""
if not file or not file.filename:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"error": {"code": "NO_FILE", "message": "No file provided"}},
)
mime_type = file.content_type or ""
if not ocr_service.validate_mime_type(mime_type):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"error": {
"code": "INVALID_MIME_TYPE",
"message": f"Invalid MIME type: {mime_type}. Only image/* types are allowed.",
}
},
)
# Read file content
file_bytes = await file.read()
if not ocr_service.validate_file_size(len(file_bytes)):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"error": {
"code": "FILE_TOO_LARGE",
"message": f"File size exceeds limit of {settings.MAX_FILE_SIZE_MB} MB",
}
},
)
# Parse optional vehicle_id
parsed_vehicle_id: uuid.UUID | None = None
if vehicle_id:
try:
parsed_vehicle_id = uuid.UUID(vehicle_id)
except (ValueError, TypeError):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"error": {"code": "INVALID_VEHICLE_ID", "message": "Invalid vehicle UUID"}},
)
try:
ocr_result = await ocr_service.upload_file(
db=db,
file_bytes=file_bytes,
file_name=file.filename or "upload.png",
mime_type=mime_type,
vehicle_id=parsed_vehicle_id,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"error": {"code": "UPLOAD_FAILED", "message": str(exc)}},
)
# Queue background processing
background_tasks.add_task(run_ocr_processing, ocr_result.id)
return OCRUploadResponse(
message="OCR processing queued",
ocr_result_id=ocr_result.id,
status=ocr_result.status.value if isinstance(ocr_result.status, OCRStatus) else str(ocr_result.status),
)
@router.get(
"/results/{result_id}",
response_model=OCRResultResponse,
status_code=status.HTTP_200_OK,
)
async def get_ocr_result(
result_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get a single OCR result by ID."""
ocr_result = await ocr_service.get_result(db, result_id)
if ocr_result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "OCR_NOT_FOUND", "message": "OCR result not found"}},
)
return OCRResultResponse.model_validate(ocr_result)
@router.get(
"/results",
response_model=OCRResultListResponse,
status_code=status.HTTP_200_OK,
)
async def list_ocr_results(
db: AsyncSession = Depends(get_db),
vehicle_id: uuid.UUID | None = Query(None, description="Filter by vehicle ID"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(20, ge=1, le=100, description="Items per page"),
current_user: User = Depends(get_current_user),
):
"""List OCR results, optionally filtered by vehicle_id."""
items, total = await ocr_service.list_results(
db=db,
vehicle_id=vehicle_id,
page=page,
page_size=page_size,
)
return OCRResultListResponse(
items=[OCRResultResponse.model_validate(item) for item in items],
total=total,
page=page,
page_size=page_size,
)
@router.post(
"/results/{result_id}/apply",
response_model=OCRApplyResponse,
status_code=status.HTTP_200_OK,
)
async def apply_ocr_to_vehicle(
result_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Apply OCR structured data to the linked vehicle."""
try:
ocr_result, vehicle, updated_fields = await ocr_service.apply_to_vehicle(db, result_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": {"code": "APPLY_FAILED", "message": str(exc)}},
)
return OCRApplyResponse(
message="OCR data applied to vehicle",
ocr_result_id=ocr_result.id,
vehicle_id=vehicle.id,
updated_fields=updated_fields,
)