Add service platform foundation
This commit is contained in:
141
app/api/admin.py
Normal file
141
app/api/admin.py
Normal file
@@ -0,0 +1,141 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_telegram_user, log_audit, require_platform_role
|
||||
from app.db.session import get_session
|
||||
from app.models.car import AuditLog, ServiceCenter, ServiceCenterVerification, ServiceVisit
|
||||
from app.models.user import User
|
||||
from app.schemas.service_center import ServiceCenterRead, ServiceVisitRead
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
|
||||
def require_admin_or_verifier(user: User) -> None:
|
||||
require_platform_role(user, {"admin", "verifier", "moderator"})
|
||||
|
||||
|
||||
@router.get("/service-centers/pending", response_model=list[ServiceCenterRead])
|
||||
async def pending_service_centers(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> list[ServiceCenter]:
|
||||
require_admin_or_verifier(current_user)
|
||||
result = await session.execute(
|
||||
select(ServiceCenter)
|
||||
.where(ServiceCenter.verification_status == "pending")
|
||||
.order_by(ServiceCenter.created_at.asc())
|
||||
)
|
||||
return list(result.scalars())
|
||||
|
||||
|
||||
@router.post("/service-centers/{service_center_id}/verify", response_model=ServiceCenterRead)
|
||||
async def verify_service_center(
|
||||
service_center_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> ServiceCenter:
|
||||
require_admin_or_verifier(current_user)
|
||||
center = await session.get(ServiceCenter, service_center_id)
|
||||
if center is None:
|
||||
raise HTTPException(status_code=404, detail="Service center not found")
|
||||
center.verification_status = "verified"
|
||||
center.verified_at = datetime.now(UTC)
|
||||
await mark_latest_verification(session, center.id, "verified", current_user.id)
|
||||
await log_audit(session, actor=current_user, action="service_center.verify", target_type="service_center", target_id=center.id)
|
||||
await session.commit()
|
||||
await session.refresh(center)
|
||||
return center
|
||||
|
||||
|
||||
@router.post("/service-centers/{service_center_id}/reject", response_model=ServiceCenterRead)
|
||||
async def reject_service_center(
|
||||
service_center_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> ServiceCenter:
|
||||
require_admin_or_verifier(current_user)
|
||||
center = await session.get(ServiceCenter, service_center_id)
|
||||
if center is None:
|
||||
raise HTTPException(status_code=404, detail="Service center not found")
|
||||
center.verification_status = "rejected"
|
||||
await mark_latest_verification(session, center.id, "rejected", current_user.id)
|
||||
await log_audit(session, actor=current_user, action="service_center.reject", target_type="service_center", target_id=center.id)
|
||||
await session.commit()
|
||||
await session.refresh(center)
|
||||
return center
|
||||
|
||||
|
||||
@router.post("/service-centers/{service_center_id}/suspend", response_model=ServiceCenterRead)
|
||||
async def suspend_service_center(
|
||||
service_center_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> ServiceCenter:
|
||||
require_platform_role(current_user, {"admin"})
|
||||
center = await session.get(ServiceCenter, service_center_id)
|
||||
if center is None:
|
||||
raise HTTPException(status_code=404, detail="Service center not found")
|
||||
center.verification_status = "suspended"
|
||||
center.suspended_at = datetime.now(UTC)
|
||||
await log_audit(session, actor=current_user, action="service_center.suspend", target_type="service_center", target_id=center.id)
|
||||
await session.commit()
|
||||
await session.refresh(center)
|
||||
return center
|
||||
|
||||
|
||||
@router.get("/audit-log")
|
||||
async def audit_log(
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> list[dict]:
|
||||
require_platform_role(current_user, {"admin", "verifier", "moderator"})
|
||||
limit = min(max(limit, 1), 200)
|
||||
result = await session.execute(
|
||||
select(AuditLog).order_by(AuditLog.created_at.desc()).limit(limit).offset(max(offset, 0))
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": item.id,
|
||||
"actor_user_id": item.actor_user_id,
|
||||
"actor_role": item.actor_role,
|
||||
"action": item.action,
|
||||
"target_type": item.target_type,
|
||||
"target_id": item.target_id,
|
||||
"metadata_json": item.metadata_json,
|
||||
"created_at": item.created_at,
|
||||
}
|
||||
for item in result.scalars()
|
||||
]
|
||||
|
||||
|
||||
@router.get("/disputes", response_model=list[ServiceVisitRead])
|
||||
async def disputes(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> list[ServiceVisit]:
|
||||
require_admin_or_verifier(current_user)
|
||||
result = await session.execute(
|
||||
select(ServiceVisit).where(ServiceVisit.status == "disputed").order_by(ServiceVisit.updated_at.desc())
|
||||
)
|
||||
return list(result.scalars())
|
||||
|
||||
|
||||
async def mark_latest_verification(
|
||||
session: AsyncSession, service_center_id: int, status: str, reviewed_by: int
|
||||
) -> None:
|
||||
result = await session.execute(
|
||||
select(ServiceCenterVerification)
|
||||
.where(ServiceCenterVerification.service_center_id == service_center_id)
|
||||
.order_by(ServiceCenterVerification.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
verification = result.scalar_one_or_none()
|
||||
if verification:
|
||||
verification.status = status
|
||||
verification.reviewed_by = reviewed_by
|
||||
verification.reviewed_at = datetime.now(UTC)
|
||||
@@ -7,17 +7,27 @@ from app.db.session import get_session
|
||||
from app.models.car import Car
|
||||
from app.models.user import User
|
||||
from app.schemas.car import CarCreate, CarRead, CarUpdate
|
||||
from app.services.vehicle_identity import normalize_license_plate, validate_vin
|
||||
|
||||
router = APIRouter(prefix="/cars", tags=["cars"])
|
||||
|
||||
|
||||
def apply_identity_fields(data: dict) -> dict:
|
||||
if "plate_number" in data:
|
||||
data["license_plate_display"] = data["plate_number"]
|
||||
data["license_plate_normalized"] = normalize_license_plate(data["plate_number"])
|
||||
if "vin" in data:
|
||||
data["vin_normalized"] = validate_vin(data["vin"])
|
||||
return data
|
||||
|
||||
|
||||
@router.post("", response_model=CarRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_car(
|
||||
payload: CarCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> Car:
|
||||
data = payload.model_dump(exclude={"owner_id"})
|
||||
data = apply_identity_fields(payload.model_dump(exclude={"owner_id"}))
|
||||
car = Car(**data, owner_id=current_user.id)
|
||||
session.add(car)
|
||||
await session.commit()
|
||||
@@ -65,7 +75,7 @@ async def update_car(
|
||||
raise HTTPException(status_code=404, detail="Car not found")
|
||||
if car.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
for field, value in apply_identity_fields(payload.model_dump(exclude_unset=True)).items():
|
||||
setattr(car, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(car)
|
||||
|
||||
67
app/api/change_requests.py
Normal file
67
app/api/change_requests.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_telegram_user, log_audit
|
||||
from app.api.service_visits import apply_vehicle_change
|
||||
from app.db.session import get_session
|
||||
from app.models.car import Car, VehicleDataChangeRequest
|
||||
from app.models.user import User
|
||||
from app.schemas.service_center import VehicleDataChangeRequestRead
|
||||
|
||||
router = APIRouter(prefix="/vehicle-change-requests", tags=["vehicle-change-requests"])
|
||||
|
||||
|
||||
@router.post("/{request_id}/approve", response_model=VehicleDataChangeRequestRead)
|
||||
async def approve_vehicle_change_request(
|
||||
request_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> VehicleDataChangeRequest:
|
||||
request = await session.get(VehicleDataChangeRequest, request_id)
|
||||
if request is None:
|
||||
raise HTTPException(status_code=404, detail="Change request not found")
|
||||
if request.owner_user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
vehicle = await session.get(Car, request.vehicle_id)
|
||||
if vehicle is None:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
apply_vehicle_change(vehicle, request.field_name, request.new_value)
|
||||
request.status = "approved"
|
||||
request.resolved_at = datetime.now(UTC)
|
||||
await log_audit(
|
||||
session,
|
||||
actor=current_user,
|
||||
action="vehicle_change_request.approve",
|
||||
target_type="vehicle_change_request",
|
||||
target_id=request_id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(request)
|
||||
return request
|
||||
|
||||
|
||||
@router.post("/{request_id}/reject", response_model=VehicleDataChangeRequestRead)
|
||||
async def reject_vehicle_change_request(
|
||||
request_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> VehicleDataChangeRequest:
|
||||
request = await session.get(VehicleDataChangeRequest, request_id)
|
||||
if request is None:
|
||||
raise HTTPException(status_code=404, detail="Change request not found")
|
||||
if request.owner_user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
request.status = "rejected"
|
||||
request.resolved_at = datetime.now(UTC)
|
||||
await log_audit(
|
||||
session,
|
||||
actor=current_user,
|
||||
action="vehicle_change_request.reject",
|
||||
target_type="vehicle_change_request",
|
||||
target_id=request_id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(request)
|
||||
return request
|
||||
@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.session import get_session
|
||||
from app.models.car import Car
|
||||
from app.models.car import AuditLog, Car, ServiceCenter, ServiceEmployee, VehicleAccess
|
||||
from app.models.user import User
|
||||
from app.services.telegram_auth import verify_webapp_init_data
|
||||
|
||||
@@ -20,6 +20,7 @@ async def get_or_create_telegram_user(
|
||||
last_name: str | None = None,
|
||||
locale: str | None = None,
|
||||
currency: str | None = None,
|
||||
platform_role: str | None = None,
|
||||
) -> User:
|
||||
result = await session.execute(select(User).where(User.telegram_id == telegram_id))
|
||||
user = result.scalar_one_or_none()
|
||||
@@ -30,6 +31,7 @@ async def get_or_create_telegram_user(
|
||||
"last_name": last_name,
|
||||
"locale": locale,
|
||||
"currency": currency,
|
||||
"platform_role": platform_role,
|
||||
}
|
||||
if user is None:
|
||||
user = User(**{key: value for key, value in payload.items() if value is not None})
|
||||
@@ -92,3 +94,95 @@ async def get_owned_car(
|
||||
if car.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
return car
|
||||
|
||||
|
||||
async def user_has_vehicle_access(
|
||||
session: AsyncSession, vehicle_id: int, user_id: int, roles: set[str] | None = None
|
||||
) -> bool:
|
||||
stmt = select(VehicleAccess).where(
|
||||
VehicleAccess.vehicle_id == vehicle_id,
|
||||
VehicleAccess.user_id == user_id,
|
||||
VehicleAccess.status == "active",
|
||||
)
|
||||
if roles:
|
||||
stmt = stmt.where(VehicleAccess.role.in_(roles))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def ensure_vehicle_owner_or_access(
|
||||
session: AsyncSession,
|
||||
vehicle_id: int,
|
||||
user: User,
|
||||
roles: set[str] | None = None,
|
||||
) -> Car:
|
||||
car = await session.get(Car, vehicle_id)
|
||||
if car is None:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
if car.owner_id == user.id:
|
||||
return car
|
||||
if await user_has_vehicle_access(session, vehicle_id, user.id, roles):
|
||||
return car
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
|
||||
def require_platform_role(user: User, allowed: set[str]) -> None:
|
||||
if user.platform_role not in allowed:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
|
||||
async def ensure_service_employee(
|
||||
session: AsyncSession,
|
||||
service_center_id: int,
|
||||
user: User,
|
||||
allowed_roles: set[str] | None = None,
|
||||
) -> ServiceEmployee:
|
||||
result = await session.execute(
|
||||
select(ServiceEmployee).where(
|
||||
ServiceEmployee.service_center_id == service_center_id,
|
||||
ServiceEmployee.user_id == user.id,
|
||||
ServiceEmployee.status == "active",
|
||||
)
|
||||
)
|
||||
employee = result.scalar_one_or_none()
|
||||
center = await session.get(ServiceCenter, service_center_id)
|
||||
owner_allowed = center is not None and center.owner_user_id == user.id
|
||||
if employee is None and owner_allowed:
|
||||
employee = ServiceEmployee(
|
||||
service_center_id=service_center_id,
|
||||
user_id=user.id,
|
||||
role="owner",
|
||||
status="active",
|
||||
)
|
||||
session.add(employee)
|
||||
await session.flush()
|
||||
if employee is None:
|
||||
raise HTTPException(status_code=403, detail="Service center access required")
|
||||
if allowed_roles and employee.role not in allowed_roles:
|
||||
raise HTTPException(status_code=403, detail="Insufficient service role")
|
||||
return employee
|
||||
|
||||
|
||||
async def log_audit(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
actor: User | None,
|
||||
action: str,
|
||||
target_type: str,
|
||||
target_id: int | str | None = None,
|
||||
metadata: dict | None = None,
|
||||
ip: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
) -> None:
|
||||
session.add(
|
||||
AuditLog(
|
||||
actor_user_id=actor.id if actor else None,
|
||||
actor_role=actor.platform_role if actor else None,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=str(target_id) if target_id is not None else None,
|
||||
metadata_json=metadata,
|
||||
ip=ip,
|
||||
user_agent=user_agent[:256] if user_agent else None,
|
||||
)
|
||||
)
|
||||
|
||||
157
app/api/my.py
Normal file
157
app/api/my.py
Normal file
@@ -0,0 +1,157 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_telegram_user, log_audit
|
||||
from app.db.session import get_session
|
||||
from app.models.car import Car, ServiceVisit, VehicleAccess
|
||||
from app.models.user import User
|
||||
from app.schemas.service_center import (
|
||||
VehicleAccessGrant,
|
||||
VehicleAccessRead,
|
||||
VehicleCreate,
|
||||
VehicleRead,
|
||||
VehicleUpdate,
|
||||
)
|
||||
from app.schemas.user import UserRead
|
||||
from app.services.vehicle_identity import normalize_license_plate, validate_vin
|
||||
|
||||
router = APIRouter(tags=["my"])
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserRead)
|
||||
async def me(current_user: User = Depends(get_current_telegram_user)) -> User:
|
||||
return current_user
|
||||
|
||||
|
||||
def vehicle_data(payload: VehicleCreate | VehicleUpdate, *, partial: bool = False) -> dict:
|
||||
raw = payload.model_dump(exclude_unset=partial)
|
||||
data = {
|
||||
key: value
|
||||
for key, value in raw.items()
|
||||
if key not in {"license_plate", "license_plate_country", "vin"}
|
||||
}
|
||||
if "license_plate" in raw:
|
||||
data["license_plate_display"] = raw["license_plate"]
|
||||
data["license_plate_normalized"] = normalize_license_plate(raw["license_plate"])
|
||||
data["plate_number"] = raw["license_plate"]
|
||||
if "license_plate_country" in raw:
|
||||
data["license_plate_country"] = (
|
||||
raw["license_plate_country"].upper() if raw["license_plate_country"] else None
|
||||
)
|
||||
if "vin" in raw:
|
||||
data["vin_normalized"] = validate_vin(raw["vin"])
|
||||
data["vin"] = raw["vin"]
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/my/vehicles", response_model=list[VehicleRead])
|
||||
async def my_vehicles(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> list[Car]:
|
||||
result = await session.execute(
|
||||
select(Car)
|
||||
.join(VehicleAccess, VehicleAccess.vehicle_id == Car.id)
|
||||
.where(VehicleAccess.user_id == current_user.id, VehicleAccess.status == "active")
|
||||
.order_by(Car.created_at.desc())
|
||||
)
|
||||
return list(result.scalars())
|
||||
|
||||
|
||||
@router.post("/my/vehicles", response_model=VehicleRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_vehicle(
|
||||
payload: VehicleCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> Car:
|
||||
car = Car(**vehicle_data(payload), owner_id=current_user.id)
|
||||
session.add(car)
|
||||
await session.flush()
|
||||
session.add(VehicleAccess(vehicle_id=car.id, user_id=current_user.id, role="owner", status="active"))
|
||||
await log_audit(session, actor=current_user, action="vehicle.create", target_type="vehicle", target_id=car.id)
|
||||
await session.commit()
|
||||
await session.refresh(car)
|
||||
return car
|
||||
|
||||
|
||||
@router.patch("/my/vehicles/{vehicle_id}", response_model=VehicleRead)
|
||||
async def update_vehicle(
|
||||
vehicle_id: int,
|
||||
payload: VehicleUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> Car:
|
||||
car = await session.get(Car, vehicle_id)
|
||||
if car is None:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
if car.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
for field, value in vehicle_data(payload, partial=True).items():
|
||||
setattr(car, field, value)
|
||||
await log_audit(session, actor=current_user, action="vehicle.update", target_type="vehicle", target_id=car.id)
|
||||
await session.commit()
|
||||
await session.refresh(car)
|
||||
return car
|
||||
|
||||
|
||||
@router.get("/my/vehicles/{vehicle_id}/service-history")
|
||||
async def vehicle_service_history(
|
||||
vehicle_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> dict:
|
||||
car = await session.get(Car, vehicle_id)
|
||||
if car is None:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
if car.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
result = await session.execute(
|
||||
select(ServiceVisit)
|
||||
.where(ServiceVisit.vehicle_id == vehicle_id)
|
||||
.order_by(ServiceVisit.visit_date.desc())
|
||||
)
|
||||
visits = list(result.scalars())
|
||||
return {"vehicle_id": vehicle_id, "service_visits": jsonable_encoder(visits)}
|
||||
|
||||
|
||||
@router.post("/my/vehicles/{vehicle_id}/grant-service-access", response_model=VehicleAccessRead)
|
||||
async def grant_vehicle_access(
|
||||
vehicle_id: int,
|
||||
payload: VehicleAccessGrant,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> VehicleAccess:
|
||||
car = await session.get(Car, vehicle_id)
|
||||
if car is None:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
if car.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
if not payload.user_id:
|
||||
raise HTTPException(status_code=400, detail="user_id is required for access grants")
|
||||
result = await session.execute(
|
||||
select(VehicleAccess).where(
|
||||
VehicleAccess.vehicle_id == vehicle_id,
|
||||
VehicleAccess.user_id == payload.user_id,
|
||||
VehicleAccess.role == payload.role,
|
||||
)
|
||||
)
|
||||
access = result.scalar_one_or_none()
|
||||
if access is None:
|
||||
access = VehicleAccess(vehicle_id=vehicle_id, user_id=payload.user_id, role=payload.role, status="active")
|
||||
session.add(access)
|
||||
else:
|
||||
access.status = "active"
|
||||
access.revoked_at = None
|
||||
await log_audit(
|
||||
session,
|
||||
actor=current_user,
|
||||
action="vehicle_access.grant",
|
||||
target_type="vehicle",
|
||||
target_id=vehicle_id,
|
||||
metadata={"granted_user_id": payload.user_id, "role": payload.role},
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(access)
|
||||
return access
|
||||
@@ -6,6 +6,7 @@ from pydantic import BaseModel
|
||||
|
||||
from app.api.deps import get_current_telegram_user
|
||||
from app.models.user import User
|
||||
from app.services.ocr_provider import get_ocr_provider
|
||||
|
||||
router = APIRouter(prefix="/ocr", tags=["ocr"])
|
||||
|
||||
@@ -19,6 +20,17 @@ class ReceiptSuggestion(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class OCRCandidateRead(BaseModel):
|
||||
type: str
|
||||
value: str
|
||||
confidence: float
|
||||
|
||||
|
||||
class OCRResultRead(BaseModel):
|
||||
recognized_text: str
|
||||
candidates: list[OCRCandidateRead]
|
||||
|
||||
|
||||
@router.post("/parse-text-receipt", response_model=ReceiptSuggestion)
|
||||
async def parse_text_receipt(
|
||||
file: UploadFile = File(...),
|
||||
@@ -81,6 +93,42 @@ async def scan_fuel_receipt(
|
||||
return await parse_text_receipt(file, current_user)
|
||||
|
||||
|
||||
@router.post("/license-plate", response_model=OCRResultRead)
|
||||
async def recognize_license_plate(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> OCRResultRead:
|
||||
result = await get_ocr_provider().recognize(await file.read(), file.filename)
|
||||
return OCRResultRead(
|
||||
recognized_text=result.recognized_text,
|
||||
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "license_plate"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/vin", response_model=OCRResultRead)
|
||||
async def recognize_vin(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> OCRResultRead:
|
||||
result = await get_ocr_provider().recognize(await file.read(), file.filename)
|
||||
return OCRResultRead(
|
||||
recognized_text=result.recognized_text,
|
||||
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "vin"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/service-document", response_model=OCRResultRead)
|
||||
async def recognize_service_document(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> OCRResultRead:
|
||||
result = await get_ocr_provider().recognize(await file.read(), file.filename)
|
||||
return OCRResultRead(
|
||||
recognized_text=result.recognized_text,
|
||||
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates],
|
||||
)
|
||||
|
||||
|
||||
def detect_station(text: str) -> str | None:
|
||||
stations = {
|
||||
"shell": "Shell",
|
||||
|
||||
@@ -2,35 +2,93 @@ from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import require_internal_api_token
|
||||
from app.api.deps import (
|
||||
ensure_service_employee,
|
||||
get_current_telegram_user,
|
||||
get_or_create_telegram_user,
|
||||
log_audit,
|
||||
require_internal_api_token,
|
||||
)
|
||||
from app.db.session import get_session
|
||||
from app.models.car import Car, CarServiceLink, ServiceCenter, ServiceInboxMessage
|
||||
from app.models.car import (
|
||||
Car,
|
||||
CarServiceLink,
|
||||
ServiceCenter,
|
||||
ServiceCenterVerification,
|
||||
ServiceEmployee,
|
||||
ServiceInboxMessage,
|
||||
ServiceVisit,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.schemas.service_center import (
|
||||
CarServiceLinkCreate,
|
||||
CarServiceLinkRead,
|
||||
ServiceCenterCreate,
|
||||
ServiceCenterRead,
|
||||
ServiceCenterVerificationCreate,
|
||||
ServiceCenterVerificationRead,
|
||||
ServiceEmployeeInvite,
|
||||
ServiceEmployeeRead,
|
||||
ServiceInboxCreate,
|
||||
ServiceInboxRead,
|
||||
ServiceVisitCreate,
|
||||
ServiceVisitRead,
|
||||
VehicleSearchRequest,
|
||||
VehicleSearchResult,
|
||||
)
|
||||
from app.services.vehicle_identity import mask_license_plate, mask_vin
|
||||
|
||||
router = APIRouter(prefix="/service-centers", tags=["service-centers"])
|
||||
|
||||
|
||||
@router.post("", response_model=ServiceCenterRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_service_center(
|
||||
payload: ServiceCenterCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
x_internal_api_token: str | None = Header(default=None, alias="X-Internal-API-Token"),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> ServiceCenter:
|
||||
require_internal_api_token(x_internal_api_token)
|
||||
center = ServiceCenter(**payload.model_dump())
|
||||
center = ServiceCenter(
|
||||
name=payload.display_name,
|
||||
display_name=payload.display_name,
|
||||
legal_name=payload.legal_name,
|
||||
country=payload.country.upper() if payload.country else None,
|
||||
city=payload.city,
|
||||
address=payload.address,
|
||||
phone=payload.phone,
|
||||
contact_phone=payload.contact_phone or payload.phone,
|
||||
telegram_chat_id=payload.telegram_chat_id,
|
||||
business_registration_number=payload.business_registration_number,
|
||||
owner_user_id=current_user.id,
|
||||
verification_status="pending",
|
||||
)
|
||||
session.add(center)
|
||||
await session.flush()
|
||||
employee = ServiceEmployee(
|
||||
service_center_id=center.id,
|
||||
user_id=current_user.id,
|
||||
role="owner",
|
||||
status="active",
|
||||
)
|
||||
session.add(employee)
|
||||
await log_audit(session, actor=current_user, action="service_center.create", target_type="service_center", target_id=center.id)
|
||||
await session.commit()
|
||||
await session.refresh(center)
|
||||
return center
|
||||
|
||||
|
||||
@router.get("/my", response_model=list[ServiceCenterRead])
|
||||
async def my_service_centers(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> list[ServiceCenter]:
|
||||
result = await session.execute(
|
||||
select(ServiceCenter)
|
||||
.join(ServiceEmployee, ServiceEmployee.service_center_id == ServiceCenter.id)
|
||||
.where(ServiceEmployee.user_id == current_user.id, ServiceEmployee.status == "active")
|
||||
.order_by(ServiceCenter.created_at.desc())
|
||||
)
|
||||
return list(result.scalars())
|
||||
|
||||
|
||||
@router.get("", response_model=list[ServiceCenterRead])
|
||||
async def list_service_centers(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
@@ -41,6 +99,152 @@ async def list_service_centers(
|
||||
return list(result.scalars())
|
||||
|
||||
|
||||
@router.post("/{service_center_id}/verification", response_model=ServiceCenterVerificationRead)
|
||||
async def submit_verification(
|
||||
service_center_id: int,
|
||||
payload: ServiceCenterVerificationCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> ServiceCenterVerification:
|
||||
await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager"})
|
||||
verification = ServiceCenterVerification(
|
||||
service_center_id=service_center_id,
|
||||
submitted_documents=payload.submitted_documents,
|
||||
comment=payload.comment,
|
||||
status="pending",
|
||||
)
|
||||
session.add(verification)
|
||||
center = await session.get(ServiceCenter, service_center_id)
|
||||
if center:
|
||||
center.verification_status = "pending"
|
||||
await log_audit(session, actor=current_user, action="service_center.verification.submit", target_type="service_center", target_id=service_center_id)
|
||||
await session.commit()
|
||||
await session.refresh(verification)
|
||||
return verification
|
||||
|
||||
|
||||
@router.post("/{service_center_id}/employees/invite", response_model=ServiceEmployeeRead)
|
||||
async def invite_employee(
|
||||
service_center_id: int,
|
||||
payload: ServiceEmployeeInvite,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> ServiceEmployee:
|
||||
await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager"})
|
||||
user = await get_or_create_telegram_user(session, telegram_id=payload.telegram_id)
|
||||
result = await session.execute(
|
||||
select(ServiceEmployee).where(
|
||||
ServiceEmployee.service_center_id == service_center_id,
|
||||
ServiceEmployee.user_id == user.id,
|
||||
)
|
||||
)
|
||||
employee = result.scalar_one_or_none()
|
||||
if employee is None:
|
||||
employee = ServiceEmployee(
|
||||
service_center_id=service_center_id,
|
||||
user_id=user.id,
|
||||
role=payload.role,
|
||||
permissions=payload.permissions,
|
||||
status="invited",
|
||||
)
|
||||
session.add(employee)
|
||||
else:
|
||||
employee.role = payload.role
|
||||
employee.permissions = payload.permissions
|
||||
employee.status = "invited"
|
||||
await log_audit(session, actor=current_user, action="service_employee.invite", target_type="service_center", target_id=service_center_id, metadata={"telegram_id": payload.telegram_id})
|
||||
await session.commit()
|
||||
await session.refresh(employee)
|
||||
return employee
|
||||
|
||||
|
||||
@router.get("/{service_center_id}/visits", response_model=list[ServiceVisitRead])
|
||||
async def service_center_visits(
|
||||
service_center_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> list[ServiceVisit]:
|
||||
await ensure_service_employee(session, service_center_id, current_user)
|
||||
result = await session.execute(
|
||||
select(ServiceVisit)
|
||||
.where(ServiceVisit.service_center_id == service_center_id)
|
||||
.order_by(ServiceVisit.visit_date.desc(), ServiceVisit.id.desc())
|
||||
)
|
||||
return list(result.scalars())
|
||||
|
||||
|
||||
@router.post("/{service_center_id}/visits", response_model=ServiceVisitRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_visit(
|
||||
service_center_id: int,
|
||||
payload: ServiceVisitCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> ServiceVisit:
|
||||
employee = await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager", "receptionist"})
|
||||
vehicle = await session.get(Car, payload.vehicle_id)
|
||||
if vehicle is None:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
center = await session.get(ServiceCenter, service_center_id)
|
||||
if center and center.verification_status not in {"verified", "pending"}:
|
||||
raise HTTPException(status_code=403, detail="Service center is not allowed to create visits")
|
||||
visit = ServiceVisit(
|
||||
service_center_id=service_center_id,
|
||||
vehicle_id=payload.vehicle_id,
|
||||
created_by_employee_id=employee.id,
|
||||
visit_date=payload.visit_date,
|
||||
odometer=payload.odometer,
|
||||
notes=payload.notes,
|
||||
total_cost=payload.total_cost,
|
||||
currency=payload.currency,
|
||||
status="draft",
|
||||
)
|
||||
session.add(visit)
|
||||
await log_audit(session, actor=current_user, action="service_visit.create", target_type="service_visit", metadata={"vehicle_id": payload.vehicle_id})
|
||||
await session.commit()
|
||||
await session.refresh(visit)
|
||||
return visit
|
||||
|
||||
|
||||
@router.post("/{service_center_id}/vehicle-access/request")
|
||||
async def request_vehicle_access(
|
||||
service_center_id: int,
|
||||
payload: VehicleSearchRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> VehicleSearchResult:
|
||||
await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager", "receptionist"})
|
||||
stmt = select(Car)
|
||||
if payload.vin:
|
||||
stmt = stmt.where(Car.vin_normalized == payload.vin)
|
||||
elif payload.license_plate:
|
||||
stmt = stmt.where(Car.license_plate_normalized == payload.license_plate)
|
||||
if payload.country_code:
|
||||
stmt = stmt.where(Car.license_plate_country == payload.country_code.upper())
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="license_plate or vin is required")
|
||||
vehicle = (await session.execute(stmt.limit(1))).scalar_one_or_none()
|
||||
await log_audit(
|
||||
session,
|
||||
actor=current_user,
|
||||
action="vehicle_access.request",
|
||||
target_type="vehicle",
|
||||
target_id=vehicle.id if vehicle else None,
|
||||
metadata={"service_center_id": service_center_id, "found": bool(vehicle)},
|
||||
)
|
||||
await session.commit()
|
||||
if vehicle is None:
|
||||
return VehicleSearchResult(access_status="not_found")
|
||||
return VehicleSearchResult(
|
||||
vehicle_id=vehicle.id,
|
||||
make=vehicle.make,
|
||||
model=vehicle.model,
|
||||
year=vehicle.year,
|
||||
masked_license_plate=mask_license_plate(vehicle.license_plate_display or vehicle.plate_number),
|
||||
masked_vin=mask_vin(vehicle.vin_normalized or vehicle.vin),
|
||||
access_status="request_logged",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/links", response_model=CarServiceLinkRead, status_code=status.HTTP_201_CREATED)
|
||||
async def link_car_to_service(
|
||||
payload: CarServiceLinkCreate,
|
||||
|
||||
205
app/api/service_visits.py
Normal file
205
app/api/service_visits.py
Normal file
@@ -0,0 +1,205 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import ensure_service_employee, get_current_telegram_user, log_audit
|
||||
from app.db.session import get_session
|
||||
from app.models.car import Car, ServiceVisit, ServiceWorkItem, VehicleDataChangeRequest
|
||||
from app.models.user import User
|
||||
from app.schemas.service_center import (
|
||||
ServiceVisitRead,
|
||||
ServiceWorkItemCreate,
|
||||
ServiceWorkItemRead,
|
||||
VehicleDataChangeRequestCreate,
|
||||
VehicleDataChangeRequestRead,
|
||||
)
|
||||
from app.services.vehicle_identity import normalize_license_plate, validate_vin
|
||||
|
||||
router = APIRouter(prefix="/service-visits", tags=["service-visits"])
|
||||
|
||||
|
||||
async def get_visit_or_404(session: AsyncSession, visit_id: int) -> ServiceVisit:
|
||||
visit = await session.get(ServiceVisit, visit_id)
|
||||
if visit is None:
|
||||
raise HTTPException(status_code=404, detail="Service visit not found")
|
||||
return visit
|
||||
|
||||
|
||||
@router.post("/{visit_id}/work-items", response_model=ServiceWorkItemRead, status_code=status.HTTP_201_CREATED)
|
||||
async def add_work_item(
|
||||
visit_id: int,
|
||||
payload: ServiceWorkItemCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> ServiceWorkItem:
|
||||
visit = await get_visit_or_404(session, visit_id)
|
||||
await ensure_service_employee(
|
||||
session,
|
||||
visit.service_center_id,
|
||||
current_user,
|
||||
{"owner", "manager", "mechanic"},
|
||||
)
|
||||
if visit.status not in {"draft", "pending_owner_confirmation"}:
|
||||
raise HTTPException(status_code=409, detail="Visit cannot be edited in current status")
|
||||
item = ServiceWorkItem(service_visit_id=visit_id, **payload.model_dump())
|
||||
session.add(item)
|
||||
if payload.price is not None:
|
||||
visit.total_cost = (visit.total_cost or 0) + payload.price
|
||||
await log_audit(session, actor=current_user, action="service_work_item.create", target_type="service_visit", target_id=visit_id)
|
||||
await session.commit()
|
||||
await session.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
@router.post("/{visit_id}/complete", response_model=ServiceVisitRead)
|
||||
async def complete_visit(
|
||||
visit_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> ServiceVisit:
|
||||
visit = await get_visit_or_404(session, visit_id)
|
||||
await ensure_service_employee(session, visit.service_center_id, current_user, {"owner", "manager"})
|
||||
if visit.status not in {"draft", "pending_owner_confirmation"}:
|
||||
raise HTTPException(status_code=409, detail="Visit cannot be completed")
|
||||
visit.status = "pending_owner_confirmation"
|
||||
await log_audit(session, actor=current_user, action="service_visit.complete", target_type="service_visit", target_id=visit_id)
|
||||
await session.commit()
|
||||
await session.refresh(visit)
|
||||
return visit
|
||||
|
||||
|
||||
@router.post("/{visit_id}/confirm", response_model=ServiceVisitRead)
|
||||
async def confirm_visit(
|
||||
visit_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> ServiceVisit:
|
||||
visit = await get_visit_or_404(session, visit_id)
|
||||
vehicle = await session.get(Car, visit.vehicle_id)
|
||||
if vehicle is None:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
if vehicle.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
visit.status = "confirmed"
|
||||
visit.owner_resolved_at = datetime.now(UTC)
|
||||
if visit.odometer and (vehicle.current_odometer is None or visit.odometer > vehicle.current_odometer):
|
||||
vehicle.current_odometer = visit.odometer
|
||||
await log_audit(session, actor=current_user, action="service_visit.confirm", target_type="service_visit", target_id=visit_id)
|
||||
await session.commit()
|
||||
await session.refresh(visit)
|
||||
return visit
|
||||
|
||||
|
||||
@router.post("/{visit_id}/dispute", response_model=ServiceVisitRead)
|
||||
async def dispute_visit(
|
||||
visit_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> ServiceVisit:
|
||||
visit = await get_visit_or_404(session, visit_id)
|
||||
vehicle = await session.get(Car, visit.vehicle_id)
|
||||
if vehicle is None:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
if vehicle.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
visit.status = "disputed"
|
||||
visit.owner_resolved_at = datetime.now(UTC)
|
||||
await log_audit(session, actor=current_user, action="service_visit.dispute", target_type="service_visit", target_id=visit_id)
|
||||
await session.commit()
|
||||
await session.refresh(visit)
|
||||
return visit
|
||||
|
||||
|
||||
@router.post("/{visit_id}/vehicle-change-requests", response_model=VehicleDataChangeRequestRead)
|
||||
async def create_vehicle_change_request(
|
||||
visit_id: int,
|
||||
payload: VehicleDataChangeRequestCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> VehicleDataChangeRequest:
|
||||
visit = await get_visit_or_404(session, visit_id)
|
||||
employee = await ensure_service_employee(
|
||||
session,
|
||||
visit.service_center_id,
|
||||
current_user,
|
||||
{"owner", "manager", "mechanic", "receptionist"},
|
||||
)
|
||||
if visit.vehicle_id != payload.vehicle_id:
|
||||
raise HTTPException(status_code=400, detail="Vehicle does not match visit")
|
||||
vehicle = await session.get(Car, payload.vehicle_id)
|
||||
if vehicle is None:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
old_value = getattr(vehicle, payload.field_name, None)
|
||||
request = VehicleDataChangeRequest(
|
||||
vehicle_id=payload.vehicle_id,
|
||||
requested_by_service_center_id=visit.service_center_id,
|
||||
requested_by_employee_id=employee.id,
|
||||
field_name=payload.field_name,
|
||||
old_value=str(old_value) if old_value is not None else None,
|
||||
new_value=payload.new_value,
|
||||
status="pending",
|
||||
owner_user_id=vehicle.owner_id,
|
||||
)
|
||||
session.add(request)
|
||||
await log_audit(session, actor=current_user, action="vehicle_change_request.create", target_type="vehicle", target_id=payload.vehicle_id, metadata={"field_name": payload.field_name})
|
||||
await session.commit()
|
||||
await session.refresh(request)
|
||||
return request
|
||||
|
||||
|
||||
@router.post("/vehicle-change-requests/{request_id}/approve", response_model=VehicleDataChangeRequestRead)
|
||||
async def approve_vehicle_change_request(
|
||||
request_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> VehicleDataChangeRequest:
|
||||
request = await session.get(VehicleDataChangeRequest, request_id)
|
||||
if request is None:
|
||||
raise HTTPException(status_code=404, detail="Change request not found")
|
||||
if request.owner_user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
vehicle = await session.get(Car, request.vehicle_id)
|
||||
if vehicle is None:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
apply_vehicle_change(vehicle, request.field_name, request.new_value)
|
||||
request.status = "approved"
|
||||
request.resolved_at = datetime.now(UTC)
|
||||
await log_audit(session, actor=current_user, action="vehicle_change_request.approve", target_type="vehicle_change_request", target_id=request_id)
|
||||
await session.commit()
|
||||
await session.refresh(request)
|
||||
return request
|
||||
|
||||
|
||||
@router.post("/vehicle-change-requests/{request_id}/reject", response_model=VehicleDataChangeRequestRead)
|
||||
async def reject_vehicle_change_request(
|
||||
request_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> VehicleDataChangeRequest:
|
||||
request = await session.get(VehicleDataChangeRequest, request_id)
|
||||
if request is None:
|
||||
raise HTTPException(status_code=404, detail="Change request not found")
|
||||
if request.owner_user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
request.status = "rejected"
|
||||
request.resolved_at = datetime.now(UTC)
|
||||
await log_audit(session, actor=current_user, action="vehicle_change_request.reject", target_type="vehicle_change_request", target_id=request_id)
|
||||
await session.commit()
|
||||
await session.refresh(request)
|
||||
return request
|
||||
|
||||
|
||||
def apply_vehicle_change(vehicle: Car, field_name: str, value: str | None) -> None:
|
||||
if field_name in {"license_plate", "license_plate_display"}:
|
||||
vehicle.license_plate_display = value
|
||||
vehicle.license_plate_normalized = normalize_license_plate(value)
|
||||
vehicle.plate_number = value
|
||||
return
|
||||
if field_name in {"vin", "vin_normalized"}:
|
||||
vehicle.vin = value
|
||||
vehicle.vin_normalized = validate_vin(value)
|
||||
return
|
||||
if not hasattr(vehicle, field_name):
|
||||
raise HTTPException(status_code=400, detail="Unsupported vehicle field")
|
||||
setattr(vehicle, field_name, value)
|
||||
@@ -47,7 +47,7 @@ async def upsert_user(
|
||||
@router.get("/auth/config", response_model=AuthConfig)
|
||||
async def auth_config() -> AuthConfig:
|
||||
return AuthConfig(
|
||||
bot_username=settings.bot_username or "seoulmate_officialbot",
|
||||
bot_username=settings.bot_username or None,
|
||||
vapid_public_key=settings.vapid_public_key or None,
|
||||
app_env=settings.app_env,
|
||||
allow_dev_auth=settings.allow_dev_auth and not settings.is_production,
|
||||
|
||||
17
app/main.py
17
app/main.py
@@ -2,7 +2,18 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.api import cars, catalog, entries, ocr, service_centers, users
|
||||
from app.api import (
|
||||
admin,
|
||||
cars,
|
||||
catalog,
|
||||
change_requests,
|
||||
entries,
|
||||
my,
|
||||
ocr,
|
||||
service_centers,
|
||||
service_visits,
|
||||
users,
|
||||
)
|
||||
from app.core.config import settings
|
||||
|
||||
app = FastAPI(title="Drivers Bot API", version="0.1.0")
|
||||
@@ -19,11 +30,15 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
app.include_router(users.router, prefix="/api")
|
||||
app.include_router(my.router, prefix="/api")
|
||||
app.include_router(catalog.router, prefix="/api")
|
||||
app.include_router(cars.router, prefix="/api")
|
||||
app.include_router(entries.router, prefix="/api")
|
||||
app.include_router(ocr.router, prefix="/api")
|
||||
app.include_router(service_centers.router, prefix="/api")
|
||||
app.include_router(service_visits.router, prefix="/api")
|
||||
app.include_router(change_requests.router, prefix="/api")
|
||||
app.include_router(admin.router, prefix="/api")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
@@ -2,6 +2,7 @@ from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Date,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
@@ -29,6 +30,10 @@ class Car(Base):
|
||||
year: Mapped[int | None]
|
||||
plate_number: Mapped[str | None] = mapped_column(String(32))
|
||||
vin: Mapped[str | None] = mapped_column(String(32))
|
||||
license_plate_display: Mapped[str | None] = mapped_column(String(32))
|
||||
license_plate_normalized: Mapped[str | None] = mapped_column(String(32), index=True)
|
||||
license_plate_country: Mapped[str | None] = mapped_column(String(2), index=True)
|
||||
vin_normalized: Mapped[str | None] = mapped_column(String(17), unique=True, index=True)
|
||||
fuel_type: Mapped[str | None] = mapped_column(String(32))
|
||||
target_consumption_l_per_100km: Mapped[Decimal | None] = mapped_column(Numeric(6, 2))
|
||||
fuel_tank_volume_l: Mapped[Decimal | None] = mapped_column(Numeric(6, 2))
|
||||
@@ -102,13 +107,25 @@ class ServiceCenter(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(160), unique=True, index=True)
|
||||
legal_name: Mapped[str | None] = mapped_column(String(240))
|
||||
display_name: Mapped[str | None] = mapped_column(String(160), index=True)
|
||||
country: Mapped[str | None] = mapped_column(String(2), index=True)
|
||||
city: Mapped[str | None] = mapped_column(String(120))
|
||||
telegram_chat_id: Mapped[str | None] = mapped_column(String(80), unique=True, index=True)
|
||||
phone: Mapped[str | None] = mapped_column(String(40))
|
||||
contact_phone: Mapped[str | None] = mapped_column(String(40))
|
||||
address: Mapped[str | None] = mapped_column(String(240))
|
||||
business_registration_number: Mapped[str | None] = mapped_column(String(80))
|
||||
verification_status: Mapped[str] = mapped_column(String(24), default="pending", server_default="pending", index=True)
|
||||
owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
suspended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
car_links = relationship("CarServiceLink", back_populates="service_center", cascade="all, delete-orphan")
|
||||
inbox_messages = relationship("ServiceInboxMessage", back_populates="service_center")
|
||||
employees = relationship("ServiceEmployee", back_populates="service_center", cascade="all, delete-orphan")
|
||||
visits = relationship("ServiceVisit", back_populates="service_center")
|
||||
|
||||
|
||||
class CarServiceLink(Base):
|
||||
@@ -140,3 +157,118 @@ class ServiceInboxMessage(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
service_center = relationship("ServiceCenter", back_populates="inbox_messages")
|
||||
|
||||
|
||||
class VehicleAccess(Base):
|
||||
__tablename__ = "vehicle_access"
|
||||
__table_args__ = (UniqueConstraint("vehicle_id", "user_id", "role", name="uq_vehicle_access_user_role"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
vehicle_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
role: Mapped[str] = mapped_column(String(24), default="owner", server_default="owner", index=True)
|
||||
status: Mapped[str] = mapped_column(String(24), default="active", server_default="active", index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class ServiceCenterVerification(Base):
|
||||
__tablename__ = "service_center_verifications"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
service_center_id: Mapped[int] = mapped_column(ForeignKey("service_centers.id", ondelete="CASCADE"), index=True)
|
||||
submitted_documents: Mapped[list | None] = mapped_column(JSON)
|
||||
comment: Mapped[str | None] = mapped_column(Text)
|
||||
status: Mapped[str] = mapped_column(String(24), default="pending", server_default="pending", index=True)
|
||||
reviewed_by: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
|
||||
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class ServiceEmployee(Base):
|
||||
__tablename__ = "service_employees"
|
||||
__table_args__ = (UniqueConstraint("service_center_id", "user_id", name="uq_service_employee_user"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
service_center_id: Mapped[int] = mapped_column(ForeignKey("service_centers.id", ondelete="CASCADE"), index=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
role: Mapped[str] = mapped_column(String(32), default="receptionist", server_default="receptionist", index=True)
|
||||
permissions: Mapped[dict | None] = mapped_column(JSON)
|
||||
status: Mapped[str] = mapped_column(String(24), default="active", server_default="active", index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
service_center = relationship("ServiceCenter", back_populates="employees")
|
||||
|
||||
|
||||
class ServiceVisit(Base):
|
||||
__tablename__ = "service_visits"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
service_center_id: Mapped[int] = mapped_column(ForeignKey("service_centers.id", ondelete="CASCADE"), index=True)
|
||||
vehicle_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True)
|
||||
created_by_employee_id: Mapped[int | None] = mapped_column(ForeignKey("service_employees.id", ondelete="SET NULL"), index=True)
|
||||
visit_date: Mapped[date] = mapped_column(Date, index=True)
|
||||
odometer: Mapped[int | None]
|
||||
status: Mapped[str] = mapped_column(String(40), default="draft", server_default="draft", index=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
total_cost: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
|
||||
currency: Mapped[str] = mapped_column(String(3), default="RUB", server_default="RUB")
|
||||
owner_resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
service_center = relationship("ServiceCenter", back_populates="visits")
|
||||
work_items = relationship("ServiceWorkItem", back_populates="visit", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ServiceWorkItem(Base):
|
||||
__tablename__ = "service_work_items"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
service_visit_id: Mapped[int] = mapped_column(ForeignKey("service_visits.id", ondelete="CASCADE"), index=True)
|
||||
work_type: Mapped[str] = mapped_column(String(40), default="other", server_default="other", index=True)
|
||||
title: Mapped[str] = mapped_column(String(180))
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
parts: Mapped[list | None] = mapped_column(JSON)
|
||||
oil_brand: Mapped[str | None] = mapped_column(String(80))
|
||||
oil_viscosity: Mapped[str | None] = mapped_column(String(40))
|
||||
oil_volume: Mapped[Decimal | None] = mapped_column(Numeric(5, 2))
|
||||
next_due_odometer: Mapped[int | None]
|
||||
next_due_date: Mapped[date | None] = mapped_column(Date)
|
||||
price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
visit = relationship("ServiceVisit", back_populates="work_items")
|
||||
|
||||
|
||||
class VehicleDataChangeRequest(Base):
|
||||
__tablename__ = "vehicle_data_change_requests"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
vehicle_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True)
|
||||
requested_by_service_center_id: Mapped[int | None] = mapped_column(ForeignKey("service_centers.id", ondelete="SET NULL"), index=True)
|
||||
requested_by_employee_id: Mapped[int | None] = mapped_column(ForeignKey("service_employees.id", ondelete="SET NULL"), index=True)
|
||||
field_name: Mapped[str] = mapped_column(String(80), index=True)
|
||||
old_value: Mapped[str | None] = mapped_column(Text)
|
||||
new_value: Mapped[str | None] = mapped_column(Text)
|
||||
status: Mapped[str] = mapped_column(String(24), default="pending", server_default="pending", index=True)
|
||||
owner_user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
|
||||
actor_role: Mapped[str | None] = mapped_column(String(64))
|
||||
action: Mapped[str] = mapped_column(String(120), index=True)
|
||||
target_type: Mapped[str] = mapped_column(String(80), index=True)
|
||||
target_id: Mapped[str | None] = mapped_column(String(80), index=True)
|
||||
ip: Mapped[str | None] = mapped_column(String(64))
|
||||
user_agent: Mapped[str | None] = mapped_column(String(256))
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
|
||||
@@ -14,6 +14,7 @@ class User(Base):
|
||||
username: Mapped[str | None] = mapped_column(String(128))
|
||||
first_name: Mapped[str | None] = mapped_column(String(128))
|
||||
last_name: Mapped[str | None] = mapped_column(String(128))
|
||||
platform_role: Mapped[str] = mapped_column(String(32), default="user", server_default="user", index=True)
|
||||
locale: Mapped[str] = mapped_column(String(8), default="ru", server_default="ru")
|
||||
currency: Mapped[str] = mapped_column(String(3), default="RUB", server_default="RUB")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
@@ -1,22 +1,227 @@
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
from app.services.vehicle_identity import normalize_license_plate, validate_vin
|
||||
|
||||
|
||||
class ServiceCenterCreate(BaseModel):
|
||||
name: str
|
||||
legal_name: str | None = None
|
||||
display_name: str
|
||||
country: str | None = None
|
||||
city: str | None = None
|
||||
address: str | None = None
|
||||
phone: str | None = None
|
||||
business_registration_number: str | None = None
|
||||
telegram_chat_id: str | None = None
|
||||
contact_phone: str | None = None
|
||||
address: str | None = None
|
||||
|
||||
|
||||
class ServiceCenterRead(ServiceCenterCreate):
|
||||
id: int
|
||||
name: str
|
||||
verification_status: str
|
||||
owner_user_id: int | None = None
|
||||
created_at: datetime
|
||||
verified_at: datetime | None = None
|
||||
suspended_at: datetime | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ServiceCenterVerificationCreate(BaseModel):
|
||||
submitted_documents: list[dict] | None = None
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
class ServiceCenterVerificationRead(ServiceCenterVerificationCreate):
|
||||
id: int
|
||||
service_center_id: int
|
||||
status: str
|
||||
reviewed_by: int | None = None
|
||||
reviewed_at: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ServiceEmployeeInvite(BaseModel):
|
||||
telegram_id: int
|
||||
role: str = "receptionist"
|
||||
permissions: dict | None = None
|
||||
|
||||
|
||||
class ServiceEmployeeRead(BaseModel):
|
||||
id: int
|
||||
service_center_id: int
|
||||
user_id: int
|
||||
role: str
|
||||
permissions: dict | None = None
|
||||
status: str
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class VehicleAccessGrant(BaseModel):
|
||||
service_center_id: int | None = None
|
||||
user_id: int | None = None
|
||||
role: str = "viewer"
|
||||
|
||||
|
||||
class VehicleAccessRead(BaseModel):
|
||||
id: int
|
||||
vehicle_id: int
|
||||
user_id: int
|
||||
role: str
|
||||
status: str
|
||||
created_at: datetime
|
||||
revoked_at: datetime | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class VehicleCreate(BaseModel):
|
||||
name: str
|
||||
make: str | None = None
|
||||
model: str | None = None
|
||||
year: int | None = None
|
||||
license_plate: str | None = None
|
||||
license_plate_country: str | None = None
|
||||
vin: str | None = None
|
||||
current_odometer: int | None = None
|
||||
engine_oil_type: str | None = None
|
||||
engine_oil_volume_l: Decimal | None = None
|
||||
|
||||
@field_validator("vin")
|
||||
@classmethod
|
||||
def validate_vin_field(cls, value: str | None) -> str | None:
|
||||
return validate_vin(value)
|
||||
|
||||
|
||||
class VehicleUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
make: str | None = None
|
||||
model: str | None = None
|
||||
year: int | None = None
|
||||
license_plate: str | None = None
|
||||
license_plate_country: str | None = None
|
||||
vin: str | None = None
|
||||
current_odometer: int | None = None
|
||||
engine_oil_type: str | None = None
|
||||
engine_oil_volume_l: Decimal | None = None
|
||||
|
||||
@field_validator("vin")
|
||||
@classmethod
|
||||
def validate_vin_field(cls, value: str | None) -> str | None:
|
||||
return validate_vin(value)
|
||||
|
||||
|
||||
class VehicleRead(BaseModel):
|
||||
id: int
|
||||
owner_id: int
|
||||
name: str
|
||||
make: str | None = None
|
||||
model: str | None = None
|
||||
year: int | None = None
|
||||
license_plate_display: str | None = None
|
||||
license_plate_country: str | None = None
|
||||
vin_normalized: str | None = None
|
||||
current_odometer: int | None = None
|
||||
engine_oil_type: str | None = None
|
||||
engine_oil_volume_l: Decimal | None = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ServiceVisitCreate(BaseModel):
|
||||
vehicle_id: int
|
||||
visit_date: date
|
||||
odometer: int | None = None
|
||||
notes: str | None = None
|
||||
total_cost: Decimal | None = None
|
||||
currency: str = "RUB"
|
||||
|
||||
|
||||
class ServiceVisitRead(ServiceVisitCreate):
|
||||
id: int
|
||||
service_center_id: int
|
||||
created_by_employee_id: int | None = None
|
||||
status: str
|
||||
owner_resolved_at: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ServiceWorkItemCreate(BaseModel):
|
||||
work_type: str = "other"
|
||||
title: str
|
||||
description: str | None = None
|
||||
parts: list[dict] | None = None
|
||||
oil_brand: str | None = None
|
||||
oil_viscosity: str | None = None
|
||||
oil_volume: Decimal | None = None
|
||||
next_due_odometer: int | None = None
|
||||
next_due_date: date | None = None
|
||||
price: Decimal | None = None
|
||||
|
||||
|
||||
class ServiceWorkItemRead(ServiceWorkItemCreate):
|
||||
id: int
|
||||
service_visit_id: int
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class VehicleDataChangeRequestCreate(BaseModel):
|
||||
vehicle_id: int
|
||||
field_name: str
|
||||
new_value: str | None = None
|
||||
|
||||
|
||||
class VehicleDataChangeRequestRead(VehicleDataChangeRequestCreate):
|
||||
id: int
|
||||
requested_by_service_center_id: int | None = None
|
||||
requested_by_employee_id: int | None = None
|
||||
old_value: str | None = None
|
||||
status: str
|
||||
owner_user_id: int
|
||||
created_at: datetime
|
||||
resolved_at: datetime | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class VehicleSearchRequest(BaseModel):
|
||||
license_plate: str | None = None
|
||||
country_code: str | None = None
|
||||
vin: str | None = None
|
||||
|
||||
@field_validator("vin")
|
||||
@classmethod
|
||||
def validate_vin_field(cls, value: str | None) -> str | None:
|
||||
return validate_vin(value)
|
||||
|
||||
@field_validator("license_plate")
|
||||
@classmethod
|
||||
def normalize_plate_field(cls, value: str | None) -> str | None:
|
||||
return normalize_license_plate(value)
|
||||
|
||||
|
||||
class VehicleSearchResult(BaseModel):
|
||||
vehicle_id: int | None = None
|
||||
make: str | None = None
|
||||
model: str | None = None
|
||||
year: int | None = None
|
||||
masked_license_plate: str | None = None
|
||||
masked_vin: str | None = None
|
||||
access_status: str = "none"
|
||||
|
||||
|
||||
class CarServiceLinkCreate(BaseModel):
|
||||
car_id: int
|
||||
service_center_id: int
|
||||
|
||||
@@ -10,6 +10,7 @@ class UserUpsert(BaseModel):
|
||||
last_name: str | None = None
|
||||
locale: str | None = None
|
||||
currency: str | None = None
|
||||
platform_role: str | None = None
|
||||
|
||||
|
||||
class WebAppAuthRequest(BaseModel):
|
||||
@@ -27,7 +28,7 @@ class TelegramLoginRequest(BaseModel):
|
||||
|
||||
|
||||
class AuthConfig(BaseModel):
|
||||
bot_username: str
|
||||
bot_username: str | None = None
|
||||
vapid_public_key: str | None = None
|
||||
app_env: str
|
||||
allow_dev_auth: bool = False
|
||||
|
||||
48
app/services/ocr_provider.py
Normal file
48
app/services/ocr_provider.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.services.vehicle_identity import normalize_license_plate, validate_vin
|
||||
|
||||
|
||||
@dataclass
|
||||
class OcrCandidate:
|
||||
type: str
|
||||
value: str
|
||||
confidence: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class OcrResult:
|
||||
recognized_text: str
|
||||
candidates: list[OcrCandidate]
|
||||
|
||||
|
||||
class OCRProvider:
|
||||
async def recognize(self, content: bytes, filename: str | None = None) -> OcrResult:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class StubOCRProvider(OCRProvider):
|
||||
async def recognize(self, content: bytes, filename: str | None = None) -> OcrResult:
|
||||
text = " ".join(
|
||||
[
|
||||
filename or "",
|
||||
content.decode("utf-8", errors="ignore"),
|
||||
]
|
||||
)
|
||||
compact = re.sub(r"\s+", " ", text).strip()
|
||||
candidates: list[OcrCandidate] = []
|
||||
for raw in re.findall(r"\b[A-HJ-NPR-Z0-9]{17}\b", compact.upper()):
|
||||
try:
|
||||
candidates.append(OcrCandidate(type="vin", value=validate_vin(raw) or raw, confidence=0.84))
|
||||
except ValueError:
|
||||
continue
|
||||
for raw in re.findall(r"\b[0-9A-ZА-Я가-힣][0-9A-ZА-Я가-힣\-\s]{4,10}\b", compact.upper()):
|
||||
normalized = normalize_license_plate(raw)
|
||||
if normalized and 5 <= len(normalized) <= 10 and not any(item.value == normalized for item in candidates):
|
||||
candidates.append(OcrCandidate(type="license_plate", value=normalized, confidence=0.62))
|
||||
return OcrResult(recognized_text=compact, candidates=candidates[:8])
|
||||
|
||||
|
||||
def get_ocr_provider() -> OCRProvider:
|
||||
return StubOCRProvider()
|
||||
44
app/services/vehicle_identity.py
Normal file
44
app/services/vehicle_identity.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import re
|
||||
|
||||
VIN_RE = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$")
|
||||
|
||||
|
||||
def normalize_vin(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
normalized = re.sub(r"[\s-]+", "", value).upper()
|
||||
return normalized or None
|
||||
|
||||
|
||||
def validate_vin(value: str | None) -> str | None:
|
||||
normalized = normalize_vin(value)
|
||||
if normalized is None:
|
||||
return None
|
||||
if not VIN_RE.match(normalized):
|
||||
raise ValueError("VIN must contain 17 characters and cannot include I, O, or Q")
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_license_plate(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
normalized = re.sub(r"[\s\-_.]+", "", value).upper()
|
||||
return normalized or None
|
||||
|
||||
|
||||
def mask_vin(value: str | None) -> str | None:
|
||||
normalized = normalize_vin(value)
|
||||
if not normalized:
|
||||
return None
|
||||
if len(normalized) <= 6:
|
||||
return "*" * len(normalized)
|
||||
return f"{normalized[:3]}{'*' * 10}{normalized[-4:]}"
|
||||
|
||||
|
||||
def mask_license_plate(value: str | None) -> str | None:
|
||||
normalized = normalize_license_plate(value)
|
||||
if not normalized:
|
||||
return None
|
||||
if len(normalized) <= 3:
|
||||
return "*" * len(normalized)
|
||||
return f"{normalized[:2]}{'*' * max(len(normalized) - 4, 2)}{normalized[-2:]}"
|
||||
Reference in New Issue
Block a user