Compare commits

...

6 Commits

Author SHA1 Message Date
37bddae6ca feat: ouvre la fiche d'un lot dans l'interface
All checks were successful
Build and Publish Docker Image / Build App Image (push) Successful in 1m24s
Build and Publish Docker Image / Build Summary (push) Successful in 3s
Nouvel onglet « Lots » : identité, chiffres, chronologie et intervenants
d'un lot, choisi dans un sélecteur. Le lot vit dans l'URL (`/lots/26`) pour
qu'une fiche se partage et survive au rechargement.

La chronologie mêle recettes et dépenses dans l'ordre des comptes rendus :
c'est là que se lit la vie du lot, y compris ce que la base ne porte pas
ailleurs — les dates d'entrée et de sortie des locataires sont vides, mais
les honoraires de mise en location et les états des lieux les racontent.

Chaque entreprise du tableau des intervenants se déplie sur ses
interventions. Le détail reprend les lignes déjà chargées plutôt que d'en
redemander : un second calcul pourrait diverger du total affiché.

Le solde n'étant pas un résultat net, la page le dit sous les tuiles, à
l'endroit où le chiffre se lit, et rappelle le montant des charges
d'immeuble laissées hors des lots.

Le taux de recouvrement s'efface quand rien n'est facturé : les 100 %
renvoyés dans ce cas afficheraient un lot sain à côté de 49 000 € d'impayé.

Couleurs reprises des tuiles existantes — `.card` et `.badge` ne fixant
aucune couleur de texte, une valeur sans classe héritait du noir et
disparaissait sur le fond sombre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 10:42:14 +02:00
6a014cdb71 feat: rassemble sur un lot ce que les comptes rendus en disent
Les pages existantes agrègent le parc ; aucune ne descend à un lot pour
remettre ses lignes bout à bout. `/api/lots/{id}/analyse` renvoie son
identité (fiche saisie comprise, trous laissés visibles), ses totaux, sa
chronologie de recettes et de dépenses, et les entreprises intervenues.

Deux limites sont assumées plutôt que contournées :

- les dépenses d'un lot sont celles que le compte rendu lui impute. Aucune
  clé de répartition n'existe en base — ni tantièmes, ni surfaces complètes
  — donc les charges d'immeuble ne sont pas ventilées : elles sont exposées
  à part, et le solde d'un lot n'est pas un résultat net ;
- les lignes sont rendues telles qu'extraites, sans regroupement ni
  dédoublonnage. Un acompte et son solde restent deux lignes.

Les totaux passent par `flux_par` et `restant_du_par` au lieu de resommer
sur place : ces fonctions portent la distinction entre ce qui se cumule et
ce qui est une photo, et la rejouer à la main ferait diverger cette page de
la page Recettes.

Le montant d'un intervenant est net du crédit, comme chaque ligne de la
chronologie, pour que le détail d'une entreprise retrouve son total — un
avoir rend d'ailleurs ce montant négatif, ce que le compte rendu porte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 10:42:02 +02:00
dc5e8b995f feat: ouvre la saisie des logements dans un tableau
La fiche compte une dizaine de champs pour une vingtaine de lots : un
formulaire par lot imposerait autant d'allers-retours. Le tableau garde
l'ergonomie du tableur d'où viennent ces données — une ligne par lot, les
colonnes dans le même ordre, chaque cellule enregistrée en la quittant.

Ce que le serveur calcule est affiché comme tel et non saisissable :
échéance du DPE (rouge si périmé, ambre à moins d'un an) et écart de
surface. Le type vu par le PDF reste visible à côté du type saisi quand les
deux se contredisent, plutôt que d'être remplacé sans le dire.

Tri et filtre vivent dans l'en-tête de chaque colonne, jamais au-dessus du
tableau : c'est là qu'on les cherche en lisant la colonne. Une liste de
choix peut proposer, dans un groupe à part, ce qui n'est pas une valeur de
la colonne — rattachement d'un lot aux comptes rendus, désaccord de type.
L'en-tête se fige au défilement, ce qui demande de borner la hauteur du
tableau : sans conteneur à hauteur limitée, `sticky` n'a rien à quoi se
tenir.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 17:40:11 +02:00
fdffe27f06 feat: étend le référentiel au parc entier et à la suppression des lots
Trois manques que la saisie a fait apparaître :

Le tableau porte une colonne immeuble, la liste ne peut donc plus être
suspendue à un immeuble choisi d'avance : elle renvoie tout le parc, chaque
ligne emportant de quoi nommer son immeuble sans requête de plus.

Un lot qu'aucune ligne de compte rendu ne mentionne ne décrit rien : il
encombre la saisie et doit pouvoir disparaître. La garde est côté serveur —
un lot porteur de revenus ou de dépenses est refusé, ses montants
partiraient avec lui.

Le nom d'usage s'enregistre, et la réponse recalcule les compteurs de
l'immeuble plutôt que de les laisser à zéro : elle remplace l'immeuble dans
les listes du client, qui le croirait vide de lots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 15:41:24 +02:00
9f5f46a93a feat: donne aux immeubles un nom d'usage
Les comptes rendus n'identifient un immeuble que par son code de gestion
(« 33689020 »), illisible partout où il s'affiche. La dénomination
(« Servient ») le remplace à l'écran sans toucher au code, qui reste la clé
venue des PDF.

La colonne s'ajoute à une table déjà installée : elle passe donc par le
rattrapage de schéma, qui accepte désormais l'absence de valeur de
rattrapage. Déduire un nom d'usage du code en inventerait un.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 15:40:27 +02:00
5a08b0c7e5 feat: décrit les logements dans un référentiel saisi à la main
Les lots n'étaient connus que par l'extraction PDF : un numéro, un type
souvent vide, et rien sur le bien lui-même. Cette table de caractéristiques
(surface, étage, bâtiment, chauffage, DPE, rapprochement impôts) donne au
référentiel une source de vérité indépendante des comptes rendus.

Table séparée de `lots` à dessein : une ré-extraction ne peut alors pas
écraser la saisie, et le désaccord sur le type de lot reste visible au lieu
d'être arbitré en silence. La fiche gagne, le PDF comble les trous.

Échéance du DPE et écart de surface ne sont pas stockés mais calculés : une
colonne dérivée finirait par mentir après une correction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:00:42 +02:00
25 changed files with 2949 additions and 44 deletions

View File

@@ -15,7 +15,7 @@
:key="link.to"
:to="link.to"
class="px-3 py-1.5 text-sm rounded-lg transition-colors"
:class="$route.path === link.to ? 'bg-gray-800 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-800'"
:class="estActif(link.to) ? 'bg-gray-800 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-800'"
>
{{ link.label }}
</router-link>
@@ -44,19 +44,29 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { pendingFile } from './store'
import { FEATURE_IA } from './features'
const route = useRoute()
const router = useRouter()
const fileInput = ref(null)
// Une fiche de lot (/lots/26) doit garder son onglet allumé : comparer les
// chemins à l'identique éteindrait la navigation dès qu'une page a des
// sous-routes.
function estActif(chemin) {
return route.path === chemin || route.path.startsWith(`${chemin}/`)
}
// Les libelles suivent le vocabulaire de l'accueil : recettes et depenses. Les
// URL gardent leurs noms d'origine (/revenus, /analytics), qui circulent dans
// des liens deja partages.
const navLinks = [
{ to: '/', label: 'Accueil' },
{ to: '/documents', label: 'Documents' },
{ to: '/logements', label: 'Logements' },
{ to: '/lots', label: 'Lots' },
{ to: '/revenus', label: 'Recettes' },
{ to: '/analytics', label: 'Dépenses' },
...(FEATURE_IA ? [{ to: '/ia', label: 'IA' }] : []),

View File

@@ -0,0 +1,227 @@
<template>
<tr :class="{ 'bg-gray-800/30': etat === 'enregistrement' }">
<!-- Nom d'usage de l'immeuble : saisi ici faute d'un écran à lui, et
répercuté sur toutes ses lignes puisqu'il ne décrit pas le lot. -->
<td class="whitespace-nowrap">
<input
v-model="nomImmeuble"
class="input-cell w-32 text-white"
:placeholder="ligne.immeuble_code"
:title="`Nom d'usage de l'immeuble ${ligne.immeuble_code} : le modifier vaut pour tous ses lots`"
@change="renommerImmeuble"
/>
</td>
<!-- Porte : identifiant du lot, non modifiable ici -->
<td class="font-mono text-white whitespace-nowrap">
{{ ligne.numero }}
<!-- Un lot que rien ne rattache à un compte rendu ne décrit rien : il
s'efface d'ici, sinon il encombre la saisie à chaque ligne. -->
<button v-if="inutilise"
class="badge badge-neutral ml-2 hover:bg-red-500/20 hover:text-red-400 transition-colors"
:title="`Aucune ligne de compte rendu ne renvoie au lot ${ligne.numero}.\nCliquer pour le supprimer du référentiel.`"
@click="demanderSuppression">
inutilisé
</button>
<span v-else class="badge badge-info ml-2 whitespace-nowrap" :title="detailRattachements">
{{ resumeRattachements }}
</span>
</td>
<td><input v-model="fiche.bat" class="input-cell w-20" placeholder="—" @change="enregistrer" /></td>
<td>
<div class="flex items-center gap-1">
<input v-model="fiche.type" class="input-cell w-40" :placeholder="ligne.type_extrait || '—'" @change="enregistrer" />
<span v-if="ligne.type_ecart"
class="badge badge-warning whitespace-nowrap"
:title="`Le PDF annonce « ${ligne.type_extrait} »`">
PDF : {{ ligne.type_extrait }}
</span>
</div>
</td>
<td><input v-model="fiche.etage" class="input-cell w-16" placeholder="—" @change="enregistrer" /></td>
<td>
<input v-model.number="fiche.surface" type="number" min="0" step="0.01"
class="input-cell w-24 text-right" placeholder="—" @change="enregistrer" />
</td>
<td>
<input v-model="fiche.surface_date_diag" type="date" class="input-cell w-36" @change="enregistrer" />
</td>
<td>
<input v-model="fiche.numero_fiscal" class="input-cell w-36" placeholder="—" @change="enregistrer" />
</td>
<td>
<select v-model="fiche.dpe_classe" class="input-cell w-16" @change="enregistrer">
<option :value="null"></option>
<option v-for="classe in DPE_CLASSES" :key="classe" :value="classe">{{ classe }}</option>
</select>
</td>
<td>
<input v-model="fiche.dpe_date_realisation" type="date" class="input-cell w-36" @change="enregistrer" />
</td>
<!-- Échéance : déduite de la date de réalisation, jamais saisie -->
<td class="whitespace-nowrap">
<span v-if="!derives.dpe_echeance" class="text-gray-600"></span>
<span v-else class="badge" :class="badgeEcheance">{{ formatDate(derives.dpe_echeance) }}</span>
</td>
<td><input v-model="fiche.chauffage" class="input-cell w-32" placeholder="—" @change="enregistrer" /></td>
<td>
<input v-model.number="fiche.surface_impots" type="number" min="0" step="0.01"
class="input-cell w-24 text-right" placeholder="—" @change="enregistrer" />
</td>
<!-- Écart de surface : calculé, affiché même nul pour dire que le rapprochement est fait -->
<td class="text-right whitespace-nowrap">
<span v-if="derives.delta_surface == null" class="text-gray-600"></span>
<span v-else class="badge" :class="derives.delta_surface === 0 ? 'badge-success' : 'badge-warning'">
{{ formatDelta(derives.delta_surface) }}
</span>
</td>
<td>
<input v-model="fiche.note_impots" class="input-cell w-56" placeholder="—" @change="enregistrer" />
</td>
<td class="w-8 text-center">
<span v-if="etat === 'enregistrement'" class="spinner w-3 h-3 align-middle" title="Enregistrement"></span>
<span v-else-if="etat === 'ok'" class="text-green-400" title="Enregistré"></span>
<span v-else-if="etat === 'erreur'" class="text-red-400 cursor-help" :title="erreur">!</span>
</td>
</tr>
</template>
<script setup>
import { reactive, ref, computed, watch } from 'vue'
import { formatDate } from '../../utils/format'
const API = import.meta.env.VITE_API_URL || ''
const DPE_CLASSES = ['A', 'B', 'C', 'D', 'E', 'F', 'G']
//: Champs envoyés au serveur. L'ordre suit celui du tableau de saisie.
const CHAMPS = [
'bat', 'etage', 'type', 'surface', 'surface_date_diag', 'chauffage',
'dpe_classe', 'dpe_date_realisation', 'numero_fiscal', 'surface_impots', 'note_impots'
]
const props = defineProps({
ligne: { type: Object, required: true }
})
const emit = defineEmits(['enregistree', 'supprimer', 'renommer-immeuble'])
const nomImmeuble = ref(props.ligne.immeuble_denomination || '')
watch(
() => props.ligne.immeuble_denomination,
(denomination) => { nomImmeuble.value = denomination || '' }
)
// Le renommage remonte : il touche toutes les lignes du même immeuble, donc il
// appartient au tableau et non à celle qui l'a déclenché.
function renommerImmeuble() {
emit('renommer-immeuble', { immeubleId: props.ligne.immeuble_id, denomination: nomImmeuble.value })
}
const inutilise = computed(() => props.ligne.nb_revenus === 0 && props.ligne.nb_depenses === 0)
function accorder(nombre, singulier, pluriel = `${singulier}s`) {
return `${nombre} ${nombre > 1 ? pluriel : singulier}`
}
// Le badge dit ce que les comptes rendus rattachent à ce lot. Abrégé, il ne
// voulait rien dire : « 11 rev. » se lit aussi bien comme onze euros.
const resumeRattachements = computed(() => {
const parties = [accorder(props.ligne.nb_revenus, 'revenu')]
if (props.ligne.nb_depenses) parties.push(accorder(props.ligne.nb_depenses, 'dépense'))
return parties.join(' · ')
})
const detailRattachements = computed(() => {
const revenus = accorder(props.ligne.nb_revenus, 'ligne de revenus', 'lignes de revenus')
const depenses = accorder(props.ligne.nb_depenses, 'dépense')
return (
`Les comptes rendus rattachent ${revenus} et ${depenses} au lot ${props.ligne.numero}.\n` +
'Un lot rattaché ne peut pas être supprimé : ses montants partiraient avec lui.'
)
})
// La suppression remonte : la confirmation et l'appel appartiennent au tableau,
// qui seul peut retirer la ligne de la liste.
function demanderSuppression() {
emit('supprimer', props.ligne)
}
const fiche = reactive(Object.fromEntries(CHAMPS.map((champ) => [champ, null])))
const etat = ref('')
const erreur = ref('')
// Valeurs calculées par le serveur : elles ne se devinent pas côté client, on
// affiche celles de la dernière réponse.
const derives = computed(() => ({
dpe_echeance: props.ligne.caracteristiques?.dpe_echeance ?? null,
delta_surface: props.ligne.caracteristiques?.delta_surface ?? null
}))
watch(
() => props.ligne.caracteristiques,
(caracteristiques) => {
for (const champ of CHAMPS) fiche[champ] = caracteristiques?.[champ] ?? null
},
{ immediate: true }
)
// Un DPE périmé interdit de relouer sans le refaire : il doit sauter aux yeux
// avant l'échéance, pas le jour où le locataire part.
const badgeEcheance = computed(() => {
const echeance = new Date(derives.value.dpe_echeance)
const dans12Mois = new Date()
dans12Mois.setFullYear(dans12Mois.getFullYear() + 1)
if (echeance < new Date()) return 'badge-danger'
if (echeance < dans12Mois) return 'badge-warning'
return 'badge-neutral'
})
function formatDelta(valeur) {
const signe = valeur > 0 ? '+' : ''
return `${signe}${valeur}`
}
async function enregistrer() {
etat.value = 'enregistrement'
erreur.value = ''
try {
const response = await fetch(`${API}/api/lots/${props.ligne.id}/caracteristiques`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.fromEntries(CHAMPS.map((champ) => [champ, fiche[champ] === '' ? null : fiche[champ]])))
})
if (!response.ok) {
const detail = await response.json().catch(() => null)
throw new Error(detail?.detail?.[0]?.msg || detail?.detail || 'Enregistrement refusé')
}
emit('enregistree', await response.json())
etat.value = 'ok'
setTimeout(() => { if (etat.value === 'ok') etat.value = '' }, 2000)
} catch (e) {
// La saisie reste à l'écran : l'effacer ferait perdre la valeur tapée.
etat.value = 'erreur'
erreur.value = e.message
}
}
</script>

