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:
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user