# backend/app/api/shopping.py import uuid from datetime import datetime, timezone, date as date_type from decimal import Decimal from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import Response from sqlalchemy import select, text, or_ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.core.database import get_session from app.models.shopping import ShoppingList, ListItem, Product, Store from app.schemas.shopping import ( ShoppingListCreate, ShoppingListUpdate, ShoppingListResponse, ShoppingListDetailResponse, ListItemCreate, ListItemUpdate, ListItemResponse, ProductCreate, ProductUpdate, ProductResponse, StoreCreate, StoreUpdate, StoreResponse, ) router = APIRouter() def _iso_week_label() -> str: now = datetime.now(tz=timezone.utc) iso = now.isocalendar() return f"S{iso[1]} {iso[0]}" def _item_to_response(item: ListItem) -> ListItemResponse: display_name = item.custom_name or (item.product.name if item.product else "Article inconnu") return ListItemResponse( id=item.id, product_id=item.product_id, custom_name=item.custom_name, display_name=display_name, quantity=item.quantity, unit=item.unit, is_checked=item.is_checked, price_recorded=item.price_recorded, carried_over=item.carried_over, sort_order=item.sort_order, ) def _list_to_response(lst: ShoppingList) -> ShoppingListResponse: items = lst.items if lst.items is not None else [] return ShoppingListResponse( id=lst.id, name=lst.name, store_id=lst.store_id, week_date=lst.week_date, status=lst.status, created_at=lst.created_at, item_count=len(items), checked_count=sum(1 for i in items if i.is_checked), ) # ── Stores ──────────────────────────────────────────────────────────────────── @router.get("/stores", response_model=list[StoreResponse]) async def list_stores(session: AsyncSession = Depends(get_session)): result = await session.execute(select(Store).order_by(Store.name)) return result.scalars().all() @router.post("/stores", response_model=StoreResponse, status_code=201) async def create_store(payload: StoreCreate, session: AsyncSession = Depends(get_session)): store = Store(**payload.model_dump()) session.add(store) await session.commit() await session.refresh(store) return store @router.patch("/stores/{store_id}", response_model=StoreResponse) async def update_store(store_id: uuid.UUID, payload: StoreUpdate, session: AsyncSession = Depends(get_session)): store = await session.get(Store, store_id) if not store: raise HTTPException(404, "Boutique introuvable") for field, value in payload.model_dump(exclude_unset=True).items(): setattr(store, field, value) await session.commit() await session.refresh(store) return store @router.delete("/stores/{store_id}", status_code=204) async def delete_store(store_id: uuid.UUID, session: AsyncSession = Depends(get_session)): store = await session.get(Store, store_id) if not store: raise HTTPException(404, "Boutique introuvable") await session.delete(store) await session.commit() return Response(status_code=204) # ── Products ────────────────────────────────────────────────────────────────── @router.get("/products", response_model=list[ProductResponse]) async def search_products( q: str | None = Query(default=None), limit: int = 500, session: AsyncSession = Depends(get_session), ): stmt = select(Product).order_by(Product.frequency_score.desc(), Product.name) if q: stmt = stmt.where(or_(Product.name.ilike(f"%{q}%"), Product.brand.ilike(f"%{q}%"))) stmt = stmt.limit(limit) result = await session.execute(stmt) return result.scalars().all() @router.post("/products", response_model=ProductResponse, status_code=201) async def create_product(payload: ProductCreate, session: AsyncSession = Depends(get_session)): product = Product(**payload.model_dump()) session.add(product) await session.commit() await session.refresh(product) return product @router.patch("/products/{product_id}", response_model=ProductResponse) async def update_product( product_id: uuid.UUID, payload: ProductUpdate, session: AsyncSession = Depends(get_session), ): product = await session.get(Product, product_id) if not product: raise HTTPException(404, "Produit introuvable") for field, value in payload.model_dump(exclude_unset=True).items(): setattr(product, field, value) await session.commit() await session.refresh(product) return product @router.delete("/products/{product_id}", status_code=204) async def delete_product(product_id: uuid.UUID, session: AsyncSession = Depends(get_session)): product = await session.get(Product, product_id) if not product: raise HTTPException(404, "Produit introuvable") await session.delete(product) await session.commit() return Response(status_code=204) # ── Lists ───────────────────────────────────────────────────────────────────── @router.get("/lists", response_model=list[ShoppingListResponse]) async def list_shopping_lists(session: AsyncSession = Depends(get_session)): stmt = ( select(ShoppingList) .options(selectinload(ShoppingList.items)) .order_by(ShoppingList.created_at.desc()) ) result = await session.execute(stmt) lists = result.scalars().all() return [_list_to_response(lst) for lst in lists] @router.post("/lists", response_model=ShoppingListDetailResponse, status_code=201) async def create_shopping_list( payload: ShoppingListCreate, session: AsyncSession = Depends(get_session), ): data = payload.model_dump() if not data.get('name'): data['name'] = _iso_week_label() lst = ShoppingList(**data) session.add(lst) await session.commit() await session.refresh(lst, ["items"]) return ShoppingListDetailResponse( **_list_to_response(lst).model_dump(), items=[], ) @router.get("/lists/{list_id}", response_model=ShoppingListDetailResponse) async def get_shopping_list(list_id: uuid.UUID, session: AsyncSession = Depends(get_session)): stmt = ( select(ShoppingList) .where(ShoppingList.id == list_id) .options(selectinload(ShoppingList.items).selectinload(ListItem.product)) ) result = await session.execute(stmt) lst = result.scalar_one_or_none() if not lst: raise HTTPException(404, "Liste introuvable") sorted_items = sorted(lst.items, key=lambda i: (i.sort_order or 999, str(i.id))) return ShoppingListDetailResponse( **_list_to_response(lst).model_dump(), items=[_item_to_response(i) for i in sorted_items], ) @router.patch("/lists/{list_id}", response_model=ShoppingListDetailResponse) async def update_shopping_list( list_id: uuid.UUID, payload: ShoppingListUpdate, session: AsyncSession = Depends(get_session), ): stmt = ( select(ShoppingList) .where(ShoppingList.id == list_id) .options(selectinload(ShoppingList.items).selectinload(ListItem.product)) ) result = await session.execute(stmt) lst = result.scalar_one_or_none() if not lst: raise HTTPException(404, "Liste introuvable") for field, value in payload.model_dump(exclude_unset=True).items(): setattr(lst, field, value) await session.commit() sorted_items = sorted(lst.items, key=lambda i: (i.sort_order or 999, str(i.id))) return ShoppingListDetailResponse( **_list_to_response(lst).model_dump(), items=[_item_to_response(i) for i in sorted_items], ) @router.delete("/lists/{list_id}", status_code=204) async def delete_shopping_list(list_id: uuid.UUID, session: AsyncSession = Depends(get_session)): lst = await session.get(ShoppingList, list_id) if not lst: raise HTTPException(404, "Liste introuvable") await session.delete(lst) await session.commit() return Response(status_code=204) # ── Items ───────────────────────────────────────────────────────────────────── @router.post("/lists/{list_id}/items", response_model=ListItemResponse, status_code=201) async def add_item( list_id: uuid.UUID, payload: ListItemCreate, session: AsyncSession = Depends(get_session), ): lst = await session.get(ShoppingList, list_id) if not lst: raise HTTPException(404, "Liste introuvable") item = ListItem(list_id=list_id, **payload.model_dump()) session.add(item) await session.commit() stmt = ( select(ListItem) .where(ListItem.id == item.id) .options(selectinload(ListItem.product)) ) result = await session.execute(stmt) item = result.scalar_one() return _item_to_response(item) @router.patch("/lists/{list_id}/items/{item_id}", response_model=ListItemResponse) async def update_item( list_id: uuid.UUID, item_id: uuid.UUID, payload: ListItemUpdate, session: AsyncSession = Depends(get_session), ): stmt = ( select(ListItem) .where(ListItem.id == item_id, ListItem.list_id == list_id) .options(selectinload(ListItem.product)) ) result = await session.execute(stmt) item = result.scalar_one_or_none() if not item: raise HTTPException(404, "Article introuvable") was_checked = item.is_checked for field, value in payload.model_dump(exclude_unset=True).items(): setattr(item, field, value) # Mise à jour des stats produit lors du premier cochage if not was_checked and item.is_checked and item.product_id: product = await session.get(Product, item.product_id) if product: today = date_type.today() if product.last_purchased_at and product.last_purchased_at < today: days = (today - product.last_purchased_at).days if product.avg_interval_days is None: product.avg_interval_days = Decimal(str(days)) else: # Moyenne mobile exponentielle (70 % passé, 30 % nouvel intervalle) product.avg_interval_days = Decimal(str( round(float(product.avg_interval_days) * 0.7 + days * 0.3, 1) )) product.last_purchased_at = today product.frequency_score += 1 await session.commit() await session.refresh(item, ["product"]) return _item_to_response(item) @router.delete("/lists/{list_id}/items/{item_id}", status_code=204) async def delete_item( list_id: uuid.UUID, item_id: uuid.UUID, session: AsyncSession = Depends(get_session), ): stmt = select(ListItem).where(ListItem.id == item_id, ListItem.list_id == list_id) result = await session.execute(stmt) item = result.scalar_one_or_none() if not item: raise HTTPException(404, "Article introuvable") await session.delete(item) await session.commit() return Response(status_code=204) @router.post("/lists/generate", response_model=ShoppingListDetailResponse, status_code=201) async def generate_magic_list(session: AsyncSession = Depends(get_session)): """Génère une liste à partir du score de fréquence (retard / intervalle moyen).""" query = text(""" WITH achats AS ( SELECT COALESCE(li.custom_name, p.name) AS nom, li.product_id, li.custom_name, p.default_unit, sl.created_at::date AS date_achat FROM shopping.list_items li JOIN shopping.lists sl ON sl.id = li.list_id LEFT JOIN shopping.products p ON p.id = li.product_id WHERE li.is_checked = true ), achats_with_lag AS ( SELECT nom, product_id, custom_name, default_unit, date_achat, date_achat - LAG(date_achat) OVER (PARTITION BY nom ORDER BY date_achat) AS interval_days FROM achats ), stats AS ( SELECT nom, product_id, custom_name, default_unit, MAX(date_achat) AS last_purchased, COUNT(*) AS nb_achats, AVG(interval_days) AS avg_interval_days FROM achats_with_lag GROUP BY nom, product_id, custom_name, default_unit ) SELECT nom, product_id, custom_name, default_unit, last_purchased, nb_achats, COALESCE(avg_interval_days, 30) AS avg_interval_days, (CURRENT_DATE - last_purchased)::float / NULLIF(COALESCE(avg_interval_days, 30), 0) AS score FROM stats WHERE (CURRENT_DATE - last_purchased)::float / NULLIF(COALESCE(avg_interval_days, 30), 0) >= 0.7 ORDER BY score DESC LIMIT 50 """) result = await session.execute(query) rows = result.mappings().all() new_list = ShoppingList(name=_iso_week_label(), status="draft") session.add(new_list) await session.flush() for row in rows: session.add(ListItem( list_id=new_list.id, product_id=row["product_id"], custom_name=row["custom_name"], unit=row["default_unit"], )) await session.commit() stmt = ( select(ShoppingList) .where(ShoppingList.id == new_list.id) .options(selectinload(ShoppingList.items).selectinload(ListItem.product)) ) result2 = await session.execute(stmt) new_list = result2.scalar_one() sorted_items = sorted(new_list.items, key=lambda i: (i.sort_order or 999, str(i.id))) return ShoppingListDetailResponse( **_list_to_response(new_list).model_dump(), items=[_item_to_response(i) for i in sorted_items], ) @router.post("/lists/{list_id}/finish", response_model=ShoppingListDetailResponse) async def finish_shopping(list_id: uuid.UUID, session: AsyncSession = Depends(get_session)): stmt = ( select(ShoppingList) .where(ShoppingList.id == list_id) .options(selectinload(ShoppingList.items).selectinload(ListItem.product)) ) result = await session.execute(stmt) lst = result.scalar_one_or_none() if not lst: raise HTTPException(404, "Liste introuvable") lst.status = "done" unchecked = [i for i in lst.items if not i.is_checked] if unchecked: new_list = ShoppingList(store_id=lst.store_id, status="draft", name=_iso_week_label()) session.add(new_list) await session.flush() for item in unchecked: session.add(ListItem( list_id=new_list.id, product_id=item.product_id, custom_name=item.custom_name, quantity=item.quantity, unit=item.unit, sort_order=item.sort_order, carried_over=True, )) await session.commit() sorted_items = sorted(lst.items, key=lambda i: (i.sort_order or 999, str(i.id))) return ShoppingListDetailResponse( **_list_to_response(lst).model_dump(), items=[_item_to_response(i) for i in sorted_items], )