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,197 @@
<template>
<div class="relative" ref="conteneur">
<!-- Le declencheur reprend l'allure d'un select : dans une grille de
filtres, un controle qui ne ressemble pas aux autres se cherche. -->
<button
type="button"
class="input flex items-center justify-between gap-2 text-left"
@click="basculerPanneau"
>
<span class="truncate" :class="selection.length ? 'text-white' : 'text-gray-500'">
{{ resume }}
</span>
<svg
class="w-4 h-4 shrink-0 text-gray-500 transition-transform"
:class="{ 'rotate-180': ouvert }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div
v-if="ouvert"
class="absolute z-50 w-full min-w-[16rem] mt-1 bg-gray-900 border border-gray-700 rounded-lg shadow-lg"
>
<div class="p-2 border-b border-gray-800">
<input
ref="champRecherche"
v-model="recherche"
type="text"
:placeholder="placeholderRecherche"
class="input"
@keydown.escape="fermerPanneau"
@keydown.enter.prevent="cocherLePremier"
/>
</div>
<div
v-if="selection.length"
class="px-3 py-1.5 flex items-center justify-between text-xs border-b border-gray-800"
>
<span class="text-gray-400">{{ selection.length }} {{ selection.length > 1 ? 'selectionnes' : 'selectionne' }}</span>
<button type="button" class="text-blue-400 hover:text-blue-300" @click="toutDecocher">
Tout decocher
</button>
</div>
<div class="max-h-60 overflow-y-auto custom-scrollbar">
<label
v-for="option in optionsFiltrees"
:key="option.valeur"
class="flex items-center gap-2 px-3 py-2 cursor-pointer text-sm hover:bg-gray-800"
>
<input
type="checkbox"
class="shrink-0 accent-blue-500"
:checked="estSelectionnee(option.valeur)"
@change="basculerOption(option.valeur)"
/>
<span class="flex-1 truncate text-gray-300" v-html="surligner(option.libelle)"></span>
<span v-if="option.complement" class="shrink-0 text-xs text-gray-500">
{{ option.complement }}
</span>
</label>
<div v-if="!optionsFiltrees.length" class="px-3 py-3 text-sm text-gray-500">
Aucun resultat pour « {{ recherche }} »
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const props = defineProps({
/** Valeurs cochees. Un tableau vide vaut « aucun filtre », pas « rien ». */
modelValue: { type: Array, default: () => [] },
/** [{ valeur, libelle, complement? }] */
options: { type: Array, default: () => [] },
libelleVide: { type: String, default: 'Tous' },
placeholderRecherche: { type: String, default: 'Rechercher...' },
/** Nom au pluriel employe dans le resume : « 3 fournisseurs ». */
nomPluriel: { type: String, default: 'elements' }
})
const emit = defineEmits(['update:modelValue'])
const conteneur = ref(null)
const champRecherche = ref(null)
const ouvert = ref(false)
const recherche = ref('')
const selection = computed(() => props.modelValue ?? [])
/** Une seule valeur cochee s'affiche en clair : le compte seul cacherait
laquelle, alors que c'est justement le cas ou on veut la lire. */
const resume = computed(() => {
if (!selection.value.length) return props.libelleVide
if (selection.value.length === 1) {
const option = props.options.find((o) => o.valeur === selection.value[0])
return option?.libelle ?? String(selection.value[0])
}
return `${selection.value.length} ${props.nomPluriel}`
})
const optionsFiltrees = computed(() => {
const requete = recherche.value.trim().toLowerCase()
if (!requete) return props.options
return props.options.filter((o) => o.libelle.toLowerCase().includes(requete))
})
function estSelectionnee(valeur) {
return selection.value.includes(valeur)
}
function basculerOption(valeur) {
const suivante = estSelectionnee(valeur)
? selection.value.filter((v) => v !== valeur)
: [...selection.value, valeur]
emit('update:modelValue', suivante)
}
/** Entree coche la seule option restante : filtrer puis valider est le geste
naturel quand on sait deja quel fournisseur on cherche. */
function cocherLePremier() {
if (optionsFiltrees.value.length) {
basculerOption(optionsFiltrees.value[0].valeur)
recherche.value = ''
}
}
function toutDecocher() {
emit('update:modelValue', [])
}
function basculerPanneau() {
ouvert.value ? fermerPanneau() : ouvrirPanneau()
}
function ouvrirPanneau() {
ouvert.value = true
recherche.value = ''
nextTick(() => champRecherche.value?.focus())
}
function fermerPanneau() {
ouvert.value = false
}
function surClicExterieur(evenement) {
if (ouvert.value && conteneur.value && !conteneur.value.contains(evenement.target)) {
fermerPanneau()
}
}
/** Les valeurs cochees qui ne figurent plus dans les options seraient
invisibles tout en filtrant : on les laisse tomber avec les options. */
watch(
() => props.options,
(options) => {
if (!options.length || !selection.value.length) return
const connues = new Set(options.map((o) => o.valeur))
const retenues = selection.value.filter((v) => connues.has(v))
if (retenues.length !== selection.value.length) {
emit('update:modelValue', retenues)
}
}
)
onMounted(() => document.addEventListener('mousedown', surClicExterieur))
onBeforeUnmount(() => document.removeEventListener('mousedown', surClicExterieur))
function echapper(texte) {
return texte.replace(/[&<>"']/g, (c) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
})[c])
}
function surligner(texte) {
const echappe = echapper(texte)
const requete = recherche.value.trim()
if (!requete) return echappe
const motif = echapper(requete).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return echappe.replace(
new RegExp(`(${motif})`, 'gi'),
'<mark class="bg-amber-500/30 text-amber-200 rounded px-0.5">$1</mark>'
)
}
</script>

