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>
|
<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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
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"])
|
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)
|
||||||
|
|||||||
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