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:
197
frontend/src/components/SelectionMultiple.vue
Normal file
197
frontend/src/components/SelectionMultiple.vue
Normal 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) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
})[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>
|
||||
@@ -1,5 +1,7 @@
|
||||
<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">
|
||||
<h3 class="card-title">Filtres</h3>
|
||||
<button
|
||||
@@ -67,6 +69,9 @@
|
||||
class="input"
|
||||
>
|
||||
<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">
|
||||
{{ tag.nom }}
|
||||
</option>
|
||||
@@ -95,15 +100,16 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Fournisseur (recherche) -->
|
||||
<!-- Fournisseurs (selection multiple, filtrable au clavier) -->
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Fournisseur</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="filters.fournisseur"
|
||||
@input="emitFilters"
|
||||
placeholder="Rechercher..."
|
||||
class="input"
|
||||
<label class="block text-xs text-gray-400 mb-1">Fournisseurs</label>
|
||||
<SelectionMultiple
|
||||
:model-value="filters.fournisseurs"
|
||||
:options="optionsFournisseurs"
|
||||
libelle-vide="Tous"
|
||||
nom-pluriel="fournisseurs"
|
||||
placeholder-recherche="Filtrer la liste..."
|
||||
@update:model-value="onFournisseursChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,14 +118,19 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import SelectionMultiple from '../SelectionMultiple.vue'
|
||||
|
||||
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
|
||||
const immeubles = ref([])
|
||||
const lots = ref([])
|
||||
const categories = ref([])
|
||||
const tags = ref([])
|
||||
const fournisseurs = ref([])
|
||||
|
||||
// Local filters state
|
||||
const filters = reactive({
|
||||
@@ -127,11 +138,24 @@ const filters = reactive({
|
||||
lot_id: null,
|
||||
categorie: null,
|
||||
tag_id: null,
|
||||
fournisseur: null,
|
||||
fournisseurs: [],
|
||||
date_debut: 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(() => {
|
||||
if (!filters.immeuble_id) return []
|
||||
return lots.value.filter(l => l.immeuble_id === filters.immeuble_id)
|
||||
@@ -144,7 +168,7 @@ const hasActiveFilters = computed(() => {
|
||||
filters.tag_id !== null ||
|
||||
filters.date_debut !== null ||
|
||||
filters.date_fin !== null ||
|
||||
(filters.fournisseur && filters.fournisseur.length > 0)
|
||||
filters.fournisseurs.length > 0
|
||||
})
|
||||
|
||||
function onImmeubleChange() {
|
||||
@@ -153,6 +177,11 @@ function onImmeubleChange() {
|
||||
emitFilters()
|
||||
}
|
||||
|
||||
function onFournisseursChange(valeurs) {
|
||||
filters.fournisseurs = valeurs
|
||||
emitFilters()
|
||||
}
|
||||
|
||||
function emitFilters() {
|
||||
console.log('Emitting filters:', { ...filters })
|
||||
emit('filter-change', { ...filters })
|
||||
@@ -163,7 +192,7 @@ function resetFilters() {
|
||||
filters.lot_id = null
|
||||
filters.categorie = null
|
||||
filters.tag_id = null
|
||||
filters.fournisseur = null
|
||||
filters.fournisseurs = []
|
||||
filters.date_debut = null
|
||||
filters.date_fin = null
|
||||
emitFilters()
|
||||
@@ -179,23 +208,26 @@ function formatCategorie(cat) {
|
||||
|
||||
async function loadReferenceData() {
|
||||
try {
|
||||
const [immeublesRes, lotsRes, categoriesRes, tagsRes] = await Promise.all([
|
||||
const [immeublesRes, lotsRes, categoriesRes, tagsRes, fournisseursRes] = await Promise.all([
|
||||
fetch('/api/immeubles'),
|
||||
fetch('/api/lots'),
|
||||
fetch('/api/analytics/categories'),
|
||||
fetch('/api/tags')
|
||||
fetch('/api/tags'),
|
||||
fetch('/api/fournisseurs')
|
||||
])
|
||||
|
||||
if (immeublesRes.ok) immeubles.value = await immeublesRes.json()
|
||||
if (lotsRes.ok) lots.value = await lotsRes.json()
|
||||
if (categoriesRes.ok) categories.value = await categoriesRes.json()
|
||||
if (tagsRes.ok) tags.value = await tagsRes.json()
|
||||
if (fournisseursRes.ok) fournisseurs.value = await fournisseursRes.json()
|
||||
|
||||
console.log('Reference data loaded:', {
|
||||
immeubles: immeubles.value.length,
|
||||
lots: lots.value.length,
|
||||
categories: categories.value.length,
|
||||
tags: tags.value.length
|
||||
tags: tags.value.length,
|
||||
fournisseurs: fournisseurs.value.length
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to load reference data:', err)
|
||||
|
||||
@@ -55,7 +55,7 @@ let currentFilters = {
|
||||
lot_id: null,
|
||||
categorie: null,
|
||||
tag_id: null,
|
||||
fournisseur: null,
|
||||
fournisseurs: [],
|
||||
date_debut: null,
|
||||
date_fin: null
|
||||
}
|
||||
@@ -97,7 +97,8 @@ function buildQueryParams() {
|
||||
if (currentFilters.lot_id !== null) params.append('lot_id', currentFilters.lot_id)
|
||||
if (currentFilters.categorie !== null) params.append('categorie', currentFilters.categorie)
|
||||
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_fin) params.append('date_fin', currentFilters.date_fin)
|
||||
|
||||
|
||||
49
frontend/tests/selectionMultiple.test.js
Normal file
49
frontend/tests/selectionMultiple.test.js
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
131
tests/test_analytics_filtres.py
Normal file
131
tests/test_analytics_filtres.py
Normal 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)
|
||||
|
||||
Reference in New Issue
Block a user