feat: filtre les dépenses sur plusieurs fournisseurs et sur l'absence de tag

Le filtre fournisseur était une recherche de sous-chaîne : impossible de
comparer deux fournisseurs, et « PPR » ramenait ses homonymes au passage.
Il devient une sélection multiple exacte, prise dans un menu déroulant
filtrable au clavier (SelectionMultiple, générique et réutilisable). La
liste vient de /api/fournisseurs, remise en ordre alphabétique — l'API la
trie par montant, ce qui se lit bien dans un classement mais rend
introuvable un fournisseur qu'on cherche à l'œil.

Côté API, `fournisseur` devient répétable et s'entend comme un OU exact.

Le filtre tag, lui, ne savait pas demander « ce qui n'est pas encore
tagué » — c'est pourtant la question qui amorce le travail de tagging.
`tag_id=0` le demande, et l'option « Sans tag » l'ouvre depuis la page.

Au passage, les deux endpoints dupliquaient leurs filtres : ils partagent
désormais _appliquer_filtres, pour que les totaux du résumé ne puissent
plus porter sur d'autres lignes que la table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 17:29:03 +02:00
parent 7c19676400
commit e81cd5ffa4
6 changed files with 500 additions and 55 deletions

View File

@@ -25,6 +25,47 @@ from ..schemas.models import (
router = APIRouter(prefix="/api", tags=["analytics"])
#: Valeur de `tag_id` demandant les depenses sans tag. C'est la meme clef que
#: celle employee pour agreger les non taggues dans `by_tag` : « aucun tag » est
#: un choix de filtre a part entiere, pas l'absence de filtre (`tag_id` omis).
SANS_TAG = 0
def _appliquer_filtres(
stmt,
*,
immeuble_id: int | None,
lot_id: int | None,
tag_id: int | None,
categorie: str | None,
fournisseurs: list[str] | None,
date_debut: date | None,
date_fin: date | None,
):
"""Applique les filtres communs a la table et au resume.
Les deux endpoints doivent voir exactement le meme perimetre : les totaux du
resume ne veulent rien dire s'ils portent sur d'autres lignes que la table.
"""
if immeuble_id is not None:
stmt = stmt.where(Depense.immeuble_id == immeuble_id)
if lot_id is not None:
stmt = stmt.where(Depense.lot_id == lot_id)
if tag_id is not None:
if tag_id == SANS_TAG:
stmt = stmt.where(Depense.tag_id.is_(None))
else:
stmt = stmt.where(Depense.tag_id == tag_id)
if categorie is not None:
stmt = stmt.where(Depense.categorie == categorie)
if fournisseurs:
stmt = stmt.where(Depense.fournisseur.in_(fournisseurs))
if date_debut is not None:
stmt = stmt.where(Document.date >= date_debut)
if date_fin is not None:
stmt = stmt.where(Document.date <= date_fin)
return stmt
# ============================================================
# Reference data endpoints (for filters)
@@ -180,9 +221,11 @@ async def list_tags_with_stats(
async def get_depenses(
immeuble_id: int | None = Query(None, description="Filtrer par immeuble"),
lot_id: int | None = Query(None, description="Filtrer par lot"),
tag_id: int | None = Query(None, description="Filtrer par tag"),
tag_id: int | None = Query(None, description="Filtrer par tag (0 = sans tag)"),
categorie: str | None = Query(None, description="Filtrer par categorie"),
fournisseur: str | None = Query(None, description="Filtrer par fournisseur"),
fournisseur: list[str] | None = Query(
None, description="Fournisseurs retenus (parametre repetable)"
),
date_debut: date | None = Query(None, description="Date de debut (YYYY-MM-DD)"),
date_fin: date | None = Query(None, description="Date de fin (YYYY-MM-DD)"),
limit: int = Query(500, description="Nombre maximum de resultats"),
@@ -194,9 +237,10 @@ async def get_depenses(
Filtres disponibles:
- **immeuble_id**: ID de l'immeuble
- **lot_id**: ID du lot
- **tag_id**: ID du tag
- **tag_id**: ID du tag, ou 0 pour les depenses sans tag
- **categorie**: Categorie de depense
- **fournisseur**: Nom du fournisseur
- **fournisseur**: nom exact d'un fournisseur, repetable pour en retenir
plusieurs (`?fournisseur=A&fournisseur=B`)
- **date_debut**: Date de debut (incluse)
- **date_fin**: Date de fin (incluse)
- **limit**: Nombre max de resultats (defaut: 500)
@@ -219,21 +263,16 @@ async def get_depenses(
.order_by(Document.date.desc(), Depense.id.desc())
)
# Apply filters
if immeuble_id is not None:
stmt = stmt.where(Depense.immeuble_id == immeuble_id)
if lot_id is not None:
stmt = stmt.where(Depense.lot_id == lot_id)
if tag_id is not None:
stmt = stmt.where(Depense.tag_id == tag_id)
if categorie is not None:
stmt = stmt.where(Depense.categorie == categorie)
if fournisseur is not None:
stmt = stmt.where(Depense.fournisseur.ilike(f"%{fournisseur}%"))
if date_debut is not None:
stmt = stmt.where(Document.date >= date_debut)
if date_fin is not None:
stmt = stmt.where(Document.date <= date_fin)
stmt = _appliquer_filtres(
stmt,
immeuble_id=immeuble_id,
lot_id=lot_id,
tag_id=tag_id,
categorie=categorie,
fournisseurs=fournisseur,
date_debut=date_debut,
date_fin=date_fin,
)
stmt = stmt.limit(limit).offset(offset)
@@ -271,9 +310,11 @@ async def get_depenses(
async def get_depenses_summary(
immeuble_id: int | None = Query(None, description="Filtrer par immeuble"),
lot_id: int | None = Query(None, description="Filtrer par lot"),
tag_id: int | None = Query(None, description="Filtrer par tag"),
tag_id: int | None = Query(None, description="Filtrer par tag (0 = sans tag)"),
categorie: str | None = Query(None, description="Filtrer par categorie"),
fournisseur: str | None = Query(None, description="Filtrer par fournisseur"),
fournisseur: list[str] | None = Query(
None, description="Fournisseurs retenus (parametre repetable)"
),
date_debut: date | None = Query(None, description="Date de debut (YYYY-MM-DD)"),
date_fin: date | None = Query(None, description="Date de fin (YYYY-MM-DD)"),
session: Session = Depends(get_session),
@@ -289,22 +330,16 @@ async def get_depenses_summary(
- Top fournisseurs
"""
# Base query with filters
base_stmt = select(Depense).join(Document, Depense.document_id == Document.id)
if immeuble_id is not None:
base_stmt = base_stmt.where(Depense.immeuble_id == immeuble_id)
if lot_id is not None:
base_stmt = base_stmt.where(Depense.lot_id == lot_id)
if tag_id is not None:
base_stmt = base_stmt.where(Depense.tag_id == tag_id)
if categorie is not None:
base_stmt = base_stmt.where(Depense.categorie == categorie)
if fournisseur is not None:
base_stmt = base_stmt.where(Depense.fournisseur.ilike(f"%{fournisseur}%"))
if date_debut is not None:
base_stmt = base_stmt.where(Document.date >= date_debut)
if date_fin is not None:
base_stmt = base_stmt.where(Document.date <= date_fin)
base_stmt = _appliquer_filtres(
select(Depense).join(Document, Depense.document_id == Document.id),
immeuble_id=immeuble_id,
lot_id=lot_id,
tag_id=tag_id,
categorie=categorie,
fournisseurs=fournisseur,
date_debut=date_debut,
date_fin=date_fin,
)
# Get all matching depenses
result = session.execute(base_stmt)