Files
home_stock/backend/app/routers/import_csv.py
2026-02-01 01:45:51 +01:00

345 lines
11 KiB
Python

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