diff --git a/backend/app/api/valuations.py b/backend/app/api/valuations.py new file mode 100644 index 0000000..56b260a --- /dev/null +++ b/backend/app/api/valuations.py @@ -0,0 +1,607 @@ +import math +from datetime import datetime, timezone +from decimal import Decimal +from typing import Annotated + +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from sqlalchemy.dialects.postgresql import insert as pg_insert + +from app.core.deps import get_db, get_current_user +from app.models.user import User, UserRole +from app.models.valuation import Valuation, ValuationComment, ValuationHistory, ValuationPhoto, ValuationStatus, ValuationPriority +from app.models.vehicle import VehicleRegistry, VehicleVersion, PlateVersionCandidate, ApiUsageLog +from app.models.motornet_valuation import MotornetValuation +from app.schemas.valuation import ( + CommentCreate, + CommentResponse, + HistoryResponse, + MotornetValuationResponse, + PaginatedValuations, + PhotoResponse, + ValuationCreate, + ValuationListItem, + ValuationResponse, + VehicleInfo, +) +from app.services import storage +from app.integrations.motornet import valuate_vehicle, MotornetUnavailableError + +import structlog + +logger = structlog.get_logger() + +router = APIRouter() + +PRIORITY_RANK = { + ValuationPriority.contratto: 1, + ValuationPriority.preventivo: 2, + ValuationPriority.valutazione: 3, +} + +SELLER_ROLES = {UserRole.venditore} +EVALUATOR_ROLES = {UserRole.valutatore, UserRole.admin} +ALL_ALLOWED = SELLER_ROLES | EVALUATOR_ROLES | {UserRole.backoffice} + + +def _check_access(valuation: Valuation, user: User) -> None: + if user.role in EVALUATOR_ROLES: + return + if user.role in {UserRole.backoffice}: + return + if valuation.user_id == user.id: + return + if user.group_id and valuation.group_id == user.group_id: + return + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Accesso non consentito") + + +async def _build_vehicle_info(plate: str, motornet_code: str | None, db: AsyncSession) -> VehicleInfo | None: + result = await db.execute(select(VehicleRegistry).where(VehicleRegistry.plate == plate)) + reg = result.scalar_one_or_none() + if not reg: + return VehicleInfo( + plate=plate, + vin=None, + vehicle_type=None, + registration_date=None, + version_label=None, + brand_name=None, + model_description=None, + gamma_description=None, + list_price=None, + ) + + version_label = None + brand_name = None + model_description = None + gamma_description = None + list_price = None + + code = motornet_code or reg.selected_motornet_code + if code: + vr = await db.execute(select(VehicleVersion).where(VehicleVersion.motornet_code == code)) + ver = vr.scalar_one_or_none() + if ver: + version_label = ver.version_label + brand_name = ver.brand_name + model_description = ver.model_description + gamma_description = ver.gamma_description + list_price = ver.list_price + + return VehicleInfo( + plate=reg.plate, + vin=reg.vin, + vehicle_type=reg.vehicle_type, + registration_date=reg.registration_date, + version_label=version_label, + brand_name=brand_name, + model_description=model_description, + gamma_description=gamma_description, + list_price=list_price, + ) + + +async def _build_list_item(valuation: Valuation, db: AsyncSession) -> ValuationListItem: + brand_name = None + model_description = None + version_label = None + + code = valuation.motornet_code + vr = await db.execute(select(VehicleRegistry).where(VehicleRegistry.plate == valuation.plate)) + reg = vr.scalar_one_or_none() + if not code and reg: + code = reg.selected_motornet_code + + if code: + vres = await db.execute(select(VehicleVersion).where(VehicleVersion.motornet_code == code)) + ver = vres.scalar_one_or_none() + if ver: + brand_name = ver.brand_name + model_description = ver.model_description + version_label = ver.version_label + + return ValuationListItem( + id=valuation.id, + plate=valuation.plate, + status=valuation.status.value, + priority=valuation.priority.value, + priority_rank=valuation.priority_rank, + mileage=valuation.mileage, + is_frozen=valuation.is_frozen, + created_at=valuation.created_at, + updated_at=valuation.updated_at, + brand_name=brand_name, + model_description=model_description, + version_label=version_label, + final_value=valuation.final_value, + ) + + +async def _try_motornet_valuation( + valuation: Valuation, + db: AsyncSession, +) -> MotornetValuation | None: + motornet_code = valuation.motornet_code + if not motornet_code: + logger.info("motornet_valuation_skip_no_code", valuation_id=valuation.id) + return None + + reg_result = await db.execute( + select(VehicleRegistry).where(VehicleRegistry.plate == valuation.plate) + ) + reg = reg_result.scalar_one_or_none() + registration_date = reg.registration_date if reg else None + + if not registration_date: + logger.info("motornet_valuation_skip_no_date", valuation_id=valuation.id, plate=valuation.plate) + return None + + anno = registration_date.year + mese = registration_date.month + + try: + data = await valuate_vehicle( + motornet_code=motornet_code, + anno=anno, + mese=mese, + km=valuation.mileage, + targa=valuation.plate, + ) + except MotornetUnavailableError as exc: + logger.warning("motornet_valuation_failed", valuation_id=valuation.id, error=str(exc)) + return None + + val_data = data.get("valutazione") or {} + valutazioni_disponibili: int | None = data.get("valutazioniDisponibili") + + usage_log = ApiUsageLog( + source="motornet_valuation", + plate=valuation.plate, + hit_cache=False, + remaining_queries=valutazioni_disponibili, + called_at=datetime.now(timezone.utc), + ) + db.add(usage_log) + + alimentazione_desc: str | None = None + alim = val_data.get("alimentazione") + if alim and isinstance(alim, dict): + alimentazione_desc = alim.get("descrizione") + + brand_name: str | None = None + marca = val_data.get("marca") + if marca and isinstance(marca, dict): + brand_name = marca.get("nome") + + def _dec(v) -> Decimal | None: + if v is None: + return None + try: + return Decimal(str(v)) + except Exception: + return None + + mn_val = MotornetValuation( + valuation_id=valuation.id, + plate=valuation.plate, + motornet_code=motornet_code, + fetched_at=datetime.now(timezone.utc), + registration_year=anno, + registration_month=mese, + mileage=valuation.mileage, + motornet_id=val_data.get("id"), + ediz_dati=val_data.get("edizDati"), + brand_name=brand_name, + model_description=val_data.get("modello"), + allestimento=val_data.get("allestimento"), + alimentazione=alimentazione_desc, + immagine_url=val_data.get("immagine"), + xml_url=val_data.get("xml"), + percorrenza_media_km=val_data.get("percorrenzaMediaKm"), + quotazione_blu=_dec(val_data.get("quotazioneEurotaxBlu")), + quotazione_blu_km=_dec(val_data.get("quotazioneEurotaxBluKm")), + quotazione_giallo=_dec(val_data.get("quotazioneEurotaxGiallo")), + quotazione_giallo_km=_dec(val_data.get("quotazioneEurotaxGialloKm")), + quotazione_blu_totale=_dec(val_data.get("quotazioneEurotaxBluTotale")), + quotazione_giallo_totale=_dec(val_data.get("quotazioneEurotaxGialloTotale")), + variazione_km=_dec(val_data.get("variazioneKm")), + prezzo_listino=_dec(val_data.get("prezzoListino")), + prezzo_accessori=_dec(val_data.get("prezzoAccessori")), + totale_riparazioni_carrozzeria=_dec(val_data.get("totaleRiparazioniCarrozzeria")), + totale_riparazioni_meccanica=_dec(val_data.get("totaleRiparazioniMeccanica")), + raw_response=data, + ) + db.add(mn_val) + await db.commit() + await db.refresh(mn_val) + return mn_val + + +def _build_motornet_response(mn: MotornetValuation) -> MotornetValuationResponse: + return MotornetValuationResponse( + id=mn.id, + valuation_id=mn.valuation_id, + plate=mn.plate, + motornet_code=mn.motornet_code, + fetched_at=mn.fetched_at, + registration_year=mn.registration_year, + registration_month=mn.registration_month, + mileage=mn.mileage, + motornet_id=mn.motornet_id, + ediz_dati=mn.ediz_dati, + brand_name=mn.brand_name, + model_description=mn.model_description, + allestimento=mn.allestimento, + alimentazione=mn.alimentazione, + immagine_url=mn.immagine_url, + xml_url=mn.xml_url, + percorrenza_media_km=mn.percorrenza_media_km, + quotazione_blu=mn.quotazione_blu, + quotazione_blu_km=mn.quotazione_blu_km, + quotazione_giallo=mn.quotazione_giallo, + quotazione_giallo_km=mn.quotazione_giallo_km, + quotazione_blu_totale=mn.quotazione_blu_totale, + quotazione_giallo_totale=mn.quotazione_giallo_totale, + variazione_km=mn.variazione_km, + prezzo_listino=mn.prezzo_listino, + prezzo_accessori=mn.prezzo_accessori, + totale_riparazioni_carrozzeria=mn.totale_riparazioni_carrozzeria, + totale_riparazioni_meccanica=mn.totale_riparazioni_meccanica, + raw_response=mn.raw_response, + ) + + +@router.post("", response_model=ValuationResponse, status_code=status.HTTP_201_CREATED) +async def create_valuation( + body: ValuationCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + if current_user.role not in {UserRole.venditore, UserRole.valutatore, UserRole.admin}: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permessi insufficienti") + + try: + priority_enum = ValuationPriority(body.priority) + except ValueError: + priority_enum = ValuationPriority.valutazione + + priority_rank = PRIORITY_RANK[priority_enum] + + normalized_plate = body.plate.upper().strip() + reg_stmt = pg_insert(VehicleRegistry).values( + plate=normalized_plate, + source="manual", + fetched_at=datetime.now(timezone.utc), + ).on_conflict_do_nothing(index_elements=["plate"]) + await db.execute(reg_stmt) + + valuation = Valuation( + plate=body.plate.upper().strip(), + user_id=current_user.id, + group_id=current_user.group_id, + motornet_code=body.motornet_code, + mileage=body.mileage, + retake_regime=body.retake_regime, + expected_return_date=body.expected_return_date, + interest_fuel=body.interest_fuel, + priority=priority_enum, + priority_rank=priority_rank, + notes=body.notes, + status=ValuationStatus.inviata, + is_frozen=True, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + db.add(valuation) + await db.flush() + + history_entry = ValuationHistory( + valuation_id=valuation.id, + changed_by=current_user.id, + field_name="status", + old_value=None, + new_value=ValuationStatus.inviata.value, + changed_at=datetime.now(timezone.utc), + ) + db.add(history_entry) + await db.commit() + await db.refresh(valuation) + + mn_record = await _try_motornet_valuation(valuation, db) + + vehicle = await _build_vehicle_info(valuation.plate, valuation.motornet_code, db) + + motornet_valuations = [] + if mn_record: + motornet_valuations = [_build_motornet_response(mn_record)] + + return ValuationResponse( + id=valuation.id, + plate=valuation.plate, + user_id=valuation.user_id, + group_id=valuation.group_id, + motornet_code=valuation.motornet_code, + mileage=valuation.mileage, + retake_regime=valuation.retake_regime.value if valuation.retake_regime else None, + expected_return_date=valuation.expected_return_date, + interest_fuel=valuation.interest_fuel, + priority=valuation.priority.value, + notes=valuation.notes, + status=valuation.status.value, + final_value=valuation.final_value, + evaluator_notes=valuation.evaluator_notes, + is_frozen=valuation.is_frozen, + priority_rank=valuation.priority_rank, + created_at=valuation.created_at, + updated_at=valuation.updated_at, + vehicle=vehicle, + photos=[], + comments=[], + history=[ + HistoryResponse( + id=history_entry.id, + field_name=history_entry.field_name, + old_value=history_entry.old_value, + new_value=history_entry.new_value, + changed_by=history_entry.changed_by, + changed_by_name=current_user.full_name, + changed_at=history_entry.changed_at, + ) + ], + motornet_valuations=motornet_valuations, + ) + + +@router.get("", response_model=PaginatedValuations) +async def list_valuations( + plate: str | None = Query(None), + status_filter: str | None = Query(None, alias="status"), + priority: str | None = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + query = select(Valuation) + + if current_user.role == UserRole.venditore: + if current_user.group_id: + query = query.where(Valuation.group_id == current_user.group_id) + else: + query = query.where(Valuation.user_id == current_user.id) + + if plate: + query = query.where(Valuation.plate.ilike(f"%{plate.upper()}%")) + if status_filter: + query = query.where(Valuation.status == status_filter) + if priority: + query = query.where(Valuation.priority == priority) + + count_q = select(func.count()).select_from(query.subquery()) + total_result = await db.execute(count_q) + total = total_result.scalar_one() + + query = query.order_by(Valuation.created_at.desc()) + query = query.offset((page - 1) * page_size).limit(page_size) + + result = await db.execute(query) + valuations = result.scalars().all() + + items = [] + for v in valuations: + items.append(await _build_list_item(v, db)) + + return PaginatedValuations( + items=items, + total=total, + page=page, + page_size=page_size, + pages=max(1, math.ceil(total / page_size)), + ) + + +@router.get("/{valuation_id}", response_model=ValuationResponse) +async def get_valuation( + valuation_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + result = await db.execute( + select(Valuation) + .options( + selectinload(Valuation.photos), + selectinload(Valuation.comments), + selectinload(Valuation.history), + selectinload(Valuation.motornet_valuations), + ) + .where(Valuation.id == valuation_id) + ) + valuation = result.scalar_one_or_none() + if not valuation: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Valutazione non trovata") + + _check_access(valuation, current_user) + + vehicle = await _build_vehicle_info(valuation.plate, valuation.motornet_code, db) + + user_ids = {valuation.user_id} + user_ids.update(c.user_id for c in valuation.comments) + user_ids.update(h.changed_by for h in valuation.history) + user_ids.update(p.uploaded_by for p in valuation.photos) + + users_result = await db.execute(select(User).where(User.id.in_(user_ids))) + users_map = {u.id: u.full_name for u in users_result.scalars().all()} + + comments = [] + for c in sorted(valuation.comments, key=lambda x: x.created_at): + if not c.visible_to_all and current_user.role not in EVALUATOR_ROLES: + continue + comments.append( + CommentResponse( + id=c.id, + valuation_id=c.valuation_id, + user_id=c.user_id, + user_full_name=users_map.get(c.user_id), + content=c.content, + visible_to_all=c.visible_to_all, + created_at=c.created_at, + ) + ) + + photos = [] + for p in sorted(valuation.photos, key=lambda x: x.uploaded_at): + url = storage.get_presigned_url(p.storage_path) + photos.append( + PhotoResponse( + id=p.id, + valuation_id=p.valuation_id, + filename=p.filename, + storage_path=p.storage_path, + uploaded_by=p.uploaded_by, + uploaded_at=p.uploaded_at, + url=url, + ) + ) + + history = [] + for h in sorted(valuation.history, key=lambda x: x.changed_at): + history.append( + HistoryResponse( + id=h.id, + field_name=h.field_name, + old_value=h.old_value, + new_value=h.new_value, + changed_by=h.changed_by, + changed_by_name=users_map.get(h.changed_by), + changed_at=h.changed_at, + ) + ) + + motornet_valuations = sorted(valuation.motornet_valuations, key=lambda x: x.fetched_at) + motornet_responses = [_build_motornet_response(mn) for mn in motornet_valuations] + + return ValuationResponse( + id=valuation.id, + plate=valuation.plate, + user_id=valuation.user_id, + group_id=valuation.group_id, + motornet_code=valuation.motornet_code, + mileage=valuation.mileage, + retake_regime=valuation.retake_regime.value if valuation.retake_regime else None, + expected_return_date=valuation.expected_return_date, + interest_fuel=valuation.interest_fuel, + priority=valuation.priority.value, + notes=valuation.notes, + status=valuation.status.value, + final_value=valuation.final_value, + evaluator_notes=valuation.evaluator_notes, + is_frozen=valuation.is_frozen, + priority_rank=valuation.priority_rank, + created_at=valuation.created_at, + updated_at=valuation.updated_at, + vehicle=vehicle, + photos=photos, + comments=comments, + history=history, + motornet_valuations=motornet_responses, + ) + + +@router.post("/{valuation_id}/photos", response_model=PhotoResponse, status_code=status.HTTP_201_CREATED) +async def upload_photo( + valuation_id: int, + file: Annotated[UploadFile, File(...)], + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + result = await db.execute(select(Valuation).where(Valuation.id == valuation_id)) + valuation = result.scalar_one_or_none() + if not valuation: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Valutazione non trovata") + + _check_access(valuation, current_user) + + storage_path = await storage.upload_photo(file, valuation_id) + filename = file.filename or "foto" + + photo = ValuationPhoto( + valuation_id=valuation_id, + filename=filename, + storage_path=storage_path, + uploaded_by=current_user.id, + uploaded_at=datetime.now(timezone.utc), + ) + db.add(photo) + await db.commit() + await db.refresh(photo) + + url = storage.get_presigned_url(storage_path) + return PhotoResponse( + id=photo.id, + valuation_id=photo.valuation_id, + filename=photo.filename, + storage_path=photo.storage_path, + uploaded_by=photo.uploaded_by, + uploaded_at=photo.uploaded_at, + url=url, + ) + + +@router.post("/{valuation_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED) +async def add_comment( + valuation_id: int, + body: CommentCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + result = await db.execute(select(Valuation).where(Valuation.id == valuation_id)) + valuation = result.scalar_one_or_none() + if not valuation: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Valutazione non trovata") + + _check_access(valuation, current_user) + + comment = ValuationComment( + valuation_id=valuation_id, + user_id=current_user.id, + content=body.content, + visible_to_all=body.visible_to_all, + created_at=datetime.now(timezone.utc), + ) + db.add(comment) + await db.commit() + await db.refresh(comment) + + return CommentResponse( + id=comment.id, + valuation_id=comment.valuation_id, + user_id=comment.user_id, + user_full_name=current_user.full_name, + content=comment.content, + visible_to_all=comment.visible_to_all, + created_at=comment.created_at, + )