View File

@@ -1,5 +1,7 @@
<template> <template>
<div class="card card-body space-y-4"> <!-- overflow-visible : `.card` rogne son contenu, ce qui decapitait le menu
deroulant des fournisseurs. Rien d'autre ne deborde de cette carte. -->
<div class="card card-body space-y-4 overflow-visible">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h3 class="card-title">Filtres</h3> <h3 class="card-title">Filtres</h3>
<button <button
@@ -67,6 +69,9 @@
class="input" class="input"
> >
<option :value="null">Tous</option> <option :value="null">Tous</option>
<!-- « Sans tag » est un choix de filtre, pas l'absence de filtre :
c'est ainsi qu'on retrouve les depenses restant a tagger. -->
<option :value="SANS_TAG">Sans tag</option>
<option v-for="tag in tags" :key="tag.id" :value="tag.id"> <option v-for="tag in tags" :key="tag.id" :value="tag.id">
{{ tag.nom }} {{ tag.nom }}
</option> </option>
@@ -95,15 +100,16 @@
/> />
</div> </div>
<!-- Fournisseur (recherche) --> <!-- Fournisseurs (selection multiple, filtrable au clavier) -->
<div> <div>
<label class="block text-xs text-gray-400 mb-1">Fournisseur</label> <label class="block text-xs text-gray-400 mb-1">Fournisseurs</label>
<input <SelectionMultiple
type="text" :model-value="filters.fournisseurs"
v-model="filters.fournisseur" :options="optionsFournisseurs"
@input="emitFilters" libelle-vide="Tous"
placeholder="Rechercher..." nom-pluriel="fournisseurs"
class="input" placeholder-recherche="Filtrer la liste..."
@update:model-value="onFournisseursChange"
/> />
</div> </div>
</div> </div>
@@ -112,14 +118,19 @@
<script setup> <script setup>
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import SelectionMultiple from '../SelectionMultiple.vue'
const emit = defineEmits(['filter-change']) const emit = defineEmits(['filter-change'])
/** Valeur de tag_id demandant les depenses sans tag (cf. SANS_TAG cote API). */
const SANS_TAG = 0
// Reference data // Reference data
const immeubles = ref([]) const immeubles = ref([])
const lots = ref([]) const lots = ref([])
const categories = ref([]) const categories = ref([])
const tags = ref([]) const tags = ref([])
const fournisseurs = ref([])
// Local filters state // Local filters state
const filters = reactive({ const filters = reactive({
@@ -127,11 +138,24 @@ const filters = reactive({
lot_id: null, lot_id: null,
categorie: null, categorie: null,
tag_id: null, tag_id: null,
fournisseur: null, fournisseurs: [],
date_debut: null, date_debut: null,
date_fin: null date_fin: null
}) })
/** Ordre alphabetique - l'API les trie par montant, ce qui se lit bien dans un
classement mais rend introuvable un fournisseur qu'on cherche a l'oeil. Le
nombre de depenses reste affiche a droite pour situer le poids. */
const optionsFournisseurs = computed(() =>
[...fournisseurs.value]
.sort((a, b) => a.nom.localeCompare(b.nom, 'fr'))
.map((f) => ({
valeur: f.nom,
libelle: f.nom,
complement: `${f.nb_depenses}`
}))
)
const filteredLots = computed(() => { const filteredLots = computed(() => {
if (!filters.immeuble_id) return [] if (!filters.immeuble_id) return []
return lots.value.filter(l => l.immeuble_id === filters.immeuble_id) return lots.value.filter(l => l.immeuble_id === filters.immeuble_id)
@@ -144,7 +168,7 @@ const hasActiveFilters = computed(() => {
filters.tag_id !== null || filters.tag_id !== null ||
filters.date_debut !== null || filters.date_debut !== null ||
filters.date_fin !== null || filters.date_fin !== null ||
(filters.fournisseur && filters.fournisseur.length > 0) filters.fournisseurs.length > 0
}) })
function onImmeubleChange() { function onImmeubleChange() {
@@ -153,6 +177,11 @@ function onImmeubleChange() {
emitFilters() emitFilters()
} }
function onFournisseursChange(valeurs) {
filters.fournisseurs = valeurs
emitFilters()
}
function emitFilters() { function emitFilters() {
console.log('Emitting filters:', { ...filters }) console.log('Emitting filters:', { ...filters })
emit('filter-change', { ...filters }) emit('filter-change', { ...filters })
@@ -163,7 +192,7 @@ function resetFilters() {
filters.lot_id = null filters.lot_id = null
filters.categorie = null filters.categorie = null
filters.tag_id = null filters.tag_id = null
filters.fournisseur = null filters.fournisseurs = []
filters.date_debut = null filters.date_debut = null
filters.date_fin = null filters.date_fin = null
emitFilters() emitFilters()
@@ -179,23 +208,26 @@ function formatCategorie(cat) {
async function loadReferenceData() { async function loadReferenceData() {
try { try {
const [immeublesRes, lotsRes, categoriesRes, tagsRes] = await Promise.all([ const [immeublesRes, lotsRes, categoriesRes, tagsRes, fournisseursRes] = await Promise.all([
fetch('/api/immeubles'), fetch('/api/immeubles'),
fetch('/api/lots'), fetch('/api/lots'),
fetch('/api/analytics/categories'), fetch('/api/analytics/categories'),
fetch('/api/tags') fetch('/api/tags'),
fetch('/api/fournisseurs')
]) ])
if (immeublesRes.ok) immeubles.value = await immeublesRes.json() if (immeublesRes.ok) immeubles.value = await immeublesRes.json()
if (lotsRes.ok) lots.value = await lotsRes.json() if (lotsRes.ok) lots.value = await lotsRes.json()
if (categoriesRes.ok) categories.value = await categoriesRes.json() if (categoriesRes.ok) categories.value = await categoriesRes.json()
if (tagsRes.ok) tags.value = await tagsRes.json() if (tagsRes.ok) tags.value = await tagsRes.json()
if (fournisseursRes.ok) fournisseurs.value = await fournisseursRes.json()
console.log('Reference data loaded:', { console.log('Reference data loaded:', {
immeubles: immeubles.value.length, immeubles: immeubles.value.length,
lots: lots.value.length, lots: lots.value.length,
categories: categories.value.length, categories: categories.value.length,
tags: tags.value.length tags: tags.value.length,
fournisseurs: fournisseurs.value.length
}) })
} catch (err) { } catch (err) {
console.error('Failed to load reference data:', err) console.error('Failed to load reference data:', err)

View File

@@ -55,7 +55,7 @@ let currentFilters = {
lot_id: null, lot_id: null,
categorie: null, categorie: null,
tag_id: null, tag_id: null,
fournisseur: null, fournisseurs: [],
date_debut: null, date_debut: null,
date_fin: null date_fin: null
} }
@@ -97,7 +97,8 @@ function buildQueryParams() {
if (currentFilters.lot_id !== null) params.append('lot_id', currentFilters.lot_id) if (currentFilters.lot_id !== null) params.append('lot_id', currentFilters.lot_id)
if (currentFilters.categorie !== null) params.append('categorie', currentFilters.categorie) if (currentFilters.categorie !== null) params.append('categorie', currentFilters.categorie)
if (currentFilters.tag_id !== null) params.append('tag_id', currentFilters.tag_id) if (currentFilters.tag_id !== null) params.append('tag_id', currentFilters.tag_id)
if (currentFilters.fournisseur) params.append('fournisseur', currentFilters.fournisseur) // Un parametre `fournisseur` par fournisseur retenu : l'API en fait un OR.
for (const nom of currentFilters.fournisseurs || []) params.append('fournisseur', nom)
if (currentFilters.date_debut) params.append('date_debut', currentFilters.date_debut) if (currentFilters.date_debut) params.append('date_debut', currentFilters.date_debut)
if (currentFilters.date_fin) params.append('date_fin', currentFilters.date_fin) if (currentFilters.date_fin) params.append('date_fin', currentFilters.date_fin)

View File

@@ -0,0 +1,49 @@
// Le declencheur du menu multi-selection est ce qui reste visible une fois le
// menu referme : il doit dire l'etat du filtre sans qu'on ait a le rouvrir.
import { describe, expect, it } from 'vitest'
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
import SelectionMultiple from '../src/components/SelectionMultiple.vue'
const FOURNISSEURS = [
{ valeur: 'ACME', libelle: 'ACME', complement: '12' },
{ valeur: 'BOREAL', libelle: 'BOREAL', complement: '3' },
{ valeur: 'CERES', libelle: 'CERES', complement: '1' },
]
function rendu(proprietes = {}) {
return renderToString(
createSSRApp(SelectionMultiple, {
options: FOURNISSEURS,
libelleVide: 'Tous',
nomPluriel: 'fournisseurs',
...proprietes,
})
)
}
describe('SelectionMultiple', () => {
it('annonce l absence de filtre quand rien n est coche', async () => {
expect(await rendu({ modelValue: [] })).toContain('Tous')
})
it('nomme le fournisseur quand il n y en a qu un', async () => {
// Le compte seul (« 1 fournisseur ») cacherait lequel, alors que c'est
// justement le cas ou la reponse tient dans le bouton.
const html = await rendu({ modelValue: ['BOREAL'] })
expect(html).toContain('BOREAL')
expect(html).not.toContain('1 fournisseurs')
})
it('compte les fournisseurs des qu il y en a plusieurs', async () => {
expect(await rendu({ modelValue: ['ACME', 'CERES'] })).toContain('2 fournisseurs')
})
it('garde le menu ferme au premier rendu', async () => {
// Une liste de dizaines de fournisseurs deployee d'entree recouvrirait la
// page a chaque chargement.
expect(await rendu({ modelValue: [] })).not.toContain('Rechercher')
})
})

View File

@@ -25,6 +25,47 @@ from ..schemas.models import (
router = APIRouter(prefix="/api", tags=["analytics"]) 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) # Reference data endpoints (for filters)
@@ -180,9 +221,11 @@ async def list_tags_with_stats(
async def get_depenses( async def get_depenses(
immeuble_id: int | None = Query(None, description="Filtrer par immeuble"), immeuble_id: int | None = Query(None, description="Filtrer par immeuble"),
lot_id: int | None = Query(None, description="Filtrer par lot"), 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"), 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_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)"), date_fin: date | None = Query(None, description="Date de fin (YYYY-MM-DD)"),
limit: int = Query(500, description="Nombre maximum de resultats"), limit: int = Query(500, description="Nombre maximum de resultats"),
@@ -194,9 +237,10 @@ async def get_depenses(
Filtres disponibles: Filtres disponibles:
- **immeuble_id**: ID de l'immeuble - **immeuble_id**: ID de l'immeuble
- **lot_id**: ID du lot - **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 - **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_debut**: Date de debut (incluse)
- **date_fin**: Date de fin (incluse) - **date_fin**: Date de fin (incluse)
- **limit**: Nombre max de resultats (defaut: 500) - **limit**: Nombre max de resultats (defaut: 500)
@@ -219,21 +263,16 @@ async def get_depenses(
.order_by(Document.date.desc(), Depense.id.desc()) .order_by(Document.date.desc(), Depense.id.desc())
) )
# Apply filters stmt = _appliquer_filtres(
if immeuble_id is not None: stmt,
stmt = stmt.where(Depense.immeuble_id == immeuble_id) immeuble_id=immeuble_id,
if lot_id is not None: lot_id=lot_id,
stmt = stmt.where(Depense.lot_id == lot_id) tag_id=tag_id,
if tag_id is not None: categorie=categorie,
stmt = stmt.where(Depense.tag_id == tag_id) fournisseurs=fournisseur,
if categorie is not None: date_debut=date_debut,
stmt = stmt.where(Depense.categorie == categorie) date_fin=date_fin,
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 = stmt.limit(limit).offset(offset) stmt = stmt.limit(limit).offset(offset)
@@ -271,9 +310,11 @@ async def get_depenses(
async def get_depenses_summary( async def get_depenses_summary(
immeuble_id: int | None = Query(None, description="Filtrer par immeuble"), immeuble_id: int | None = Query(None, description="Filtrer par immeuble"),
lot_id: int | None = Query(None, description="Filtrer par lot"), 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"), 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_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)"), date_fin: date | None = Query(None, description="Date de fin (YYYY-MM-DD)"),
session: Session = Depends(get_session), session: Session = Depends(get_session),
@@ -289,22 +330,16 @@ async def get_depenses_summary(
- Top fournisseurs - Top fournisseurs
""" """
# Base query with filters # Base query with filters
base_stmt = select(Depense).join(Document, Depense.document_id == Document.id) base_stmt = _appliquer_filtres(
select(Depense).join(Document, Depense.document_id == Document.id),
if immeuble_id is not None: immeuble_id=immeuble_id,
base_stmt = base_stmt.where(Depense.immeuble_id == immeuble_id) lot_id=lot_id,
if lot_id is not None: tag_id=tag_id,
base_stmt = base_stmt.where(Depense.lot_id == lot_id) categorie=categorie,
if tag_id is not None: fournisseurs=fournisseur,
base_stmt = base_stmt.where(Depense.tag_id == tag_id) date_debut=date_debut,
if categorie is not None: date_fin=date_fin,
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)
# Get all matching depenses # Get all matching depenses
result = session.execute(base_stmt) result = session.execute(base_stmt)

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)