generated from gilles/template-webapp
345 lines
11 KiB
Python
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,
|
|
)
|