View File

@@ -0,0 +1,251 @@
/**
* Colonnes du tableau des logements : en-têtes, tri et filtres.
*
* L'ordre de cette liste est celui des cellules de `LigneLogement.vue` — les
* deux se lisent ensemble, une colonne ajoutée ici doit l'être là aussi.
*
* `valeur` sert au tri et aux filtres ; ce que la cellule affiche peut être
* plus riche (badges, champs de saisie), mais c'est cette valeur brute qui est
* comparée, pour que trier « Écart » range des nombres et non des libellés.
*
* Chaque colonne porte son type de filtre, posé dans son en-tête :
* - `choix` : liste des valeurs présentes (plus « (vide) »)
* - `texte` : sous-chaîne, insensible à la casse et aux accents
* - `nombre` : comparaison, `>100`, `<=50`, `!=0`, ou une valeur exacte
* - `echeance` : où en est le DPE par rapport à sa péremption
*
* Une liste de choix peut proposer, au-dessus des valeurs, des entrées qui
* n'en sont pas — le rattachement d'un lot, un désaccord avec le PDF. Elles
* vivent dans leur propre groupe pour rester distinctes de ce que la colonne
* contient vraiment.
*/
const fiche = (ligne) => ligne.caracteristiques ?? {}
/** Marque le désaccord entre le type saisi et celui du PDF, filtrable comme une valeur. */
export const TYPE_EN_DESACCORD = '⚠ en désaccord avec le PDF'
/** Option des listes de choix pour les lots dont la colonne est vide. */
export const VIDE = '(vide)'
/** Lots rattachés à au moins une ligne de compte rendu. */
export const ETAT_UTILISES = 'Lots utilisés'
/** Lots qu'aucune ligne de compte rendu ne mentionne : les supprimables. */
export const ETAT_INUTILISES = 'Lots inutilisés'
export const COLONNES = [
{
cle: 'immeuble',
libelle: 'Immeuble',
valeur: (l) => l.immeuble_denomination || l.immeuble_code,
filtre: 'choix'
},
{ cle: 'numero', libelle: 'Porte', valeur: (l) => l.numero, filtre: 'choix' },
{ cle: 'bat', libelle: 'Bât', valeur: (l) => fiche(l).bat, filtre: 'choix' },
{ cle: 'type', libelle: 'Type', valeur: (l) => l.type_effectif, filtre: 'choix' },
{ cle: 'etage', libelle: 'Étage', valeur: (l) => fiche(l).etage, filtre: 'choix' },
{
cle: 'surface',
libelle: 'Surface',
valeur: (l) => fiche(l).surface,
filtre: 'nombre',
align: 'text-right'
},
{
cle: 'surface_date_diag',
libelle: 'Diag. surface',
valeur: (l) => fiche(l).surface_date_diag,
filtre: 'texte'
},
{
cle: 'numero_fiscal',
libelle: 'N° fiscal',
valeur: (l) => fiche(l).numero_fiscal,
filtre: 'texte'
},
{ cle: 'dpe_classe', libelle: 'DPE', valeur: (l) => fiche(l).dpe_classe, filtre: 'choix' },
{
cle: 'dpe_date_realisation',
libelle: 'Réalisé le',
valeur: (l) => fiche(l).dpe_date_realisation,
filtre: 'texte'
},
{
cle: 'dpe_echeance',
libelle: 'Échéance',
valeur: (l) => fiche(l).dpe_echeance,
filtre: 'echeance'
},
{ cle: 'chauffage', libelle: 'Chauffage', valeur: (l) => fiche(l).chauffage, filtre: 'choix' },
{
cle: 'surface_impots',
libelle: 'Surf. impôts',
valeur: (l) => fiche(l).surface_impots,
filtre: 'nombre',
align: 'text-right'
},
{
cle: 'delta_surface',
libelle: 'Écart',
valeur: (l) => fiche(l).delta_surface,
filtre: 'nombre',
align: 'text-right'
},
{
cle: 'note_impots',
libelle: 'Info impôts',
valeur: (l) => fiche(l).note_impots,
filtre: 'texte'
}
]
export const OPTIONS_ECHEANCE = [
{ valeur: 'perime', libelle: 'Périmé' },
{ valeur: 'bientot', libelle: 'Moins dun an' },
{ valeur: 'valide', libelle: 'Valide' },
{ valeur: 'absent', libelle: 'Sans DPE' }
]
const estVide = (valeur) => valeur == null || valeur === ''
// « Electrique » doit trouver « Électrique » : après décomposition NFD, les
// accents deviennent des marques combinantes (\p{Mn}) qu'on retire par leur
// catégorie Unicode — les écrire en clair donnerait un littéral invisible à la
// relecture.
function sansAccents(valeur) {
return String(valeur)
.normalize('NFD')
.replace(/\p{Mn}/gu, '')
.toLowerCase()
}
/**
* Valeurs présentes dans une colonne, pour peupler sa liste de choix.
*
* Seules les valeurs réellement là sont proposées : une liste figée
* afficherait des chauffages qu'aucun lot n'a.
*/
export function valeursDistinctes(lignes, colonne) {
const valeurs = new Set()
for (const ligne of lignes) {
const valeur = colonne.valeur(ligne)
valeurs.add(estVide(valeur) ? VIDE : String(valeur))
}
return [...valeurs].sort((a, b) => {
if (a === VIDE) return 1
if (b === VIDE) return -1
return a.localeCompare(b, 'fr', { numeric: true })
})
}
/**
* Contenu de la liste déroulante d'une colonne, en groupes.
*
* Les entrées qui ne sont pas des valeurs de la colonne (rattachement d'un lot,
* désaccord avec le PDF) sont proposées à part : mélangées aux numéros de
* porte, elles se liraient comme des portes.
*/
export function groupesFiltre(lignes, colonne) {
const valeurs = valeursDistinctes(lignes, colonne)
if (colonne.cle === 'numero') {
return [
{ libelle: 'Rattachement', options: [ETAT_UTILISES, ETAT_INUTILISES] },
{ libelle: 'Porte', options: valeurs }
]
}
if (colonne.cle === 'type' && lignes.some((ligne) => ligne.type_ecart)) {
return [
{ libelle: 'Contrôle', options: [TYPE_EN_DESACCORD] },
{ libelle: 'Type', options: valeurs }
]
}
return [{ libelle: '', options: valeurs }]
}
function passeNombre(valeur, saisie) {
const match = saisie.match(/^\s*(>=|<=|!=|<>|≠|>|<|=)?\s*(-?[\d.,]+)\s*$/)
if (!match) return true // saisie incomplète : ne rien masquer
const seuil = parseFloat(match[2].replace(',', '.'))
if (Number.isNaN(seuil)) return true
if (valeur == null) return false
switch (match[1]) {
case '>': return valeur > seuil
case '<': return valeur < seuil
case '>=': return valeur >= seuil
case '<=': return valeur <= seuil
case '!=':
case '<>':
case '≠': return valeur !== seuil
default: return valeur === seuil
}
}
function passeEcheance(echeance, choix) {
if (choix === 'absent') return estVide(echeance)
if (estVide(echeance)) return false
const date = new Date(echeance)
const aujourdhui = new Date()
const dans12Mois = new Date()
dans12Mois.setFullYear(dans12Mois.getFullYear() + 1)
if (choix === 'perime') return date < aujourdhui
if (choix === 'bientot') return date >= aujourdhui && date < dans12Mois
return date >= dans12Mois
}
/** Une ligne survit-elle au filtre posé sur cette colonne ? */
export function passeFiltre(ligne, colonne, saisie) {
if (estVide(saisie)) return true
const valeur = colonne.valeur(ligne)
switch (colonne.filtre) {
case 'echeance':
return passeEcheance(valeur, saisie)
case 'choix': {
if (saisie === ETAT_UTILISES || saisie === ETAT_INUTILISES) {
const inutilise = ligne.nb_revenus === 0 && ligne.nb_depenses === 0
return saisie === ETAT_INUTILISES ? inutilise : !inutilise
}
if (saisie === TYPE_EN_DESACCORD) return ligne.type_ecart
if (saisie === VIDE) return estVide(valeur)
return String(valeur) === saisie
}
case 'nombre':
return passeNombre(valeur, saisie)
default:
return !estVide(valeur) && sansAccents(valeur).includes(sansAccents(saisie))
}
}
/**
* Comparateur d'une colonne, dans le sens demandé.
*
* Les cases vides vont toujours en fin de tri, quel que soit le sens : trier
* par surface pour voir les plus grandes ne doit pas d'abord dérouler tous les
* lots non renseignés.
*/
export function comparer(colonne, sens) {
const signe = sens === 'desc' ? -1 : 1
return (a, b) => {
const va = colonne.valeur(a)
const vb = colonne.valeur(b)
if (estVide(va) && estVide(vb)) return 0
if (estVide(va)) return 1
if (estVide(vb)) return -1
if (typeof va === 'number' && typeof vb === 'number') return signe * (va - vb)
return signe * String(va).localeCompare(String(vb), 'fr', { numeric: true })
}
}

View File

