"""Router pour l'import de fichiers CSV (AliExpress).""" import csv import io import re from datetime import date from typing import Annotated from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.models.item import Item, ItemStatus from app.models.location import Location, LocationType from app.repositories.item import ItemRepository from app.repositories.location import LocationRepository from app.repositories.shop import ShopRepository router = APIRouter(prefix="/import", tags=["Import"]) # Mapping des mois français → numéro MOIS_FR: dict[str, int] = { "janv.": 1, "févr.": 2, "mars": 3, "avr.": 4, "mai": 5, "juin": 6, "juil.": 7, "août": 8, "sept.": 9, "oct.": 10, "nov.": 11, "déc.": 12, } def parse_date_fr(date_str: str) -> str | None: """Parse une date au format français AliExpress ('5 sept. 2025') → '2025-09-05'.""" if not date_str or not date_str.strip(): return None date_str = date_str.strip() # Format attendu : "5 sept. 2025" match = re.match(r"(\d{1,2})\s+(\S+)\s+(\d{4})", date_str) if not match: return None day, month_str, year = match.groups() month = MOIS_FR.get(month_str.lower()) if month is None: # Essai avec le mois tel quel (ex: "mars" sans point) month = MOIS_FR.get(month_str.lower().rstrip(".")) if month is None: return None return f"{year}-{month:02d}-{int(day):02d}" def parse_price(price_str: str) -> float | None: """Parse un prix depuis le CSV ('37.19' ou '4,59') → float.""" if not price_str or not price_str.strip(): return None cleaned = price_str.strip().replace(",", ".") try: val = float(cleaned) return val if val > 0 else None except ValueError: return None def parse_quantity(qty_str: str) -> int: """Parse une quantité ('1.00' ou '2') → int.""" if not qty_str or not qty_str.strip(): return 1 try: return max(1, int(float(qty_str.strip()))) except ValueError: return 1 def fix_url(url: str) -> str | None: """Corrige les URLs AliExpress (ajoute https: si nécessaire).""" if not url or not url.strip(): return None url = url.strip() if url.startswith("//"): return f"https:{url}" return url def parse_attributes(attr_str: str) -> dict[str, str] | None: """Parse les attributs AliExpress en dictionnaire.""" if not attr_str or not attr_str.strip(): return None parts = [p.strip() for p in attr_str.split(",") if p.strip()] if not parts: return None result: dict[str, str] = {} for i, part in enumerate(parts): result[f"attribut_{i + 1}"] = part return result # Schémas de réponse class ImportPreviewItem(BaseModel): """Un item parsé depuis le CSV, prêt pour la preview.""" index: int name: str price: float | None = None quantity: int = 1 purchase_date: str | None = None seller_name: str | None = None url: str | None = None image_url: str | None = None attributes: dict[str, str] | None = None order_id: str | None = None order_status: str | None = None total_price: float | None = None is_duplicate: bool = False class ImportPreviewResponse(BaseModel): """Réponse de preview d'import.""" items: list[ImportPreviewItem] total_items: int errors: list[str] class ImportResultResponse(BaseModel): """Réponse après import effectif.""" items_created: int shops_created: int errors: list[str] def parse_aliexpress_csv(content: str) -> tuple[list[ImportPreviewItem], list[str]]: """Parse le CSV AliExpress et retourne les items + erreurs.""" items: list[ImportPreviewItem] = [] errors: list[str] = [] # Supprimer le BOM UTF-8 si présent if content.startswith("\ufeff"): content = content[1:] reader = csv.DictReader(io.StringIO(content)) # Collecter les lignes totaux par order_id pour récupérer le prix réel totals_by_order: dict[str, float] = {} all_rows: list[dict[str, str]] = [] for row in reader: all_rows.append(row) order_id = row.get("Order Id", "").strip() item_title = row.get("Item title", "").strip() total_price_str = row.get("Total price", "").strip() # Ligne totaux : pas de titre d'item mais un total if not item_title and total_price_str: price = parse_price(total_price_str) if price and order_id: totals_by_order[order_id] = price # Deuxième passe : extraire les items index = 0 for row in all_rows: item_title = row.get("Item title", "").strip() if not item_title: continue # Ignorer les lignes totaux order_id = row.get("Order Id", "").strip() try: item = ImportPreviewItem( index=index, name=item_title, price=parse_price(row.get("Item price", "")), quantity=parse_quantity(row.get("Item quantity", "")), purchase_date=parse_date_fr(row.get("Order date", "")), seller_name=row.get("Store Name", "").strip() or None, url=fix_url(row.get("Item product link", "")), image_url=fix_url(row.get("Item image url", "")), attributes=parse_attributes(row.get("Item attributes", "")), order_id=order_id or None, order_status=row.get("Order Status", "").strip() or None, total_price=totals_by_order.get(order_id), ) items.append(item) index += 1 except Exception as e: errors.append(f"Ligne {index}: {e}") index += 1 return items, errors @router.post( "/csv/aliexpress/preview", response_model=ImportPreviewResponse, summary="Prévisualiser un import CSV AliExpress", ) async def preview_aliexpress_csv( file: Annotated[UploadFile, File(description="Fichier CSV AliExpress")], session: AsyncSession = Depends(get_db), ) -> ImportPreviewResponse: """Parse le CSV et retourne une preview des items à importer.""" if not file.filename or not file.filename.lower().endswith(".csv"): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Le fichier doit être un CSV (.csv)", ) content = await file.read() try: text = content.decode("utf-8-sig") except UnicodeDecodeError: text = content.decode("latin-1") items, errors = parse_aliexpress_csv(text) # Détecter les doublons par URL for item in items: if item.url: result = await session.execute( select(Item.id).where(Item.url == item.url).limit(1) ) if result.first(): item.is_duplicate = True return ImportPreviewResponse( items=items, total_items=len(items), errors=errors, ) @router.post( "/csv/aliexpress/import", response_model=ImportResultResponse, status_code=status.HTTP_201_CREATED, summary="Importer les items depuis un CSV AliExpress", ) async def import_aliexpress_csv( file: Annotated[UploadFile, File(description="Fichier CSV AliExpress")], category_id: Annotated[int, Form(description="Catégorie par défaut")], item_status: Annotated[str, Form(description="Statut par défaut")] = "in_stock", selected_indices: Annotated[str, Form(description="Indices des items à importer (virgules)")] = "", session: AsyncSession = Depends(get_db), ) -> ImportResultResponse: """Importe les items sélectionnés depuis le CSV.""" if not file.filename or not file.filename.lower().endswith(".csv"): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Le fichier doit être un CSV (.csv)", ) content = await file.read() try: text = content.decode("utf-8-sig") except UnicodeDecodeError: text = content.decode("latin-1") items, parse_errors = parse_aliexpress_csv(text) # Filtrer par indices sélectionnés if selected_indices.strip(): try: selected = set(int(i.strip()) for i in selected_indices.split(",") if i.strip()) items = [item for item in items if item.index in selected] except ValueError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Format d'indices invalide", ) # Valider le statut try: status_enum = ItemStatus(item_status) except ValueError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Statut invalide : {item_status}", ) item_repo = ItemRepository(session) shop_repo = ShopRepository(session) errors: list[str] = [] items_created = 0 shop_created = False # Résoudre ou créer la boutique unique "AliExpress" aliexpress_shop = await shop_repo.get_by_name("AliExpress") if not aliexpress_shop: aliexpress_shop = await shop_repo.create( name="AliExpress", url="https://www.aliexpress.com", ) shop_created = True shop_id = aliexpress_shop.id # Résoudre ou créer l'emplacement "Non assigné" loc_result = await session.execute( select(Location).where(Location.name == "Non assigné") ) non_assigne_loc = loc_result.scalar_one_or_none() if not non_assigne_loc: loc_repo = LocationRepository(session) non_assigne_loc = await loc_repo.create_with_path( name="Non assigné", type=LocationType.ROOM, ) location_id = non_assigne_loc.id for item in items: try: # Fusionner le nom du vendeur dans les caractéristiques characteristics = item.attributes or {} if item.seller_name: characteristics["vendeur"] = item.seller_name # Convertir la date string en objet date Python purchase_date_obj = None if item.purchase_date: try: purchase_date_obj = date.fromisoformat(item.purchase_date) except ValueError: pass # Créer l'item item_data = { "name": item.name, "quantity": item.quantity, "status": status_enum, "price": item.price, "purchase_date": purchase_date_obj, "url": item.url, "characteristics": characteristics or None, "notes": f"Commande AliExpress #{item.order_id}" if item.order_id else None, "category_id": category_id, "location_id": location_id, "shop_id": shop_id, } await item_repo.create(**item_data) items_created += 1 except Exception as e: errors.append(f"{item.name}: {e}") await session.commit() return ImportResultResponse( items_created=items_created, shops_created=1 if shop_created else 0, errors=errors, )