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

@@ -0,0 +1,131 @@
"""Filtres de la page Depenses : plusieurs fournisseurs, et l'absence de tag.
Le filtre fournisseur cherchait une sous-chaine, ce qui interdisait d'en
comparer deux et retenait au passage leurs homonymes. Il retient desormais une
liste de noms exacts. Le filtre tag, lui, ne savait pas demander « ce qui n'est
pas encore tague » - c'est pourtant la question qui amorce le travail de
tagging.
Ces tests verrouillent aussi l'accord entre la table et le resume : les deux
endpoints doivent voir le meme perimetre, sinon les totaux affiches ne sont pas
ceux des lignes listees.
"""
import copy
import pytest
from plesna_gerance.database.models import Depense, Tag
from plesna_gerance.database.service import DatabaseService
@pytest.fixture
def depenses_de_trois_fournisseurs(db_session, sample_data):
"""Quatre depenses : ACME (2), ACME SUD (1), BOREAL (1).
« ACME SUD » est la pour verifier qu'une selection sur « ACME » ne
l'emporte pas au passage, ce que faisait l'ancienne recherche.
"""
donnees = copy.deepcopy(sample_data)
donnees["recapitulatif_operations"] = [
{
"categorie": "DEPENSES_LOCATIVES",
"fournisseur": "ACME",
"description": "Nettoyage janvier",
"montants": {"debit": 50.0},
},
{
"categorie": "DEPENSES_LOCATIVES",
"fournisseur": "ACME",
"description": "Nettoyage fevrier",
"montants": {"debit": 60.0},
},
{
"categorie": "TRAVAUX",
"fournisseur": "ACME SUD",
"description": "Reprise peinture",
"montants": {"debit": 200.0},
},
{
"categorie": "TRAVAUX",
"fournisseur": "BOREAL",
"description": "Toiture",
"montants": {"debit": 300.0},
},
]
DatabaseService(db_session).save_document(data=donnees)
return donnees
def _noms(lignes):
"""Les fournisseurs des lignes retournees, tries pour comparer sans l'ordre."""
return sorted(ligne["fournisseur"] for ligne in lignes)
def test_retient_les_fournisseurs_demandes(api_client, depenses_de_trois_fournisseurs):
reponse = api_client.get(
"/api/analytics/depenses?fournisseur=ACME&fournisseur=BOREAL"
)
assert reponse.status_code == 200
assert _noms(reponse.json()) == ["ACME", "ACME", "BOREAL"]
def test_un_fournisseur_selectionne_exclut_ses_homonymes(
api_client, depenses_de_trois_fournisseurs
):
"""« ACME » ne doit plus ramener « ACME SUD » : la selection est exacte."""
reponse = api_client.get("/api/analytics/depenses?fournisseur=ACME")
assert _noms(reponse.json()) == ["ACME", "ACME"]
def test_sans_fournisseur_demande_tout_reste_visible(
api_client, depenses_de_trois_fournisseurs
):
assert len(api_client.get("/api/analytics/depenses").json()) == 4
def test_le_resume_porte_sur_les_memes_lignes_que_la_table(
api_client, depenses_de_trois_fournisseurs
):
requete = "fournisseur=ACME&fournisseur=BOREAL"
lignes = api_client.get(f"/api/analytics/depenses?{requete}").json()
resume = api_client.get(f"/api/analytics/depenses/summary?{requete}").json()
assert resume["total_count"] == len(lignes)
assert resume["total_debit"] == pytest.approx(
sum(ligne["debit"] for ligne in lignes)
)
assert resume["total_debit"] == pytest.approx(410.0)
def test_filtre_les_depenses_sans_tag(
api_client, db_session, depenses_de_trois_fournisseurs
):
"""tag_id=0 demande les depenses non taggees, la ou l'omettre les prend toutes."""
tag = db_session.query(Tag).order_by(Tag.id).first()
taggee = db_session.query(Depense).filter(Depense.fournisseur == "BOREAL").one()
taggee.tag_id = tag.id
db_session.flush()
sans_tag = api_client.get("/api/analytics/depenses?tag_id=0").json()
avec_ce_tag = api_client.get(f"/api/analytics/depenses?tag_id={tag.id}").json()
toutes = api_client.get("/api/analytics/depenses").json()
assert _noms(sans_tag) == ["ACME", "ACME", "ACME SUD"]
assert _noms(avec_ce_tag) == ["BOREAL"]
assert len(toutes) == len(sans_tag) + len(avec_ce_tag)
def test_le_resume_par_tag_montre_les_non_taggees(
api_client, depenses_de_trois_fournisseurs
):
"""La masse non taggee reste une part du camembert, pas un trou."""
resume = api_client.get("/api/analytics/depenses/summary").json()
non_taggees = [part for part in resume["by_tag"] if part["tag_id"] is None]
assert len(non_taggees) == 1
assert non_taggees[0]["count"] == 4
assert non_taggees[0]["total_debit"] == pytest.approx(610.0)