@@ -0,0 +1,247 @@
<template>
<div class="page">
<div class="page-content">
<div class="page-header">
<div>
<h1 class="page-title">Logements</h1>
<p class="page-subtitle">
Ce que les comptes rendus ne disent pas d'un lot : surface, étage, DPE, chauffage.
</p>
</div>
</div>
<div v-if="chargement" class="empty-state">
<span class="spinner w-6 h-6"></span>
</div>
<div v-else-if="!lots.length" class="empty-state">
Aucun lot en base : importez d'abord un compte rendu.
</div>
<template v-else>
<div class="card">
<div class="card-header flex-wrap">
<div class="flex items-center gap-3">
<h2 class="card-title">{{ lignesAffichees.length }} / {{ lots.length }} lots · {{ nbFiches }} décrits</h2>
<span v-if="nbEcartsType" class="badge badge-warning">
{{ nbEcartsType }} type{{ nbEcartsType > 1 ? 's' : '' }} en désaccord avec le PDF
</span>
<span v-if="nbEcartsSurface" class="badge badge-warning">
{{ nbEcartsSurface }} écart{{ nbEcartsSurface > 1 ? 's' : '' }} de surface
</span>
</div>
<button
v-if="filtresActifs"
class="btn btn-secondary btn-sm"
@click="reinitialiserFiltres"
>Effacer les filtres</button>
</div>
<!-- Hauteur bornée : sans elle, le defilement appartiendrait à la
page et l'en-tête collant n'aurait rien à quoi se coller. -->
<div class="table-wrap custom-scrollbar overflow-y-auto max-h-[calc(100vh-14rem)]">
<table class="table">
<thead>
<!-- Libellé et filtre dans la même cellule : le filtre se
cherche dans sa colonne, et l'en-tête reste d'un bloc quand
il se fige en haut du tableau. -->
<tr>
<th
v-for="colonne in COLONNES"
:key="colonne.cle"
class="sticky top-0 z-20 bg-gray-800 align-bottom"
>
<button
class="select-none hover:text-white uppercase"
:class="colonne.align === 'text-right' ? 'w-full text-right' : ''"
:title="`Trier par ${colonne.libelle}`"
@click="trierPar(colonne.cle)"
>
{{ colonne.libelle }}
<span class="text-blue-400">{{ triCle === colonne.cle ? (triSens === 'asc' ? '▲' : '▼') : '' }}</span>
</button>
<div class="mt-1 font-normal normal-case tracking-normal">
<select
v-if="colonne.filtre === 'choix'"
v-model="filtres[colonne.cle]"
class="input-cell w-full border-gray-700 text-xs"
>
<option value="">Tous</option>
<template v-for="groupe in choix[colonne.cle]" :key="groupe.libelle">
<optgroup v-if="groupe.libelle" :label="groupe.libelle">
<option v-for="valeur in groupe.options" :key="valeur" :value="valeur">{{ valeur }}</option>
</optgroup>
<template v-else>
<option v-for="valeur in groupe.options" :key="valeur" :value="valeur">{{ valeur }}</option>
</template>
</template>
</select>
<select
v-else-if="colonne.filtre === 'echeance'"
v-model="filtres[colonne.cle]"
class="input-cell w-full border-gray-700 text-xs"
>
<option value="">Tous</option>
<option v-for="option in OPTIONS_ECHEANCE" :key="option.valeur" :value="option.valeur">{{ option.libelle }}</option>
</select>
<input
v-else
v-model="filtres[colonne.cle]"
class="input-cell w-full border-gray-700 text-xs"
:class="colonne.align"
:placeholder="colonne.filtre === 'nombre' ? '> 100' : 'filtrer…'"
:title="colonne.filtre === 'nombre' ? 'Comparaison acceptée : > 100, <= 50, != 0, ou une valeur exacte' : ''"
/>
</div>
</th>
<th class="sticky top-0 z-20 bg-gray-800"></th>
</tr>
</thead>
<tbody>
<LigneLogement
v-for="ligne in lignesAffichees"
:key="ligne.id"
:ligne="ligne"
@enregistree="majLigne"
@supprimer="supprimerLot"
@renommer-immeuble="renommerImmeuble"
/>
</tbody>
</table>
</div>
<div v-if="!lignesAffichees.length" class="empty-state">
Aucun lot ne correspond aux filtres.
</div>
</div>
<p class="form-hint">
Chaque cellule s'enregistre dès qu'elle est quittée. L'échéance du DPE et l'écart
de surface sont calculés, jamais saisis.
</p>
</template>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import LigneLogement from '../components/referentiel/LigneLogement.vue'
import {
COLONNES,
OPTIONS_ECHEANCE,
comparer,
groupesFiltre,
passeFiltre
} from '../components/referentiel/colonnes'
const API = import.meta.env.VITE_API_URL || ''
const lots = ref([])
const chargement = ref(true)
const filtres = ref(Object.fromEntries(COLONNES.map((colonne) => [colonne.cle, ''])))
const triCle = ref('numero')
const triSens = ref('asc')
const nbFiches = computed(() => lots.value.filter((ligne) => ligne.caracteristiques).length)
const nbEcartsType = computed(() => lots.value.filter((ligne) => ligne.type_ecart).length)
const nbEcartsSurface = computed(
() => lots.value.filter((ligne) => (ligne.caracteristiques?.delta_surface ?? 0) !== 0).length
)
// Listes de choix construites sur les lots chargés : proposer un chauffage
// qu'aucun lot n'a donnerait un filtre qui ne renvoie jamais rien.
const choix = computed(() =>
Object.fromEntries(
COLONNES.filter((colonne) => colonne.filtre === 'choix').map((colonne) => [
colonne.cle,
groupesFiltre(lots.value, colonne)
])
)
)
const filtresActifs = computed(() => Object.values(filtres.value).some((valeur) => valeur !== ''))
const lignesAffichees = computed(() => {
const colonneTri = COLONNES.find((colonne) => colonne.cle === triCle.value) ?? COLONNES[0]
return lots.value
.filter((ligne) =>
COLONNES.every((colonne) => passeFiltre(ligne, colonne, filtres.value[colonne.cle]))
)
.sort(comparer(colonneTri, triSens.value))
})
function reinitialiserFiltres() {
filtres.value = Object.fromEntries(COLONNES.map((colonne) => [colonne.cle, '']))
}
function trierPar(cle) {
if (triCle.value === cle) {
triSens.value = triSens.value === 'asc' ? 'desc' : 'asc'
} else {
triCle.value = cle
triSens.value = 'asc'
}
}
async function chargerLots() {
const response = await fetch(`${API}/api/lots/referentiel`)
lots.value = await response.json()
}
// Le nom d'usage appartient à l'immeuble : toutes ses lignes le portent, elles
// changent donc ensemble. Ne rafraîchir que la ligne éditée afficherait deux
// noms pour un même immeuble jusqu'au rechargement.
async function renommerImmeuble({ immeubleId, denomination }) {
const response = await fetch(`${API}/api/immeubles/${immeubleId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ denomination })
})
if (!response.ok) return
const immeuble = await response.json()
lots.value = lots.value.map((ligne) =>
ligne.immeuble_id === immeuble.id
? { ...ligne, immeuble_denomination: immeuble.denomination }
: ligne
)
}
// Le serveur renvoie la ligne telle qu'il vient de l'enregistrer, dérivés
// compris : la remplacer évite de recharger tout le tableau à chaque frappe.
function majLigne(ligne) {
const index = lots.value.findIndex((existante) => existante.id === ligne.id)
if (index !== -1) lots.value[index] = ligne
}
async function supprimerLot(ligne) {
const decrit = ligne.caracteristiques ? ' Sa fiche sera perdue.' : ''
if (!confirm(`Supprimer le lot ${ligne.numero} ?${decrit}`)) return
const response = await fetch(`${API}/api/lots/${ligne.id}`, { method: 'DELETE' })
if (response.ok) {
lots.value = lots.value.filter((existante) => existante.id !== ligne.id)
return
}
// Le refus vient du serveur (lot rattaché à un compte rendu) : le montrer tel
// quel plutôt que de faire disparaître la ligne à tort.
const detail = await response.json().catch(() => null)
alert(detail?.detail || 'Suppression refusée.')
}
onMounted(async () => {
try {
await chargerLots()
} finally {
chargement.value = false
}
})
</script>

View File

@@ -0,0 +1,384 @@
<template>
<div class="page">
<div class="page-content">
<div class="page-header">
<div>
<h1 class="page-title">Lot</h1>
<p class="page-subtitle">
Ce que les comptes rendus portent sur un lot, dans l'ordre où ils l'ont porté.
</p>
</div>
<select v-model="lotChoisi" class="input w-64">
<option v-for="lot in lots" :key="lot.id" :value="lot.id">
{{ lot.numero }} {{ lot.immeuble_denomination || lot.immeuble_code }}
<template v-if="lot.type_effectif"> · {{ lot.type_effectif }}</template>
</option>
</select>
</div>
<div v-if="chargement" class="empty-state">
<span class="spinner w-6 h-6"></span>
</div>
<div v-else-if="!lots.length" class="empty-state">
Aucun lot en base : importez d'abord un compte rendu.
</div>
<template v-else-if="analyse">
<!-- Identité : les cases vides de la fiche restent vides et renvoient
vers la saisie, plutôt que d'être comblées ou masquées. -->
<div class="card">
<div class="card-header">
<h2 class="card-title">
Lot {{ analyse.identite.numero }}
<span class="text-gray-400 font-normal">
· {{ analyse.identite.immeuble_denomination || analyse.identite.immeuble_code }}
</span>
</h2>
<router-link to="/logements" class="btn btn-secondary btn-sm">
Compléter la fiche
</router-link>
</div>
<div class="card-body grid grid-cols-2 md:grid-cols-6 gap-4 text-sm">
<div v-for="champ in CHAMPS_IDENTITE" :key="champ.cle">
<div class="text-xs text-gray-400 uppercase tracking-wide">{{ champ.libelle }}</div>
<div :class="valeurIdentite(champ) === '—' ? 'text-gray-600' : 'text-white'">
{{ valeurIdentite(champ) }}
</div>
</div>
</div>
<div class="card-body pt-0 text-sm">
<span class="text-xs text-gray-400 uppercase tracking-wide">Locataires</span>
<span v-if="!analyse.identite.locataires.length" class="ml-2 text-gray-600"></span>
<span
v-for="nom in analyse.identite.locataires"
:key="nom"
class="badge badge-neutral ml-2"
>{{ nom }}</span>
</div>
</div>
<!-- Chiffres -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<div v-for="tuile in tuiles" :key="tuile.libelle" class="card card-body">
<div class="text-xs text-gray-400 uppercase tracking-wide">{{ tuile.libelle }}</div>
<div class="text-2xl font-bold" :class="tuile.classe">{{ tuile.valeur }}</div>
<div v-if="tuile.detail" class="text-xs text-gray-500 mt-1">{{ tuile.detail }}</div>
</div>
</div>
<!-- Le solde n'est pas un résultat : le dire il s'affiche, pas
dans une documentation que personne n'ouvre. -->
<p class="form-hint">
Le solde ne retient que les dépenses imputées à ce lot par le compte rendu.
Les charges de l'immeuble ({{ formatMontant(analyse.chiffres.depenses_immeuble_non_reparties) }}
sur la période, tous lots confondus) n'y sont pas réparties : aucun document ne dit
quelle part revient à quel lot.
</p>
<!-- Chronologie -->
<div class="card">
<div class="card-header">
<h2 class="card-title">Chronologie · {{ analyse.chronologie.length }} lignes</h2>
<label class="flex items-center gap-2 cursor-pointer">
<input
v-model="depensesSeules"
type="checkbox"
class="w-4 h-4 rounded border-gray-700 accent-blue-500 [color-scheme:dark]"
/>
<span class="text-sm text-gray-300">Dépenses seulement</span>
</label>
</div>
<div class="table-wrap custom-scrollbar">
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Nature</th>
<th>Libellé</th>
<th>Fournisseur</th>
<th class="text-right">Montant</th>
<th class="text-right">Réglé</th>
<th class="text-right">Impayé</th>
</tr>
</thead>
<tbody>
<tr v-for="(ligne, index) in chronologieAffichee" :key="index">
<td class="whitespace-nowrap">{{ formatDate(ligne.date) }}</td>
<td>
<span class="badge" :class="badgeNature(ligne)">{{ libelleNature(ligne) }}</span>
</td>
<td>
{{ ligne.libelle || '—' }}
<div v-if="ligne.categorie" class="text-xs text-gray-500">{{ ligne.categorie }}</div>
<div v-else-if="ligne.periode_debut" class="text-xs text-gray-500">
{{ formatDate(ligne.periode_debut) }} {{ formatDate(ligne.periode_fin) }}
</div>
</td>
<td class="text-gray-400">{{ ligne.fournisseur || '—' }}</td>
<td class="text-right" :class="ligne.nature === 'depense' ? 'text-red-400' : 'text-gray-300'">
{{ formatMontantPrecis(ligne.montant) }}
</td>
<td class="text-right" :class="ligne.regle ? 'text-blue-400' : 'text-gray-500'">
{{ ligne.regle == null ? '—' : formatMontantPrecis(ligne.regle) }}
</td>
<td class="text-right" :class="ligne.impaye ? 'text-amber-400' : 'text-gray-500'">
{{ ligne.impaye == null ? '—' : formatMontantPrecis(ligne.impaye) }}
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="!chronologieAffichee.length" class="empty-state">
Aucune ligne pour ce lot.
</div>
</div>
<!-- Intervenants -->
<div class="card">
<div class="card-header">
<h2 class="card-title">Intervenants · {{ analyse.intervenants.length }}</h2>
</div>
<div class="table-wrap custom-scrollbar">
<table class="table">
<thead>
<tr>
<th>Entreprise</th>
<th class="text-right">Interventions</th>
<th class="text-right">Montant</th>
<th>Dernière</th>
<th></th>
</tr>
</thead>
<tbody>
<template
v-for="intervenant in analyse.intervenants"
:key="intervenant.fournisseur"
>
<tr
class="cursor-pointer"
@click="basculerDetail(intervenant.fournisseur)"
>
<td class="text-white">{{ intervenant.fournisseur }}</td>
<td class="text-right">{{ intervenant.nb_interventions }}</td>
<td class="text-right text-red-400">{{ formatMontantPrecis(intervenant.montant) }}</td>
<td>{{ formatDate(intervenant.derniere_date) }}</td>
<td class="text-right">
<button class="btn btn-sm btn-secondary">
{{ detailsOuverts[intervenant.fournisseur] ? 'Masquer' : 'Détail' }}
</button>
</td>
</tr>
<!-- Le détail reprend les lignes déjà chargées : ce que la
chronologie montre pour cette entreprise, sans nouvel
appel ni second calcul qui pourrait diverger du total. -->
<tr v-if="detailsOuverts[intervenant.fournisseur]" class="hover:bg-transparent">
<td colspan="5" class="bg-gray-950 p-0">
<table class="table">
<tbody>
<tr
v-for="(ligne, index) in interventions(intervenant.fournisseur)"
:key="index"
>
<td class="whitespace-nowrap w-32">{{ formatDate(ligne.date) }}</td>
<td>
{{ ligne.libelle || '—' }}
<div v-if="ligne.categorie" class="text-xs text-gray-500">
{{ ligne.categorie }}
</div>
</td>
<td class="text-right text-red-400 w-32">
{{ formatMontantPrecis(ligne.montant) }}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div v-if="!analyse.intervenants.length" class="empty-state">
Aucune entreprise n'est intervenue sur ce lot.
</div>
</div>
</template>
<!-- Changer de lot recharge la fiche : sans cet état, la page se viderait
en silence entre deux lots. -->
<div v-else class="empty-state">
<span class="spinner w-6 h-6"></span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { formatDate, formatMontant, formatMontantPrecis } from '../utils/format'
const API = import.meta.env.VITE_API_URL || ''
const route = useRoute()
const router = useRouter()
const lots = ref([])
const lotChoisi = ref(null)
const analyse = ref(null)
const chargement = ref(true)
const depensesSeules = ref(false)
const detailsOuverts = reactive({})
const CHAMPS_IDENTITE = [
{ cle: 'type_effectif', libelle: 'Type' },
{ cle: 'surface', libelle: 'Surface', unite: ' m²' },
{ cle: 'etage', libelle: 'Étage' },
{ cle: 'bat', libelle: 'Bâtiment' },
{ cle: 'chauffage', libelle: 'Chauffage' },
{ cle: 'dpe_classe', libelle: 'DPE' }
]
function valeurIdentite(champ) {
const valeur = analyse.value?.identite?.[champ.cle]
if (valeur == null || valeur === '') return '—'
return `${valeur}${champ.unite || ''}`
}
// Palette reprise des autres tuiles de l'application : le facturé au vert des
// recettes, l'encaissé au bleu des règlements, l'impayé à l'ambre des alertes,
// la dépense au rouge du débit. Chaque valeur porte toujours une couleur : sans
// classe, elle hériterait du noir et disparaîtrait sur le fond sombre.
function couleurTaux(taux) {
if (taux >= 95) return 'text-green-400'
if (taux >= 80) return 'text-yellow-400'
return 'text-red-400'
}
const tuiles = computed(() => {
const c = analyse.value.chiffres
const rienFacture = c.facture <= 0
return [
{
libelle: 'Facturé',
valeur: formatMontant(c.facture),
classe: 'text-green-400',
detail: 'reports exclus'
},
{
libelle: 'Encaissé',
valeur: formatMontant(c.encaisse),
classe: 'text-blue-400'
},
{
libelle: 'Restant dû',
valeur: formatMontant(c.restant_du),
classe: c.restant_du > 0 ? 'text-amber-400' : 'text-gray-400',
detail: 'au dernier compte rendu'
},
{
libelle: 'Recouvrement',
// Sans rien de facturé, un taux de 100 % ferait passer pour sain un lot
// qui ne facture plus rien tout en devant 49 000 € : mieux vaut ne pas
// afficher de taux que d'en afficher un qui rassure à tort.
valeur: rienFacture ? '—' : `${c.taux_recouvrement} %`,
classe: rienFacture ? 'text-gray-500' : couleurTaux(c.taux_recouvrement),
detail: rienFacture ? 'rien de facturé' : null
},
{
libelle: 'Dépenses',
valeur: formatMontant(c.depenses_debit - c.depenses_credit),
classe: 'text-red-400',
detail: `${c.nb_operations} opération${c.nb_operations > 1 ? 's' : ''}`
},
{
libelle: 'Solde',
valeur: formatMontant(c.solde),
classe: c.solde < 0 ? 'text-red-400' : 'text-green-400',
detail: 'hors charges communes'
}
]
})
/** Nature d'une ligne : dépense, report de solde, ou recette de la période. */
function libelleNature(ligne) {
if (ligne.nature === 'depense') return ligne.tag || 'Dépense'
return ligne.est_report ? 'Report' : 'Recette'
}
function badgeNature(ligne) {
if (ligne.nature === 'depense') return 'badge-danger'
return ligne.est_report ? 'badge-neutral' : 'badge-success'
}
// Interventions regroupées par entreprise, dans l'ordre de la chronologie dont
// elles sortent. Le détail d'une entreprise est donc exactement le sous-ensemble
// de lignes que le tableau du dessus montre déjà.
const interventionsParFournisseur = computed(() => {
const groupes = {}
for (const ligne of analyse.value.chronologie) {
if (ligne.nature !== 'depense' || !ligne.fournisseur) continue
;(groupes[ligne.fournisseur] ??= []).push(ligne)
}
return groupes
})
function interventions(fournisseur) {
return interventionsParFournisseur.value[fournisseur] ?? []
}
function basculerDetail(fournisseur) {
detailsOuverts[fournisseur] = !detailsOuverts[fournisseur]
}
const chronologieAffichee = computed(() =>
depensesSeules.value
? analyse.value.chronologie.filter((ligne) => ligne.nature === 'depense')
: analyse.value.chronologie
)
async function chargerLots() {
const response = await fetch(`${API}/api/lots/referentiel`)
lots.value = await response.json()
}
async function chargerAnalyse(lotId) {
analyse.value = null
// Les dépliages appartiennent au lot affiché : gardés, une entreprise
// présente sur deux lots (PPR par exemple) arriverait déjà ouverte.
for (const fournisseur of Object.keys(detailsOuverts)) delete detailsOuverts[fournisseur]
const response = await fetch(`${API}/api/lots/${lotId}/analyse`)
if (response.ok) analyse.value = await response.json()
}
// L'URL porte le lot : une fiche se partage et se recharge sans repasser par
// le sélecteur.
watch(lotChoisi, (lotId) => {
if (lotId == null) return
if (String(lotId) !== route.params.id) router.replace(`/lots/${lotId}`)
chargerAnalyse(lotId)
})
onMounted(async () => {
try {
await chargerLots()
const demande = Number(route.params.id)
lotChoisi.value = lots.value.some((lot) => lot.id === demande)
? demande
: (lots.value[0]?.id ?? null)
} finally {
chargement.value = false
}
})
</script>

View File

@@ -4,6 +4,8 @@ import ExtractPage from './pages/ExtractPage.vue'
import AnalyticsPage from './pages/AnalyticsPage.vue'
import RevenusPage from './pages/RevenusPage.vue'
import DocumentsPage from './pages/DocumentsPage.vue'
import LogementsPage from './pages/LogementsPage.vue'
import LotPage from './pages/LotPage.vue'
import EditDocumentPage from './pages/EditDocumentPage.vue'
import ReExtractionPage from './pages/ReExtractionPage.vue'
import ConfigPage from './pages/ConfigPage.vue'
@@ -55,6 +57,18 @@ const routes = [
name: 'documents',
component: DocumentsPage
},
{
path: '/logements',
name: 'logements',
component: LogementsPage
},
// Le lot vit dans l'URL : une fiche se partage et survit au rechargement.
// `/lots` sans identifiant ouvre le premier lot plutôt qu'une page vide.
{
path: '/lots/:id?',
name: 'lot',
component: LotPage
},
{
path: '/documents/:id/edit',
name: 'edit-document',

View File

@@ -112,6 +112,15 @@
@apply cursor-pointer;
}
/* Champ de saisie dans une cellule de tableau. Bordure invisible au repos :
une grille de 14 colonnes encadrees serait illisible, alors qu'un tableau
de saisie doit d'abord se lire. */
.input-cell {
@apply bg-transparent border border-transparent rounded px-2 py-1 text-sm text-white
placeholder-gray-600 hover:border-gray-700 focus:outline-none focus:border-blue-500
focus:bg-gray-950 transition-colors [color-scheme:dark];
}
.form-hint {
@apply text-xs text-gray-500 mt-1;
}

View File

@@ -0,0 +1,167 @@
// Les filtres de colonnes décident de ce que l'utilisateur voit : un filtre trop
// zélé masque un lot sans rien dire, ce qui se remarque d'autant moins que le
// tableau reste plausible. Ils sont donc testés cas par cas.
import { describe, expect, it } from 'vitest'
import {
COLONNES,
ETAT_INUTILISES,
ETAT_UTILISES,
TYPE_EN_DESACCORD,
VIDE,
comparer,
groupesFiltre,
passeFiltre,
valeursDistinctes,
} from '../src/components/referentiel/colonnes.js'
const colonne = (cle) => COLONNES.find((c) => c.cle === cle)
function lot(numero, caracteristiques = null, extra = {}) {
return {
id: Number(numero),
numero,
immeuble_id: 1,
immeuble_code: '33689020',
immeuble_denomination: 'Servient',
type_effectif: caracteristiques?.type ?? null,
type_ecart: false,
nb_revenus: 0,
nb_depenses: 0,
caracteristiques,
...extra,
}
}
// Un immeuble réduit : un local décrit, un appartement décrit, un lot vierge.
const LOTS = [
lot('01', {
bat: 'Rue',
etage: 'RC',
type: 'Loc. Commercial',
surface: 148,
surface_impots: 146,
delta_surface: -2,
chauffage: 'Électrique',
dpe_classe: 'C',
dpe_echeance: '2031-02-08',
numero_fiscal: '690123456789',
}, { nb_revenus: 5 }),
lot('02', {
bat: 'Cour',
etage: '1',
type: 'Appartement T3',
surface: 62,
surface_impots: 62,
delta_surface: 0,
chauffage: 'Gaz',
dpe_classe: 'F',
dpe_echeance: '2020-01-01',
}, { nb_revenus: 3, type_ecart: true }),
lot('0003'),
]
const filtrer = (cle, saisie) => LOTS.filter((l) => passeFiltre(l, colonne(cle), saisie)).map((l) => l.numero)
describe('filtres de colonne', () => {
it('ne masque rien tant quaucune valeur nest saisie', () => {
expect(filtrer('chauffage', '')).toEqual(['01', '02', '0003'])
})
it('filtre sur une valeur exacte de liste', () => {
expect(filtrer('dpe_classe', 'F')).toEqual(['02'])
})
it('isole les lots dont la colonne est vide', () => {
expect(filtrer('chauffage', VIDE)).toEqual(['0003'])
})
it('isole les types en désaccord avec le PDF', () => {
expect(filtrer('type', TYPE_EN_DESACCORD)).toEqual(['02'])
})
it('compare les nombres au lieu de comparer leur écriture', () => {
// '100' contient '10', mais 62 n'est pas > 100 : une recherche textuelle
// renverrait ici les deux lots décrits.
expect(filtrer('surface', '> 100')).toEqual(['01'])
expect(filtrer('surface', '<=62')).toEqual(['02'])
})
it('sait isoler un écart de surface non nul', () => {
expect(filtrer('delta_surface', '!=0')).toEqual(['01'])
})
it('ne masque rien sur une saisie numérique incomplète', () => {
expect(filtrer('surface', '>')).toEqual(['01', '02', '0003'])
})
it('ignore casse et accents dans les filtres texte', () => {
expect(filtrer('numero_fiscal', '6901')).toEqual(['01'])
})
it('filtre par immeuble sur son nom dusage', () => {
const autre = { ...lot('01'), immeuble_id: 2, immeuble_code: 'M', immeuble_denomination: null }
const parc = [...LOTS, autre]
const servient = parc.filter((l) => passeFiltre(l, colonne('immeuble'), 'Servient'))
// Sans nom d'usage, l'immeuble reste filtrable par son code.
const marietton = parc.filter((l) => passeFiltre(l, colonne('immeuble'), 'M'))
expect(servient).toHaveLength(3)
expect(marietton).toEqual([autre])
})
it('sépare les lots rattachés à un compte rendu des autres', () => {
expect(filtrer('numero', ETAT_INUTILISES)).toEqual(['0003'])
expect(filtrer('numero', ETAT_UTILISES)).toEqual(['01', '02'])
})
it('filtre aussi sur un numéro de porte précis', () => {
expect(filtrer('numero', '02')).toEqual(['02'])
})
it('classe les DPE selon leur péremption', () => {
expect(filtrer('dpe_echeance', 'perime')).toEqual(['02'])
expect(filtrer('dpe_echeance', 'valide')).toEqual(['01'])
expect(filtrer('dpe_echeance', 'absent')).toEqual(['0003'])
})
})
describe('listes de choix', () => {
it('ne propose que les valeurs présentes, le vide en dernier', () => {
// Ordre alphabétique français : « É » se classe avec « E », donc avant « G ».
expect(valeursDistinctes(LOTS, colonne('chauffage'))).toEqual(['Électrique', 'Gaz', VIDE])
})
it('propose le désaccord à part des types, quand il y en a un', () => {
const groupes = groupesFiltre(LOTS, colonne('type'))
expect(groupes[0].options).toEqual([TYPE_EN_DESACCORD])
expect(groupes[1].options).toContain('Appartement T3')
})
it('ne propose le contrôle des types que sil a lieu dêtre', () => {
const sansDesaccord = LOTS.map((l) => ({ ...l, type_ecart: false }))
expect(groupesFiltre(sansDesaccord, colonne('type'))).toHaveLength(1)
})
it('propose les numéros de porte en plus du rattachement', () => {
const [rattachement, portes] = groupesFiltre(LOTS, colonne('numero'))
expect(rattachement.options).toEqual([ETAT_UTILISES, ETAT_INUTILISES])
expect(portes.options).toEqual(['01', '02', '0003'])
})
})
describe('tri', () => {
it('trie les nombres comme des nombres', () => {
const tries = [...LOTS].sort(comparer(colonne('surface'), 'desc')).map((l) => l.numero)
expect(tries).toEqual(['01', '02', '0003'])
})
it('renvoie les cases vides en fin, dans les deux sens', () => {
const asc = [...LOTS].sort(comparer(colonne('surface'), 'asc')).map((l) => l.numero)
expect(asc).toEqual(['02', '01', '0003'])
})
})

View File

@@ -18,6 +18,8 @@ from .routes import (
documents_router,
extraction_router,
ia_router,
lot_analyse_router,
referentiel_router,
revenus_router,
tags_router,
)
@@ -52,6 +54,8 @@ app.include_router(tags_router)
app.include_router(analytics_router)
app.include_router(dashboard_router)
app.include_router(revenus_router)
app.include_router(referentiel_router)
app.include_router(lot_analyse_router)
if FEATURE_IA:
app.include_router(ia_router)
app.include_router(config_router)

View File

@@ -6,6 +6,8 @@ from .dashboard import router as dashboard_router
from .documents import router as documents_router
from .extraction import router as extraction_router
from .ia import router as ia_router
from .lot_analyse import router as lot_analyse_router
from .referentiel import router as referentiel_router
from .revenus import router as revenus_router
from .tags import router as tags_router
@@ -16,6 +18,8 @@ __all__ = [
"analytics_router",
"dashboard_router",
"revenus_router",
"referentiel_router",
"lot_analyse_router",
"ia_router",
"config_router",
]

View File

@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
from ...database import get_session
from ...database.models import Depense, Document, Immeuble, Lot, Tag
from ...services.referentiel import TYPE_LOT_EFFECTIF, joindre_fiche
from ..schemas.models import (
CategorySummary,
DepenseDetail,
@@ -58,6 +59,7 @@ async def list_immeubles(
ImmeubleResponse(
id=row.Immeuble.id,
code=row.Immeuble.code,
denomination=row.Immeuble.denomination,
adresse=row.Immeuble.adresse,
ville=row.Immeuble.ville,
code_postal=row.Immeuble.code_postal,
@@ -77,11 +79,15 @@ async def list_lots(
- **immeuble_id**: ID de l'immeuble pour filtrer (optionnel)
"""
stmt = (
select(Lot, Immeuble.code.label("immeuble_code"))
.join(Immeuble, Lot.immeuble_id == Immeuble.id)
.order_by(Immeuble.code, Lot.numero)
)
stmt = joindre_fiche(
select(
Lot.id,
Lot.numero,
Lot.immeuble_id,
TYPE_LOT_EFFECTIF.label("type"),
Immeuble.code.label("immeuble_code"),
).join(Immeuble, Lot.immeuble_id == Immeuble.id)
).order_by(Immeuble.code, Lot.numero)
if immeuble_id is not None:
stmt = stmt.where(Lot.immeuble_id == immeuble_id)
@@ -91,10 +97,10 @@ async def list_lots(
return [
LotResponse(
id=row.Lot.id,
numero=row.Lot.numero,
type=row.Lot.type,
immeuble_id=row.Lot.immeuble_id,
id=row.id,
numero=row.numero,
type=row.type,
immeuble_id=row.immeuble_id,
immeuble_code=row.immeuble_code,
)
for row in rows

View File

@@ -0,0 +1,363 @@
"""Vue d'un lot : ce que les comptes rendus disent de lui, et rien de plus.
Les autres pages agrègent le parc ; celle-ci descend à un lot et remet ses
lignes bout à bout — loyers facturés, règlements, interventions — dans l'ordre
où les comptes rendus les ont portées.
Deux limites sont assumées plutôt que contournées :
- **les dépenses d'un lot sont celles que le compte rendu lui impute**, pas une
quote-part des charges d'immeuble. Aucune clé de répartition n'existe en base
(ni tantièmes, ni surfaces complètes) : en inventer une donnerait des montants
qu'aucun document ne justifie. Le solde d'un lot n'est donc pas un résultat
net, et `depenses_immeuble_non_reparties` rappelle ce qui reste dehors ;
- **les lignes sont rendues telles qu'extraites**, sans regroupement ni
dédoublonnage. Un acompte et son solde restent deux lignes, parce que le
compte rendu les porte ainsi.
"""
from datetime import date
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from ...database import get_session
from ...database.models import (
Depense,
Document,
Immeuble,
Locataire,
Lot,
Revenu,
Tag,
)
from ...services.referentiel import type_effectif
from ...services.revenus_query import (
TYPE_LIGNE_REPORT,
flux_par,
restant_du_par,
taux_de_recouvrement,
)
router = APIRouter(prefix="/api", tags=["lots"])
class LotIdentite(BaseModel):
"""Qui est ce lot : son rattachement, et ce que sa fiche en dit."""
id: int
numero: str
immeuble_id: int
immeuble_code: str | None
immeuble_denomination: str | None
type_effectif: str | None
# Fiche saisie : `null` tant qu'elle ne l'est pas. La page montre le trou
# plutôt que de le combler.
surface: float | None = None
etage: str | None = None
bat: str | None = None
chauffage: str | None = None
dpe_classe: str | None = None
#: Noms portés par les comptes rendus. Les dates d'entrée et de sortie ne
#: sont pas extraites : l'ordre n'a pas de sens ici.
locataires: list[str] = []
class LotChiffres(BaseModel):
"""Totaux du lot sur tout son historique."""
# Recettes. `facture` exclut les reports de solde, `encaisse` les inclut :
# un règlement de vieille dette est bien un encaissement de la période.
facture: float = 0.0
encaisse: float = 0.0
loyers: float = 0.0
provisions: float = 0.0
#: Dette du lot au dernier compte rendu de son immeuble (photo, non cumulée).
restant_du: float = 0.0
taux_recouvrement: float = 100.0
# Dépenses imputées au lot par le compte rendu.
depenses_debit: float = 0.0
depenses_credit: float = 0.0
depenses_deductible: float = 0.0
depenses_locatif: float = 0.0
nb_operations: int = 0
#: Encaissé moins décaissé sur le lot. Pas un résultat : les charges
#: d'immeuble n'y sont pas (voir le module).
solde: float = 0.0
#: Charges de l'immeuble non imputées à un lot, sur toute la période.
#: Affiché comme contexte, jamais ventilé.
depenses_immeuble_non_reparties: float = 0.0
class LigneChronologie(BaseModel):
"""Une ligne de compte rendu concernant le lot, recette ou dépense."""
date: date
document_id: int
nature: str # "recette" | "depense"
libelle: str
categorie: str | None = None
# Dépense
fournisseur: str | None = None
tag: str | None = None
# Recette
type_ligne: str | None = None
periode_debut: date | None = None
periode_fin: date | None = None
regle: float | None = None
impaye: float | None = None
#: Montant de la ligne : total facturé pour une recette, débit net de crédit
#: pour une dépense.
montant: float = 0.0
#: Report du solde antérieur : déjà compté par un compte rendu précédent, il
#: s'affiche mais n'entre dans aucun cumul.
est_report: bool = False
class Intervenant(BaseModel):
"""Une entreprise intervenue sur le lot."""
fournisseur: str
nb_interventions: int
#: Somme des lignes du fournisseur, crédit déduit — le total que le détail
#: déplié doit retrouver ligne à ligne.
montant: float
derniere_date: date | None = None
class LotAnalyseResponse(BaseModel):
"""Fiche complète d'un lot."""
identite: LotIdentite
chiffres: LotChiffres
chronologie: list[LigneChronologie]
intervenants: list[Intervenant]
def _identite(session: Session, lot: Lot) -> LotIdentite:
"""Identité du lot, fiche saisie comprise quand elle existe."""
immeuble = session.get(Immeuble, lot.immeuble_id)
fiche = lot.caracteristiques
noms = session.execute(
select(Locataire.nom).where(Locataire.lot_id == lot.id).order_by(Locataire.nom)
).scalars()
return LotIdentite(
id=lot.id,
numero=lot.numero,
immeuble_id=lot.immeuble_id,
immeuble_code=immeuble.code if immeuble else None,
immeuble_denomination=immeuble.denomination if immeuble else None,
type_effectif=type_effectif(lot),
surface=fiche.surface if fiche else None,
etage=fiche.etage if fiche else None,
bat=fiche.bat if fiche else None,
chauffage=fiche.chauffage if fiche else None,
dpe_classe=fiche.dpe_classe if fiche else None,
locataires=list(noms),
)
def _chiffres(session: Session, lot: Lot) -> LotChiffres:
"""Totaux du lot, en réutilisant les règles flux/stock des revenus.
Passer par `flux_par` et `restant_du_par` plutôt que de resommer ici : ces
fonctions portent la distinction entre ce qui se cumule et ce qui est une
photo, et la rejouer à la main la ferait diverger de la page Recettes.
"""
flux = flux_par(Revenu.lot_id)
dette = restant_du_par(Revenu.lot_id)
recettes = session.execute(
select(
flux.c.facture,
flux.c.encaisse,
flux.c.facture_regle,
flux.c.loyers,
flux.c.provisions,
).where(flux.c.cle == lot.id)
).first()
restant_du = session.execute(
select(dette.c.restant_du).where(dette.c.cle == lot.id)
).scalar()
depenses = session.execute(
select(
func.coalesce(func.sum(Depense.debit), 0.0),
func.coalesce(func.sum(Depense.credit), 0.0),
func.coalesce(func.sum(Depense.deductible), 0.0),
func.coalesce(func.sum(Depense.locatif), 0.0),
func.count(Depense.id),
).where(Depense.lot_id == lot.id)
).one()
debit, credit, deductible, locatif, nb_operations = depenses
# Charges de l'immeuble laissées hors des lots, pour situer le solde.
commun = session.execute(
select(func.coalesce(func.sum(Depense.debit), 0.0)).where(
Depense.immeuble_id == lot.immeuble_id, Depense.lot_id.is_(None)
)
).scalar_one()
encaisse = (recettes.encaisse if recettes else 0.0) or 0.0
return LotChiffres(
facture=(recettes.facture if recettes else 0.0) or 0.0,
encaisse=encaisse,
loyers=(recettes.loyers if recettes else 0.0) or 0.0,
provisions=(recettes.provisions if recettes else 0.0) or 0.0,
restant_du=restant_du or 0.0,
taux_recouvrement=taux_de_recouvrement(
recettes.facture if recettes else None,
recettes.facture_regle if recettes else None,
),
depenses_debit=debit,
depenses_credit=credit,
depenses_deductible=deductible,
depenses_locatif=locatif,
nb_operations=nb_operations,
solde=round(encaisse - debit + credit, 2),
depenses_immeuble_non_reparties=commun,
)
def _libelle_recette(revenu: Revenu) -> str:
"""Ce que la ligne de recette dit d'elle-même.
Le libellé d'une ligne « divers » porte le motif réel (régularisation,
ordures ménagères…) : le préférer au type générique, qui n'apprendrait rien.
"""
return revenu.divers_libelle or revenu.type_ligne
def _chronologie(session: Session, lot: Lot) -> list[LigneChronologie]:
"""Recettes et dépenses du lot, remises dans l'ordre des comptes rendus."""
lignes: list[LigneChronologie] = []
revenus = session.execute(
select(Revenu, Document.date)
.join(Document, Revenu.document_id == Document.id)
.where(Revenu.lot_id == lot.id)
).all()
for revenu, date_document in revenus:
lignes.append(
LigneChronologie(
date=date_document,
document_id=revenu.document_id,
nature="recette",
libelle=_libelle_recette(revenu),
type_ligne=revenu.type_ligne,
periode_debut=revenu.periode_debut,
periode_fin=revenu.periode_fin,
montant=revenu.total or 0.0,
regle=revenu.regles or 0.0,
impaye=revenu.impayes or 0.0,
est_report=revenu.type_ligne == TYPE_LIGNE_REPORT,
)
)
depenses = session.execute(
select(Depense, Document.date, Tag.nom)
.join(Document, Depense.document_id == Document.id)
.outerjoin(Tag, Tag.id == Depense.tag_id)
.where(Depense.lot_id == lot.id)
).all()
for depense, date_document, tag in depenses:
lignes.append(
LigneChronologie(
date=date_document,
document_id=depense.document_id,
nature="depense",
libelle=depense.description or depense.sous_categorie or "",
categorie=depense.sous_categorie,
fournisseur=depense.fournisseur,
tag=tag,
montant=round((depense.debit or 0.0) - (depense.credit or 0.0), 2),
)
)
# Tri en Python : les deux sources sont déjà chargées et un lot en porte
# quelques dizaines de lignes. Les recettes d'abord à date égale, parce
# qu'un compte rendu présente la situation locative avant les opérations.
lignes.sort(key=lambda ligne: (ligne.date, ligne.nature != "recette"))
return lignes
def _intervenants(session: Session, lot: Lot) -> list[Intervenant]:
"""Entreprises intervenues sur le lot, la plus engagée en tête.
Simple regroupement sur le fournisseur porté par chaque opération : rien
n'est rapproché ni déduit au-delà de ce que la colonne contient.
Le montant est net du crédit, comme celui de chaque ligne de la
chronologie : déplier une entreprise doit retrouver son total, pas un autre
chiffre. Un avoir (« Remise état des lieux ») rend d'ailleurs ce montant
négatif, ce qui est bien ce que le compte rendu porte.
"""
montant_net = func.coalesce(
func.sum(
func.coalesce(Depense.debit, 0.0) - func.coalesce(Depense.credit, 0.0)
),
0.0,
)
rows = session.execute(
select(
Depense.fournisseur,
func.count(Depense.id),
montant_net,
func.max(Document.date),
)
.join(Document, Depense.document_id == Document.id)
.where(Depense.lot_id == lot.id, Depense.fournisseur.is_not(None))
.group_by(Depense.fournisseur)
.order_by(montant_net.desc())
).all()
return [
Intervenant(
fournisseur=fournisseur,
nb_interventions=nb,
montant=montant,
derniere_date=derniere_date,
)
for fournisseur, nb, montant, derniere_date in rows
]
@router.get("/lots/{lot_id}/analyse", response_model=LotAnalyseResponse)
async def analyser_lot(
lot_id: int,
session: Session = Depends(get_session),
) -> LotAnalyseResponse:
"""Tout ce que les comptes rendus portent sur un lot.
- **lot_id**: ID du lot
Sans borne de période : l'historique est court et le montrer entier évite
qu'un filtre par défaut cache des opérations sans le dire.
"""
lot = session.get(Lot, lot_id)
if lot is None:
raise HTTPException(status_code=404, detail="Lot introuvable.")
return LotAnalyseResponse(
identite=_identite(session, lot),
chiffres=_chiffres(session, lot),
chronologie=_chronologie(session, lot),
intervenants=_intervenants(session, lot),
)

View File

@@ -0,0 +1,248 @@
"""Référentiel des logements — caractéristiques saisies à la main.
Ces données ne viennent pas des PDF : elles décrivent le bien (surface, étage,
DPE, chauffage) et donnent au référentiel une source de vérité indépendante de
l'extraction.
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from ...database import get_session
from ...database.models import Depense, Immeuble, Lot, LotCaracteristiques, Revenu
from ...services.referentiel import type_effectif
from ...utils.logements import delta_surface, dpe_echeance, type_en_ecart
from ..schemas.models import (
CaracteristiquesBody,
CaracteristiquesResponse,
ImmeubleBody,
ImmeubleResponse,
LotReferentielResponse,
)
router = APIRouter(prefix="/api", tags=["referentiel"])
#: Champs de la fiche, dans l'ordre de saisie. Sert à recopier le corps de la
#: requête vers le modèle sans énumérer les champs à chaque fois.
CHAMPS_FICHE = tuple(CaracteristiquesBody.model_fields)
def _caracteristiques_response(
fiche: LotCaracteristiques | None,
) -> CaracteristiquesResponse | None:
"""Fiche augmentée de ses valeurs dérivées, ou None si elle n'existe pas."""
if fiche is None:
return None
return CaracteristiquesResponse(
**{champ: getattr(fiche, champ) for champ in CHAMPS_FICHE},
dpe_echeance=dpe_echeance(fiche.dpe_date_realisation),
delta_surface=delta_surface(fiche.surface, fiche.surface_impots),
updated_at=fiche.updated_at,
)
def _lot_response(
lot: Lot,
immeuble_code: str | None,
nb_revenus: int,
nb_depenses: int,
immeuble_denomination: str | None = None,
) -> LotReferentielResponse:
"""Assemble la ligne de référentiel d'un lot."""
fiche = lot.caracteristiques
return LotReferentielResponse(
id=lot.id,
numero=lot.numero,
immeuble_id=lot.immeuble_id,
immeuble_code=immeuble_code,
immeuble_denomination=immeuble_denomination,
type_extrait=lot.type,
type_effectif=type_effectif(lot),
type_ecart=type_en_ecart(lot.type, fiche.type if fiche else None),
caracteristiques=_caracteristiques_response(fiche),
nb_revenus=nb_revenus,
nb_depenses=nb_depenses,
)
def _requete_lignes():
"""Requête des lots avec le nombre de revenus et de dépenses rattachés.
Partagée par la liste et l'enregistrement : une ligne renvoyée après
écriture doit être comptée comme celle du tableau, sinon un lot bien occupé
se met à passer pour inutilisé dès qu'on le décrit.
Sous-requêtes corrélées plutôt que des jointures : compter revenus et
dépenses dans la même jointure multiplierait les lignes entre elles.
"""
nb_revenus = (
select(func.count(Revenu.id))
.where(Revenu.lot_id == Lot.id)
.correlate(Lot)
.scalar_subquery()
)
nb_depenses = (
select(func.count(Depense.id))
.where(Depense.lot_id == Lot.id)
.correlate(Lot)
.scalar_subquery()
)
return select(Lot, nb_revenus.label("nb_revenus"), nb_depenses.label("nb_depenses"))
@router.get("/lots/referentiel", response_model=list[LotReferentielResponse])
async def list_lots_referentiel(
immeuble_id: int | None = Query(None, description="Filtrer par immeuble"),
session: Session = Depends(get_session),
) -> list[LotReferentielResponse]:
"""Liste les lots avec leur fiche de caractéristiques.
- **immeuble_id**: ID de l'immeuble pour restreindre la liste (optionnel)
Tout le parc par défaut : le tableau de saisie porte une colonne immeuble,
et comparer deux immeubles au m² n'a de sens que s'ils s'affichent ensemble.
Les lots sans fiche sont renvoyés avec `caracteristiques` à `null` : le
tableau doit montrer les lignes vides autant que les remplies.
"""
if immeuble_id is not None and session.get(Immeuble, immeuble_id) is None:
raise HTTPException(status_code=404, detail="Immeuble introuvable.")
stmt = (
_requete_lignes()
.add_columns(Immeuble.code, Immeuble.denomination)
.join(Immeuble, Lot.immeuble_id == Immeuble.id)
.order_by(Immeuble.code, Lot.numero)
)
if immeuble_id is not None:
stmt = stmt.where(Lot.immeuble_id == immeuble_id)
return [
_lot_response(
row.Lot,
row.code,
immeuble_denomination=row.denomination,
nb_revenus=row.nb_revenus or 0,
nb_depenses=row.nb_depenses or 0,
)
for row in session.execute(stmt).all()
]
@router.put("/lots/{lot_id}/caracteristiques", response_model=LotReferentielResponse)
async def upsert_caracteristiques(
lot_id: int,
body: CaracteristiquesBody,
session: Session = Depends(get_session),
) -> LotReferentielResponse:
"""Enregistre la fiche d'un lot, en la créant si elle n'existe pas encore.
- **lot_id**: ID du lot
Le corps décrit la fiche complète : un champ omis ou vidé efface la valeur
précédente, pour qu'une correction dans le tableau ne laisse pas de reste.
"""
lot = session.get(Lot, lot_id)
if lot is None:
raise HTTPException(status_code=404, detail="Lot introuvable.")
fiche = lot.caracteristiques
if fiche is None:
fiche = LotCaracteristiques(lot_id=lot.id)
session.add(fiche)
lot.caracteristiques = fiche
for champ in CHAMPS_FICHE:
setattr(fiche, champ, getattr(body, champ))
session.commit()
row = session.execute(_requete_lignes().where(Lot.id == lot_id)).one()
immeuble = session.get(Immeuble, lot.immeuble_id)
return _lot_response(
row.Lot,
immeuble.code if immeuble else None,
immeuble_denomination=immeuble.denomination if immeuble else None,
nb_revenus=row.nb_revenus or 0,
nb_depenses=row.nb_depenses or 0,
)
@router.delete("/lots/{lot_id}", status_code=204)
async def supprimer_lot(
lot_id: int,
session: Session = Depends(get_session),
) -> None:
"""Supprime un lot que rien ne rattache à un document.
- **lot_id**: ID du lot
Sert à nettoyer les lots laissés par un ancien format de numérotation, qui
encombrent le tableau de saisie sans rien décrire. Un lot qui porte des
revenus ou des dépenses est refusé : le supprimer emporterait des montants
du compte rendu, et un compte faux est pire qu'une ligne en trop.
"""
lot = session.get(Lot, lot_id)
if lot is None:
raise HTTPException(status_code=404, detail="Lot introuvable.")
row = session.execute(_requete_lignes().where(Lot.id == lot_id)).one()
if row.nb_revenus or row.nb_depenses:
raise HTTPException(
status_code=409,
detail=(
f"Le lot {lot.numero} porte {row.nb_revenus} revenu(s) et "
f"{row.nb_depenses} dépense(s) : il ne peut pas être supprimé."
),
)
# Les locataires du lot partent avec lui (cascade). Un locataire qui aurait
# encore des revenus les rattacherait au lot, deja refuse ci-dessus.
session.delete(lot)
session.commit()
@router.put("/immeubles/{immeuble_id}", response_model=ImmeubleResponse)
async def renommer_immeuble(
immeuble_id: int,
body: ImmeubleBody,
session: Session = Depends(get_session),
) -> ImmeubleResponse:
"""Donne à l'immeuble son nom d'usage.
- **immeuble_id**: ID de l'immeuble
Le code de gestion ("33689020") vient des PDF et ne se remplace pas ; la
dénomination ("Servient") s'affiche à sa place partout où l'immeuble est
cité.
"""
immeuble = session.get(Immeuble, immeuble_id)
if immeuble is None:
raise HTTPException(status_code=404, detail="Immeuble introuvable.")
immeuble.denomination = body.denomination
session.commit()
# Compteurs recalcules plutot que laisses a zero : la reponse remplace
# l'immeuble dans les listes du client, qui le croirait vide de lots.
nb_lots = session.execute(
select(func.count(Lot.id)).where(Lot.immeuble_id == immeuble.id)
).scalar_one()
nb_depenses = session.execute(
select(func.count(Depense.id)).where(Depense.immeuble_id == immeuble.id)
).scalar_one()
return ImmeubleResponse(
id=immeuble.id,
code=immeuble.code,
denomination=immeuble.denomination,
adresse=immeuble.adresse,
ville=immeuble.ville,
code_postal=immeuble.code_postal,
nb_lots=nb_lots,
nb_depenses=nb_depenses,
)

View File

@@ -15,6 +15,7 @@ from ...database.models import (
Lot,
Revenu,
)
from ...services.referentiel import TYPE_LOT_EFFECTIF, joindre_fiche
from ...services.revenus_query import (
est_flux,
flux_par,
@@ -390,26 +391,28 @@ async def get_revenus_by_lot(
flux = flux_par(Revenu.lot_id)
dette = restant_du_par(Revenu.lot_id)
stmt = (
select(
Lot.id,
Lot.numero,
Lot.type,
Immeuble.code,
func.max(Locataire.nom).label("locataire_nom"),
flux.c.facture,
flux.c.encaisse,
dette.c.restant_du,
func.max(Document.date).label("derniere_date"),
joindre_fiche(
select(
Lot.id,
Lot.numero,
TYPE_LOT_EFFECTIF.label("type"),
Immeuble.code,
func.max(Locataire.nom).label("locataire_nom"),
flux.c.facture,
flux.c.encaisse,
dette.c.restant_du,
func.max(Document.date).label("derniere_date"),
)
.join(Immeuble, Lot.immeuble_id == Immeuble.id)
.join(Revenu, Revenu.lot_id == Lot.id)
.join(Document, Revenu.document_id == Document.id)
.outerjoin(
Locataire,
and_(Locataire.lot_id == Lot.id, Locataire.date_fin.is_(None)),
)
.outerjoin(flux, flux.c.cle == Lot.id)
.outerjoin(dette, dette.c.cle == Lot.id)
)
.join(Immeuble, Lot.immeuble_id == Immeuble.id)
.join(Revenu, Revenu.lot_id == Lot.id)
.join(Document, Revenu.document_id == Document.id)
.outerjoin(
Locataire,
and_(Locataire.lot_id == Lot.id, Locataire.date_fin.is_(None)),
)
.outerjoin(flux, flux.c.cle == Lot.id)
.outerjoin(dette, dette.c.cle == Lot.id)
.group_by(Lot.id)
.order_by(desc("restant_du"), desc("facture"))
.limit(limit)

View File

@@ -1,10 +1,14 @@
"""Pydantic schemas for API request/response models."""
from .models import (
CaracteristiquesBody,
CaracteristiquesResponse,
DepenseDetail,
DepensesSummary,
DocumentSummary,
ImmeubleBody,
ImmeubleResponse,
LotReferentielResponse,
LotResponse,
PredictTagsRequest,
SaveRequest,
@@ -18,6 +22,10 @@ __all__ = [
"DocumentSummary",
"DepenseDetail",
"DepensesSummary",
"ImmeubleBody",
"ImmeubleResponse",
"LotResponse",
"CaracteristiquesBody",
"CaracteristiquesResponse",
"LotReferentielResponse",
]

View File

@@ -1,9 +1,11 @@
"""Pydantic models for API requests and responses."""
from datetime import date
from datetime import date, datetime
from typing import Any
from pydantic import BaseModel
from pydantic import BaseModel, Field, field_validator
from ...utils.logements import DPE_CLASSES
# ============================================================
# Requests
@@ -70,6 +72,7 @@ class ImmeubleResponse(BaseModel):
id: int
code: str
denomination: str | None = None
adresse: str | None
ville: str | None
code_postal: str | None
@@ -180,3 +183,98 @@ class DepensesSummary(BaseModel):
by_tag: list[TagSummary]
by_month: list[MonthlySummary]
by_fournisseur: list[FournisseurSummary]
# ============================================================
# Référentiel des logements
# ============================================================
class ImmeubleBody(BaseModel):
"""Ce qui se saisit sur un immeuble : son nom d'usage."""
denomination: str | None = None
@field_validator("denomination")
@classmethod
def _texte_vide_vaut_absent(cls, value: str | None) -> str | None:
"""Effacer le nom doit rendre l'immeuble à son code, pas le nommer « »."""
if value is None:
return None
return value.strip() or None
class CaracteristiquesBody(BaseModel):
"""Caractéristiques d'un logement telles que saisies.
Tous les champs sont optionnels : la fiche se remplit progressivement, et
une fiche partielle vaut mieux qu'une fiche refusée.
"""
bat: str | None = None
etage: str | None = None
type: str | None = None
surface: float | None = Field(None, ge=0)
surface_date_diag: date | None = None
chauffage: str | None = None
dpe_classe: str | None = None
dpe_date_realisation: date | None = None
numero_fiscal: str | None = None
surface_impots: float | None = Field(None, ge=0)
note_impots: str | None = None
@field_validator("dpe_classe")
@classmethod
def _classe_connue(cls, value: str | None) -> str | None:
"""Refuse une classe hors A-G, faute de quoi les KPI DPE mentiraient."""
if value is None or value == "":
return None
classe = value.strip().upper()
if classe not in DPE_CLASSES:
raise ValueError(f"Classe DPE inconnue : {value} (attendu A-G)")
return classe
@field_validator(
"bat", "etage", "type", "chauffage", "numero_fiscal", "note_impots"
)
@classmethod
def _texte_vide_vaut_absent(cls, value: str | None) -> str | None:
"""Un champ vide dans le tableau doit effacer la valeur, pas la figer."""
if value is None:
return None
value = value.strip()
return value or None
class CaracteristiquesResponse(CaracteristiquesBody):
"""Caractéristiques saisies, augmentées de leurs valeurs dérivées."""
#: Péremption du DPE, déduite de la date de réalisation (+10 ans).
dpe_echeance: date | None = None
#: Surface impôts moins surface mesurée ; None si une des deux manque.
delta_surface: float | None = None
updated_at: datetime | None = None
class LotReferentielResponse(BaseModel):
"""Un lot et sa fiche, tels qu'affichés dans le tableau du référentiel."""
id: int
numero: str
immeuble_id: int
immeuble_code: str | None
#: Nom d'usage de l'immeuble ; le tableau retombe sur le code s'il manque.
immeuble_denomination: str | None = None
#: Type de lot vu par l'extraction PDF, conservé tel quel.
type_extrait: str | None
#: Type retenu : celui de la fiche s'il existe, sinon celui du PDF.
type_effectif: str | None
#: Vrai quand les deux sources se contredisent (comparaison normalisée).
type_ecart: bool = False
caracteristiques: CaracteristiquesResponse | None = None
#: Rattachements existants : un lot qui en a n'est pas supprimable.
nb_revenus: int = 0
nb_depenses: int = 0

View File

@@ -2,7 +2,17 @@
from . import storage
from .connection import get_engine, get_session, get_session_factory, init_db
from .models import Base, Depense, Document, Immeuble, Locataire, Lot, Revenu, Setting
from .models import (
Base,
Depense,
Document,
Immeuble,
Locataire,
Lot,
LotCaracteristiques,
Revenu,
Setting,
)
from .service import DatabaseService, DuplicateDocumentError
__all__ = [
@@ -14,6 +24,7 @@ __all__ = [
"Document",
"Immeuble",
"Lot",
"LotCaracteristiques",
"Locataire",
"Revenu",
"Depense",

View File

@@ -96,14 +96,19 @@ def init_db(db_path: Path | None = None) -> Path:
#: Colonnes ajoutees apres coup, par table : nom -> (definition SQL, valeur de
#: rattrapage pour les lignes existantes). `create_all` ne modifie pas une table
#: deja presente, et le projet n'utilise pas d'outil de migration : sans ce
#: rattrapage, une base installee cesserait de fonctionner apres mise a jour.
#: rattrapage pour les lignes existantes, ou None quand il n'y a rien de vrai a
#: y mettre). `create_all` ne modifie pas une table deja presente, et le projet
#: n'utilise pas d'outil de migration : sans ce rattrapage, une base installee
#: cesserait de fonctionner apres mise a jour.
_ADDED_COLUMNS = {
"documents": {
# Les documents deja en base ont ete extraits lors de leur import.
"extracted_at": ("DATETIME", "created_at"),
},
"immeubles": {
# Le nom d'usage se saisit : le deduire du code inventerait un nom.
"denomination": ("VARCHAR(100)", None),
},
}
@@ -119,8 +124,11 @@ def _apply_schema_updates(engine):
for name, (definition, backfill) in columns.items():
if name in existing:
continue
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {name} {definition}"))
conn.execute(text(f"UPDATE {table} SET {name} = {backfill}"))
conn.execute(
text(f"ALTER TABLE {table} ADD COLUMN {name} {definition}")
)
if backfill is not None:
conn.execute(text(f"UPDATE {table} SET {name} = {backfill}"))
def _seed_tags_if_empty(engine):

View File

@@ -65,6 +65,9 @@ class Immeuble(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
code = Column(String(20), unique=True, nullable=False, index=True)
#: Nom d'usage ("Servient"), saisi : les PDF ne donnent qu'un code de
#: gestion, illisible partout où l'immeuble est cité.
denomination = Column(String(100), nullable=True)
adresse = Column(String(255), nullable=True)
ville = Column(String(100), nullable=True)
code_postal = Column(String(10), nullable=True)
@@ -103,11 +106,58 @@ class Lot(Base):
)
revenus = relationship("Revenu", back_populates="lot")
depenses = relationship("Depense", back_populates="lot")
caracteristiques = relationship(
"LotCaracteristiques",
back_populates="lot",
uselist=False,
cascade="all, delete-orphan",
)
def __repr__(self) -> str:
return f"<Lot(numero={self.numero}, type={self.type})>"
class LotCaracteristiques(Base):
"""Caractéristiques d'un logement, saisies à la main.
Table séparée de `lots` à dessein : `lots` porte ce que l'extraction PDF
sait d'un lot, celle-ci ce que le propriétaire en sait. Une ré-extraction ne
peut donc structurellement pas écraser la saisie, et l'écart entre les deux
sources (le type de lot) reste calculable au lieu d'être perdu.
"""
__tablename__ = "lot_caracteristiques"
id = Column(Integer, primary_key=True, autoincrement=True)
lot_id = Column(Integer, ForeignKey("lots.id"), nullable=False, unique=True)
# Description physique
bat = Column(String(50), nullable=True) # "Rue", "Cour"
etage = Column(String(20), nullable=True) # "RC", "1", "SS", "Combles"
type = Column(String(100), nullable=True) # prioritaire sur Lot.type
surface = Column(Float, nullable=True) # m², mesure Oralia
surface_date_diag = Column(Date, nullable=True)
chauffage = Column(String(100), nullable=True)
# Réglementaire
dpe_classe = Column(String(1), nullable=True) # A..G
dpe_date_realisation = Column(Date, nullable=True) # échéance = +10 ans
# Rapprochement avec les impôts
numero_fiscal = Column(String(50), nullable=True) # clé de recherche impots.gouv
surface_impots = Column(Float, nullable=True) # m² déclarés
note_impots = Column(Text, nullable=True)
created_at = Column(DateTime, default=_utcnow)
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
# Relations
lot = relationship("Lot", back_populates="caracteristiques")
def __repr__(self) -> str:
return f"<LotCaracteristiques(lot_id={self.lot_id}, surface={self.surface})>"
class Locataire(Base):
"""Table des locataires avec historique."""

View File

@@ -0,0 +1,34 @@
"""Arbitrage entre ce que dit le PDF d'un lot et ce que sa fiche en dit.
Le type d'un lot est connu de deux sources : l'extraction PDF, qui le remplit
parfois mal et souvent pas du tout, et la fiche saisie à la main, qui fait foi.
La règle est donc « la fiche gagne, le PDF comble les trous » — et elle vit ici,
en un seul endroit, sous ses deux formes : une expression SQL pour les
agrégats, une fonction Python pour l'ORM. Les faire diverger reviendrait à
afficher deux types différents pour un même lot selon la page consultée.
Le désaccord entre les deux sources n'est jamais résolu en silence : il reste
visible via `type_en_ecart` (voir `utils.logements`).
"""
from sqlalchemy import Select, func
from ..database.models import Lot, LotCaracteristiques
#: Type de lot retenu, en SQL. Requiert la jointure de `joindre_fiche`.
TYPE_LOT_EFFECTIF = func.coalesce(LotCaracteristiques.type, Lot.type)
def joindre_fiche(stmt: Select) -> Select:
"""Ajoute à une requête sur `Lot` la jointure vers sa fiche.
La relation est 1↔1 : la jointure ne multiplie aucune ligne, elle peut donc
s'ajouter à une requête agrégée sans fausser les totaux.
"""
return stmt.outerjoin(LotCaracteristiques, LotCaracteristiques.lot_id == Lot.id)
def type_effectif(lot: Lot) -> str | None:
"""Type de lot retenu, depuis un objet chargé par l'ORM."""
fiche = lot.caracteristiques
return (fiche.type if fiche else None) or lot.type

View File

@@ -0,0 +1,88 @@
"""Valeurs dérivées des caractéristiques d'un logement.
Ni l'échéance du DPE ni l'écart de surface ne sont stockés : ce sont des
conséquences de valeurs saisies, et une colonne dérivée finit toujours par
mentir après une correction. Elles se calculent ici, en un seul endroit, pour
que l'API et les futurs KPI donnent le même résultat.
"""
import re
from datetime import date
#: Durée de validité d'un DPE réalisé après la réforme de 2021 (10 ans).
DPE_VALIDITE_ANNEES = 10
#: Classes possibles d'un DPE, de la plus performante à la moins performante.
DPE_CLASSES = ("A", "B", "C", "D", "E", "F", "G")
def dpe_echeance(date_realisation: date | None) -> date | None:
"""Date de péremption d'un DPE réalisé à `date_realisation`.
Args:
date_realisation: Date de réalisation du diagnostic, ou None
Returns:
Date de fin de validité, ou None si la date de réalisation manque
"""
if date_realisation is None:
return None
annee = date_realisation.year + DPE_VALIDITE_ANNEES
try:
return date_realisation.replace(year=annee)
except ValueError:
# 29 février d'une année bissextile vers une année qui ne l'est pas.
return date(annee, 2, 28)
def delta_surface(surface: float | None, surface_impots: float | None) -> float | None:
"""Écart entre la surface déclarée aux impôts et la surface mesurée.
Signe positif : les impôts retiennent plus de surface que la mesure Oralia.
Args:
surface: Surface mesurée (m²)
surface_impots: Surface déclarée aux impôts (m²)
Returns:
L'écart en m², ou None si une des deux surfaces manque
"""
if surface is None or surface_impots is None:
return None
return round(surface_impots - surface, 2)
def _type_comparable(valeur: str | None) -> str:
"""Écriture normalisée d'un type de lot, pour comparaison seulement."""
if not valeur:
return ""
return re.sub(r"[^a-z0-9]+", " ", valeur.lower()).strip()
def type_en_ecart(type_extrait: str | None, type_saisi: str | None) -> bool:
"""Le PDF et la fiche annoncent-ils deux types de lot différents ?
La comparaison ignore casse et ponctuation : le PDF écrit
"Loc. Commercial" là où une saisie donne "Loc, Commercial" ou
"loc commercial". Signaler ces trois-là comme un désaccord noierait le seul
écart qui compte, celui où les deux sources ne parlent pas du même logement.
Un type absent d'un côté n'est pas un écart : c'est une information qui
manque, pas une contradiction.
Args:
type_extrait: Type de lot vu par l'extraction PDF
type_saisi: Type de lot saisi dans la fiche
Returns:
True si les deux valeurs sont renseignées et se contredisent
"""
extrait = _type_comparable(type_extrait)
saisi = _type_comparable(type_saisi)
if not extrait or not saisi:
return False
return extrait != saisi

57
tests/test_logements.py Normal file
View File

@@ -0,0 +1,57 @@
"""Tests des valeurs derivees des caracteristiques d'un logement.
Echeance de DPE et ecart de surface ne sont pas stockes : ils se recalculent a
chaque lecture. Ces tests figent les regles de calcul, seul endroit ou une
erreur passerait inapercue puisqu'aucune donnee saisie ne la contredirait.
"""
from datetime import date
from plesna_gerance.utils.logements import (
delta_surface,
dpe_echeance,
type_en_ecart,
)
def test_un_dpe_vaut_dix_ans():
assert dpe_echeance(date(2021, 2, 8)) == date(2031, 2, 8)
def test_un_dpe_du_29_fevrier_expire_le_28():
"""2020 est bissextile, 2030 non : la date doit reculer, pas exploser."""
assert dpe_echeance(date(2020, 2, 29)) == date(2030, 2, 28)
def test_sans_date_de_dpe_pas_d_echeance():
assert dpe_echeance(None) is None
def test_ecart_de_surface_positif_quand_les_impots_en_retiennent_plus():
assert delta_surface(100.0, 119.0) == 19.0
def test_ecart_de_surface_negatif_quand_la_mesure_est_plus_grande():
assert delta_surface(148.0, 146.0) == -2.0
def test_une_seule_surface_ne_donne_aucun_ecart():
"""Un ecart de 0 affirmerait que les deux sources concordent."""
assert delta_surface(148.0, None) is None
assert delta_surface(None, 146.0) is None
def test_meme_type_ecrit_autrement_n_est_pas_un_ecart():
assert not type_en_ecart("Loc. Commercial", "Loc, Commercial")
assert not type_en_ecart("Appartement T3", "appartement t3")
def test_deux_types_differents_sont_un_ecart():
assert type_en_ecart("Appartement T3", "Appartement T2")
def test_un_type_absent_n_est_pas_un_ecart():
"""Le PDF laisse souvent le type vide : ce n'est pas une contradiction."""
assert not type_en_ecart(None, "Appartement T3")
assert not type_en_ecart("Appartement T3", None)
assert not type_en_ecart("", "")

225
tests/test_lot_analyse.py Normal file
View File

@@ -0,0 +1,225 @@
"""Tests de la fiche d'un lot.
Cette page restitue un lot tel que les comptes rendus le portent. Les tests
protègent donc ce qui la rendrait fausse ou trompeuse : cumuler un report de
solde, ventiler des charges d'immeuble qu'aucun document n'attribue, ou
regrouper des lignes que le compte rendu a émises séparément.
"""
import pytest
from plesna_gerance.database.models import Immeuble, Lot
from plesna_gerance.database.service import DatabaseService
@pytest.fixture
def donnees(db_session, sample_data):
"""Deux comptes rendus sur un lot : un loyer réglé, puis un report impayé.
Le second document facture un loyer resté impayé et reporte le solde du
premier — la configuration exacte où un cumul naïf compterait deux fois la
même dette.
"""
service = DatabaseService(db_session)
service.save_document(data=sample_data)
suivant = {
**sample_data,
"metadata": {
**sample_data["metadata"],
"document": {
"reference": "REF002",
"date": "2024-02-15",
"type": "COMPTE RENDU DE GESTION",
},
},
"situation_locataires": [
{
"lot": {"numero": "01", "type": "Appartement"},
"locataire": {"nom": "DUPONT"},
"lignes": [
{
"type": "solde_anterieur",
"total": 300.0,
"regles": 0.0,
"impayes": 300.0,
},
{
"type": "loyer",
"periode": {"debut": "2024-02-01", "fin": "2024-02-29"},
"loyers": 500.0,
"total": 500.0,
"regles": 200.0,
"impayes": 300.0,
},
],
}
],
"recapitulatif_operations": [
{
"categorie": "DEPENSES_NON_RECUPERABLES",
"sous_categorie": "Travaux divers",
"fournisseur": "PLOMBERIE",
"description": "S01 ACOMPTE 40% remplacement chaudière",
# Le parser déduit ce numéro du préfixe de la description ; la
# fixture le fournit tel qu'il arrive en base.
"lot_numero": "01",
"montants": {"debit": 400.0, "deductible": 400.0},
},
{
"categorie": "DEPENSES_NON_RECUPERABLES",
"sous_categorie": "Travaux divers",
"fournisseur": "PLOMBERIE",
"description": "S01 SOLDE remplacement chaudière",
"lot_numero": "01",
"montants": {"debit": 600.0, "deductible": 600.0},
},
{
"categorie": "HONORAIRES_DE_GESTION",
"sous_categorie": "Frais d'expert",
"fournisseur": "EXPERTISE",
"description": "S01 - Remise état des lieux sortie",
"lot_numero": "01",
# Un avoir : la ligne rend au propriétaire au lieu de lui coûter.
"montants": {"debit": 0.0, "credit": 35.4},
},
],
}
service.save_document(data=suivant)
immeuble = db_session.query(Immeuble).filter(Immeuble.code == "IMM1").one()
lot = db_session.query(Lot).filter(Lot.immeuble_id == immeuble.id).one()
return immeuble, lot
def test_lot_inconnu_donne_404(api_client, donnees):
assert api_client.get("/api/lots/999999/analyse").status_code == 404
def test_identite_montre_les_trous_de_la_fiche(api_client, donnees):
"""Une caractéristique non saisie reste nulle : la page doit le montrer."""
_, lot = donnees
identite = api_client.get(f"/api/lots/{lot.id}/analyse").json()["identite"]
assert identite["numero"] == "01"
assert identite["immeuble_code"] == "IMM1"
assert identite["type_effectif"] == "Appartement"
assert identite["surface"] is None
assert identite["dpe_classe"] is None
assert identite["locataires"] == ["DUPONT"]
def test_le_report_de_solde_ne_gonfle_pas_le_facture(api_client, donnees):
"""Le facturé ne retient que les loyers, jamais la dette reportée.
Deux loyers de 500 € : cumuler en plus le report de 300 € afficherait 1300 €
facturés pour un lot qui n'a jamais rien facturé de tel.
"""
_, lot = donnees
chiffres = api_client.get(f"/api/lots/{lot.id}/analyse").json()["chiffres"]
assert chiffres["facture"] == 1000.0
assert chiffres["encaisse"] == 700.0
#: Photo du dernier compte rendu (300 de report + 300 de loyer), pas un cumul.
assert chiffres["restant_du"] == 600.0
assert chiffres["taux_recouvrement"] == 70.0
def test_les_charges_d_immeuble_restent_hors_du_lot(api_client, donnees):
"""Le nettoyage de l'immeuble ne doit pas atterrir dans un lot.
Aucune donnée ne dit quelle part revient à quel lot : la ventiler
inventerait des montants. Elle est exposée à part, comme contexte.
"""
_, lot = donnees
chiffres = api_client.get(f"/api/lots/{lot.id}/analyse").json()["chiffres"]
# 400 + 600 de travaux imputés au lot, sans le nettoyage de l'immeuble.
assert chiffres["depenses_debit"] == 1000.0
assert chiffres["depenses_credit"] == 35.4
assert chiffres["nb_operations"] == 3
# Le nettoyage du premier compte rendu, resté sans lot.
assert chiffres["depenses_immeuble_non_reparties"] == 50.0
# Encaissé (700) moins décaissé (1000), avoir rendu (35,40) : charges
# communes exclues.
assert chiffres["solde"] == -264.6
def test_la_chronologie_garde_les_lignes_telles_qu_extraites(api_client, donnees):
"""Acompte et solde restent deux lignes : le compte rendu les porte ainsi."""
_, lot = donnees
chronologie = api_client.get(f"/api/lots/{lot.id}/analyse").json()["chronologie"]
travaux = [ligne for ligne in chronologie if ligne["fournisseur"] == "PLOMBERIE"]
assert len(travaux) == 2
assert {ligne["montant"] for ligne in travaux} == {400.0, 600.0}
def test_la_chronologie_est_ordonnee_et_signale_les_reports(api_client, donnees):
"""L'ordre des comptes rendus est l'ordre de lecture de la page."""
_, lot = donnees
chronologie = api_client.get(f"/api/lots/{lot.id}/analyse").json()["chronologie"]
dates = [ligne["date"] for ligne in chronologie]
assert dates == sorted(dates)
assert dates[0] == "2024-01-15"
reports = [ligne for ligne in chronologie if ligne["est_report"]]
assert len(reports) == 1
assert reports[0]["montant"] == 300.0
def test_les_intervenants_agregent_le_fournisseur_du_compte_rendu(api_client, donnees):
"""Une entreprise, ses interventions et son montant — rien de déduit."""
_, lot = donnees
intervenants = api_client.get(f"/api/lots/{lot.id}/analyse").json()["intervenants"]
par_nom = {ligne["fournisseur"]: ligne for ligne in intervenants}
assert set(par_nom) == {"PLOMBERIE", "EXPERTISE"}
assert par_nom["PLOMBERIE"]["nb_interventions"] == 2
assert par_nom["PLOMBERIE"]["montant"] == 1000.0
assert par_nom["PLOMBERIE"]["derniere_date"] == "2024-02-15"
def test_un_avoir_rend_le_montant_de_l_intervenant_negatif(api_client, donnees):
"""Le crédit est déduit : une remise ne doit pas s'afficher comme un coût."""
_, lot = donnees
intervenants = api_client.get(f"/api/lots/{lot.id}/analyse").json()["intervenants"]
expertise = next(
ligne for ligne in intervenants if ligne["fournisseur"] == "EXPERTISE"
)
assert expertise["montant"] == -35.4
# La plus engagée en tête : un avoir se classe donc en dernier.
assert intervenants[-1]["fournisseur"] == "EXPERTISE"
def test_le_total_d_un_intervenant_est_celui_de_ses_lignes(api_client, donnees):
"""Invariant du dépliage : le détail doit retrouver le total affiché.
Les deux chiffres viennent de calculs séparés (agrégat SQL d'un côté, lignes
de la chronologie de l'autre) ; les laisser diverger ferait mentir la ligne
qu'on vient d'ouvrir.
"""
_, lot = donnees
analyse = api_client.get(f"/api/lots/{lot.id}/analyse").json()
for intervenant in analyse["intervenants"]:
lignes = [
ligne
for ligne in analyse["chronologie"]
if ligne["fournisseur"] == intervenant["fournisseur"]
]
assert len(lignes) == intervenant["nb_interventions"]
assert (
round(sum(ligne["montant"] for ligne in lignes), 2)
== intervenant["montant"]
)

View File

@@ -0,0 +1,360 @@
"""Tests du referentiel des logements.
Ces caracteristiques sont saisies a la main : elles n'ont aucune autre source
que l'utilisateur, donc rien ne les reconstituerait si un enregistrement les
perdait. Les tests portent sur ce qui menace cette saisie : l'ecraser depuis un
PDF, la vider a moitie, ou la laisser contredire l'extraction en silence.
"""
import pytest
from plesna_gerance.database.models import (
Depense,
Immeuble,
Lot,
LotCaracteristiques,
)
from plesna_gerance.database.service import DatabaseService
@pytest.fixture
def immeuble_et_lot(db_session, sample_data):
"""Un immeuble et son lot 01, tels que l'extraction les cree."""
DatabaseService(db_session).save_document(data=sample_data)
immeuble = db_session.query(Immeuble).filter(Immeuble.code == "IMM1").one()
lot = db_session.query(Lot).filter(Lot.immeuble_id == immeuble.id).one()
return immeuble, lot
FICHE = {
"bat": "Rue",
"etage": "RC",
"type": "Loc. Commercial",
"surface": 148.0,
"surface_date_diag": "2019-06-01",
"chauffage": "Electrique",
"dpe_classe": "C",
"dpe_date_realisation": "2021-02-08",
"numero_fiscal": "690123456789",
"surface_impots": 146.0,
"note_impots": "Surface relevee sur l'avis 2024",
}
def test_liste_les_lots_sans_fiche(api_client, immeuble_et_lot):
"""Un lot jamais decrit doit apparaitre, sinon il n'est pas saisissable."""
immeuble, lot = immeuble_et_lot
response = api_client.get(f"/api/lots/referentiel?immeuble_id={immeuble.id}")
assert response.status_code == 200
lignes = response.json()
assert len(lignes) == 1
assert lignes[0]["id"] == lot.id
assert lignes[0]["numero"] == "01"
assert lignes[0]["caracteristiques"] is None
assert lignes[0]["type_extrait"] == "Appartement"
assert lignes[0]["type_effectif"] == "Appartement"
assert lignes[0]["type_ecart"] is False
def test_liste_un_immeuble_inconnu_donne_404(api_client):
assert api_client.get("/api/lots/referentiel?immeuble_id=99999").status_code == 404
def test_liste_tout_le_parc_par_defaut(api_client, immeuble_et_lot, db_session):
"""Le tableau porte une colonne immeuble : il les montre donc tous.
Restreindre a un immeuble par defaut obligerait a le choisir avant de voir
quoi que ce soit, alors que le parc entier tient dans un ecran.
"""
autre = Immeuble(code="IMM2", denomination="Marietton")
db_session.add(autre)
db_session.flush()
db_session.add(Lot(immeuble_id=autre.id, numero="01"))
db_session.commit()
lignes = api_client.get("/api/lots/referentiel").json()
assert len(lignes) == 2
assert {ligne["immeuble_code"] for ligne in lignes} == {"IMM1", "IMM2"}
def test_expose_le_nom_d_usage_de_l_immeuble(api_client, immeuble_et_lot):
"""Chaque ligne porte de quoi nommer son immeuble sans requete de plus."""
immeuble, _ = immeuble_et_lot
api_client.put(f"/api/immeubles/{immeuble.id}", json={"denomination": "Servient"})
ligne = api_client.get("/api/lots/referentiel").json()[0]
assert ligne["immeuble_denomination"] == "Servient"
assert ligne["immeuble_code"] == "IMM1"
def test_enregistre_puis_relit_une_fiche(api_client, immeuble_et_lot):
immeuble, lot = immeuble_et_lot
response = api_client.put(f"/api/lots/{lot.id}/caracteristiques", json=FICHE)
assert response.status_code == 200
fiche = response.json()["caracteristiques"]
assert fiche["surface"] == 148.0
assert fiche["etage"] == "RC"
assert fiche["numero_fiscal"] == "690123456789"
relu = api_client.get(f"/api/lots/referentiel?immeuble_id={immeuble.id}").json()[0]
assert relu["caracteristiques"]["surface"] == 148.0
assert relu["caracteristiques"]["dpe_classe"] == "C"
def test_expose_les_valeurs_derivees(api_client, immeuble_et_lot):
"""L'echeance du DPE et l'ecart de surface arrivent calcules."""
_, lot = immeuble_et_lot
fiche = api_client.put(f"/api/lots/{lot.id}/caracteristiques", json=FICHE).json()[
"caracteristiques"
]
assert fiche["dpe_echeance"] == "2031-02-08"
assert fiche["delta_surface"] == -2.0
def test_une_seconde_ecriture_met_a_jour_sans_dupliquer(api_client, immeuble_et_lot):
immeuble, lot = immeuble_et_lot
api_client.put(f"/api/lots/{lot.id}/caracteristiques", json=FICHE)
api_client.put(
f"/api/lots/{lot.id}/caracteristiques", json={**FICHE, "surface": 150.0}
)
lignes = api_client.get(f"/api/lots/referentiel?immeuble_id={immeuble.id}").json()
assert len(lignes) == 1
assert lignes[0]["caracteristiques"]["surface"] == 150.0
def test_un_champ_vide_efface_la_valeur(api_client, immeuble_et_lot):
"""Corriger une erreur de saisie doit pouvoir revenir a « inconnu »."""
_, lot = immeuble_et_lot
api_client.put(f"/api/lots/{lot.id}/caracteristiques", json=FICHE)
fiche = api_client.put(
f"/api/lots/{lot.id}/caracteristiques",
json={**FICHE, "etage": " ", "surface": None},
).json()["caracteristiques"]
assert fiche["etage"] is None
assert fiche["surface"] is None
assert fiche["delta_surface"] is None
def test_refuse_une_classe_dpe_inconnue(api_client, immeuble_et_lot):
_, lot = immeuble_et_lot
response = api_client.put(
f"/api/lots/{lot.id}/caracteristiques", json={**FICHE, "dpe_classe": "Z"}
)
assert response.status_code == 422
def test_refuse_une_surface_negative(api_client, immeuble_et_lot):
_, lot = immeuble_et_lot
response = api_client.put(
f"/api/lots/{lot.id}/caracteristiques", json={**FICHE, "surface": -10}
)
assert response.status_code == 422
def test_ecrire_sur_un_lot_inconnu_donne_404(api_client):
assert (
api_client.put("/api/lots/99999/caracteristiques", json=FICHE).status_code
== 404
)
def test_signale_un_desaccord_de_type_sans_effacer_le_pdf(api_client, immeuble_et_lot):
"""Les deux types restent lisibles : la fiche tranche, le PDF reste visible."""
_, lot = immeuble_et_lot
ligne = api_client.put(
f"/api/lots/{lot.id}/caracteristiques", json={**FICHE, "type": "Appartement T3"}
).json()
assert ligne["type_extrait"] == "Appartement"
assert ligne["type_effectif"] == "Appartement T3"
assert ligne["type_ecart"] is True
def test_le_type_saisi_prime_dans_la_liste_des_lots(api_client, immeuble_et_lot):
"""La priorite du referentiel vaut partout, pas seulement sur sa page."""
_, lot = immeuble_et_lot
api_client.put(
f"/api/lots/{lot.id}/caracteristiques", json={**FICHE, "type": "Appartement T3"}
)
lots = api_client.get("/api/lots").json()
assert [ligne["type"] for ligne in lots] == ["Appartement T3"]
def test_une_re_extraction_ne_touche_pas_la_fiche(
api_client, immeuble_et_lot, db_session, sample_data
):
"""Le point critique : reimporter le PDF ne doit rien perdre de la saisie."""
immeuble, lot = immeuble_et_lot
api_client.put(f"/api/lots/{lot.id}/caracteristiques", json=FICHE)
DatabaseService(db_session).save_document(data=sample_data, overwrite=True)
lignes = api_client.get(f"/api/lots/referentiel?immeuble_id={immeuble.id}").json()
fiche = next(ligne for ligne in lignes if ligne["id"] == lot.id)["caracteristiques"]
assert fiche is not None, "la re-extraction a perdu la fiche du lot"
assert fiche["surface"] == 148.0
def test_compte_les_rattachements_du_lot(api_client, immeuble_et_lot):
"""Un lot avec des revenus n'est pas un orphelin : la liste doit le dire."""
immeuble, lot = immeuble_et_lot
ligne = api_client.get(f"/api/lots/referentiel?immeuble_id={immeuble.id}").json()[0]
assert ligne["nb_revenus"] == 1
# La depense de la fixture porte sur l'immeuble (lot_id NULL). La compter
# ici rendrait tout lot de l'immeuble faussement non supprimable.
assert ligne["nb_depenses"] == 0
def test_l_enregistrement_renvoie_les_memes_comptes_que_la_liste(
api_client, immeuble_et_lot
):
"""Décrire un lot occupé ne doit pas le faire passer pour inutilisé.
La réponse du PUT remplace la ligne dans le tableau : si elle rapporte zéro
revenu, le lot se pare d'un « inutilisé » que la liste dément au rechargement.
"""
_, lot = immeuble_et_lot
ligne = api_client.put(f"/api/lots/{lot.id}/caracteristiques", json=FICHE).json()
assert ligne["nb_revenus"] == 1
def test_compte_les_depenses_propres_au_lot(api_client, immeuble_et_lot, db_session):
"""Une depense rattachee au lot, elle, doit bien remonter sur sa ligne."""
immeuble, lot = immeuble_et_lot
depense = db_session.query(Depense).one()
depense.lot_id = lot.id
db_session.commit()
ligne = api_client.get(f"/api/lots/referentiel?immeuble_id={immeuble.id}").json()[0]
assert ligne["nb_depenses"] == 1
# ============================================================
# Suppression des lots sans rattachement
# ============================================================
def test_supprime_un_lot_inutilise(api_client, immeuble_et_lot, db_session):
"""Les lots d'un ancien format de numerotation doivent pouvoir disparaitre."""
immeuble, _ = immeuble_et_lot
orphelin = Lot(immeuble_id=immeuble.id, numero="0001", type="Appartement T1")
db_session.add(orphelin)
db_session.commit()
response = api_client.delete(f"/api/lots/{orphelin.id}")
assert response.status_code == 204
numeros = [
ligne["numero"]
for ligne in api_client.get(
f"/api/lots/referentiel?immeuble_id={immeuble.id}"
).json()
]
assert numeros == ["01"]
def test_supprimer_un_lot_emporte_sa_fiche(api_client, immeuble_et_lot, db_session):
"""Sans cascade, la fiche resterait en base sans lot pour la porter."""
immeuble, _ = immeuble_et_lot
orphelin = Lot(immeuble_id=immeuble.id, numero="0001")
db_session.add(orphelin)
db_session.commit()
api_client.put(f"/api/lots/{orphelin.id}/caracteristiques", json=FICHE)
api_client.delete(f"/api/lots/{orphelin.id}")
assert db_session.query(LotCaracteristiques).count() == 0
def test_refuse_de_supprimer_un_lot_avec_des_revenus(api_client, immeuble_et_lot):
"""Le refus protege des montants : les perdre fausserait les comptes."""
_, lot = immeuble_et_lot
response = api_client.delete(f"/api/lots/{lot.id}")
assert response.status_code == 409
assert "revenu" in response.json()["detail"]
def test_refuse_de_supprimer_un_lot_avec_des_depenses(
api_client, immeuble_et_lot, db_session
):
"""Une depense seule suffit a retenir le lot, meme sans aucun revenu."""
immeuble, _ = immeuble_et_lot
lot_charge = Lot(immeuble_id=immeuble.id, numero="0002")
db_session.add(lot_charge)
db_session.commit()
depense = db_session.query(Depense).one()
depense.lot_id = lot_charge.id
db_session.commit()
response = api_client.delete(f"/api/lots/{lot_charge.id}")
assert response.status_code == 409
def test_supprimer_un_lot_inconnu_donne_404(api_client):
assert api_client.delete("/api/lots/99999").status_code == 404
# ============================================================
# Nom d'usage de l'immeuble
# ============================================================
def test_nomme_un_immeuble(api_client, immeuble_et_lot):
immeuble, _ = immeuble_et_lot
response = api_client.put(
f"/api/immeubles/{immeuble.id}", json={"denomination": "Servient"}
)
assert response.status_code == 200
assert response.json()["denomination"] == "Servient"
assert api_client.get("/api/immeubles").json()[0]["denomination"] == "Servient"
# La reponse remplace l'immeuble dans les listes du client : des compteurs a
# zero le feraient passer pour un immeuble sans lot.
assert response.json()["nb_lots"] == 1
def test_effacer_le_nom_rend_l_immeuble_a_son_code(api_client, immeuble_et_lot):
immeuble, _ = immeuble_et_lot
api_client.put(f"/api/immeubles/{immeuble.id}", json={"denomination": "Servient"})
response = api_client.put(
f"/api/immeubles/{immeuble.id}", json={"denomination": " "}
)
assert response.json()["denomination"] is None
assert response.json()["code"] == "IMM1"
def test_nommer_un_immeuble_inconnu_donne_404(api_client):
response = api_client.put("/api/immeubles/99999", json={"denomination": "Servient"})
assert response.status_code == 404

View File

@@ -24,11 +24,7 @@ def test_ajoute_extracted_at_a_une_base_existante(tmp_path, monkeypatch):
importe_le = datetime(2026, 3, 1, 10, 0, tzinfo=timezone.utc)
with engine.begin() as conn:
conn.execute(text("ALTER TABLE documents DROP COLUMN extracted_at"))
conn.execute(
text(
"INSERT INTO immeubles (code) VALUES ('IMM1');"
)
)
conn.execute(text("INSERT INTO immeubles (code) VALUES ('IMM1');"))
conn.execute(
text(
"INSERT INTO documents (reference, date, immeuble_id, json_data,"
@@ -55,6 +51,37 @@ def test_ajoute_extracted_at_a_une_base_existante(tmp_path, monkeypatch):
connection.reset_connection()
def test_ajoute_denomination_a_une_base_existante(tmp_path, monkeypatch):
"""La colonne arrive vide : deduire un nom d'usage du code l'inventerait."""
db_path = tmp_path / "ancienne.sqlite"
monkeypatch.setenv("PLESNA_DB_PATH", str(db_path))
monkeypatch.setenv("PLESNA_STORAGE_PATH", str(tmp_path / "documents"))
connection.reset_connection()
connection.init_db(db_path)
engine = connection.get_engine(db_path)
with engine.begin() as conn:
conn.execute(text("ALTER TABLE immeubles DROP COLUMN denomination"))
conn.execute(text("INSERT INTO immeubles (code) VALUES ('33689020')"))
connection.reset_connection()
connection.init_db(db_path)
with connection.get_engine(db_path).begin() as conn:
colonnes = {
row[1] for row in conn.execute(text("PRAGMA table_info(immeubles)"))
}
assert "denomination" in colonnes
denomination, code = conn.execute(
text("SELECT denomination, code FROM immeubles")
).one()
assert denomination is None
assert code == "33689020"
connection.reset_connection()
def test_rattrapage_idempotent(tmp_path, monkeypatch):
"""Relancer init_db sur une base a jour ne doit rien casser."""
db_path = tmp_path / "a_jour.sqlite"
@@ -67,7 +94,9 @@ def test_rattrapage_idempotent(tmp_path, monkeypatch):
connection.init_db(db_path)
with connection.get_engine(db_path).begin() as conn:
colonnes = [row[1] for row in conn.execute(text("PRAGMA table_info(documents)"))]
colonnes = [
row[1] for row in conn.execute(text("PRAGMA table_info(documents)"))
]
assert colonnes.count("extracted_at") == 1
connection.reset_connection()