From da5eb4916e5cf18603d05650700b6e8b753d2eea Mon Sep 17 00:00:00 2001 From: Gilles Soulier Date: Sun, 24 May 2026 15:50:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(shopping):=20endpoint=20g=C3=A9n=C3=A9rati?= =?UTF-8?q?on=20liste=20magique=20(score=20fr=C3=A9quence=20V1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute POST /api/shopping/lists/generate qui calcule un score retard/intervalle par article (achats_with_lag CTE pour contourner la limite PostgreSQL sur AVG+LAG imbriqués) et génère une liste draft avec les articles >= 0.7. Co-Authored-By: Claude Sonnet 4.6 --- backend/app/api/shopping.py | 90 ++++++++++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/backend/app/api/shopping.py b/backend/app/api/shopping.py index fa6d948..8fc57fa 100644 --- a/backend/app/api/shopping.py +++ b/backend/app/api/shopping.py @@ -2,7 +2,7 @@ import uuid from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import Response -from sqlalchemy import select +from sqlalchemy import select, text from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -216,6 +216,94 @@ async def delete_item( 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="Liste magique", 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 = (