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, )