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
|
import uuid
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
@@ -216,6 +216,94 @@ async def delete_item(
|
|||||||
return Response(status_code=204)
|
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)
|
@router.post("/lists/{list_id}/finish", response_model=ShoppingListDetailResponse)
|
||||||
async def finish_shopping(list_id: uuid.UUID, session: AsyncSession = Depends(get_session)):
|
async def finish_shopping(list_id: uuid.UUID, session: AsyncSession = Depends(get_session)):
|
||||||
stmt = (
|
stmt = (
|
||||||
|
|||||||
Reference in New Issue
Block a user