feat(shopping): endpoint génération liste magique (score fréquence V1)

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 15:50:30 +02:00
parent 43736709a9
commit da5eb4916e
+89 -1
View File
@@ -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 = (