Compare commits

25 Commits

Author SHA1 Message Date
233f000b2a chore: passe en version 0.1.2
All checks were successful
Build and Publish Docker Image / Tests (push) Successful in 2m46s
Build and Publish Docker Image / Build App Image (push) Successful in 1m13s
Build and Publish Docker Image / Build Summary (push) Successful in 3s
2026-08-22 21:18:45 +02:00
24db55c46c fix(bureau): affiche les PDF et conserve les réglages dans la fenêtre native
Deux réglages du moteur manquaient pour que l'aperçu fonctionne hors
navigateur, tous deux constatés en lançant la fenêtre native.

Qt WebEngine laisse PdfViewerEnabled à False et le conditionne à
PluginsEnabled : le volet n'affichait qu'un bouton « Ouvrir » au lieu du
document. pywebview n'expose pas ces attributs et construit son propre
profil, donc on les pose sur la vue au chargement de la fenêtre — bien
avant qu'un document, et donc une <iframe>, soit ouvert. Sans objet sous
Windows, où WebView2 embarque déjà son lecteur.

pywebview démarre par ailleurs en mode privé, sur un profil éphémère qui
ne conserve ni cookies ni localStorage. L'interface y range la largeur du
volet d'aperçu, et le lecteur PDF du moteur y garde l'état de sa barre
latérale de vignettes : les deux se seraient réinitialisés à chaque
lancement. L'application ne charge que son propre serveur local, rien de
tiers n'est stocké au passage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:12:58 +02:00
552fc453dd feat(apercu): rend ajustable le partage entre PDF et données
Le volet PDF était figé à 40 % de la fenêtre. C'est le vrai levier de
lisibilité : élargir le volet augmente d'autant l'échelle du document, et
vers 768 px l'ajustement à la largeur vaut 97 %, soit 100 % de fait. La
poignée se glisse, la largeur est mémorisée et partagée entre l'écran
d'extraction et celui d'édition, le double-clic revient au défaut — porté
de 40 % à 50 % pour que la page tienne presque sans défiler.

Un voile couvre la fenêtre pendant le glissement : sans lui l'<iframe> du
PDF capte les évènements souris et le redimensionnement se fige dès qu'on
la survole.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:12:58 +02:00
e2e2f5bc0e feat(apercu): revient au lecteur PDF natif du moteur
Le rendu maison sur <canvas> dimensionnait chaque page à la largeur du
volet, sans jamais pouvoir aller au-delà : sur une page A4 (595,3 pt) dans
un volet de 495 px — soit 40 % d'une fenêtre bureau de 1280 px — cela
faisait une échelle de 0,83x, et les tableaux du CRG composés en 7 pt
tombaient à 5,8 px de haut. Illisible, sans recours : aucun zoom, aucun
re-rendu au redimensionnement (la canvas était simplement étirée en CSS),
et devicePixelRatio à 1 sur écran non-HiDPI, donc aucun suréchantillonnage.

L'<iframe> rend au moteur le zoom, la recherche, l'impression et la
sélection de texte, tous absents jusqu'ici, et redessine à chaque niveau
de zoom. Le document s'ouvre à `#zoom=100` : « ajuster à la largeur » ne
sauverait rien dans un volet étroit (54 %, soit 5 px pour du 7 pt) alors
qu'à 100 % le 7 pt fait 9,3 px. Les mots-clés `page-width`, `view=FitH` et
`pagemode=none` sont ignorés par le lecteur de Chrome ; une valeur
numérique est honorée.

Un lien « ouvrir dans un nouvel onglet » sert de porte de sortie si le
moteur n'embarque pas de lecteur PDF.

pdfjs-dist n'a plus d'utilisateur : bundle principal 966 Ko -> 492 Ko, et
le worker de 1,3 Mo n'est plus émis.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:12:58 +02:00
b15f5c95b6 fix: distingue un lot reloué d'un lot sorti de la gestion
La fiche du lot 15 annonçait « dernier loyer connu, arrêté après févr. 26 »
alors qu'un locataire y est entré le 8 juillet — et la courbe juste dessous,
où la barre de juillet est bien là, la contredisait.

Le loyer en vigueur se lit sur les paliers, et un mois facturé au seul
prorata n'en ouvre pas : celui d'avant s'arrête donc à la dernière mensualité
pleine, quatre mois avant le nouveau bail. Ne rien lire après lui confond deux
états que le compte rendu sépare : un lot qui n'est plus loué, et un lot entre
deux locataires.

La distinction tient à la frontière qui sert déjà à séparer loyer plein et
prorata, la période portée par le compte rendu :

- un prorata qui finit le dernier jour du mois sans partir du premier facture
  la fin du mois : quelqu'un est entré, le bail court encore après ;
- un prorata qui part du premier sans l'atteindre facture le début : le bail
  s'arrête là ;
- un montant négatif ne fait ni l'un ni l'autre. Un avoir porte parfois la
  période exacte de la ligne qu'il annule, et le lire comme une entrée
  inventerait une relocation là où rien n'a été loué.

L'entrée l'emporte sur la sortie parce qu'elle vient après : ce lot voit son
locataire partir le 10 mars et le suivant arriver le 8 juillet, et c'est le
second qui décrit son état.

Le montant affiché ne bouge pas — le nouveau bail n'a été facturé qu'au
prorata, et en tirer un loyer mensuel l'inventerait. Seul ce qu'on en dit
change : « dernier loyer plein · nouveau bail depuis juil. 26 ».

Sans relocation, la sortie date enfin la fin de la location au mois où elle a
lieu : « arrêté en cours de mars 26 » plutôt qu'« arrêté après févr. 26 », qui
l'avançait d'un mois.

Sur le parc : 31 lots loués, 1 relouté, 1 réellement arrêté.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:50:54 +02:00
faab7173bd feat: ramène à la vue d'où l'on vient une fois le document enregistré
Éditer une ligne depuis Dépenses ou Recettes ouvrait le document, puis
relâchait sur la liste des documents : il fallait revenir à la page
d'analyse et refaire ses filtres à la main pour corriger la ligne suivante.

Les deux tables passent maintenant à l'éditeur la vue d'où l'on part,
filtres compris, et il y ramène à ses trois sorties : enregistrement
terminé, abandon, et document introuvable. Sans cette destination — depuis
la liste des documents, ou un lien direct — le repli reste la liste.

La destination arrive par l'URL, donc fabricable par n'importe qui : seule
une page de l'application est suivie (utils/retour.js). Une URL absolue, un
chemin protocol-relatif que le navigateur lirait comme un domaine, ou des
contre-slashs retombent sur le repli.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:29:40 +02:00
78b9bbf129 feat: fait vivre les filtres d'analyse dans l'URL
Les filtres des pages Dépenses et Recettes n'existaient que dans l'état du
composant : recharger la page les perdait, et une vue filtrée ne pouvait ni
se partager, ni se mettre en signet, ni servir de destination à un lien.

Chaque page déclare desormais comment ses filtres se lisent et s'écrivent
dans la query (utils/filtresUrl.js). Ce qui vaut sa valeur par défaut n'est
pas écrit : l'URL ne porte que ce qui a été choisi, et reste lisible. Ce qui
en arrive est validé — un identifiant qui n'est pas un entier ou une date au
mauvais format est ignoré plutôt qu'affiché de travers.

Le changement de filtre remplace l'entrée d'historique au lieu d'en empiler
une : le bouton Retour du navigateur quitte la page, il ne défait pas les
filtres un par un.

Côté Dépenses, la requête API dérive de cette même écriture : les paramètres
sont les mêmes des deux côtés, la vue et la requête ne peuvent plus diverger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:29:40 +02:00
a5a1db9968 feat: montre la répartition des dépenses par tag
Le résumé calculait déjà `by_tag`, la page Dépenses n'en faisait rien :
on lisait la dépense par catégorie comptable, jamais par poste. Les deux
répartitions se placent côte à côte — même total, deux découpages — et
l'évolution mensuelle prend toute la largeur, un axe de temps serré étant
le premier à devenir illisible.

Les dépenses sans tag y gardent leur part, en gris : c'est le travail de
tagging qui reste à faire, pas un trou dans le graphique.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:29:40 +02:00
e81cd5ffa4 feat: filtre les dépenses sur plusieurs fournisseurs et sur l'absence de tag
Le filtre fournisseur était une recherche de sous-chaîne : impossible de
comparer deux fournisseurs, et « PPR » ramenait ses homonymes au passage.
Il devient une sélection multiple exacte, prise dans un menu déroulant
filtrable au clavier (SelectionMultiple, générique et réutilisable). La
liste vient de /api/fournisseurs, remise en ordre alphabétique — l'API la
trie par montant, ce qui se lit bien dans un classement mais rend
introuvable un fournisseur qu'on cherche à l'œil.

Côté API, `fournisseur` devient répétable et s'entend comme un OU exact.

Le filtre tag, lui, ne savait pas demander « ce qui n'est pas encore
tagué » — c'est pourtant la question qui amorce le travail de tagging.
`tag_id=0` le demande, et l'option « Sans tag » l'ouvre depuis la page.

Au passage, les deux endpoints dupliquaient leurs filtres : ils partagent
désormais _appliquer_filtres, pour que les totaux du résumé ne puissent
plus porter sur d'autres lignes que la table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:29:40 +02:00
7c19676400 feat: deplie les depenses par defaut a l'edition d'un document
Deux niveaux de repli se cumulaient : la section « Recapitulatif des
operations » puis chaque carte de categorie. Lire une depense demandait
donc un clic sur la section plus un clic par categorie, avant toute
correction.

Les deux s'ouvrent desormais par defaut. « Tout replier » couvre le
besoin inverse, et le repli manuel par categorie reste disponible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:22:09 +02:00
24420027a5 feat: ouvre le choix de la période sur la fiche d'un lot
Un second sélecteur accompagne celui du lot : tout l'historique, trois
mois, six mois, un an, deux ans, cinq ans. Le défaut reste l'historique
entier — un filtre par défaut cacherait des opérations dès l'arrivée sur
la page, sans que rien ne le signale.

La période part dans l'URL, comme le lot : une fiche filtrée se partage
et se recharge telle qu'on la lisait. Une valeur qui ne correspond à
aucune durée proposée est ignorée et retirée de l'adresse.

Le filtrage est demandé au serveur plutôt que rejoué ici : le taux de
recouvrement et le restant dû sortent de requêtes SQL que le navigateur
ne peut pas refaire, et les recalculer de son côté les ferait diverger de
la chronologie affichée juste dessous.

Ce que la fenêtre écarte reste dit, là où le lecteur pourrait le croire
inexistant :

- un bandeau donne les bornes retenues, précise qu'elles sont calées sur
  le dernier compte rendu du lot et non sur aujourd'hui, compte les lignes
  antérieures exclues et offre de tout réafficher ;
- sous la courbe, le nombre de mois non tracés, et le rappel que le loyer
  en vigueur, sa date d'effet et la comparaison au parc restent lus sur
  tout l'historique — sans quoi « depuis mai 26 » au-dessus d'un graphe
  qui commence en mai passerait pour une contradiction ;
- à côté des noms, le nombre d'occupants antérieurs à la période ;
- le bloc des régularisations hors courbe s'affiche même quand la période
  n'en laisse aucune à lister, pour dire qu'il en existe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:26:33 +02:00
005675728d feat: borne l'analyse d'un lot à une période
La fiche d'un lot rendait tout son historique, et rien d'autre. Sur un
parc suivi depuis 2023, lire ce qu'un lot a fait ces trois derniers mois
supposait de faire soi-même la soustraction. Le paramètre `mois` ouvre
une fenêtre sur les chiffres, la chronologie, les intervenants, les
locataires et la courbe du loyer.

Trois décisions la gouvernent, et chacune évite un chiffre qui
mentirait :

- **la fenêtre est calée sur le dernier compte rendu du lot**, jamais sur
  aujourd'hui. Les derniers comptes rendus du parc datent de juillet
  alors qu'on est en août : une fenêtre glissante depuis la date du jour
  décalerait déjà tout d'un mois, et viderait entièrement la fiche d'un
  lot sorti de la gestion — une page vide se lisant comme une absence
  d'activité plutôt que comme un filtre trop court ;
- **le restant dû y échappe**, parce qu'un stock ne se borne pas comme un
  flux. Le ramener à la fenêtre l'annulerait dès qu'aucun compte rendu
  n'y tombe : un lot afficherait 0 € dû tout en devant plusieurs
  milliers ;
- **le loyer en vigueur et la comparaison au parc y échappent aussi.**
  La courbe, elle, est bien coupée, mais après coup : les paliers restent
  lus sur la série entière, sans quoi « depuis mai 26 » daterait de la
  borne du filtre au lieu de la révision qui a fixé ce loyer, et la
  médiane du parc changerait de mois de référence à chaque changement de
  période. Rien n'est coupé au-delà de la fenêtre : un bail trimestriel
  facturé d'avance porte des mois postérieurs au dernier compte rendu, et
  les retirer ferait croire que le lot cesse d'être loué.

Les locataires n'ont ni date d'entrée ni date de sortie — aucune n'est
extraite des comptes rendus. Leur seul rattachement au temps est donc le
document qui les porte. Hors fenêtre, la liste reste malgré tout celle de
la table : la base contient des locataires dont aucune ligne ne dépend, et
la vue par défaut ne filtre rien.

Un filtre qui masque doit dire ce qu'il masque : la réponse porte le
compte des lignes, des mois et des occupants laissés dehors, et les
bornes effectivement retenues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:26:22 +02:00
2ee8e101b8 chore: aligne le verrou uv sur la version 0.1.1
All checks were successful
Build and Publish Docker Image / Tests (push) Successful in 2m19s
Build and Publish Docker Image / Build App Image (push) Successful in 1m57s
Build and Publish Docker Image / Build Summary (push) Successful in 3s
Le passage en 0.1.1 avait laissé le verrou sur 0.1.0 ; `uv sync` le
corrige de lui-même à chaque installation, et la correction se
retrouvait donc dans l'arbre de travail de qui installait le projet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:39:14 +02:00
288b67c26f feat: montre sur la fiche le loyer mois par mois et sa place dans le parc
Un bloc « Loyer » s'intercale entre les totaux et la chronologie. Il
porte le loyer en vigueur, sa date d'effet, le prix au mètre carré et la
dernière révision, puis deux graphiques.

Le premier suit le loyer mois par mois, charges empilées et prix au m²
sur son propre axe. Un mois vacant y reste un blanc, jamais une barre au
sol ; un mois de transition prend une couleur distincte et l'infobulle
dit lequel des deux on regarde. Ce que la courbe ne peut pas porter — une
régularisation à cheval sur plusieurs mois sans en couvrir aucun — est
listé dessous plutôt que rattaché de force à un mois.

Le second situe le lot dans le parc, surface en abscisse. Il corrige une
lecture que les médianes rendent fausse : le loyer au m² décroît
fortement avec la taille, si bien qu'un studio de 21 m² affiche +60 %
au-dessus de la médiane de son immeuble sans rien devoir à sa gestion.
Ce qui se lit n'est donc pas la hauteur d'un point, mais sa position
parmi les lots de surface voisine — et une phrase le dit au-dessus du
graphe. Les autres lots s'ouvrent d'un clic ; les locaux commerciaux
gardent leur place mais changent de forme, leur prix ne suivant pas la
même logique.

Sans surface saisie, chaque case reste vide et renvoie vers Logements :
la fiche montre le trou au lieu de le combler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:39:07 +02:00
b1ff0812c1 feat: met le loyer d'un lot sur un axe de temps et le rapporte au mètre carré
La fiche d'un lot cumulait tout son historique en un chiffre. Une
révision de loyer, une vacance ou un décrochage y étaient donc
invisibles. Le bloc `loyer` remet les lignes sur un axe de temps, dont
l'unité est le mois loué et non le mois du compte rendu : un rappel de
mars facturé en avril décrit mars.

Trois formes de lignes cohabitent sous le même `type_ligne = "loyer"`,
et les confondre fausse la courbe :

- le loyer d'un mois, cas courant ;
- le loyer d'un trimestre, forme réelle des baux commerciaux du parc,
  réparti sur les mois qu'il couvre — sans quoi deux mois sur trois
  paraîtraient vides alors que le local est loué ;
- le prorata d'entrée, de sortie ou l'avoir, rattaché à son mois mais
  compté à part. Les additionner ferait passer un mois de changement de
  locataire pour un mois à loyer effondré.

Un mois sans ligne reste vide plutôt qu'à zéro : zéro dirait « loué
gratuitement », ce qu'aucun compte rendu ne dit. Un mois facturé
seulement au prorata est signalé comme transition, pour ne pas se
confondre avec une vacance.

Le mètre carré vient de la fiche saisie, qu'aucun PDF ne porte : tant
qu'elle manque, le ratio reste nul et la page renvoie vers la saisie.
Les médianes qui situent le lot rejouent exactement la même répartition
pour les autres lots — les calculer autrement ne voudrait rien dire — et
s'accompagnent toujours de leur effectif et du nombre de lots exclus
faute de surface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:38:41 +02:00
fa54d523a5 fix: recharge la fiche quand l'URL change de lot
Passer d'un lot à un autre emprunte la même route : Vue réutilise le
composant sans le remonter, et `onMounted` ne rejoue pas. L'adresse
changeait donc en laissant à l'écran la fiche du lot précédent — l'écart
le plus trompeur qui soit entre l'URL et ce qu'on lit.

Le sélecteur ne montrait rien parce qu'il passe par `lotChoisi`, jamais
par l'URL. Un lien direct vers /lots/<id> depuis une fiche déjà ouverte
suffisait pourtant à produire le décalage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:38:25 +02:00
4fe2bf75c0 chore: passe en version 0.1.1
All checks were successful
Build and Publish Docker Image / Tests (push) Successful in 12m56s
Build and Publish Docker Image / Build App Image (push) Successful in 1m52s
Build and Publish Docker Image / Build Summary (push) Successful in 3s
2026-08-01 06:08:09 +02:00
c42e190aa7 fix: fait porter la version au verrou npm aussi 2026-08-01 06:07:48 +02:00
62d6eb169c fix: remet le verrou de dependances frontend d'aplomb 2026-08-01 05:52:06 +02:00
5fc6f66955 feat: fait reposer le deploiement sur des versions taguees
All checks were successful
Build and Publish Docker Image / Tests (push) Successful in 12m53s
Build and Publish Docker Image / Build App Image (push) Successful in 2m15s
Build and Publish Docker Image / Build Summary (push) Successful in 3s
2026-07-31 15:28:11 +02:00
1d138b8ca9 fix: rend lisible le solde antérieur logé dans la colonne des périodes
Le compte rendu n'accorde pas de colonne au report de solde : il en écrit le
libellé et le montant dans la colonne « Période », et le reporte au même endroit
sur sa ligne « Totaux ». Le tableau d'édition suit cette mise en page, mais un
montant sous un en-tête « Periode » se lit mal, d'autant que le libellé y
répétait ce que la colonne « Type » affiche déjà deux cases plus loin.

L'en-tête devient « Periode / Libelle » — la colonne porte du texte libre, autant
le dire — et la ligne de report n'y garde que son montant. La ligne « Totaux »
conserve le sien, aucune colonne « Type » ne l'y expliquant.

Reste que le parser range ce montant dans le champ `loyers` de la ligne, faute
d'un champ à lui : le tableau compense à l'affichage, la donnée reste à corriger
à la racine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 14:19:36 +02:00
8c959f5d5f refactor: donne une source unique aux colonnes du compte rendu
L'ordre et les libellés des colonnes étaient retapés à six endroits : l'en-tête
du tableau, deux boucles de cellules, la ligne « Totaux », la table des libellés
et la liste des colonnes recoupées. Ces copies avaient déjà divergé — le solde
antérieur, rendu hors de la boucle, était comparé aux lignes sans que son écart
puisse s'afficher dans sa cellule. `COLONNES_CRG` devient la seule déclaration,
et tout le reste en dérive ; l'écart du solde antérieur s'affiche du coup là où
on le cherche.

`ecartsAvecLignes` rapporte maintenant l'extrait, le calculé et ce qui manque
entre les deux. Le composant refaisait cette soustraction pour son infobulle,
avec un `|| 0` là où l'utilitaire emploie `Number` et `Number.isFinite` : deux
règles de coercition pour un même calcul, libres de diverger.

Le contenu déplié d'un lot passe de `v-show` à `v-if`. Le tableau des lignes
compte une centaine de champs éditables ; les garder montés pour la vingtaine de
lots d'un document faisait re-rendre à chaque frappe deux mille champs que
personne ne regardait.

`setNestedValue` était recopié à l'identique dans trois composants et
`formatCurrency` redéfini dans le composant alors que `utils/format.js` existe
et dit lui-même que les nouveaux affichages passent par lui. Le premier part
dans `utils/chemin.js`, le second cède la place à `formatMontantPrecis`, dont le
formateur `Intl` est construit une fois pour toutes.

`EditableField` affirmait en dur que sa valeur venait du compte rendu. C'est vrai
des trois écrans qui l'emploient aujourd'hui, mais un formulaire de saisie
manuelle mentirait sans le savoir : l'origine devient une prop, avec cette
valeur par défaut.

Le type des lignes de report est défini des deux côtés de l'application sans
lien entre eux ; chacun renvoie désormais à l'autre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 14:18:58 +02:00
6a638df1ab feat: rend la situation des locataires vérifiable colonne par colonne
La carte d'un lot n'affichait que quatre des huit colonnes du compte rendu, la
colonne Total absente, et le bandeau replié montrait un total figé : corriger un
règlement ne le changeait pas. Pire, les quatre agrégats modifiables ne sont
jamais enregistrés — seules les lignes partent en base (`service.py`) — pendant
que les colonnes réellement écrites, dont Regles et Impayé, n'étaient éditables
nulle part. On corrigeait donc un champ sans effet, et pas celui qu'il fallait.

Le détail des lignes reprend maintenant le tableau du compte rendu, colonne pour
colonne, la ligne « Totaux » comprise en pied. Les dix champs d'une ligne et les
huit de la ligne Totaux sont modifiables, sans exception : cette page sert à
vérifier une extraction avant de l'enregistrer, l'utilisateur y a le dernier mot.

Rien n'est plus recalculé à l'affichage ni reporté d'un champ sur un autre.
Corriger une colonne ne déclenche que ce qui a été demandé — un total réécrit
d'office effacerait sans le dire ce que le compte rendu porte.

Un seul contrôle subsiste, en signalement pur : chaque colonne de la ligne
« Totaux » est confrontée à la somme de cette même colonne sur les lignes. Le
recoupement est celui que fait l'œil sur le tableau. Déduire le total des autres
colonnes laissait passer le cas le plus parlant — une colonne Total qui ne somme
visiblement pas, faute d'avoir extrait la valeur d'une ligne.

Sur les 388 lots des documents extraits, ce contrôle signale quatre lots, tous de
vraies extractions incomplètes : un règlement de 707,29 € qu'aucune ligne ne
porte, un « divers » de 308,76 € sauté à un changement de page, deux lots réglés
sans ligne. La colonne fautive passe en surbrillance et affiche la valeur
calculée sous le montant extrait, sans jamais s'y substituer.

Le solde antérieur quitte la colonne Loyers pour la colonne Période, où le
compte rendu l'imprime. Compté à part sans être affiché à part, il donnait une
colonne Loyers qui semblait ne pas sommer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 08:12:43 +02:00
7b033a0184 feat: montre au repos qu'un champ est extrait et modifiable
Le pointillé qui signale un champ modifiable n'apparaissait qu'au survol. Il
faut désormais le chercher à la souris pour savoir si un nombre se corrige —
supportable tant que la page n'affiche que de l'extraction, plus du tout dès
qu'une valeur déduite s'affiche à côté. Le pointillé reste donc visible au
repos : son absence devient le signal qu'un nombre ne se modifie pas.

L'infobulle disait « Cliquer pour modifier », ce qui décrit le geste mais tait
l'essentiel : d'où vient le nombre. Elle dit maintenant qu'il est extrait du
compte rendu, et distingue le champ vide — rien n'a été extrait, il reste à
saisir — du champ renseigné.

Le composant ne sert qu'aux trois sections de la page d'édition, toutes
alimentées par l'extraction : l'affirmation vaut partout où il est employé.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 08:12:22 +02:00
8d869a3dde feat: réorganise la navigation en barre latérale repliable
All checks were successful
Build and Publish Docker Image / Build App Image (push) Successful in 21s
Build and Publish Docker Image / Build Summary (push) Successful in 3s
Les sept onglets alignés dans le header ne disaient pas que trois natures de
travail s'y mêlaient. Ils passent en colonne, sous deux titres : Saisie
alimente la base (Documents, Fiches logements), Analyse en lit le contenu
(Recettes, Dépenses, Par lot). Config quitte la rangée pour un pied de menu,
c'est un réglage et non une destination de travail.

« Logements » et « Lots » se ressemblaient trop pour deux pages qui parlent du
même objet sans faire la même chose. Elles deviennent « Fiches logements » —
ce qu'on y saisit — et « Par lot » — ce qu'on y lit. Les URL ne bougent pas,
elles circulent dans des liens déjà partagés.

Le bouton « Importer un PDF » disparaît du menu : la page Documents porte déjà
le sien en tête de liste et l'accueil garde sa zone de dépôt. Ce troisième
exemplaire n'ajoutait rien.

La barre se replie sur ses seules icônes, le libellé passant en infobulle et un
filet prenant la place des titres de groupe. Le repli est gardé en
`localStorage` : c'est un réglage d'espace de travail, le retrouver déplié à
chaque rechargement serait une corvée.

Les flèches de Recettes et Dépenses sont celles des actions rapides de
l'accueil. Replié, le menu ne montre plus que ces icônes : elles doivent
désigner la même chose d'un écran à l'autre.

Le calcul de l'état actif suit l'entrée de menu dans `NavLien`, qui la rend
dépliée ou non. Une fiche de lot (`/lots/26`) garde ainsi son entrée allumée,
comme avant le déménagement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 11:24:32 +02:00
56 changed files with 5637 additions and 1760 deletions

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
# Configuration du déploiement — copier en .env (non versionné) :
# cp .env.example .env
# Version de l'image déployée, telle que publiée par la CI à partir d'un tag
# git. Un tag v0.2.0 publie les images 0.2.0, 0.2 et 0 : épingler 0.2.0 fige
# le déploiement à l'octet près, 0.2 laisse entrer les correctifs de la série.
#
# Monter de version ou revenir en arrière = changer ce numéro puis `make docker`.
PLESNA_VERSION=0.1.0

View File

@@ -13,8 +13,47 @@ env:
NAMESPACE: ${{ secrets.REGISTRY_NAMESPACE }}
jobs:
# Une image publiee est une image deployable : rien ne part au registre sans
# que la suite soit passee. Les tests golden se sautent d'eux-memes ici (le
# corpus PDF n'est pas versionne), le reste de la suite tourne.
test:
name: Tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Install backend dependencies
run: uv sync
- name: Lint
run: uv run ruff check .
- name: Backend tests
run: uv run pytest
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Frontend tests
working-directory: frontend
run: |
npm ci
npm test
build:
name: Build App Image
needs: [test]
runs-on: ubuntu-latest
steps:
- name: Checkout code
@@ -78,14 +117,16 @@ jobs:
name: Build Summary
runs-on: ubuntu-latest
needs: [build]
if: always()
# Pas de `always()` : un resume qui annonce un succes apres un build rate
# est pire que pas de resume du tout.
steps:
- name: Build summary
run: |
echo "## 🐳 Docker Image Built Successfully"
echo ""
echo "- Registry: ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/plesna-gerance"
echo "- Tags: latest, ${{ gitea.ref_name }}"
echo "- Ref: ${{ gitea.ref_name }}"
echo ""
echo "### 🚀 Deployment"
echo "docker compose up -d"
echo "Epingler la version dans .env (PLESNA_VERSION), puis :"
echo "docker compose pull && docker compose up -d"

View File

@@ -1,4 +1,4 @@
.PHONY: install dev_back dev_front dev docker docker-build
.PHONY: install dev_back dev_front dev test docker docker-build release
# Installe les dependances backend + frontend (a refaire par worktree)
install:
@@ -17,6 +17,12 @@ dev_front:
dev:
$(MAKE) dev_back & $(MAKE) dev_front & wait
# Ce que la CI verifie avant de publier une image (cf. .gitea/workflows)
test:
uv run ruff check .
uv run pytest
cd frontend && npm test
# Tire l'image publiee (registre Gitea) et lance via podman-compose
docker:
podman-compose pull
@@ -25,3 +31,9 @@ docker:
# Construit l'image en local (test avant publication)
docker-build:
podman build -f backend.Dockerfile -t plesna-gerance:local .
# Pose une version : aligne les fichiers, commite, tague (sans pousser).
# Usage : make release VERSION=0.2.0
release:
@test -n "$(VERSION)" || { echo "Usage : make release VERSION=0.2.0"; exit 1; }
uv run python scripts/release.py $(VERSION)

View File

@@ -35,6 +35,82 @@ make dev
Ouvrir **http://localhost:5173** — le proxy Vite redirige `/api` vers le backend.
## Tests
```bash
make test # ruff check + pytest + vitest, soit exactement ce que la CI verifie
```
Les tests de non-regression des parseurs comparent l'extraction de PDF reels a
des references figees. PDF (`data/`) et references (`tests/golden/`) contiennent
des donnees personnelles et ne sont pas versionnes : ces tests se **sautent**
d'eux-memes la ou le corpus est absent, CI comprise. Les lancer pour de vrai
suppose donc un corpus local (cf. `tests/test_parsers_golden.py`).
## Versions et cycle de vie
La production heberge des donnees reelles : elle suit des **versions**, pas la
pointe de `main`.
### Ce qu'est une version
Un tag git `vMAJEUR.MINEUR.CORRECTIF` (semver), pose sur `main`. Le meme numero
est ecrit dans cinq fichiers, que l'outillage tient d'accord entre eux :
`pyproject.toml`, `src/plesna_gerance/__init__.py`, `frontend/package.json`,
`frontend/package-lock.json` (qui porte lui aussi la version du paquet racine,
sans quoi `npm ci` peut refuser de tourner en CI) et `packaging/installer.iss`
(version affichee par l'installeur Windows).
Quand incrementer quoi :
| Segment | Quand | Exemple |
| --- | --- | --- |
| CORRECTIF (`0.1.1`) | correction sans changement d'usage | un montant mal lu |
| MINEUR (`0.2.0`) | nouvelle fonctionnalite, donnees existantes intactes | une nouvelle page |
| MAJEUR (`1.0.0`) | rupture : migration de base ou changement d'usage a annoncer | refonte du referentiel |
### Publier une version
```bash
make test # ce que la CI verifiera de toute facon
make release VERSION=0.2.0 # aligne les 4 fichiers, commite, pose le tag
git push origin main v0.2.0 # <- c'est ce push qui publie
```
`make release` **ne pousse pas** : le tag reste local tant qu'on ne l'a pas
envoye, ce qui laisse le temps de relire le commit de version. Le script
(`scripts/release.py`) refuse d'avancer hors de `main`, sur un arbre sale, ou
si le tag existe deja — un tag publie ne se reecrit pas.
### Ce que declenche le push d'un tag
- **`.gitea/workflows/docker-publish.yml`** : lance d'abord les tests
(`ruff`, `pytest`, `vitest`), et seulement s'ils passent construit et publie
l'image sous **trois** tags — `0.2.0`, `0.2` et `0`. Epingler `0.2.0` fige le
deploiement a l'octet pres ; `0.2` laisse entrer les correctifs de la serie.
- **`.github/workflows/build-windows.yml`** : construit l'executable et
l'installeur Windows (necessite un runner `windows-latest`).
Un push sur `main` **sans tag** publie `latest` et `main` : utile pour essayer
la pointe, jamais pour la production.
### Deployer une version
La version deployee est epinglee dans `.env`, lu par `docker-compose.yml` :
```bash
cp .env.example .env # une seule fois
# editer PLESNA_VERSION=0.2.0
make docker # pull + up -d
```
Revenir en arriere, c'est la meme manoeuvre : remettre le numero precedent dans
`.env` et relancer `make docker`. Les donnees vivent dans le volume nomme
`plesna-data`, independamment de l'image — un retour arriere d'image ne les
touche pas. En revanche une version qui a **migre le schema** de la base ne se
defait pas en changeant le numero : sauvegarder le volume avant une montee de
version majeure.
## Production (Docker)
Un **conteneur unique** : l'image embarque le frontend Vue builde, servi par
@@ -42,7 +118,7 @@ le backend FastAPI (API + interface web sur le meme port). Pas de nginx.
L'image est **publiee par la CI Gitea** (`.gitea/workflows/docker-publish.yml`)
sur `git.opytex.org/lafrite/plesna-gerance`. Le `docker-compose.yml` la **tire**
directement (pas de build local) :
directement (pas de build local), a la version epinglee dans `.env` :
```bash
make docker # podman-compose pull && podman-compose up -d

View File

@@ -7,8 +7,11 @@
FROM node:22-alpine AS frontend
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm install
# `npm ci` (et non `npm install`) : l'image doit installer exactement l'arbre
# du lock, sinon deux builds du même tag peuvent embarquer des dépendances
# différentes. Le lock n'est donc plus optionnel.
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build

View File

@@ -1,7 +1,10 @@
services:
app:
# Image publiée par la CI Gitea (pas de build local).
image: git.opytex.org/lafrite/plesna-gerance:latest
# Image publiée par la CI Gitea (pas de build local). La version déployée
# est épinglée dans .env (voir .env.example) : en production on suit un
# numéro de version, pas la pointe de main. Sans .env, on retombe sur
# `latest`, qui suit main et n'a donc pas de garantie de stabilité.
image: git.opytex.org/lafrite/plesna-gerance:${PLESNA_VERSION:-latest}
ports:
# hôte:conteneur — l'app (API + interface web) écoute sur 8000
- "8080:8000"

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "plesna-gerance-frontend",
"version": "0.1.0",
"version": "0.1.2",
"private": true,
"type": "module",
"scripts": {
@@ -11,17 +11,16 @@
},
"dependencies": {
"chart.js": "^4.4.1",
"pdfjs-dist": "^6.0.227",
"vue": "^3.4.21",
"vue-chartjs": "^5.3.0",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"@vitejs/plugin-vue": "^6.0.8",
"autoprefixer": "^10.4.18",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"vite": "^5.1.6",
"vite": "^7.3.6",
"vitest": "^4.1.10"
}
}

View File

@@ -1,39 +1,69 @@
<template>
<div class="h-screen flex flex-col bg-gray-950">
<!-- Header compact -->
<header class="flex-shrink-0 bg-gray-900 border-b border-gray-700 px-6 py-2">
<div class="flex items-center justify-between gap-6">
<router-link to="/" class="text-lg font-semibold text-white hover:text-blue-400 transition-colors whitespace-nowrap">
Plesna Gerance
<span class="text-gray-400 font-normal text-sm ml-2">Extracteur de comptes rendus</span>
</router-link>
<div class="h-screen flex bg-gray-950">
<!-- Navigation laterale -->
<aside
class="flex-shrink-0 bg-gray-900 border-r border-gray-700 flex flex-col transition-all duration-200"
:class="replie ? 'w-16' : 'w-52'"
>
<router-link
to="/"
class="block border-b border-gray-800 hover:bg-gray-800 transition-colors"
:class="replie ? 'px-2 py-3 text-center' : 'px-4 py-3'"
:title="replie ? 'Plesna Gerance' : null"
>
<template v-if="replie">
<div class="text-base font-semibold text-white">PG</div>
</template>
<template v-else>
<div class="text-base font-semibold text-white leading-tight">Plesna Gerance</div>
<div class="text-xs text-gray-500 mt-0.5">Extracteur de comptes rendus</div>
</template>
</router-link>
<!-- Navigation -->
<nav class="flex items-center gap-1">
<router-link
v-for="link in navLinks"
:key="link.to"
:to="link.to"
class="px-3 py-1.5 text-sm rounded-lg transition-colors"
:class="estActif(link.to) ? 'bg-gray-800 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-800'"
<nav class="flex-1 overflow-y-auto px-2 py-3 space-y-4">
<NavLien v-bind="accueil" :replie="replie" />
<div v-for="groupe in groupes" :key="groupe.titre">
<!-- Replie, le titre de groupe n'a plus la place de s'ecrire : un
filet garde la separation entre saisie et analyse. -->
<div
v-if="replie"
class="mx-3 mb-2 border-t border-gray-800"
:aria-label="groupe.titre"
></div>
<div
v-else
class="px-3 pb-1 text-[11px] font-semibold uppercase tracking-wider text-gray-600"
>
{{ link.label }}
</router-link>
{{ groupe.titre }}
</div>
<NavLien v-for="lien in groupe.liens" :key="lien.to" v-bind="lien" :replie="replie" />
</div>
</nav>
<!-- Import button with file input -->
<label class="btn btn-primary ml-2 cursor-pointer">
Importer un PDF
<input
ref="fileInput"
type="file"
accept="application/pdf,.pdf"
class="hidden"
@change="onFileSelect"
<!-- Config n'est pas une destination de travail : elle vit a l'ecart, en bas -->
<div class="px-2 py-3 border-t border-gray-800 space-y-1">
<NavLien v-bind="config" :replie="replie" />
<button
type="button"
class="nav-link w-full text-gray-500 hover:text-white hover:bg-gray-800"
:class="replie ? 'justify-center px-0' : ''"
:title="replie ? 'Déplier le menu' : 'Replier le menu'"
@click="replie = !replie"
>
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
:d="replie ? 'M13 5l7 7-7 7M5 5l7 7-7 7' : 'M11 19l-7-7 7-7m8 14l-7-7 7-7'"
/>
</label>
</nav>
</svg>
<span v-if="!replie" class="truncate">Replier</span>
</button>
</div>
</header>
</aside>
<!-- Main content -->
<main class="flex-1 flex overflow-hidden">
@@ -43,42 +73,88 @@
</template>
<script setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { pendingFile } from './store'
import { ref, watch } from 'vue'
import NavLien from './components/NavLien.vue'
import { FEATURE_IA } from './features'
const route = useRoute()
const router = useRouter()
const fileInput = ref(null)
// Le repli survit au rechargement : c'est un reglage d'espace de travail, pas
// un etat de page — le retrouver deplie a chaque F5 serait une corvee.
const replie = ref(localStorage.getItem('nav-repliee') === '1')
watch(replie, (valeur) => localStorage.setItem('nav-repliee', valeur ? '1' : '0'))
// 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}/`)
const accueil = {
to: '/',
label: 'Accueil',
paths: [
'M3 12l9-9 9 9M5 10v10a1 1 0 001 1h3a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1h3a1 1 0 001-1V10'
]
}
// Les libelles suivent le vocabulaire de l'accueil : recettes et depenses. Les
const config = {
to: '/config',
label: 'Config',
paths: [
'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z',
'M15 12a3 3 0 11-6 0 3 3 0 016 0z'
]
}
// Deux natures de travail, deux groupes : on alimente la base (Saisie) ou on
// lit ce qu'elle contient (Analyse). « Fiches logements » et « Par lot »
// portent des noms distincts parce que les deux pages parlent du même objet :
// l'une décrit le logement, l'autre analyse ce qui s'y est passé.
//
// Les libellés 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' }] : []),
{ to: '/config', label: 'Config' }
]
function onFileSelect(e) {
const file = e.target.files[0]
if (file && file.name.toLowerCase().endsWith('.pdf')) {
pendingFile.value = file
router.push('/extract')
//
// Les fleches de Recettes et Depenses sont celles des actions rapides de
// l'accueil : replie, le menu ne montre plus que ces icones, elles doivent
// designer la meme chose d'un ecran a l'autre.
const groupes = [
{
titre: 'Saisie',
liens: [
{
to: '/documents',
label: 'Documents',
paths: [
'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'
]
},
{
to: '/logements',
label: 'Fiches logements',
paths: [
'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4'
]
}
]
},
{
titre: 'Analyse',
liens: [
{ to: '/revenus', label: 'Recettes', paths: ['M12 19V5m0 0l-6 6m6-6l6 6'] },
{ to: '/analytics', label: 'Dépenses', paths: ['M12 5v14m0 0l6-6m-6 6l-6-6'] },
{
to: '/lots',
label: 'Par lot',
paths: [
'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z'
]
},
...(FEATURE_IA
? [
{
to: '/ia',
label: 'IA',
paths: [
'M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z'
]
}
]
: [])
]
}
e.target.value = '' // Reset input
}
]
</script>

View File

@@ -4,15 +4,20 @@
:class="{ 'w-full': fullWidth }"
>
<!-- Mode lecture -->
<!--
Le pointille reste visible au repos : sur une page ou cohabitent des
valeurs extraites et des valeurs deduites, son absence est ce qui signale
qu'un montant n'est pas modifiable.
-->
<span
v-if="!isEditing"
@click="startEditing"
class="cursor-pointer border-b border-dashed border-transparent hover:border-gray-500 hover:bg-gray-700/60 px-1 py-0.5 rounded transition-colors min-w-[2rem]"
class="cursor-pointer border-b border-dashed border-gray-600 hover:border-gray-400 hover:bg-gray-700/60 px-1 py-0.5 rounded-t transition-colors min-w-[2rem]"
:class="[
displayClass,
{ 'text-gray-500 italic': isEmpty }
]"
:title="'Cliquer pour modifier'"
:title="titreSurvol"
>
{{ displayValue }}
</span>
@@ -74,6 +79,15 @@ const props = defineProps({
inputClass: {
type: String,
default: ''
},
/**
* D' vient la valeur, pour l'infobulle. Les trois écrans qui emploient ce
* champ éditent aujourd'hui de l'extraction, d' ce défaut ; une saisie
* manuelle (référentiel, formulaire) passerait son propre libellé.
*/
origine: {
type: String,
default: 'extraite du compte rendu'
}
})
@@ -101,6 +115,14 @@ const displayValue = computed(() => {
return props.modelValue
})
// Le survol dit d'où vient la valeur, pour qu'on ne la confonde jamais avec une
// valeur déduite affichée à côté.
const titreSurvol = computed(() =>
isEmpty.value
? `Aucune valeur ${props.origine} — cliquer pour la saisir`
: `Valeur ${props.origine} — cliquer pour la modifier`
)
const inputType = computed(() => {
if (props.type === 'currency' || props.type === 'number') return 'number'
if (props.type === 'date') return 'date'

View File

@@ -177,6 +177,7 @@ import DataCard from './DataCard.vue'
import DataRow from './DataRow.vue'
import LocataireCard from './LocataireCard.vue'
import OperationCard from './OperationCard.vue'
import { setNestedValue } from '../utils/chemin'
const props = defineProps({
data: {
@@ -248,7 +249,7 @@ const copyLabel = ref('Copier')
const expandedSections = reactive({
metadata: true,
locataires: true,
operations: false
operations: true
})
// Highlight state
@@ -348,19 +349,6 @@ function cloneData() {
return JSON.parse(JSON.stringify(props.data))
}
// Helper pour setter une valeur nested
function setNestedValue(obj, path, value) {
const parts = path.split('.')
let current = obj
for (let i = 0; i < parts.length - 1; i++) {
if (!current[parts[i]]) {
current[parts[i]] = {}
}
current = current[parts[i]]
}
current[parts[parts.length - 1]] = value
}
// Mettre a jour les metadonnees
function updateMetadata(path, value) {
const updated = cloneData()

View File

@@ -43,18 +43,31 @@
/>
</button>
<div class="flex items-center gap-2">
<!-- L'extraction ne se recoupe pas : a verifier en priorite -->
<span
v-if="aDesEcarts"
class="badge badge-warning text-[10px] uppercase tracking-wide"
:title="resumeEcarts"
>
A verifier
</span>
<span
v-if="changed"
class="badge badge-warning text-[10px] uppercase tracking-wide"
>
Modifié
</span>
<div class="text-right">
<!-- Rappel de la ligne « Totaux » : elle se modifie dans le tableau, pas ici -->
<div
class="text-right"
title="Valeur extraite, reportée de la ligne Totaux — déplier le lot pour la modifier"
>
<div class="text-sm font-semibold" :class="totalClass">
{{ formatCurrency(locataire.totaux?.total) }}
{{ formatMontantPrecis(locataire.totaux?.total) }}
</div>
<div v-if="locataire.totaux?.impayes > 0" class="text-xs text-red-400">
Impayes: {{ formatCurrency(locataire.totaux?.impayes) }}
<div v-if="locataire.totaux?.impayes" class="text-xs" :class="locataire.totaux.impayes > 0 ? 'text-red-400' : 'text-blue-400'">
{{ locataire.totaux.impayes > 0 ? 'Impayes' : 'Trop-percu' }}:
{{ formatMontantPrecis(Math.abs(locataire.totaux.impayes)) }}
</div>
</div>
<!-- Bouton supprimer -->
@@ -69,156 +82,218 @@
</button>
</div>
</div>
<!-- Content -->
<div v-show="expanded" class="border-t border-gray-700 bg-gray-900/60 p-3">
<!-- Totaux editables -->
<div class="grid grid-cols-4 gap-2 text-xs mb-3">
<div class="text-center p-2 bg-gray-800 border border-gray-700 rounded">
<div class="text-gray-500 mb-1">Loyers</div>
<EditableField
:modelValue="locataire.totaux?.loyers"
@update:modelValue="updateField('totaux.loyers', $event)"
type="currency"
displayClass="font-semibold text-gray-200"
/>
</div>
<div class="text-center p-2 bg-gray-800 border border-gray-700 rounded">
<div class="text-gray-500 mb-1">Taxes</div>
<EditableField
:modelValue="locataire.totaux?.taxes"
@update:modelValue="updateField('totaux.taxes', $event)"
type="currency"
displayClass="font-semibold text-gray-200"
/>
</div>
<div class="text-center p-2 bg-gray-800 border border-gray-700 rounded">
<div class="text-gray-500 mb-1">Provisions</div>
<EditableField
:modelValue="locataire.totaux?.provisions"
@update:modelValue="updateField('totaux.provisions', $event)"
type="currency"
displayClass="font-semibold text-gray-200"
/>
</div>
<div class="text-center p-2 bg-gray-800 border border-gray-700 rounded">
<div class="text-gray-500 mb-1">Regles</div>
<EditableField
:modelValue="locataire.totaux?.regles"
@update:modelValue="updateField('totaux.regles', $event)"
type="currency"
displayClass="font-semibold text-green-400"
/>
</div>
</div>
<!-- Lignes detail -->
<div v-if="locataire.lignes?.length" class="space-y-1">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-gray-500 font-medium">Detail des lignes</span>
<button
@click="addLigne"
class="flex items-center gap-1 px-2 py-1 text-xs text-blue-400 hover:bg-blue-500/10 rounded transition-colors"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Ajouter
</button>
</div>
<div
v-for="(ligne, idx) in locataire.lignes"
:key="idx"
class="text-xs bg-gray-800 border border-gray-700 rounded p-2"
>
<div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-2 flex-1">
<!-- Type de ligne -->
<select
:value="ligne.type"
@change="updateLigneField(idx, 'type', $event.target.value)"
class="px-1.5 py-0.5 rounded text-xs border border-gray-700 bg-gray-950 text-gray-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="loyer">loyer</option>
<option value="solde_anterieur">solde_anterieur</option>
<option value="rappel_loyer">rappel_loyer</option>
<option value="divers">divers</option>
</select>
<!-- Periode -->
<div class="flex items-center gap-1 text-gray-500">
<EditableField
:modelValue="ligne.periode?.debut"
@update:modelValue="updateLigneField(idx, 'periode.debut', $event)"
type="date"
placeholder="debut"
displayClass="text-xs"
/>
<span>-</span>
<EditableField
:modelValue="ligne.periode?.fin"
@update:modelValue="updateLigneField(idx, 'periode.fin', $event)"
type="date"
placeholder="fin"
displayClass="text-xs"
/>
</div>
<!-- Libelle divers -->
<EditableField
v-if="ligne.type === 'divers'"
:modelValue="ligne.divers?.libelle"
@update:modelValue="updateLigneField(idx, 'divers.libelle', $event)"
type="text"
placeholder="libellé"
displayClass="text-xs text-gray-400 italic truncate"
/>
</div>
<div class="flex items-center gap-2">
<!-- Montant : divers -> montant divers ; sinon total (ou loyers) -->
<EditableField
v-if="ligne.type === 'divers'"
:modelValue="ligne.divers?.montant"
@update:modelValue="updateLigneField(idx, 'divers.montant', $event)"
type="currency"
displayClass="font-medium text-gray-200"
/>
<EditableField
v-else
:modelValue="ligne.total || ligne.loyers"
@update:modelValue="updateLigneField(idx, 'total', $event)"
type="currency"
displayClass="font-medium text-gray-200"
/>
<!-- Bouton supprimer ligne -->
<button
@click="removeLigne(idx)"
class="p-1 text-gray-500 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
title="Supprimer cette ligne"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
</div>
<!-- Bouton ajouter ligne si aucune -->
<div v-else class="text-center py-2">
<!-- Content : le tableau du compte rendu, colonne pour colonne.
Monte a l'ouverture (`v-if`) : un document compte une vingtaine de lots,
et garder replies une vingtaine de tableaux d'une centaine de champs
editables les ferait re-rendre a chaque frappe sans qu'on les voie. -->
<div v-if="expanded" class="border-t border-gray-700 bg-gray-900/60 p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-gray-500 font-medium">Detail des lignes</span>
<button
@click="addLigne"
class="flex items-center gap-1 px-3 py-1.5 text-xs text-blue-400 hover:bg-blue-500/10 rounded transition-colors mx-auto"
class="flex items-center gap-1 px-2 py-1 text-xs text-blue-400 hover:bg-blue-500/10 rounded transition-colors"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Ajouter une ligne
Ajouter
</button>
</div>
<div class="overflow-x-auto">
<table class="w-full text-xs border-separate border-spacing-0">
<thead>
<tr class="text-[10px] uppercase tracking-wide text-gray-500">
<th class="text-left font-medium pb-1 pr-2">Type</th>
<!-- Le compte rendu loge dans cette colonne aussi bien une periode
qu'un libelle de report : l'en-tete le dit. -->
<th class="text-left font-medium pb-1 pr-2">Periode / Libelle</th>
<th
v-for="colonne in COLONNES_TABLEAU"
:key="colonne.champ"
class="font-medium pb-1 px-2"
:class="alignement(colonne)"
>
{{ colonne.libelle }}
</th>
<th class="pb-1"></th>
</tr>
</thead>
<tbody>
<tr
v-for="(ligne, idx) in locataire.lignes"
:key="idx"
class="bg-gray-800/60 hover:bg-gray-800"
>
<td class="py-1 pr-2 border-t border-gray-700/60">
<select
:value="ligne.type"
@change="updateLigneField(idx, 'type', $event.target.value)"
class="px-1.5 py-0.5 rounded text-xs border border-gray-700 bg-gray-950 text-gray-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="loyer">loyer</option>
<option value="solde_anterieur">solde_anterieur</option>
<option value="rappel_loyer">rappel_loyer</option>
<option value="divers">divers</option>
</select>
</td>
<!-- Un report de solde porte son montant dans cette colonne sur le
compte rendu, pas dans « Loyers » : le tableau fait de meme,
sans quoi la colonne Loyers semblerait ne pas sommer. Le libelle
du compte rendu n'est pas repris, la colonne « Type » le donne. -->
<td class="py-1 pr-2 border-t border-gray-700/60">
<div v-if="ligne.type === 'solde_anterieur'" class="flex items-center gap-1 whitespace-nowrap">
<EditableField
:modelValue="ligne.loyers"
@update:modelValue="updateLigneField(idx, 'loyers', $event)"
type="currency"
displayClass="text-xs text-gray-200"
/>
</div>
<div v-else class="flex items-center gap-1 text-gray-500 whitespace-nowrap">
<EditableField
:modelValue="ligne.periode?.debut"
@update:modelValue="updateLigneField(idx, 'periode.debut', $event)"
type="date"
placeholder="debut"
displayClass="text-xs"
/>
<span>-</span>
<EditableField
:modelValue="ligne.periode?.fin"
@update:modelValue="updateLigneField(idx, 'periode.fin', $event)"
type="date"
placeholder="fin"
displayClass="text-xs"
/>
</div>
</td>
<td
v-for="colonne in COLONNES_TABLEAU"
:key="colonne.champ"
class="py-1 px-2 border-t border-gray-700/60"
>
<!-- Divers : libelle puis montant, comme sur le compte rendu.
Le libelle reste vide tant qu'il n'y en a pas, mais cliquable. -->
<div v-if="colonne.champ === 'divers'" class="flex items-center justify-between gap-2">
<EditableField
:modelValue="ligne.divers?.libelle"
@update:modelValue="updateLigneField(idx, 'divers.libelle', $event)"
type="text"
placeholder=""
displayClass="text-xs text-gray-400 italic"
/>
<EditableField
:modelValue="ligne.divers?.montant"
@update:modelValue="updateLigneField(idx, 'divers.montant', $event)"
type="currency"
displayClass="text-xs text-gray-200"
/>
</div>
<!-- Le montant d'un report est rendu dans la colonne « Periode » -->
<div
v-else-if="colonne.champ !== 'loyers' || ligne.type !== 'solde_anterieur'"
class="flex justify-end"
>
<EditableField
:modelValue="ligne[colonne.champ]"
@update:modelValue="updateLigneField(idx, colonne.champ, $event)"
type="currency"
:displayClass="`text-xs ${classeMontant(colonne.champ, ligne[colonne.champ])}`"
/>
</div>
</td>
<td class="py-1 border-t border-gray-700/60">
<button
@click="removeLigne(idx)"
class="p-1 text-gray-500 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
title="Supprimer cette ligne"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</td>
</tr>
<tr v-if="!locataire.lignes?.length">
<td colspan="10" class="py-3 text-center text-gray-500 border-t border-gray-700/60">
Aucune ligne extraite pour ce lot
</td>
</tr>
</tbody>
<!-- Ligne « Totaux » du compte rendu : extraite elle aussi, donc editable -->
<tfoot>
<tr class="bg-gray-800">
<td class="py-1 pr-2 border-t-2 border-gray-600 text-gray-300 font-medium">Totaux</td>
<!-- Le solde anterieur occupe la colonne « Periode », comme sur le
compte rendu, mais se recoupe comme les autres colonnes. -->
<td
class="py-1 pr-2 border-t-2 border-gray-600"
:class="classeCellule('solde_anterieur')"
>
<div class="flex items-center gap-1 whitespace-nowrap">
<span class="text-[10px] uppercase tracking-wide text-gray-500">Solde ant.</span>
<EditableField
:modelValue="locataire.totaux?.solde_anterieur"
@update:modelValue="updateField('totaux.solde_anterieur', $event)"
type="currency"
displayClass="text-xs text-gray-200"
/>
<span
v-if="ecarts.solde_anterieur"
class="text-[10px] text-amber-500/90 italic cursor-help whitespace-nowrap"
:title="detailEcart('solde_anterieur')"
>
calculé : {{ formatMontantPrecis(ecarts.solde_anterieur.calcule) }}
</span>
</div>
</td>
<!-- Une colonne qui ne somme pas est signalee sur toute la cellule :
c'est le premier endroit ou l'oeil verifie le compte rendu. -->
<td
v-for="colonne in COLONNES_TABLEAU"
:key="colonne.champ"
class="py-1 px-2 border-t-2 border-gray-600"
:class="classeCellule(colonne.champ)"
>
<div class="flex flex-col items-end">
<EditableField
:modelValue="locataire.totaux?.[colonne.champ]"
@update:modelValue="updateField(`totaux.${colonne.champ}`, $event)"
type="currency"
:displayClass="`text-xs font-semibold ${classeMontant(colonne.champ, locataire.totaux?.[colonne.champ])}`"
/>
<!-- Somme de la colonne. Ni pointille ni contraste : non modifiable. -->
<span
v-if="ecarts[colonne.champ]"
class="text-[10px] text-amber-500/90 italic mt-0.5 cursor-help px-1 whitespace-nowrap"
:title="detailEcart(colonne.champ)"
>
calculé : {{ formatMontantPrecis(ecarts[colonne.champ].calcule) }}
</span>
</div>
</td>
<td class="border-t-2 border-gray-600"></td>
</tr>
</tfoot>
</table>
</div>
<p v-if="aDesEcarts" class="mt-2 text-[10px] text-gray-500 italic">
Colonne(s) en surbrillance : le montant du compte rendu ne correspond pas a ce que
totalisent les lignes ci-dessus (« calculé : »), signe qu'une valeur n'a pas ete
extraite. Seuls les champs soulignes sont modifiables, et eux seuls partent en base.
</p>
</div>
</div>
</template>
@@ -226,6 +301,14 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import EditableField from './EditableField.vue'
import { setNestedValue } from '../utils/chemin'
import { formatMontantPrecis } from '../utils/format'
import {
COLONNES_CRG,
COLONNES_TABLEAU,
ecartsAvecLignes,
totauxCalcules,
} from '../utils/totauxLocataire'
const props = defineProps({
locataire: {
@@ -248,6 +331,10 @@ const props = defineProps({
const emit = defineEmits(['update:locataire', 'remove'])
const LIBELLES = Object.fromEntries(
COLONNES_CRG.map(({ champ, libelle }) => [champ, libelle])
)
const expanded = ref(false)
const rootEl = ref(null)
const isHighlighted = ref(false)
@@ -265,6 +352,32 @@ watch(() => props.highlighted, (val) => {
}
}, { immediate: true })
// Tout ce qui est affiche vient de l'extraction. Les lignes sont neanmoins
// reagregees pour un seul usage : reperer les lots ou l'extraction se contredit.
// Ces valeurs ne sont ni enregistrees, ni reportees dans les champs.
const calcules = computed(() => totauxCalcules(props.locataire.lignes))
const ecarts = computed(() => ecartsAvecLignes(props.locataire.totaux, calcules.value))
const aDesEcarts = computed(() => Object.keys(ecarts.value).length > 0)
const resumeEcarts = computed(() =>
Object.keys(ecarts.value)
.map((champ) => detailEcart(champ))
.join('\n\n')
)
function detailEcart(champ) {
const nb = props.locataire.lignes?.length || 0
const { extrait, calcule, manquant } = ecarts.value[champ]
return (
`${LIBELLES[champ]}\n` +
`Extrait du compte rendu : ${formatMontantPrecis(extrait)}\n` +
`Calculé sur les ${nb} ligne(s) : ${formatMontantPrecis(calcule)}\n` +
`Écart de ${formatMontantPrecis(manquant)} : aucune ligne ne porte ce montant.`
)
}
const totalClass = computed(() => {
const total = props.locataire.totaux?.total || 0
if (total > 0) return 'text-green-400'
@@ -272,9 +385,20 @@ const totalClass = computed(() => {
return 'text-gray-400'
})
function formatCurrency(value) {
if (value === null || value === undefined) return '-'
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value)
// Les regles apparaissent en vert sur le compte rendu, les impayes en rouge.
function classeMontant(champ, valeur) {
if (champ === 'regles') return 'text-green-400'
if (champ === 'impayes') return valeur ? 'text-red-400' : 'text-gray-200'
return 'text-gray-200'
}
function classeCellule(champ) {
return ecarts.value[champ] ? 'bg-amber-500/10 ring-1 ring-inset ring-amber-500/40' : ''
}
// Le compte rendu aligne ses montants a droite et le libelle « Divers » a gauche.
function alignement(colonne) {
return colonne.champ === 'divers' ? 'text-left' : 'text-right'
}
// Mettre a jour un champ nested (ex: "lot.numero", "totaux.loyers")
@@ -284,30 +408,14 @@ function updateField(path, value) {
emit('update:locataire', updated)
}
// Helper pour setter une valeur nested
function setNestedValue(obj, path, value) {
const parts = path.split('.')
let current = obj
for (let i = 0; i < parts.length - 1; i++) {
if (!current[parts[i]]) {
current[parts[i]] = {}
}
current = current[parts[i]]
}
current[parts[parts.length - 1]] = value
}
// Mettre a jour un champ d'une ligne
// Mettre a jour un champ d'une ligne. Aucun autre champ n'est touche : une
// correction de l'utilisateur ne doit rien declencher qu'il n'ait pas demande.
function updateLigneField(ligneIndex, path, value) {
const updated = JSON.parse(JSON.stringify(props.locataire))
if (!updated.lignes) updated.lignes = []
if (path.includes('.')) {
setNestedValue(updated.lignes[ligneIndex], path, value)
} else {
updated.lignes[ligneIndex][path] = value
}
setNestedValue(updated.lignes[ligneIndex], path, value)
emit('update:locataire', updated)
}
@@ -315,7 +423,7 @@ function updateLigneField(ligneIndex, path, value) {
function addLigne() {
const updated = JSON.parse(JSON.stringify(props.locataire))
if (!updated.lignes) updated.lignes = []
updated.lignes.push({
type: 'loyer',
periode: { debut: null, fin: null },
@@ -327,7 +435,7 @@ function addLigne() {
regles: 0,
impayes: 0
})
emit('update:locataire', updated)
}

View File

@@ -0,0 +1,45 @@
<template>
<router-link
:to="to"
class="nav-link"
:class="[
actif ? 'bg-gray-800 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-800',
replie ? 'justify-center px-0' : ''
]"
:title="replie ? label : null"
:aria-label="label"
>
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-for="d in paths"
:key="d"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
:d="d"
/>
</svg>
<span v-if="!replie" class="truncate">{{ label }}</span>
</router-link>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const props = defineProps({
to: { type: String, required: true },
label: { type: String, required: true },
paths: { type: Array, required: true },
replie: { type: Boolean, default: false }
})
const route = useRoute()
// Une fiche de lot (/lots/26) doit garder son entrée allumée : comparer les
// chemins à l'identique éteindrait la navigation dès qu'une page a des
// sous-routes.
const actif = computed(
() => route.path === props.to || route.path.startsWith(`${props.to}/`)
)
</script>

View File

@@ -194,6 +194,7 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import EditableField from './EditableField.vue'
import { setNestedValue } from '../utils/chemin'
const props = defineProps({
categorie: {
@@ -216,7 +217,9 @@ const props = defineProps({
const emit = defineEmits(['update:operations'])
const expanded = ref(false)
// Déplié par défaut : les dépenses sont l'information qu'on vient consulter et
// corriger, les replier une par une coûtait un clic avant toute lecture.
const expanded = ref(true)
const opRefs = ref({})
const highlightedIdx = ref(-1)
@@ -257,19 +260,6 @@ function formatCurrency(value) {
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value)
}
// Helper pour setter une valeur nested
function setNestedValue(obj, path, value) {
const parts = path.split('.')
let current = obj
for (let i = 0; i < parts.length - 1; i++) {
if (!current[parts[i]]) {
current[parts[i]] = {}
}
current = current[parts[i]]
}
current[parts[parts.length - 1]] = value
}
// Mettre a jour un champ d'une operation
function updateOperationField(opIndex, path, value) {
const updatedOperations = JSON.parse(JSON.stringify(props.operations))

View File

@@ -1,53 +1,51 @@
<template>
<div class="flex flex-col h-full bg-gray-950 overflow-hidden relative">
<!-- Header -->
<div class="flex-shrink-0 flex items-center px-3 py-2 bg-gray-900 border-b border-gray-700 text-white">
<span class="text-sm font-medium truncate">{{ fileName }}</span>
<div class="flex flex-col h-full bg-gray-950 overflow-hidden">
<!-- Bandeau -->
<div class="flex-shrink-0 flex items-center gap-2 px-3 py-2 bg-gray-900 border-b border-gray-700 text-white">
<span class="text-sm font-medium truncate flex-1">{{ fileName }}</span>
<!-- Porte de sortie : si le moteur n'a pas de lecteur PDF intégré, le
volet reste vide et ce lien reste le seul moyen d'ouvrir le
document. Il sert aussi à le lire en grand sans quitter la page. -->
<a
v-if="pdfUrl"
:href="pdfUrl"
target="_blank"
rel="noopener"
class="flex-shrink-0 p-1 rounded text-gray-400 hover:text-white hover:bg-gray-700 transition-colors"
title="Ouvrir le PDF dans un nouvel onglet"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
<!-- États -->
<div
v-if="loading"
class="flex-1 flex items-center justify-center text-gray-500 text-sm"
>
Chargement du PDF
</div>
<div
v-else-if="error"
class="flex-1 flex items-center justify-center px-4 text-center text-red-400 text-sm"
>
{{ error }}
</div>
<!-- Rendu PDF.js : une <canvas> par page, empilées et scrollables.
Ne dépend pas du lecteur PDF natif du moteur (Qt WebEngine,
WebView2, navigateur) -> rendu identique partout. -->
<div
v-else-if="hasSource"
ref="container"
class="flex-1 overflow-auto custom-scrollbar p-2 bg-gray-950"
/>
<!-- Empty state -->
<div
v-else
class="flex-1 flex items-center justify-center text-gray-500 text-sm"
>
Aucun PDF sélectionné
<div class="flex-1 relative overflow-hidden">
<!-- Lecteur PDF natif du moteur : il apporte le zoom, la recherche, la
sélection de texte et l'impression sans code de notre côté, et
redessine à chaque niveau de zoom (donc net partout). Un rendu
maison sur <canvas> était figé à la largeur du volet, soit 0,83x
sur une page A4 dans une fenêtre de 1280 px : les tableaux du CRG
en 7 pt tombaient à 6 px de haut, illisibles. -->
<iframe
v-if="pdfUrl"
:key="pdfUrl"
:src="urlLecteur"
type="application/pdf"
:title="fileName"
class="absolute inset-0 w-full h-full border-0"
/>
<!-- Aucune source -->
<div v-else class="h-full flex items-center justify-center text-gray-500 text-sm">
Aucun PDF sélectionné
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, onUnmounted, nextTick } from 'vue'
// Build « legacy » (et non le build par défaut) : il embarque les polyfills
// nécessaires aux moteurs Chromium embarqués (Qt WebEngine, WebView2), plus
// anciens que la dernière version de Chrome. Le build moderne utilise
// `Map.prototype.getOrInsertComputed` (proposition JS récente) sans polyfill,
// ce qui casse le rendu dans la fenêtre bureau. Voir docs pdf.js « legacy ».
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs'
import PdfWorker from 'pdfjs-dist/legacy/build/pdf.worker.min.mjs?url'
// Worker bundlé localement par Vite (pas de CDN) -> fonctionne hors-ligne.
pdfjsLib.GlobalWorkerOptions.workerSrc = PdfWorker
import { computed, ref, watch, onUnmounted } from 'vue'
const props = defineProps({
file: { type: File, default: null },
@@ -55,92 +53,44 @@ const props = defineProps({
fileName: { type: String, default: 'document.pdf' }
})
const container = ref(null)
const loading = ref(false)
const error = ref(null)
const hasSource = ref(false)
const pdfUrl = ref(null)
let objectUrl = null
let loadingTask = null
// Jeton de génération : invalide les rendus concurrents (changement de source).
let renderToken = 0
/** Ouverture à l'échelle 1:1. Le zoom par défaut du lecteur intégré tombe vers
35-40 % dans un volet étroit, ce qui réduit les tableaux du CRG (7 pt) à
3 px de haut. « Ajuster à la largeur » ne sauve rien ici dans un volet de
430 px cela ne donne que 54 %, soit 5 px alors qu'à 100 % le 7 pt fait
9,3 px et redevient lisible, quitte à défiler tant que le volet est étroit.
Les mots-clés `page-width`, `view=FitH` et `pagemode=none` sont ignorés par
le lecteur de Chrome ; une valeur numérique, elle, est bien honorée
(vérifié à la main). Le reste se règle dans le lecteur lui-même : boutons
/ + pour le zoom, ☰ pour replier la barre latérale de vignettes. Ces deux
choix sont mémorisés par le moteur, d' le profil persistant imposé à la
fenêtre bureau (voir `desktop.py`). */
const urlLecteur = computed(() => (pdfUrl.value ? `${pdfUrl.value}#zoom=100` : null))
async function resolveSource() {
if (props.file) return { data: await props.file.arrayBuffer() }
if (props.url) return { url: props.url }
return null
}
async function destroyTask() {
if (loadingTask) {
try {
await loadingTask.destroy()
} catch {
/* ignore */
}
loadingTask = null
function libererObjectUrl() {
if (objectUrl) {
URL.revokeObjectURL(objectUrl)
objectUrl = null
}
}
async function render() {
const token = ++renderToken
error.value = null
await destroyTask()
const source = await resolveSource()
hasSource.value = source !== null
if (!source) {
loading.value = false
return
}
loading.value = true
try {
loadingTask = pdfjsLib.getDocument(source)
const doc = await loadingTask.promise
if (token !== renderToken) return
// Le conteneur n'est monté qu'une fois loading=false.
loading.value = false
await nextTick()
const el = container.value
if (!el || token !== renderToken) return
el.innerHTML = ''
const targetWidth = Math.max(el.clientWidth - 16, 0)
const dpr = window.devicePixelRatio || 1
for (let n = 1; n <= doc.numPages; n++) {
if (token !== renderToken) return
const page = await doc.getPage(n)
const base = page.getViewport({ scale: 1 })
const scale = targetWidth > 0 ? targetWidth / base.width : 1
const viewport = page.getViewport({ scale })
const canvas = document.createElement('canvas')
canvas.width = Math.floor(viewport.width * dpr)
canvas.height = Math.floor(viewport.height * dpr)
canvas.style.width = '100%'
canvas.className = 'block mx-auto mb-2 bg-white shadow'
const ctx = canvas.getContext('2d')
ctx.scale(dpr, dpr)
await page.render({ canvasContext: ctx, viewport }).promise
if (token !== renderToken) return
el.appendChild(canvas)
watch(
[() => props.file, () => props.url],
([fichier, url]) => {
libererObjectUrl()
if (url) {
pdfUrl.value = url
} else if (fichier) {
objectUrl = URL.createObjectURL(fichier)
pdfUrl.value = objectUrl
} else {
pdfUrl.value = null
}
} catch (e) {
console.error('[PdfPreview] render error:', e?.message, e)
if (token === renderToken) {
error.value = "Impossible d'afficher le PDF."
loading.value = false
}
}
}
},
{ immediate: true }
)
watch([() => props.file, () => props.url], render, { immediate: true })
onUnmounted(() => {
renderToken++
destroyTask()
})
onUnmounted(libererObjectUrl)
</script>

View File

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

View File

@@ -0,0 +1,117 @@
<template>
<div ref="conteneur" class="flex-1 flex overflow-hidden">
<div
class="flex flex-col overflow-hidden"
:class="classeGauche"
:style="{ width: `${largeurGauche}%` }"
>
<slot name="gauche" />
</div>
<!-- Bande de saisie de 8 px pour un trait de 1 px : viser un filet d'un
pixel à la souris est pénible. Les marges négatives annulent sa
largeur dans le calcul du flux, pour que les deux volets se partagent
bien 100 % ; la bande déborde donc de 4 px sur chacun, d' le z-10. -->
<div
class="group flex-shrink-0 w-2 -mx-1 z-10 flex justify-center cursor-col-resize"
role="separator"
aria-orientation="vertical"
title="Glisser pour redimensionner — double-clic pour réinitialiser"
@mousedown.prevent="demarrerGlissement"
@dblclick="reinitialiser"
>
<div
class="h-full w-px bg-gray-700 group-hover:bg-blue-500 transition-colors"
:class="{ '!bg-blue-500': glissement }"
/>
</div>
<div
class="flex flex-col overflow-hidden"
:class="classeDroite"
:style="{ width: `${100 - largeurGauche}%` }"
>
<slot name="droite" />
</div>
<!-- Voile actif seulement pendant le glissement. Sans lui, l'<iframe> du
PDF avale les évènements souris dès que le curseur la survole et le
redimensionnement se fige en cours de route. -->
<div v-if="glissement" class="fixed inset-0 z-50 cursor-col-resize" />
</div>
</template>
<script setup>
import { ref, onUnmounted } from 'vue'
const props = defineProps({
/** Clé localStorage : la largeur choisie est propre à chaque écran. */
cleStockage: { type: String, required: true },
/** Largeur du volet gauche en %, au premier affichage et au double-clic. */
largeurDefaut: { type: Number, default: 40 },
largeurMin: { type: Number, default: 20 },
largeurMax: { type: Number, default: 80 },
/** Classes posées sur les conteneurs de volet (fond, bordure...). */
classeGauche: { type: String, default: '' },
classeDroite: { type: String, default: '' }
})
const conteneur = ref(null)
const glissement = ref(false)
/** Le stockage peut être indisponible (navigation privée, réglages) ou
contenir une valeur hors bornes si les props ont changé depuis. */
function largeurMemorisee() {
try {
const valeur = Number(localStorage.getItem(props.cleStockage))
if (Number.isFinite(valeur) && valeur >= props.largeurMin && valeur <= props.largeurMax) {
return valeur
}
} catch {
/* stockage indisponible */
}
return props.largeurDefaut
}
const largeurGauche = ref(largeurMemorisee())
function memoriser(largeur) {
try {
localStorage.setItem(props.cleStockage, String(Math.round(largeur)))
} catch {
/* stockage indisponible */
}
}
function surDeplacement(evenement) {
const rect = conteneur.value?.getBoundingClientRect()
if (!rect?.width) return
const pourcentage = ((evenement.clientX - rect.left) / rect.width) * 100
largeurGauche.value = Math.min(props.largeurMax, Math.max(props.largeurMin, pourcentage))
}
function arreterGlissement() {
if (!glissement.value) return
glissement.value = false
document.removeEventListener('mousemove', surDeplacement)
document.removeEventListener('mouseup', arreterGlissement)
document.body.style.userSelect = ''
memoriser(largeurGauche.value)
}
function demarrerGlissement() {
glissement.value = true
document.addEventListener('mousemove', surDeplacement)
document.addEventListener('mouseup', arreterGlissement)
// Sans quoi le glissement sélectionne le texte des deux volets.
document.body.style.userSelect = 'none'
}
function reinitialiser() {
largeurGauche.value = props.largeurDefaut
memoriser(props.largeurDefaut)
}
// Un démontage en plein glissement laisserait les écouteurs sur `document`.
onUnmounted(arreterGlissement)
</script>

View File

@@ -84,7 +84,7 @@
</td>
<td class="text-center">
<router-link
:to="{ path: '/documents/' + dep.document_id + '/edit', query: { highlight: 'operation', fournisseur: dep.fournisseur, description: dep.description } }"
:to="lienEdition(dep)"
class="text-gray-500 hover:text-blue-400 transition-colors"
title="Editer le document source"
>
@@ -142,6 +142,9 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const props = defineProps({
depenses: {
@@ -169,6 +172,20 @@ const paginatedDepenses = computed(() =>
props.depenses.slice(startIndex.value, endIndex.value)
)
/** L'editeur recoit la vue d'ou l'on part, filtres compris : une fois
l'enregistrement termine, il y ramene au lieu de la liste des documents. */
function lienEdition(depense) {
return {
path: `/documents/${depense.document_id}/edit`,
query: {
highlight: 'operation',
fournisseur: depense.fournisseur,
description: depense.description,
retour: route.fullPath
}
}
}
function formatDate(dateStr) {
if (!dateStr) return '-'
const d = new Date(dateStr)

View File

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

View File

@@ -0,0 +1,93 @@
<template>
<div class="card card-body">
<h3 class="card-title block mb-4">Repartition par tag</h3>
<div class="h-64">
<Doughnut
v-if="chartData.labels.length > 0"
:data="chartData"
:options="chartOptions"
/>
<div v-else class="h-full flex items-center justify-center text-gray-500 text-sm">
Aucune donnee
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { Doughnut } from 'vue-chartjs'
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'
ChartJS.register(ArcElement, Tooltip, Legend)
const props = defineProps({
data: {
type: Array,
default: () => []
}
})
const colors = [
'#3B82F6', // blue
'#EF4444', // red
'#10B981', // green
'#F59E0B', // amber
'#8B5CF6', // purple
'#EC4899', // pink
'#06B6D4', // cyan
'#84CC16', // lime
'#F97316', // orange
'#6366F1', // indigo
]
/** Gris pour les depenses sans tag : elles restent visibles - c'est le
travail de tagging qui reste a faire - sans se disputer une couleur
avec les postes reels. */
const COULEUR_SANS_TAG = '#6B7280'
const parts = computed(() => props.data.filter((p) => (p.total_debit ?? 0) > 0).slice(0, 10))
const chartData = computed(() => ({
labels: parts.value.map((p) => tronquer(p.tag_nom || 'Non taggue')),
datasets: [{
data: parts.value.map((p) => p.total_debit),
backgroundColor: parts.value.map((p, i) =>
p.tag_id === null || p.tag_id === undefined ? COULEUR_SANS_TAG : colors[i % colors.length]
),
borderColor: '#1F2937',
borderWidth: 2
}]
}))
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: {
color: '#9CA3AF',
font: { size: 11 },
boxWidth: 12,
padding: 8
}
},
tooltip: {
callbacks: {
label: (ctx) => {
const value = new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR'
}).format(ctx.raw)
return ` ${value}`
}
}
}
}
}
function tronquer(nom) {
return nom.substring(0, 25)
}
</script>

View File

@@ -0,0 +1,196 @@
<template>
<div>
<div class="flex items-center justify-between mb-4 flex-wrap gap-2">
<h3 class="text-sm font-medium text-white">Loyer mois par mois</h3>
<div class="flex items-center gap-4 text-xs flex-wrap">
<span class="flex items-center gap-1">
<span class="w-3 h-3 rounded bg-green-400"></span>
<span class="text-gray-400">Loyer hors charges</span>
</span>
<span class="flex items-center gap-1">
<span class="w-3 h-3 rounded bg-slate-500"></span>
<span class="text-gray-400">Charges</span>
</span>
<span v-if="aDesProratas" class="flex items-center gap-1">
<span class="w-3 h-3 rounded bg-violet-400"></span>
<span class="text-gray-400">Prorata</span>
</span>
<span v-if="aUneSurface" class="flex items-center gap-1">
<span class="w-3 h-1.5 rounded bg-amber-400"></span>
<span class="text-gray-400">/</span>
</span>
</div>
</div>
<div v-if="!serie.length" class="h-64 flex items-center justify-center text-gray-500">
Aucun loyer facturé sur ce lot.
</div>
<div v-else class="h-72">
<Bar :data="donneesGraphe" :options="options" />
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { Bar } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarController,
BarElement,
LineController,
LineElement,
PointElement,
Tooltip,
Legend
} from 'chart.js'
import { formatMoisAnnee } from '../../utils/format'
// `LineController` en plus des éléments : la courbe du €/m² partage le graphe
// des barres, et Chart.js refuse un type de dataset dont le contrôleur n'a pas
// été enregistré — les autres graphes de l'application n'en mélangent aucun.
ChartJS.register(
CategoryScale,
LinearScale,
BarController,
BarElement,
LineController,
LineElement,
PointElement,
Tooltip,
Legend
)
const props = defineProps({
serie: { type: Array, required: true, default: () => [] },
surface: { type: Number, default: null }
})
const aUneSurface = computed(() => props.surface != null && props.surface > 0)
const aDesProratas = computed(() => props.serie.some((point) => point.prorata != null))
const EUROS = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' })
// Les mois vides restent `null` et non 0 : Chart.js laisse alors un blanc, qui
// est la lecture juste d'une vacance. Un zéro dessinerait une barre au sol,
// c'est-à-dire un loyer nul — ce qu'aucun compte rendu ne dit.
const donneesGraphe = computed(() => {
const jeux = [
{
label: 'Loyer',
data: props.serie.map((point) => point.loyer),
backgroundColor: 'rgba(74, 222, 128, 0.8)',
borderRadius: 3,
stack: 'facture',
order: 3
},
{
label: 'Charges',
data: props.serie.map((point) => point.charges),
backgroundColor: 'rgba(100, 116, 139, 0.7)',
borderRadius: 3,
stack: 'facture',
order: 3
}
]
if (aDesProratas.value) {
jeux.push({
label: 'Prorata',
data: props.serie.map((point) => point.prorata),
backgroundColor: 'rgba(167, 139, 250, 0.85)',
borderRadius: 3,
stack: 'facture',
order: 3
})
}
// Le loyer au m² se lit sur son propre axe : mis à la même échelle que des
// centaines d'euros, sa courbe serait collée au zéro.
if (aUneSurface.value) {
jeux.push({
type: 'line',
label: '€/m²',
data: props.serie.map((point) => point.loyer_m2),
yAxisID: 'y1',
borderColor: 'rgb(251, 191, 36)',
backgroundColor: 'rgb(251, 191, 36)',
borderWidth: 2,
pointRadius: 2,
tension: 0,
spanGaps: false,
order: 1
})
}
return { labels: props.serie.map((point) => formatMoisAnnee(point.mois)), datasets: jeux }
})
const options = computed(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgb(31, 41, 55)',
borderColor: 'rgb(75, 85, 99)',
borderWidth: 1,
titleColor: 'rgb(255, 255, 255)',
bodyColor: 'rgb(156, 163, 175)',
padding: 12,
callbacks: {
label: (contexte) => {
if (contexte.raw == null) return null
const valeur =
contexte.dataset.label === '€/m²'
? `${contexte.raw.toFixed(2).replace('.', ',')} €/m²`
: EUROS.format(contexte.raw)
return `${contexte.dataset.label} : ${valeur}`
},
// Un mois sans loyer plein se lit différemment selon qu'il est vacant
// ou en changement de locataire : le dire dans l'infobulle évite de
// laisser le lecteur deviner devant un trou.
afterBody: (contextes) => {
const point = props.serie[contextes[0].dataIndex]
if (!point) return null
const notes = []
if (point.en_transition) notes.push('Changement de locataire')
else if (point.loyer == null) notes.push('Aucun loyer facturé')
if (point.reparti) notes.push('Réparti depuis une facturation pluri-mensuelle')
return notes.length ? notes : null
}
}
}
},
scales: {
x: {
stacked: true,
grid: { display: false },
ticks: { color: 'rgb(156, 163, 175)', font: { size: 10 }, maxRotation: 0 }
},
y: {
stacked: true,
grid: { color: 'rgb(55, 65, 81)' },
ticks: {
color: 'rgb(156, 163, 175)',
font: { size: 11 },
callback: (valeur) => (valeur >= 1000 ? `${(valeur / 1000).toFixed(1)}k` : valeur)
}
},
y1: {
display: aUneSurface.value,
position: 'right',
grid: { display: false },
ticks: {
color: 'rgb(251, 191, 36)',
font: { size: 11 },
callback: (valeur) => `${valeur}`
}
}
}
}))
</script>

View File

@@ -0,0 +1,313 @@
<template>
<div class="card">
<div class="card-header">
<h2 class="card-title">Loyer</h2>
<router-link
v-if="!aUneSurface"
to="/logements"
class="btn btn-secondary btn-sm"
>
Saisir la surface
</router-link>
</div>
<!-- Le loyer en vigueur, son niveau au , sa dernière révision. Chaque
case garde sa place même vide : un tableau qui perd une colonne selon
le lot empêche de comparer deux fiches. -->
<div class="card-body grid grid-cols-2 lg:grid-cols-4 gap-4">
<div v-for="tuile in tuiles" :key="tuile.libelle">
<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>
<div class="card-body pt-0">
<LoyerChart :serie="loyer.serie" :surface="loyer.surface" />
<!-- La courbe suit la période choisie, les tuiles du dessus non : le dire
ici évite de lire « depuis mai 26 » au-dessus d'un graphe qui
commence en avril et d'y voir une contradiction. -->
<p v-if="loyer.mois_masques" class="form-hint mt-3">
Courbe limitée à la période choisie :
{{ loyer.mois_masques }}
{{ loyer.mois_masques > 1 ? 'mois antérieurs ne sont pas tracés' : 'mois antérieur n\'est pas tracé' }}.
Le loyer en vigueur, sa date d'effet et la comparaison au parc restent lus
sur tout l'historique.
</p>
</div>
<!-- Comparaison au parc : les médianes excluent le lot lui-même, et
l'effectif accompagne toujours le chiffre. -->
<div v-if="loyer.parc" class="card-body pt-0">
<h3 class="text-sm font-medium text-white mb-3">
Au mètre carré, face au parc
<span class="text-gray-500 font-normal">· {{ formatMoisAnnee(loyer.parc.mois) }}</span>
</h3>
<div v-if="!aUneSurface" class="form-hint">
Sans surface saisie sur ce lot, il n'y a rien à comparer. La fiche se complète
depuis
<router-link
to="/logements"
class="text-blue-400 hover:text-blue-300 transition-colors underline"
>Logements</router-link>.
</div>
<template v-else>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div v-for="repere in reperes" :key="repere.libelle" class="bg-gray-950 rounded p-3">
<div class="text-xs text-gray-400 uppercase tracking-wide">{{ repere.libelle }}</div>
<div class="text-lg font-semibold" :class="repere.classe">{{ repere.valeur }}</div>
<div class="text-xs text-gray-500 mt-1">{{ repere.detail }}</div>
</div>
</div>
<p v-if="loyer.parc.sans_surface" class="form-hint mt-3">
{{ loyer.parc.sans_surface }}
{{ loyer.parc.sans_surface > 1 ? 'lots loués ce mois-là n\'ont' : 'lot loué ce mois-là n\'a' }}
pas de surface sur sa fiche : {{ loyer.parc.sans_surface > 1 ? 'ils restent' : 'il reste' }}
hors de ces médianes.
</p>
<!-- Les médianes ci-dessus mêlent toutes les surfaces ; le nuage rend
la comparaison à taille comparable, que le chiffre seul écrase. -->
<div class="mt-6">
<ParcScatter :nuage="loyer.parc.nuage" />
</div>
</template>
</div>
<!-- Ce que la courbe ne peut pas porter reste visible : sans cette liste,
des montants disparaîtraient de la page sans que rien ne le signale.
Le bloc s'affiche aussi quand la période n'en laisse aucune, pour dire
qu'il en existe. -->
<div v-if="loyer.hors_courbe.length || loyer.hors_courbe_masquees" class="card-body pt-0">
<!-- Le compteur disparaît quand la période ne laisse rien à lister : un
« · 0 » se lirait comme une absence, alors que le texte annonce
justement des lignes mises de côté. -->
<h3 class="text-sm font-medium text-white mb-2">
Hors de la courbe<template v-if="loyer.hors_courbe.length"> · {{ loyer.hors_courbe.length }}</template>
</h3>
<p class="form-hint mb-3">
<template v-if="loyer.hors_courbe.length">
Régularisations à cheval sur plusieurs mois sans en couvrir aucun entièrement.
Les rattacher à un mois inventerait un loyer que le compte rendu ne porte pas ;
elles figurent dans la chronologie ci-dessous.
<template v-if="loyer.hors_courbe_masquees">
{{ loyer.hors_courbe_masquees }}
{{ loyer.hors_courbe_masquees > 1 ? 'autres sont antérieures' : 'autre est antérieure' }}
à la période choisie.
</template>
</template>
<template v-else>
{{ loyer.hors_courbe_masquees }}
{{ loyer.hors_courbe_masquees > 1 ? 'régularisations antérieures' : 'régularisation antérieure' }}
à la période choisie : élargir la période
{{ loyer.hors_courbe_masquees > 1 ? 'les ramène' : 'la ramène' }} ici.
</template>
</p>
<table v-if="loyer.hors_courbe.length" class="table">
<thead>
<tr>
<th>Période</th>
<th class="text-right">Montant</th>
</tr>
</thead>
<tbody>
<tr v-for="(ligne, index) in loyer.hors_courbe" :key="index">
<td class="text-gray-300">
{{ formatDate(ligne.periode_debut) }} → {{ formatDate(ligne.periode_fin) }}
</td>
<td class="text-right" :class="ligne.montant < 0 ? 'text-red-400' : 'text-gray-300'">
{{ formatMontantPrecis(ligne.montant) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import LoyerChart from './LoyerChart.vue'
import ParcScatter from './ParcScatter.vue'
import {
formatDate,
formatMoisAnnee,
formatMontant,
formatMontantPrecis
} from '../../utils/format'
const props = defineProps({
loyer: { type: Object, required: true }
})
const aUneSurface = computed(() => props.loyer.surface != null && props.loyer.surface > 0)
/** "12,14 €/m²", ou le tiret des valeurs qu'on n'a pas. */
function formatM2(valeur) {
if (valeur == null) return ''
return `${valeur.toFixed(2).replace('.', ',')} €/m²`
}
// Ce que la tuile de tête a le droit d'affirmer. Un palier qui s'arrête ne
// suffit pas à dire qu'un lot est sorti de la gestion : entre deux baux, le
// compte rendu ne porte qu'un prorata, et le lot est bel et bien reloué.
const etatDuBail = computed(() => {
const vigueur = props.loyer.en_vigueur
if (!vigueur) return { libelle: 'Loyer', classe: 'text-gray-500', detail: '' }
if (vigueur.toujours_loue) {
return {
libelle: 'Loyer en vigueur',
classe: 'text-green-400',
detail: `depuis ${formatMoisAnnee(vigueur.depuis)}`
}
}
if (vigueur.reloue_depuis) {
// Le montant reste celui du bail d'avant : le nouveau n'a encore été
// facturé qu'au prorata, et en tirer un loyer mensuel l'inventerait.
return {
libelle: 'Dernier loyer plein',
classe: 'text-gray-400',
detail: `nouveau bail depuis ${formatMoisAnnee(vigueur.reloue_depuis)}`
}
}
return {
libelle: 'Dernier loyer connu',
classe: 'text-gray-400',
detail: vigueur.sortie_en
? `arrêté en cours de ${formatMoisAnnee(vigueur.sortie_en)}`
: `arrêté après ${formatMoisAnnee(vigueur.mois)}`
}
})
const tuiles = computed(() => {
const vigueur = props.loyer.en_vigueur
if (!vigueur) {
return [
{ libelle: 'Loyer', valeur: '—', classe: 'text-gray-500', detail: 'aucun loyer facturé' },
{ libelle: 'Au m²', valeur: '—', classe: 'text-gray-500', detail: surfaceDetail() },
{ libelle: 'Dernière révision', valeur: '—', classe: 'text-gray-500' },
{ libelle: 'Surface', valeur: surfaceValeur(), classe: 'text-gray-300' }
]
}
return [
{
libelle: etatDuBail.value.libelle,
valeur: formatMontant(vigueur.loyer),
classe: etatDuBail.value.classe,
// Un lot sorti de la gestion garde un loyer affiché : sans cette mention,
// il se lirait comme une recette courante.
detail: etatDuBail.value.detail
},
{
libelle: 'Au m²',
valeur: formatM2(vigueur.loyer_m2),
classe: vigueur.loyer_m2 == null ? 'text-gray-500' : 'text-amber-400',
detail: surfaceDetail()
},
{
libelle: 'Dernière révision',
valeur: vigueur.variation_pct == null ? '—' : formatPourcent(vigueur.variation_pct),
classe: classeVariation(vigueur.variation_pct),
detail:
vigueur.precedent == null
? 'aucune depuis le premier compte rendu'
: `${formatMontant(vigueur.precedent)}${formatMontant(vigueur.loyer)} en ${formatMoisAnnee(vigueur.depuis)}`
},
{
libelle: 'Surface',
valeur: surfaceValeur(),
classe: aUneSurface.value ? 'text-gray-300' : 'text-gray-600',
detail: aUneSurface.value ? 'fiche du logement' : 'à saisir dans Logements'
}
]
})
function surfaceValeur() {
return aUneSurface.value ? `${props.loyer.surface}` : '—'
}
function surfaceDetail() {
return aUneSurface.value ? 'hors charges' : 'surface manquante'
}
function formatPourcent(valeur) {
const signe = valeur > 0 ? '+' : ''
return `${signe}${valeur.toFixed(2).replace('.', ',')} %`
}
function classeVariation(valeur) {
if (valeur == null) return 'text-gray-500'
if (valeur > 0) return 'text-green-400'
if (valeur < 0) return 'text-red-400'
return 'text-gray-300'
}
/**
* Écart du lot à une médiane, en pourcentage.
*
* L'écart dit ce que la médiane seule ne dit pas : savoir que le parc est à
* 13,52 €/m² n'apprend rien tant qu'on n'a pas fait la soustraction.
*/
function ecartA(mediane) {
const valeur = props.loyer.parc?.loyer_m2
if (valeur == null || !mediane) return null
return Math.round(((valeur - mediane) / mediane) * 1000) / 10
}
/** Effectif trop faible pour qu'une médiane décrive autre chose que le hasard. */
function effectifFragile(nombre) {
return nombre > 0 && nombre < (props.loyer.parc?.effectif_faible ?? 3)
}
function detailMediane(nombre) {
if (!nombre) return 'aucun lot comparable'
const lots = `${nombre} lot${nombre > 1 ? 's' : ''}`
return effectifFragile(nombre) ? `${lots} — trop peu pour conclure` : lots
}
const reperes = computed(() => {
const parc = props.loyer.parc
const ecartImmeuble = ecartA(parc.mediane_immeuble)
const ecartType = ecartA(parc.mediane_type)
return [
{
libelle: 'Ce lot',
valeur: formatM2(parc.loyer_m2),
classe: 'text-amber-400',
detail: 'loyer hors charges au m²'
},
{
libelle: 'Médiane de l\'immeuble',
valeur: formatM2(parc.mediane_immeuble),
classe: effectifFragile(parc.nb_immeuble) ? 'text-gray-500' : 'text-gray-300',
detail:
ecartImmeuble == null
? detailMediane(parc.nb_immeuble)
: `${formatPourcent(ecartImmeuble)} · ${detailMediane(parc.nb_immeuble)}`
},
{
// Le type vient de la fiche ou du PDF et n'a pas de forme grammaticale
// fixe (« Studio », « Appartement T2 », « Loc. Commercial ») : le poser
// après un point médian évite d'accorder un article sur une valeur libre.
libelle: parc.type_compare ? `Médiane · ${parc.type_compare}` : 'Médiane du même type',
valeur: formatM2(parc.mediane_type),
classe: effectifFragile(parc.nb_type) ? 'text-gray-500' : 'text-gray-300',
detail: parc.type_compare
? ecartType == null
? detailMediane(parc.nb_type)
: `${formatPourcent(ecartType)} · ${detailMediane(parc.nb_type)}`
: 'type du lot non renseigné'
}
]
})
</script>

View File

@@ -0,0 +1,167 @@
<template>
<div>
<div class="flex items-center justify-between mb-3 flex-wrap gap-2">
<h3 class="text-sm font-medium text-white">
Le parc par surface
<span class="text-gray-500 font-normal">· {{ nuage.length }} lots comparés</span>
</h3>
<div class="flex items-center gap-4 text-xs flex-wrap">
<span class="flex items-center gap-1">
<span class="w-3 h-3 rounded-full bg-amber-400"></span>
<span class="text-gray-400">Ce lot</span>
</span>
<span class="flex items-center gap-1">
<span class="w-3 h-3 rounded-full bg-slate-400"></span>
<span class="text-gray-400">Autres lots</span>
</span>
<span v-if="aDuCommercial" class="flex items-center gap-1">
<span class="text-slate-400 leading-none"></span>
<span class="text-gray-400">Local commercial</span>
</span>
</div>
</div>
<!-- Le loyer au décroît avec la surface : sans cette phrase, un lecteur
pourrait lire une pente naturelle du marché comme une anomalie de
gestion. -->
<p class="form-hint mb-3">
Un petit logement se loue plus cher au mètre carré qu'un grand. Ce qui se lit ici
n'est donc pas la hauteur du point seule, mais sa position par rapport aux lots
de surface voisine.
</p>
<div v-if="nuage.length < 2" class="h-64 flex items-center justify-center text-gray-500 text-sm">
Pas encore assez de surfaces saisies pour situer ce lot dans le parc.
</div>
<div v-else class="h-72">
<Scatter :data="donneesGraphe" :options="options" />
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { Scatter } from 'vue-chartjs'
import {
Chart as ChartJS,
LinearScale,
PointElement,
ScatterController,
Tooltip
} from 'chart.js'
ChartJS.register(LinearScale, PointElement, ScatterController, Tooltip)
const props = defineProps({
nuage: { type: Array, required: true, default: () => [] }
})
const router = useRouter()
/**
* Un local commercial ne se loue pas selon la même logique qu'un logement.
* Le garder dans le nuage — le parc en compte — mais le marquer, pour qu'il ne
* soit pas lu comme un comparable direct.
*/
function estCommercial(point) {
return (point.type_lot ?? '').toLowerCase().includes('commercial')
}
const aDuCommercial = computed(() => props.nuage.some(estCommercial))
const autres = computed(() => props.nuage.filter((point) => !point.est_ce_lot))
const courant = computed(() => props.nuage.filter((point) => point.est_ce_lot))
/** Chart.js n'accepte que x/y ; le reste voyage pour l'infobulle et le clic. */
function versPoints(points) {
return points.map((point) => ({ x: point.surface, y: point.loyer_m2, lot: point }))
}
const donneesGraphe = computed(() => ({
datasets: [
{
label: 'Autres lots',
data: versPoints(autres.value),
backgroundColor: 'rgba(148, 163, 184, 0.75)',
pointRadius: 6,
pointHoverRadius: 8,
pointStyle: autres.value.map((point) => (estCommercial(point) ? 'triangle' : 'circle'))
},
{
label: 'Ce lot',
data: versPoints(courant.value),
backgroundColor: 'rgb(251, 191, 36)',
borderColor: 'rgb(253, 230, 138)',
borderWidth: 2,
pointRadius: 9,
pointHoverRadius: 11,
pointStyle: courant.value.map((point) => (estCommercial(point) ? 'triangle' : 'circle'))
}
]
}))
const options = computed(() => ({
responsive: true,
maintainAspectRatio: false,
// Un lot voisin repéré dans le nuage doit pouvoir s'ouvrir : sans cela, il
// faudrait relever son numéro puis le chercher dans le sélecteur.
onClick: (evenement, elements) => {
const touche = elements[0]
if (!touche) return
const point = donneesGraphe.value.datasets[touche.datasetIndex].data[touche.index]
if (point?.lot && !point.lot.est_ce_lot) router.push(`/lots/${point.lot.lot_id}`)
},
onHover: (evenement, elements) => {
evenement.native.target.style.cursor = elements.length ? 'pointer' : 'default'
},
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgb(31, 41, 55)',
borderColor: 'rgb(75, 85, 99)',
borderWidth: 1,
titleColor: 'rgb(255, 255, 255)',
bodyColor: 'rgb(156, 163, 175)',
padding: 12,
callbacks: {
title: (contextes) => {
const lot = contextes[0].raw.lot
return `Lot ${lot.numero}${lot.immeuble_code ? ` · ${lot.immeuble_code}` : ''}`
},
label: (contexte) => {
const lot = contexte.raw.lot
const lignes = [
`${lot.surface} m² · ${lot.loyer_m2.toFixed(2).replace('.', ',')} €/m²`
]
if (lot.type_lot) lignes.push(lot.type_lot)
if (!lot.est_ce_lot) lignes.push('Cliquer pour ouvrir sa fiche')
return lignes
}
}
}
},
scales: {
x: {
type: 'linear',
title: { display: true, text: 'Surface (m²)', color: 'rgb(156, 163, 175)' },
// L'axe part de zéro : tronquer l'origine étirerait les écarts de
// surface et ferait paraître deux lots voisins bien plus éloignés.
beginAtZero: true,
grid: { color: 'rgb(55, 65, 81)' },
ticks: { color: 'rgb(156, 163, 175)', font: { size: 11 } }
},
y: {
title: { display: true, text: '€/m²', color: 'rgb(156, 163, 175)' },
beginAtZero: true,
grid: { color: 'rgb(55, 65, 81)' },
ticks: {
color: 'rgb(156, 163, 175)',
font: { size: 11 },
callback: (valeur) => `${valeur}`
}
}
}
}))
</script>

View File

@@ -101,25 +101,19 @@
<script setup>
import { ref, onMounted } from 'vue'
const props = defineProps({
/** Etat de depart, tel que la page l'a lu dans l'URL. */
filtresInitiaux: { type: Object, default: null }
})
const emit = defineEmits(['filter-change'])
const immeubles = ref([])
const filters = ref({
immeuble_id: null,
type_ligne: null,
date_debut: null,
date_fin: null,
impayes_only: false,
months: 12
})
const filters = ref({ ...filtresParDefaut(), ...(props.filtresInitiaux ?? {}) })
function emitFilters() {
emit('filter-change', { ...filters.value })
}
function resetFilters() {
filters.value = {
function filtresParDefaut() {
return {
immeuble_id: null,
type_ligne: null,
date_debut: null,
@@ -127,6 +121,14 @@ function resetFilters() {
impayes_only: false,
months: 12
}
}
function emitFilters() {
emit('filter-change', { ...filters.value })
}
function resetFilters() {
filters.value = filtresParDefaut()
emitFilters()
}

View File

@@ -77,7 +77,7 @@
</td>
<td class="text-center">
<router-link
:to="{ path: '/documents/' + revenu.document_id + '/edit', query: { highlight: 'locataire', lot_numero: revenu.lot_numero, locataire_nom: revenu.locataire_nom } }"
:to="lienEdition(revenu)"
class="text-gray-500 hover:text-blue-400 transition-colors"
title="Editer le document source"
>
@@ -140,6 +140,23 @@
<script setup>
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
/** L'editeur recoit la vue d'ou l'on part, filtres compris : une fois
l'enregistrement termine, il y ramene au lieu de la liste des documents. */
function lienEdition(revenu) {
return {
path: `/documents/${revenu.document_id}/edit`,
query: {
highlight: 'locataire',
lot_numero: revenu.lot_numero,
locataire_nom: revenu.locataire_nom,
retour: route.fullPath
}
}
}
const props = defineProps({
revenus: {

View File

@@ -16,17 +16,21 @@
</div>
<!-- Filters -->
<FilterPanel @filter-change="onFiltersChange" />
<FilterPanel :filtres-initiaux="currentFilters" @filter-change="onFiltersChange" />
<!-- KPI Cards -->
<KpiCards :summary="summary" />
<!-- Charts Row -->
<!-- Les deux repartitions cote a cote : meme total, deux decoupages. -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<CategoryChart :data="summary.by_category || []" />
<MonthlyChart :data="summary.by_month || []" />
<TagChart :data="summary.by_tag || []" />
</div>
<!-- L'evolution mensuelle prend toute la largeur : un axe de temps
serre est le premier a devenir illisible. -->
<MonthlyChart :data="summary.by_month || []" />
<!-- Top Fournisseurs -->
<TopFournisseursChart :data="summary.by_fournisseur || []" />
@@ -42,23 +46,22 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { SCHEMA_DEPENSES, ecrireFiltres, lireFiltres } from '../utils/filtresUrl.js'
import FilterPanel from '../components/analytics/FilterPanel.vue'
import KpiCards from '../components/analytics/KpiCards.vue'
import CategoryChart from '../components/analytics/CategoryChart.vue'
import TagChart from '../components/analytics/TagChart.vue'
import MonthlyChart from '../components/analytics/MonthlyChart.vue'
import TopFournisseursChart from '../components/analytics/TopFournisseursChart.vue'
import DepensesTable from '../components/analytics/DepensesTable.vue'
// Current filters state
let currentFilters = {
immeuble_id: null,
lot_id: null,
categorie: null,
tag_id: null,
fournisseur: null,
date_debut: null,
date_fin: null
}
const route = useRoute()
const router = useRouter()
// Les filtres viennent de l'URL : ouvrir un lien filtre, recharger la page ou
// revenir de l'editeur de document rend exactement la meme vue.
const currentFilters = ref(lireFiltres(route.query, SCHEMA_DEPENSES))
const summary = ref({
total_count: 0,
@@ -81,8 +84,12 @@ const lastUpdate = ref(null)
let debounceTimer = null
function onFiltersChange(newFilters) {
currentFilters = { ...newFilters }
currentFilters.value = { ...newFilters }
// `replace` et non `push` : le bouton Retour du navigateur doit quitter la
// page, pas defaire les filtres un par un.
router.replace({ query: ecrireFiltres(currentFilters.value, SCHEMA_DEPENSES) })
// Debounce
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
@@ -90,17 +97,18 @@ function onFiltersChange(newFilters) {
}, 300)
}
/** L'API attend les memes parametres que l'URL de la page : une seule ecriture
des filtres, donc aucun moyen que la vue et la requete divergent. */
function buildQueryParams() {
const params = new URLSearchParams()
if (currentFilters.immeuble_id !== null) params.append('immeuble_id', currentFilters.immeuble_id)
if (currentFilters.lot_id !== null) params.append('lot_id', currentFilters.lot_id)
if (currentFilters.categorie !== null) params.append('categorie', currentFilters.categorie)
if (currentFilters.tag_id !== null) params.append('tag_id', currentFilters.tag_id)
if (currentFilters.fournisseur) params.append('fournisseur', currentFilters.fournisseur)
if (currentFilters.date_debut) params.append('date_debut', currentFilters.date_debut)
if (currentFilters.date_fin) params.append('date_fin', currentFilters.date_fin)
const query = ecrireFiltres(currentFilters.value, SCHEMA_DEPENSES)
for (const [cle, valeur] of Object.entries(query)) {
for (const unitaire of Array.isArray(valeur) ? valeur : [valeur]) {
params.append(cle, unitaire)
}
}
return params.toString()
}
@@ -109,7 +117,7 @@ async function loadData() {
const summaryUrl = queryString ? `/api/analytics/depenses/summary?${queryString}` : '/api/analytics/depenses/summary'
const depensesUrl = queryString ? `/api/analytics/depenses?${queryString}&limit=500` : '/api/analytics/depenses?limit=500'
console.log('Loading data with filters:', currentFilters)
console.log('Loading data with filters:', currentFilters.value)
console.log('Summary URL:', summaryUrl)
isLoadingDepenses.value = true

View File

@@ -68,9 +68,14 @@
</div>
<!-- Corps : Split view PDF + Données -->
<div v-else class="flex-1 flex overflow-hidden">
<VoletsAjustables
v-else
cle-stockage="apercuPdf.largeurVolet"
:largeur-defaut="50"
classe-droite="bg-gray-950"
>
<!-- Gauche : PDF Preview -->
<div class="w-2/5 border-r border-gray-700 flex flex-col">
<template #gauche>
<div class="flex-shrink-0 px-4 py-2 bg-gray-900 border-b border-gray-700">
<span class="card-title">Aperçu PDF</span>
</div>
@@ -91,10 +96,10 @@
</div>
</div>
</div>
</div>
</template>
<!-- Droite : Édition ou Tagging -->
<div class="w-3/5 flex flex-col bg-gray-950">
<template #droite>
<!-- Bandeau erreur de re-extraction -->
<div v-if="reExtractError" class="flex-shrink-0 px-4 py-2 bg-red-500/10 border-b border-red-500/30 text-sm text-red-400">
{{ reExtractError }}
@@ -138,8 +143,8 @@
<span>{{ saveError }}</span>
</div>
</div>
</div>
</div>
</template>
</VoletsAjustables>
</div>
</template>
@@ -150,7 +155,9 @@ import PdfPreview from '../components/PdfPreview.vue'
import JsonViewer from '../components/JsonViewer.vue'
import TaggingStep from '../components/TaggingStep.vue'
import ReExtractionDiff from '../components/ReExtractionDiff.vue'
import VoletsAjustables from '../components/VoletsAjustables.vue'
import { computeExtractionDiff } from '../utils/diffExtraction.js'
import { cheminRetour } from '../utils/retour.js'
const router = useRouter()
const route = useRoute()
@@ -167,6 +174,10 @@ const reExtractError = ref(null)
const diff = ref(null)
const previousData = ref(null)
// D'ou l'on vient, et donc ou repartir une fois l'edition terminee : la table
// d'analyse avec ses filtres si elle l'a dit, la liste des documents sinon.
const destinationRetour = computed(() => cheminRetour(route.query.retour, '/documents'))
const highlightInfo = computed(() => {
const q = route.query
if (q.highlight === 'locataire') return { type: 'locataire', lot_numero: q.lot_numero, locataire_nom: q.locataire_nom }
@@ -196,7 +207,7 @@ async function loadDocument() {
} catch (err) {
console.error('Error loading document:', err)
alert('Erreur lors du chargement du document: ' + err.message)
router.push('/documents')
router.push(destinationRetour.value)
} finally {
isLoading.value = false
}
@@ -214,7 +225,7 @@ function cancel() {
const confirmed = confirm('Abandonner les modifications ?')
if (!confirmed) return
}
router.push('/documents')
router.push(destinationRetour.value)
}
function goToTagging() {
@@ -284,7 +295,7 @@ async function handleSave(depensesTags, shouldOverwrite) {
if (result.success) {
// Succès - redirection vers la liste des documents
router.push('/documents')
router.push(destinationRetour.value)
} else {
throw new Error(result.message || 'Échec de la sauvegarde')
}

View File

@@ -1,7 +1,11 @@
<template>
<div class="flex-1 flex overflow-hidden">
<VoletsAjustables
cle-stockage="apercuPdf.largeurVolet"
:largeur-defaut="50"
classe-droite="bg-gray-950"
>
<!-- Left panel: Upload or PDF Preview -->
<div class="w-2/5 flex flex-col border-r border-gray-700">
<template #gauche>
<!-- Upload zone when no file -->
<div v-if="!pdfFile" class="flex-1 flex items-center justify-center p-8 bg-gray-950">
<div class="max-w-md w-full">
@@ -36,10 +40,10 @@
:file-name="pdfFile.name"
class="flex-1"
/>
</div>
</template>
<!-- Right panel: JSON Viewer or Tagging Step -->
<div class="w-3/5 flex flex-col bg-gray-950">
<template #droite>
<!-- Extract button bar -->
<div v-if="pdfFile && !extractedData && !isExtracting && !showTagging" class="flex-shrink-0 p-4 bg-gray-900 border-b border-gray-700">
<button
@@ -111,8 +115,8 @@
:is-loading="isExtracting"
class="flex-1 overflow-hidden"
/>
</div>
</div>
</template>
</VoletsAjustables>
</template>
<script setup>
@@ -122,6 +126,7 @@ import { pendingFile } from '../store'
import PdfPreview from '../components/PdfPreview.vue'
import JsonViewer from '../components/JsonViewer.vue'
import TaggingStep from '../components/TaggingStep.vue'
import VoletsAjustables from '../components/VoletsAjustables.vue'
const router = useRouter()

View File

@@ -9,12 +9,20 @@
</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 class="flex flex-wrap items-center gap-3">
<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>
<select v-model="moisChoisis" class="input w-44">
<option v-for="periode in PERIODES" :key="periode.libelle" :value="periode.mois">
{{ periode.libelle }}
</option>
</select>
</div>
</div>
<div v-if="chargement" class="empty-state">
@@ -58,9 +66,53 @@
:key="nom"
class="badge badge-neutral ml-2"
>{{ nom }}</span>
<!-- Aucune date d'entrée ni de sortie n'est extraite : un locataire
appartient à la période parce qu'un compte rendu de la période
le porte. Ceux d'avant sont comptés, jamais effacés. -->
<span v-if="analyse.identite.locataires_masques" class="ml-2 text-xs text-gray-500">
+ {{ analyse.identite.locataires_masques }}
{{ analyse.identite.locataires_masques > 1 ? 'occupants antérieurs' : 'occupant antérieur' }}
à la période
</span>
</div>
</div>
<!-- Ce que la fenêtre retient, et surtout ce qu'elle laisse dehors :
un filtre muet ferait passer un lot amputé pour un lot calme. -->
<div v-if="fenetreActive" class="card card-body border-l-2 border-blue-500/50">
<p class="text-sm text-gray-300">
Du <span class="text-white">{{ formatDate(analyse.periode.debut) }}</span>
au <span class="text-white">{{ formatDate(analyse.periode.fin) }}</span>
<span class="text-gray-500">
· calé sur le dernier compte rendu du lot, pas sur aujourd'hui
</span>
</p>
<p class="form-hint mt-1">
<template v-if="analyse.periode.lignes_masquees">
{{ analyse.periode.lignes_masquees }}
{{ analyse.periode.lignes_masquees > 1 ? 'lignes antérieures sont exclues' : 'ligne antérieure est exclue' }}
des chiffres, de la chronologie et des intervenants ; la courbe du loyer
suit la même période.
<button class="text-blue-400 hover:text-blue-300 underline" @click="moisChoisis = null">
Tout afficher
</button>
</template>
<template v-else>
Aucune ligne du lot ne tombe hors de cette fenêtre : les chiffres sont
ceux de tout son historique.
</template>
</p>
</div>
<!-- Une fenêtre demandée sur un lot dont aucun compte rendu ne parle
n'a rien sur quoi se caler : le dire vaut mieux qu'afficher des
totaux à zéro sans raison apparente. -->
<p v-else-if="moisChoisis !== null" class="form-hint">
Aucun compte rendu ne porte de ligne sur ce lot : il n'y a pas de période à
borner, et la fiche reste entière.
</p>
<!-- 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">
@@ -79,6 +131,10 @@
quelle part revient à quel lot.
</p>
<!-- Le loyer sur un axe de temps : c'est là que se voient une révision,
une vacance ou un décrochage, que les totaux du dessus écrasent. -->
<LoyerLot :loyer="analyse.loyer" />
<!-- Chronologie -->
<div class="card">
<div class="card-header">
@@ -225,6 +281,7 @@
<script setup>
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import LoyerLot from '../components/lot/LoyerLot.vue'
import { formatDate, formatMontant, formatMontantPrecis } from '../utils/format'
const API = import.meta.env.VITE_API_URL || ''
@@ -239,6 +296,24 @@ const chargement = ref(true)
const depensesSeules = ref(false)
const detailsOuverts = reactive({})
// `null` en tête : la fiche s'ouvre sur tout l'historique, et c'est
// l'utilisateur qui décide de restreindre. Les durées montent jusqu'à cinq ans,
// au-delà desquels « tout » revient au même sur un parc suivi depuis 2024.
const PERIODES = [
{ mois: null, libelle: "Tout l'historique" },
{ mois: 3, libelle: '3 derniers mois' },
{ mois: 6, libelle: '6 derniers mois' },
{ mois: 12, libelle: 'Dernière année' },
{ mois: 24, libelle: '2 dernières années' },
{ mois: 60, libelle: '5 dernières années' }
]
const moisChoisis = ref(null)
// Une fenêtre n'est active que si le serveur a pu la caler : sur un lot sans
// aucune ligne, il n'y a pas de dernier compte rendu et donc pas de bornes.
const fenetreActive = computed(() => analyse.value?.periode?.debut != null)
const CHAMPS_IDENTITE = [
{ cle: 'type_effectif', libelle: 'Type' },
{ cle: 'surface', libelle: 'Surface', unite: ' m²' },
@@ -352,27 +427,58 @@ async function chargerLots() {
lots.value = await response.json()
}
async function chargerAnalyse(lotId) {
async function chargerAnalyse(lotId, mois) {
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`)
// Le filtre est appliqué côté serveur : les chiffres (taux de recouvrement,
// restant dû) sortent de requêtes SQL que le navigateur ne peut pas rejouer,
// et les recalculer ici les ferait diverger de la chronologie.
const parametres = mois == null ? '' : `?mois=${mois}`
const response = await fetch(`${API}/api/lots/${lotId}/analyse${parametres}`)
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) => {
// L'URL porte le lot et la période : une fiche se partage et se recharge telle
// qu'on la lisait, filtre compris.
watch([lotChoisi, moisChoisis], ([lotId, mois]) => {
if (lotId == null) return
if (String(lotId) !== route.params.id) router.replace(`/lots/${lotId}`)
chargerAnalyse(lotId)
const query = mois == null ? {} : { mois: String(mois) }
if (String(lotId) !== route.params.id || query.mois !== route.query.mois) {
router.replace({ path: `/lots/${lotId}`, query })
}
chargerAnalyse(lotId, mois)
})
/** Période lue dans l'URL, ramenée à une des durées proposées. */
function moisDeLUrl() {
const demande = Number(route.query.mois)
return PERIODES.some((periode) => periode.mois === demande) ? demande : null
}
// Passer d'un lot à l'autre reste la même route : Vue réutilise le composant
// sans le remonter, et `onMounted` ne rejoue pas. Sans ce suivi, un clic dans
// le nuage du parc changerait l'adresse en laissant la fiche du lot précédent
// à l'écran — l'écart le plus trompeur qui soit entre l'URL et ce qu'on lit.
// Le retour arrière du navigateur passe par le même chemin, période comprise.
watch(
[() => route.params.id, () => route.query.mois],
([id]) => {
const demande = Number(id)
if (Number.isFinite(demande) && demande !== lotChoisi.value) lotChoisi.value = demande
moisChoisis.value = moisDeLUrl()
}
)
onMounted(async () => {
try {
await chargerLots()
// Avant le lot : les deux changent dans le même tick, le watcher ne part
// qu'une fois et la première requête porte déjà la période de l'URL.
moisChoisis.value = moisDeLUrl()
const demande = Number(route.params.id)
lotChoisi.value = lots.value.some((lot) => lot.id === demande)
? demande

View File

@@ -26,7 +26,7 @@
</div>
<!-- Filters -->
<RevenusFilterPanel @filter-change="onFiltersChange" />
<RevenusFilterPanel :filtres-initiaux="currentFilters" @filter-change="onFiltersChange" />
<!-- KPI Cards -->
<RevenusKpiCards :kpis="summary.kpis" />
@@ -52,6 +52,8 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { SCHEMA_REVENUS, ecrireFiltres, lireFiltres } from '../utils/filtresUrl.js'
import RevenusFilterPanel from '../components/revenus/RevenusFilterPanel.vue'
import RevenusKpiCards from '../components/revenus/RevenusKpiCards.vue'
import RevenusMonthlyChart from '../components/revenus/RevenusMonthlyChart.vue'
@@ -59,15 +61,12 @@ import RevenusImmeubleChart from '../components/revenus/RevenusImmeubleChart.vue
import TopImpayesList from '../components/revenus/TopImpayesList.vue'
import RevenusTable from '../components/revenus/RevenusTable.vue'
// Current filters state
let currentFilters = {
immeuble_id: null,
type_ligne: null,
date_debut: null,
date_fin: null,
impayes_only: false,
months: 12
}
const route = useRoute()
const router = useRouter()
// Les filtres vivent dans l'URL : la vue se recharge, se partage, et l'editeur
// de document sait y ramener une fois l'enregistrement termine.
const currentFilters = ref(lireFiltres(route.query, SCHEMA_REVENUS))
const summary = ref({
kpis: {
@@ -95,8 +94,12 @@ const lastUpdate = ref(null)
let debounceTimer = null
function onFiltersChange(newFilters) {
currentFilters = { ...newFilters }
currentFilters.value = { ...newFilters }
// `replace` et non `push` : le bouton Retour du navigateur doit quitter la
// page, pas defaire les filtres un par un.
router.replace({ query: ecrireFiltres(currentFilters.value, SCHEMA_REVENUS) })
// Debounce
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
@@ -106,23 +109,25 @@ function onFiltersChange(newFilters) {
function buildSummaryParams() {
const params = new URLSearchParams()
if (currentFilters.months) params.append('months', currentFilters.months)
if (currentFilters.immeuble_id !== null) params.append('immeuble_id', currentFilters.immeuble_id)
const filtres = currentFilters.value
if (filtres.months) params.append('months', filtres.months)
if (filtres.immeuble_id !== null) params.append('immeuble_id', filtres.immeuble_id)
return params.toString()
}
function buildDetailsParams() {
const params = new URLSearchParams()
if (currentFilters.immeuble_id !== null) params.append('immeuble_id', currentFilters.immeuble_id)
if (currentFilters.type_ligne !== null) params.append('type_ligne', currentFilters.type_ligne)
if (currentFilters.date_debut) params.append('date_debut', currentFilters.date_debut)
if (currentFilters.date_fin) params.append('date_fin', currentFilters.date_fin)
if (currentFilters.impayes_only) params.append('impayes_only', 'true')
const filtres = currentFilters.value
if (filtres.immeuble_id !== null) params.append('immeuble_id', filtres.immeuble_id)
if (filtres.type_ligne !== null) params.append('type_ligne', filtres.type_ligne)
if (filtres.date_debut) params.append('date_debut', filtres.date_debut)
if (filtres.date_fin) params.append('date_fin', filtres.date_fin)
if (filtres.impayes_only) params.append('impayes_only', 'true')
params.append('limit', '500')
return params.toString()
}
@@ -136,7 +141,7 @@ async function loadData() {
const summaryUrl = summaryParams ? `/api/revenus/summary?${summaryParams}` : '/api/revenus/summary'
const detailsUrl = detailsParams ? `/api/revenus/details?${detailsParams}` : '/api/revenus/details'
console.log('Loading revenus data with filters:', currentFilters)
console.log('Loading revenus data with filters:', currentFilters.value)
try {
const [summaryRes, detailsRes] = await Promise.all([

View File

@@ -40,6 +40,13 @@
@apply text-sm text-gray-400 mt-1;
}
/* --- Navigation laterale ---------------------------------------------- */
/* Entree de menu : la couleur (actif / inactif) est posee par App.vue. */
.nav-link {
@apply flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg transition-colors;
}
/* --- Cartes ----------------------------------------------------------- */
.card {

View File

@@ -0,0 +1,20 @@
/**
* Écriture d'une valeur désignée par un chemin pointé (`"divers.montant"`).
*
* Les formulaires d'édition adressent leurs champs par ce chemin plutôt que par
* une référence : ils travaillent sur une copie fraîche de l'objet à chaque
* modification, et une référence prise avant la copie viserait l'ancien.
*/
/** Écrit `valeur` à `chemin`, en créant les objets intermédiaires manquants. */
export function setNestedValue(objet, chemin, valeur) {
const parties = chemin.split('.')
let courant = objet
for (let i = 0; i < parties.length - 1; i++) {
if (!courant[parties[i]]) {
courant[parties[i]] = {}
}
courant = courant[parties[i]]
}
courant[parties[parties.length - 1]] = valeur
}

View File

@@ -0,0 +1,143 @@
// Les filtres d'une page d'analyse vivent dans son URL : une vue filtree se
// recharge, se partage et se remet en signet, et le retour depuis l'editeur de
// document n'a plus qu'une chaine a transporter.
//
// Chaque champ declare comment il se lit depuis la query et comment il s'y
// ecrit. Ce qui vaut sa valeur par defaut n'est pas ecrit : l'URL ne porte que
// ce qui a ete choisi, et reste lisible.
/** Un entier (identifiant). `tag_id=0` est une valeur, pas une absence. */
export function entier({ param } = {}) {
return {
param,
defaut: null,
lire(brut) {
const valeur = Number(premier(brut))
return Number.isInteger(valeur) ? valeur : null
},
ecrire(valeur) {
return valeur === null || valeur === undefined ? undefined : String(valeur)
},
}
}
/** Un texte libre choisi dans une liste fermee (categorie, type de ligne). */
export function texte({ param } = {}) {
return {
param,
defaut: null,
lire(brut) {
const valeur = premier(brut)
return valeur ? valeur : null
},
ecrire(valeur) {
return valeur ? valeur : undefined
},
}
}
/** Une date ISO. Un format inattendu est ignore plutot qu'affiche de travers. */
export function date({ param } = {}) {
return {
param,
defaut: null,
lire(brut) {
const valeur = premier(brut)
return valeur && /^\d{4}-\d{2}-\d{2}$/.test(valeur) ? valeur : null
},
ecrire(valeur) {
return valeur ? valeur : undefined
},
}
}
/** Un booleen a un seul sens : seul `true` s'ecrit, l'absence vaut faux. */
export function booleen({ param } = {}) {
return {
param,
defaut: false,
lire(brut) {
return premier(brut) === 'true'
},
ecrire(valeur) {
return valeur ? 'true' : undefined
},
}
}
/** Un entier borne avec une valeur par defaut (nombre de mois d'historique). */
export function entierBorne({ defaut, min, max, param } = {}) {
return {
param,
defaut,
lire(brut) {
const valeur = Number(premier(brut))
if (!Number.isInteger(valeur) || valeur < min || valeur > max) return defaut
return valeur
},
ecrire(valeur) {
return valeur === undefined || valeur === defaut ? undefined : String(valeur)
},
}
}
/** Une liste de textes, un parametre repete par valeur retenue. */
export function liste({ param } = {}) {
return {
param,
defaut: [],
lire(brut) {
if (brut === undefined || brut === null) return []
const valeurs = Array.isArray(brut) ? brut : [brut]
return valeurs.filter((valeur) => typeof valeur === 'string' && valeur !== '')
},
ecrire(valeur) {
return valeur && valeur.length ? [...valeur] : undefined
},
}
}
/** Les filtres d'une page, reconstruits depuis la query d'une route. */
export function lireFiltres(query, schema) {
const filtres = {}
for (const [nom, champ] of Object.entries(schema)) {
filtres[nom] = champ.lire((query ?? {})[champ.param ?? nom])
}
return filtres
}
/** La query correspondant a des filtres : seuls les choix explicites y figurent. */
export function ecrireFiltres(filtres, schema) {
const query = {}
for (const [nom, champ] of Object.entries(schema)) {
const ecrit = champ.ecrire(filtres[nom])
if (ecrit !== undefined) query[champ.param ?? nom] = ecrit
}
return query
}
/** vue-router rend une chaine ou un tableau selon le nombre d'occurrences. */
function premier(brut) {
return Array.isArray(brut) ? brut[0] : brut
}
/** Filtres de la page Depenses. `fournisseur` se repete, comme cote API. */
export const SCHEMA_DEPENSES = {
immeuble_id: entier(),
lot_id: entier(),
categorie: texte(),
tag_id: entier(),
fournisseurs: liste({ param: 'fournisseur' }),
date_debut: date(),
date_fin: date(),
}
/** Filtres de la page Recettes. */
export const SCHEMA_REVENUS = {
immeuble_id: entier(),
type_ligne: texte(),
date_debut: date(),
date_fin: date(),
impayes_only: booleen(),
months: entierBorne({ defaut: 12, min: 3, max: 24 }),
}

View File

@@ -54,3 +54,15 @@ export function formatMois(chaine) {
const [, mois] = chaine.split('-')
return MOIS_COURTS[parseInt(mois, 10) - 1] ?? chaine
}
/**
* "2026-06" → "juin 26", pour les séries qui traversent plusieurs années.
*
* `formatMois` suffit quand un titre porte déjà l'année ; sur trois ans de
* comptes rendus, deux mois de juin se confondraient.
*/
export function formatMoisAnnee(chaine) {
if (!chaine) return ''
const [annee, mois] = chaine.split('-')
return `${MOIS_COURTS[parseInt(mois, 10) - 1] ?? mois} ${annee.slice(2)}`
}

View File

@@ -0,0 +1,22 @@
// Une table d'analyse envoie vers l'editeur de document la page ou revenir une
// fois l'enregistrement termine, filtres compris. Cette destination arrive par
// l'URL : elle est donc fabricable par n'importe qui, et n'est suivie que si
// elle designe une page de l'application.
/**
* La destination de retour portee par une query, ou `repli` si elle manque ou
* ne designe pas un chemin interne.
*
* Sont refuses tout ce qui peut sortir du site : une URL absolue
* (`https://ailleurs`), un chemin protocol-relatif (`//ailleurs`) que le
* navigateur traite comme un domaine, et les contre-slashs dont certains
* navigateurs font des slashs.
*/
export function cheminRetour(brut, repli = '/documents') {
const valeur = Array.isArray(brut) ? brut[0] : brut
if (typeof valeur !== 'string' || valeur === '') return repli
if (!valeur.startsWith('/')) return repli
if (valeur.startsWith('//')) return repli
if (valeur.includes('\\')) return repli
return valeur
}

View File

@@ -0,0 +1,126 @@
/**
* Colonnes du compte rendu de gérance, et recoupement des totaux d'un lot.
*
* La page d'édition n'affiche que de l'extraction, et chaque champ extrait y
* reste modifiable : c'est l'utilisateur qui tranche avant l'enregistrement.
* Ces fonctions ne corrigent donc rien — elles servent uniquement à repérer les
* lots où le compte rendu et les lignes extraites ne racontent pas la même
* histoire, signe que le parser a manqué ou déplacé un montant.
*
* Le recoupement est celui que l'œil fait sur le tableau : chaque colonne de la
* ligne « Totaux » est confrontée à la somme de cette même colonne sur les
* lignes, y compris les colonnes Total et Impayé. Les déduire des autres
* colonnes (total = loyers + taxes + provisions + divers) laisserait passer le
* cas le plus parlant — une colonne Total qui ne somme visiblement pas.
*
* Sur les 388 lots des documents extraits, ce recoupement signale 4 lots, tous
* de vraies extractions incomplètes.
*/
/**
* Type des lignes qui reportent le solde du compte rendu précédent.
*
* Même valeur que `TYPE_LIGNE_REPORT` côté Python
* (`services/revenus_query.py`), où elle sépare les stocks des flux. Les deux
* définitions décrivent la sortie du même parser et doivent bouger ensemble.
*/
const TYPE_REPORT = 'solde_anterieur'
/**
* Les colonnes du compte rendu, dans son ordre d'impression.
*
* Source unique de l'ordre et des libellés : le tableau d'édition, ses en-têtes
* et le recoupement des totaux s'en déduisent tous, plutôt que d'en tenir
* chacun sa copie.
*
* - `champ` : clé dans les totaux d'un lot
* - `champLigne` : clé correspondante sur une ligne, quand elle diffère
* - `entete` : le compte rendu ne donne pas de colonne au solde antérieur, il
* l'imprime dans la colonne « Période »
*/
export const COLONNES_CRG = [
{ champ: 'solde_anterieur', libelle: 'Solde anterieur', entete: false },
{ champ: 'loyers', libelle: 'Loyers' },
{ champ: 'taxes', libelle: 'Taxes' },
{ champ: 'provisions', libelle: 'Provisions' },
{ champ: 'divers', libelle: 'Divers', champLigne: 'divers.montant' },
{ champ: 'total', libelle: 'Total' },
{ champ: 'regles', libelle: 'Regles' },
{ champ: 'impayes', libelle: 'Impaye' },
]
/** Les colonnes qui ont un en-tête dans le tableau. */
export const COLONNES_TABLEAU = COLONNES_CRG.filter((c) => c.entete !== false)
/** Montant exploitable d'un champ : `null` et `undefined` valent zéro. */
function montant(valeur) {
const nombre = Number(valeur)
return Number.isFinite(nombre) ? nombre : 0
}
/** Arrondi au centime — sans lui, les sommes de flottants affichent 878,6400000001. */
function auCentime(valeur) {
return Math.round(valeur * 100) / 100
}
/**
* Agrège les lignes d'un lot en un jeu de totaux de même forme que `totaux`.
*
* Le solde antérieur est reporté par le parser dans la colonne `loyers` d'une
* ligne dédiée ; le sommer avec les loyers de la période le compterait deux fois.
*
* @param {Array} lignes - lignes du lot (`locataire.lignes`)
* @returns {Object} totaux déduits, une clé par colonne, arrondis au centime
*/
export function totauxCalcules(lignes) {
const liste = Array.isArray(lignes) ? lignes : []
const totaux = Object.fromEntries(COLONNES_CRG.map(({ champ }) => [champ, 0]))
for (const ligne of liste) {
if (ligne?.type === TYPE_REPORT) {
totaux.solde_anterieur += montant(ligne.loyers)
} else {
totaux.loyers += montant(ligne?.loyers)
}
totaux.taxes += montant(ligne?.taxes)
totaux.provisions += montant(ligne?.provisions)
totaux.divers += montant(ligne?.divers?.montant)
totaux.total += montant(ligne?.total)
totaux.regles += montant(ligne?.regles)
totaux.impayes += montant(ligne?.impayes)
}
for (const champ of Object.keys(totaux)) {
totaux[champ] = auCentime(totaux[champ])
}
return totaux
}
/**
* Colonnes où la ligne « Totaux » extraite contredit les lignes extraites.
*
* Un écart trahit une extraction incomplète — un règlement que le compte rendu
* n'a ventilé sur aucune ligne, un « divers » sauté lors d'un changement de
* page. Il est signalé, jamais résorbé d'office.
*
* @param {Object} extraits - `locataire.totaux`, tel que lu dans le PDF
* @param {Object} calcules - sortie de `totauxCalcules`
* @returns {Object} par colonne divergente, `{ extrait, calcule, manquant }`
*/
export function ecartsAvecLignes(extraits, calcules) {
const ecarts = {}
if (!extraits) return ecarts
for (const champ of Object.keys(calcules)) {
if (extraits[champ] === null || extraits[champ] === undefined) continue
const extrait = montant(extraits[champ])
const calcule = montant(calcules[champ])
if (Math.abs(extrait - calcule) > 0.005) {
ecarts[champ] = { extrait, calcule, manquant: auCentime(extrait - calcule) }
}
}
return ecarts
}

View File

@@ -0,0 +1,99 @@
// Les filtres d'une page d'analyse transitent par son URL. Deux exigences :
// ce qu'on relit doit etre ce qu'on a choisi, et une query fabriquee a la main
// ne doit jamais produire une vue incoherente.
import { describe, expect, it } from 'vitest'
import {
SCHEMA_DEPENSES,
SCHEMA_REVENUS,
ecrireFiltres,
lireFiltres,
} from '../src/utils/filtresUrl.js'
describe('filtres de la page Depenses', () => {
it('rend une URL vide quand rien n est filtre', () => {
const filtres = lireFiltres({}, SCHEMA_DEPENSES)
expect(filtres).toEqual({
immeuble_id: null,
lot_id: null,
categorie: null,
tag_id: null,
fournisseurs: [],
date_debut: null,
date_fin: null,
})
expect(ecrireFiltres(filtres, SCHEMA_DEPENSES)).toEqual({})
})
it('retrouve a l identique les filtres qu il a ecrits', () => {
const choisis = {
immeuble_id: 3,
lot_id: 12,
categorie: 'TRAVAUX',
tag_id: 7,
fournisseurs: ['PPR', 'MAILLET'],
date_debut: '2026-01-01',
date_fin: '2026-06-30',
}
const query = ecrireFiltres(choisis, SCHEMA_DEPENSES)
expect(query.fournisseur).toEqual(['PPR', 'MAILLET'])
expect(lireFiltres(query, SCHEMA_DEPENSES)).toEqual(choisis)
})
it('garde « sans tag » qui vaut zero, la ou l absence de filtre vaut null', () => {
// Le piege du filtre tag : 0 est un choix, pas une case vide.
expect(lireFiltres({ tag_id: '0' }, SCHEMA_DEPENSES).tag_id).toBe(0)
expect(ecrireFiltres({ tag_id: 0 }, SCHEMA_DEPENSES)).toEqual({ tag_id: '0' })
expect(ecrireFiltres({ tag_id: null }, SCHEMA_DEPENSES)).toEqual({})
})
it('accepte un fournisseur seul, que vue-router rend comme une chaine', () => {
expect(lireFiltres({ fournisseur: 'PPR' }, SCHEMA_DEPENSES).fournisseurs).toEqual(['PPR'])
})
it('ignore une valeur qui n a pas de sens plutot que d afficher de travers', () => {
const filtres = lireFiltres(
{ immeuble_id: 'abc', tag_id: '3.5', date_debut: '01/02/2026', fournisseur: '' },
SCHEMA_DEPENSES
)
expect(filtres.immeuble_id).toBeNull()
expect(filtres.tag_id).toBeNull()
expect(filtres.date_debut).toBeNull()
expect(filtres.fournisseurs).toEqual([])
})
})
describe('filtres de la page Recettes', () => {
it('n ecrit pas l historique quand il vaut sa valeur par defaut', () => {
expect(ecrireFiltres(lireFiltres({}, SCHEMA_REVENUS), SCHEMA_REVENUS)).toEqual({})
expect(lireFiltres({}, SCHEMA_REVENUS).months).toBe(12)
})
it('retrouve a l identique les filtres qu il a ecrits', () => {
const choisis = {
immeuble_id: 2,
type_ligne: 'loyer',
date_debut: '2026-01-01',
date_fin: null,
impayes_only: true,
months: 24,
}
expect(lireFiltres(ecrireFiltres(choisis, SCHEMA_REVENUS), SCHEMA_REVENUS)).toEqual(choisis)
})
it('ramene un historique hors bornes a sa valeur par defaut', () => {
expect(lireFiltres({ months: '999' }, SCHEMA_REVENUS).months).toBe(12)
expect(lireFiltres({ months: '0' }, SCHEMA_REVENUS).months).toBe(12)
})
it('ne retient « impayes uniquement » que sur un oui explicite', () => {
expect(lireFiltres({ impayes_only: 'true' }, SCHEMA_REVENUS).impayes_only).toBe(true)
expect(lireFiltres({ impayes_only: 'oui' }, SCHEMA_REVENUS).impayes_only).toBe(false)
expect(ecrireFiltres({ impayes_only: false }, SCHEMA_REVENUS)).toEqual({})
})
})

View File

@@ -0,0 +1,157 @@
// La carte locataire est la vue où l'on vérifie une extraction avant de
// l'enregistrer : elle ne doit afficher que des champs extraits, tous modifiables,
// et ne jamais substituer un calcul à ce que le compte rendu porte.
import { describe, expect, it } from 'vitest'
import { createSSRApp, h } from 'vue'
import { renderToString } from 'vue/server-renderer'
import LocataireCard from '../src/components/LocataireCard.vue'
// Lot 11 de M_33670000_2025-09-22, avec le règlement mal extrait observé sur un
// compte rendu ultérieur : le PDF annonce 7 878,00 € réglés pour 867,45 € dus.
function lotCharlot(surcharges = {}) {
return {
lot: { numero: '11', type: 'Appartement T3' },
locataire: { nom: 'CHARLOT ANDREE' },
lignes: [
{
type: 'loyer',
periode: { debut: '2025-09-01', fin: '2025-09-30' },
loyers: 798.45,
taxes: 0,
provisions: 69,
divers: { montant: 0, libelle: null },
total: 867.45,
regles: 867.45,
impayes: 0,
},
],
totaux: {
solde_anterieur: 0,
loyers: 798.45,
taxes: 0,
provisions: 69,
divers: 0,
total: 867.45,
regles: 867.45,
impayes: 0,
},
...surcharges,
}
}
// Les montants sont formatés avec des espaces insécables (séparateur de milliers,
// espace avant €) et le template en insère aux sauts de ligne : tous les blancs
// sont ramenés à un espace simple pour pouvoir chercher un montant.
// `highlighted` déplie la carte : le tableau n'est monté qu'à l'ouverture.
async function rendre(locataire) {
const app = createSSRApp({
render: () => h(LocataireCard, { locataire, highlighted: true }),
})
const html = await renderToString(app)
return html.replace(/\s+/g, ' ')
}
/** Les lignes seules, sans la ligne « Totaux » qui porte les valeurs déduites. */
function corpsDuTableau(html) {
return html.match(/<tbody>(.*)<\/tbody>/)[1]
}
describe('LocataireCard', () => {
it('reprend les colonnes du compte rendu, ligne « Totaux » comprise', async () => {
const html = await rendre(lotCharlot())
for (const colonne of ['Loyers', 'Taxes', 'Provisions', 'Divers', 'Total', 'Regles', 'Impaye']) {
expect(html).toContain(colonne)
}
expect(html).toContain('Totaux')
expect(html).toContain('Solde ant.')
})
it('laisse la ligne porter le total du compte rendu, sans le recalculer', async () => {
// Lot 13 de M_33670000_2025-08-26 : colonne « total » vide sur cette ligne,
// le compte rendu la porte sur une autre ligne du même bloc.
const lot = lotCharlot()
lot.lignes[0].loyers = 997.02
lot.lignes[0].provisions = 64
lot.lignes[0].total = 0
const lignes = corpsDuTableau(await rendre(lot))
expect(lignes).toContain('997,02 €')
expect(lignes).toContain('64,00 €')
// La somme des colonnes n'a pas à apparaître : la ligne n'est pas déduite.
expect(lignes).not.toContain('1 061,02 €')
})
it('naffiche aucune valeur déduite quand lextraction se recoupe', async () => {
const html = await rendre(lotCharlot())
expect(html).not.toContain('calc.')
expect(html).not.toContain('A verifier')
})
it('signale la colonne qui ne somme pas, sans toucher au montant extrait', async () => {
// Cas du lot 07 de S_33680000_2025-11-25 : la colonne Total des lignes reste
// à 0 alors que le compte rendu annonce 707,29 € pour le lot.
const lot = lotCharlot()
lot.lignes[0].total = 0
lot.totaux.total = 707.29
const html = await rendre(lot)
expect(html).toContain('A verifier')
expect(html).toContain('calculé : 0,00 €')
// Le montant du compte rendu reste affiché tel quel.
expect(html).toContain('707,29 €')
})
it('met la colonne en défaut en évidence', async () => {
const lot = lotCharlot()
lot.totaux.regles = 707.29
const html = await rendre(lot)
expect(html).toContain('bg-amber-500/10')
})
it('signale aussi le solde antérieur, qui na pas de colonne à lui', async () => {
// Il occupe la colonne « Période » de la ligne Totaux : son écart doit y
// apparaître comme celui des colonnes rendues par la boucle.
const lot = lotCharlot()
lot.totaux.solde_anterieur = 49170.47
const html = await rendre(lot)
expect(html).toContain('calculé : 0,00 €')
expect(html).toContain('A verifier')
})
it('dit au survol ce qui est extrait et ce qui est calculé', async () => {
const lot = lotCharlot()
lot.totaux.regles = 707.29
const html = await rendre(lot)
expect(html).toContain('Valeur extraite du compte rendu — cliquer pour la modifier')
expect(html).toContain('Extrait du compte rendu : 707,29 €')
expect(html).toContain('Calculé sur les 1 ligne(s) : 867,45 €')
})
it('signale un lot dont aucune ligne na été extraite', async () => {
const lot = lotCharlot({ lignes: [] })
const html = await rendre(lot)
expect(html).toContain('Aucune ligne extraite')
expect(html).toContain('A verifier')
})
it('montre limpayé du compte rendu dans le bandeau replié', async () => {
const lot = lotCharlot()
lot.totaux.impayes = 67.45
const html = await rendre(lot)
expect(html).toContain('Impayes: 67,45 €')
})
it('distingue un trop-perçu dun impayé', async () => {
const lot = lotCharlot()
lot.totaux.impayes = -32.55
const html = await rendre(lot)
expect(html).toContain('Trop-percu: 32,55 €')
})
})

View File

@@ -0,0 +1,44 @@
// L'apercu sert a relire le compte rendu ligne a ligne pendant qu'on corrige
// l'extraction : ce qui compte est qu'il s'ouvre a une echelle lisible, pas
// qu'il tienne entier dans le volet.
import { describe, expect, it } from 'vitest'
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
import PdfPreview from '../src/components/PdfPreview.vue'
function rendu(proprietes = {}) {
return renderToString(createSSRApp(PdfPreview, proprietes))
}
describe('PdfPreview', () => {
it('ouvre le document a l echelle 1:1', async () => {
// Le zoom par defaut du lecteur integre tombe vers 35 % dans un volet
// etroit, ce qui reduit les tableaux du CRG (7 pt) a 3 px de haut. A 100 %
// ils font 9,3 px et redeviennent lisibles, quitte a devoir defiler.
const html = await rendu({ url: '/api/documents/37/pdf' })
expect(html).toContain('/api/documents/37/pdf#zoom=100')
})
it('annonce l absence de document plutot qu un cadre vide', async () => {
const html = await rendu()
expect(html).toContain('Aucun PDF sélectionné')
expect(html).not.toContain('<iframe')
})
it('offre d ouvrir le PDF hors du volet', async () => {
// Seule issue quand le moteur n'embarque pas de lecteur PDF : sans ce
// lien, le volet resterait vide sans recours.
const html = await rendu({ url: '/api/documents/37/pdf' })
expect(html).toContain('target="_blank"')
})
it('affiche le nom du fichier relu', async () => {
const html = await rendu({ url: '/api/documents/37/pdf', fileName: '2026 07 Servient.pdf' })
expect(html).toContain('2026 07 Servient.pdf')
})
})

View File

@@ -0,0 +1,36 @@
// La destination de retour arrive par l'URL : elle est fabricable par
// n'importe qui, et un lien pieges ne doit pas pouvoir renvoyer l'utilisateur
// hors du site apres un enregistrement.
import { describe, expect, it } from 'vitest'
import { cheminRetour } from '../src/utils/retour.js'
describe('cheminRetour', () => {
it('rend la page d analyse avec ses filtres', () => {
const vue = '/analytics?immeuble_id=3&fournisseur=PPR&tag_id=0'
expect(cheminRetour(vue)).toBe(vue)
})
it('retombe sur la liste des documents quand rien n est demande', () => {
expect(cheminRetour(undefined)).toBe('/documents')
expect(cheminRetour('')).toBe('/documents')
})
it('refuse ce qui sortirait du site', () => {
// `//ailleurs.example` est un chemin protocol-relatif : le navigateur y lit
// un domaine, pas une page de l'application.
expect(cheminRetour('https://ailleurs.example')).toBe('/documents')
expect(cheminRetour('//ailleurs.example')).toBe('/documents')
expect(cheminRetour('/\\ailleurs.example')).toBe('/documents')
expect(cheminRetour('analytics')).toBe('/documents')
})
it('accepte le repli que l appelant lui donne', () => {
expect(cheminRetour(null, '/revenus')).toBe('/revenus')
})
it('ne retient que la premiere valeur quand le parametre est repete', () => {
expect(cheminRetour(['/analytics', 'https://ailleurs.example'])).toBe('/analytics')
})
})

View File

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

View File

@@ -0,0 +1,183 @@
// Le recoupement des totaux avec les lignes sert d'alerte sur les extractions
// incomplètes : il est éprouvé sur les configurations des comptes rendus réels.
import { describe, expect, it } from 'vitest'
import { ecartsAvecLignes, totauxCalcules } from '../src/utils/totauxLocataire.js'
// Lot 11 CHARLOT ANDREE : un loyer avec provision, réglé.
const ligneLoyer = {
type: 'loyer',
periode: { debut: '2026-06-01', fin: '2026-06-30' },
loyers: 809.64,
taxes: 0,
provisions: 69,
divers: { montant: 0, libelle: null },
total: 878.64,
regles: 878.64,
impayes: 0,
}
describe('totauxCalcules', () => {
it('agrège colonne par colonne, comme lœil sur le tableau', () => {
expect(totauxCalcules([ligneLoyer])).toEqual({
solde_anterieur: 0,
loyers: 809.64,
taxes: 0,
provisions: 69,
divers: 0,
total: 878.64,
regles: 878.64,
impayes: 0,
})
})
it('somme la colonne « total » telle quelle, sans la déduire des autres', () => {
// Le compte rendu ne remplit cette colonne que sur une ligne par bloc : les
// blocs se recomposent à l'échelle du lot, c'est ce total-là qui fait foi.
const totaux = totauxCalcules([
{ type: 'loyer', loyers: 997.02, provisions: 64, total: 0 },
{ type: 'loyer', loyers: 500, total: 1561.02 },
])
expect(totaux.total).toBe(1561.02)
expect(totaux.loyers).toBe(1497.02)
})
it('somme la colonne « impayé » plutôt que de la recalculer', () => {
const totaux = totauxCalcules([
{ type: 'loyer', loyers: 500, total: 500, regles: 400, impayes: 100 },
])
expect(totaux.impayes).toBe(100)
})
it('range le report de solde à part sans le confondre avec les loyers', () => {
// Lot 03 du compte rendu de février : un report de 0,63 et un loyer réglé.
const totaux = totauxCalcules([
{ type: 'solde_anterieur', loyers: 0.63, total: 0.63, regles: 0, impayes: 0.63 },
{ type: 'loyer', loyers: 640, provisions: 31, total: 671, regles: 671, impayes: 0 },
])
expect(totaux.solde_anterieur).toBe(0.63)
expect(totaux.loyers).toBe(640)
expect(totaux.total).toBe(671.63)
expect(totaux.impayes).toBe(0.63)
})
it('additionne les lignes de types différents', () => {
// Lot 09 TERRIER ADILE : régularisation de sortie, montants négatifs.
const totaux = totauxCalcules([
{ type: 'loyer', provisions: -6, total: 0 },
{ type: 'rappel_loyer', loyers: -138.98, total: 0 },
{ type: 'divers', divers: { montant: -455, libelle: 'Rembt dépot de garantie' }, total: 0 },
{ type: 'divers', divers: { montant: 23.02 }, total: -268.2, regles: -268.2 },
])
expect(totaux.loyers).toBe(-138.98)
expect(totaux.provisions).toBe(-6)
expect(totaux.divers).toBe(-431.98)
expect(totaux.total).toBe(-268.2)
expect(totaux.regles).toBe(-268.2)
})
it('traite les champs absents comme des zéros', () => {
const totaux = totauxCalcules([{ type: 'loyer', loyers: 500 }])
expect(totaux.loyers).toBe(500)
expect(totaux.total).toBe(0)
expect(totauxCalcules([]).total).toBe(0)
expect(totauxCalcules(undefined).total).toBe(0)
})
it('arrondit au centime plutôt que de traîner les flottants', () => {
const totaux = totauxCalcules([
{ type: 'loyer', loyers: 0.1 },
{ type: 'loyer', loyers: 0.2 },
])
expect(totaux.loyers).toBe(0.3)
})
})
describe('ecartsAvecLignes', () => {
const extraitsCharlot = {
solde_anterieur: 0,
loyers: 809.64,
taxes: 0,
provisions: 69,
divers: 0,
total: 878.64,
regles: 878.64,
impayes: 0,
}
it('ne signale rien quand les lignes recoupent la ligne « Totaux »', () => {
const calcules = totauxCalcules([ligneLoyer])
expect(ecartsAvecLignes(extraitsCharlot, calcules)).toEqual({})
})
it('signale une colonne Total qui ne somme pas, et le règlement qui manque avec', () => {
// Lot 07 de S_33680000_2025-11-25 : la dernière ligne a perdu ses colonnes
// Total et Regles à l'extraction, le compte rendu porte 707,29 € pour les deux.
const calcules = totauxCalcules([
{ type: 'loyer', loyers: -265.29, provisions: -50, total: 0, regles: 0 },
{ type: 'divers', divers: { montant: -87.85 }, total: -403.14, regles: -403.14 },
{ type: 'loyer', loyers: 265.29, provisions: 50, total: 0, regles: 0 },
{ type: 'divers', divers: { montant: 87.85 }, total: 403.14, regles: 403.14 },
{ type: 'loyer', loyers: 657.29, provisions: 50, total: 0, regles: 0 },
])
// Les colonnes de détail se recoupent, seules Total et Regles décrochent.
expect(calcules.loyers).toBe(657.29)
expect(calcules.provisions).toBe(50)
expect(calcules.divers).toBe(0)
const ecarts = ecartsAvecLignes(
{
solde_anterieur: 0,
loyers: 657.29,
taxes: 0,
provisions: 50,
divers: 0,
total: 707.29,
regles: 707.29,
impayes: 0,
},
calcules
)
expect(ecarts).toEqual({
total: { extrait: 707.29, calcule: 0, manquant: 707.29 },
regles: { extrait: 707.29, calcule: 0, manquant: 707.29 },
})
})
it('signale un divers absent des lignes extraites', () => {
const calcules = totauxCalcules([
{ type: 'divers', divers: { montant: -455 } },
{ type: 'divers', divers: { montant: 23.02 } },
])
expect(ecartsAvecLignes({ divers: -123.22 }, calcules)).toEqual({
divers: { extrait: -123.22, calcule: -431.98, manquant: 308.76 },
})
})
it('signale un lot dont aucune ligne na été extraite', () => {
// Lot 08 de S_33680000_2025-08-26 : 100 € réglés, aucune ligne.
const ecarts = ecartsAvecLignes({ loyers: 0, regles: 100 }, totauxCalcules([]))
expect(ecarts).toEqual({ regles: { extrait: 100, calcule: 0, manquant: 100 } })
})
it('rapporte lextrait, le calculé et ce qui manque entre les deux', () => {
const calcules = totauxCalcules([{ type: 'loyer', loyers: 500 }])
expect(ecartsAvecLignes({ loyers: 800 }, calcules)).toEqual({
loyers: { extrait: 800, calcule: 500, manquant: 300 },
})
})
it('tolère un écart darrondi sous le centime', () => {
const calcules = totauxCalcules([{ type: 'loyer', loyers: 100 }])
expect(ecartsAvecLignes({ loyers: 100.004 }, calcules)).toEqual({})
})
it('ignore une colonne que le compte rendu ne renseigne pas', () => {
const calcules = totauxCalcules([{ type: 'loyer', loyers: 100 }])
expect(ecartsAvecLignes({ loyers: 100, taxes: null }, calcules)).toEqual({})
})
it('ne compare rien sans totaux extraits', () => {
expect(ecartsAvecLignes(null, totauxCalcules([ligneLoyer]))).toEqual({})
})
})

View File

@@ -0,0 +1,81 @@
// La largeur du volet PDF est un reglage que l'utilisateur pose une fois et
// retrouve ensuite : elle doit survivre au rechargement, rester dans des bornes
// utilisables, et ne jamais empecher l'ecran de s'afficher si le stockage
// du navigateur est indisponible.
import { afterEach, describe, expect, it } from 'vitest'
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
import VoletsAjustables from '../src/components/VoletsAjustables.vue'
const CLE = 'apercuPdf.largeurVolet'
/** Remplace le stockage du navigateur, absent sous Node. */
function poserStockage(valeur, { leve = false } = {}) {
globalThis.localStorage = {
getItem: () => {
if (leve) throw new Error('stockage indisponible')
return valeur
},
setItem: () => {},
removeItem: () => {},
}
}
function rendu(proprietes = {}) {
return renderToString(
createSSRApp(VoletsAjustables, { cleStockage: CLE, largeurDefaut: 50, ...proprietes })
)
}
afterEach(() => {
delete globalThis.localStorage
})
describe('VoletsAjustables', () => {
it('applique la largeur par defaut quand rien n est memorise', async () => {
poserStockage(null)
const html = await rendu()
expect(html).toContain('width:50%')
})
it('reprend la largeur memorisee', async () => {
poserStockage('65')
const html = await rendu()
expect(html).toContain('width:65%')
// Les deux volets se partagent la totalite : la poignee ne prend pas de
// place dans le flux, sinon le second volet deborderait.
expect(html).toContain('width:35%')
})
it('ignore une largeur memorisee hors bornes', async () => {
// Sans ce garde-fou, une valeur ecrite avant un changement de bornes
// resterait coincee et rendrait un volet inutilisable.
poserStockage('95')
expect(await rendu()).toContain('width:50%')
})
it('ignore une valeur memorisee illisible', async () => {
poserStockage('beaucoup')
expect(await rendu()).toContain('width:50%')
})
it("s'affiche quand même si le stockage est indisponible", async () => {
// Navigation privee ou stockage bloque : le reglage se perd, pas l'ecran.
poserStockage(null, { leve: true })
expect(await rendu()).toContain('width:50%')
})
it('expose une poignee de redimensionnement atteignable', async () => {
poserStockage(null)
const html = await rendu()
expect(html).toContain('role="separator"')
expect(html).toContain('cursor-col-resize')
})
})

View File

@@ -5,7 +5,7 @@
; idéal pour un poste personnel. Crée un raccourci Bureau et menu Démarrer.
#define AppName "Plesna Gérance"
#define AppVersion "0.1.0"
#define AppVersion "0.1.2"
#define AppPublisher "Plesna"
#define AppExeName "PlesnaGerance.exe"

View File

@@ -1,6 +1,6 @@
[project]
name = "plesna-gerance"
version = "0.1.0"
version = "0.1.2"
description = "Extracteur de comptes rendus de gérance Oralia/ICS"
requires-python = ">=3.10"
dependencies = [

182
scripts/release.py Normal file
View File

@@ -0,0 +1,182 @@
"""Pose une version : aligne les fichiers, commite, tague.
Le numéro de version est écrit dans cinq fichiers qui doivent rester d'accord
(paquet Python, module, frontend, son lock, installeur Windows). Les tenir à
jour à la main, c'est publier tôt ou tard un tag `v0.3.0` sur un code qui se
déclare `0.1.0`. Ce script fait la mise à jour d'un bloc et refuse d'avancer au
moindre doute plutôt que de produire une version à moitié cohérente.
uv run python scripts/release.py 0.2.0
Il s'arrête avant le `git push` : le tag reste local tant qu'il n'est pas
poussé, et c'est le push qui déclenche la CI (images Docker + build Windows).
"""
import argparse
import re
import subprocess
import sys
from pathlib import Path
RACINE = Path(__file__).resolve().parent.parent
# Un fichier, le motif qui y porte la version, le remplacement, et le nombre
# d'occurrences attendues. Le motif doit capturer la version dans son dernier
# groupe pour que l'on puisse relire l'ancienne valeur ; le compte est verifie
# a chaque passage, pour que le script s'arrete si un fichier change de forme
# plutot que de laisser filer une version a moitie posee.
PORTEURS_DE_VERSION = [
(
"pyproject.toml",
re.compile(r'^version = "([^"]+)"$', re.M),
'version = "{v}"',
1,
),
(
"src/plesna_gerance/__init__.py",
re.compile(r'^__version__ = "([^"]+)"$', re.M),
'__version__ = "{v}"',
1,
),
(
"frontend/package.json",
re.compile(r'^ "version": "([^"]+)",$', re.M),
' "version": "{v}",',
1,
),
# Le lock porte la version du paquet racine a deux endroits (en-tete et
# packages[""]). Les laisser en arriere fait diverger lock et package.json,
# ce que `npm ci` peut refuser en CI selon la version de npm. On s'ancre sur
# le nom du paquet, qui n'apparait qu'a ces deux endroits.
(
"frontend/package-lock.json",
re.compile(
r'(^(\s*)"name": "plesna-gerance-frontend",\n\s*"version": ")([^"]+)',
re.M,
),
r"\g<1>{v}",
2,
),
(
"packaging/installer.iss",
re.compile(r'^#define AppVersion "([^"]+)"$', re.M),
'#define AppVersion "{v}"',
1,
),
]
SEMVER = re.compile(r"^\d+\.\d+\.\d+$")
class Refus(Exception):
"""Condition non remplie : on s'arrête sans rien modifier."""
def git(*args: str) -> str:
"""Lance une commande git dans le dépôt et renvoie sa sortie."""
resultat = subprocess.run(
["git", "-C", str(RACINE), *args],
capture_output=True,
text=True,
)
if resultat.returncode != 0:
raise Refus(f"git {' '.join(args)} a échoué :\n{resultat.stderr.strip()}")
return resultat.stdout.strip()
def verifier_le_depot(version: str, autoriser_hors_main: bool) -> None:
"""Refuse de poser une version depuis un état de dépôt douteux."""
branche = git("rev-parse", "--abbrev-ref", "HEAD")
if branche != "main" and not autoriser_hors_main:
raise Refus(
f"branche courante « {branche} » : une version se pose sur main.\n"
"Fusionner d'abord, ou forcer avec --autoriser-hors-main."
)
if git("status", "--porcelain"):
raise Refus(
"l'arbre de travail n'est pas propre : commiter ou remiser d'abord.\n"
"Une version doit correspondre à un état de code identifiable."
)
if git("tag", "--list", f"v{version}"):
raise Refus(
f"le tag v{version} existe déjà. Choisir un numéro supérieur "
"(un tag publié ne se réécrit pas)."
)
def appliquer_la_version(version: str) -> list[str]:
"""Écrit la version dans les fichiers porteurs. Renvoie ceux qui ont changé."""
modifies = []
for chemin_relatif, motif, remplacement, attendues in PORTEURS_DE_VERSION:
chemin = RACINE / chemin_relatif
contenu = chemin.read_text(encoding="utf-8")
# Le dernier groupe capture la version elle-même, quel que soit le
# nombre de groupes servant à l'ancrage.
trouvees = [
correspondance.groups()[-1] for correspondance in motif.finditer(contenu)
]
if len(trouvees) != attendues:
raise Refus(
f"{chemin_relatif} : {len(trouvees)} version(s) trouvée(s), "
f"{attendues} attendue(s). Le fichier a changé de forme : "
"corriger le motif dans scripts/release.py."
)
if all(trouvee == version for trouvee in trouvees):
continue
chemin.write_text(
motif.sub(remplacement.format(v=version), contenu), encoding="utf-8"
)
modifies.append(chemin_relatif)
return modifies
def main() -> int:
parseur = argparse.ArgumentParser(description=__doc__)
parseur.add_argument("version", help="numéro de version, sans le v (ex. 0.2.0)")
parseur.add_argument(
"--autoriser-hors-main",
action="store_true",
help="pose la version depuis une autre branche (rattrapage)",
)
arguments = parseur.parse_args()
version = arguments.version.lstrip("v")
if not SEMVER.match(version):
print(
f"Version « {arguments.version} » invalide : attendu MAJEUR.MINEUR.CORRECTIF "
"(ex. 0.2.0).",
file=sys.stderr,
)
return 1
try:
verifier_le_depot(version, arguments.autoriser_hors_main)
modifies = appliquer_la_version(version)
if modifies:
git("add", *modifies)
git("commit", "-m", f"chore: passe en version {version}")
print(f"Version écrite dans : {', '.join(modifies)}")
else:
print(f"Les fichiers déclarent déjà {version} : aucun commit de version.")
git("tag", "-a", f"v{version}", "-m", f"Version {version}")
except Refus as refus:
print(f"Publication interrompue : {refus}", file=sys.stderr)
return 1
print(f"Tag v{version} posé sur {git('rev-parse', '--short', 'HEAD')}.")
print("\nRien n'est publié tant que le tag n'est pas poussé :")
print(f" git push origin main v{version}")
print("\nLe push déclenche la CI : images Docker taguées puis build Windows.")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -5,7 +5,7 @@ Ce package extrait les informations structurées des PDFs de comptes rendus
de gérance générés par le logiciel de gestion immobilière Oralia/ICS.
"""
__version__ = "0.1.0"
__version__ = "0.1.2"
from .extractor import extract_compte_rendu

View File

@@ -25,6 +25,47 @@ from ..schemas.models import (
router = APIRouter(prefix="/api", tags=["analytics"])
#: Valeur de `tag_id` demandant les depenses sans tag. C'est la meme clef que
#: celle employee pour agreger les non taggues dans `by_tag` : « aucun tag » est
#: un choix de filtre a part entiere, pas l'absence de filtre (`tag_id` omis).
SANS_TAG = 0
def _appliquer_filtres(
stmt,
*,
immeuble_id: int | None,
lot_id: int | None,
tag_id: int | None,
categorie: str | None,
fournisseurs: list[str] | None,
date_debut: date | None,
date_fin: date | None,
):
"""Applique les filtres communs a la table et au resume.
Les deux endpoints doivent voir exactement le meme perimetre : les totaux du
resume ne veulent rien dire s'ils portent sur d'autres lignes que la table.
"""
if immeuble_id is not None:
stmt = stmt.where(Depense.immeuble_id == immeuble_id)
if lot_id is not None:
stmt = stmt.where(Depense.lot_id == lot_id)
if tag_id is not None:
if tag_id == SANS_TAG:
stmt = stmt.where(Depense.tag_id.is_(None))
else:
stmt = stmt.where(Depense.tag_id == tag_id)
if categorie is not None:
stmt = stmt.where(Depense.categorie == categorie)
if fournisseurs:
stmt = stmt.where(Depense.fournisseur.in_(fournisseurs))
if date_debut is not None:
stmt = stmt.where(Document.date >= date_debut)
if date_fin is not None:
stmt = stmt.where(Document.date <= date_fin)
return stmt
# ============================================================
# Reference data endpoints (for filters)
@@ -180,9 +221,11 @@ async def list_tags_with_stats(
async def get_depenses(
immeuble_id: int | None = Query(None, description="Filtrer par immeuble"),
lot_id: int | None = Query(None, description="Filtrer par lot"),
tag_id: int | None = Query(None, description="Filtrer par tag"),
tag_id: int | None = Query(None, description="Filtrer par tag (0 = sans tag)"),
categorie: str | None = Query(None, description="Filtrer par categorie"),
fournisseur: str | None = Query(None, description="Filtrer par fournisseur"),
fournisseur: list[str] | None = Query(
None, description="Fournisseurs retenus (parametre repetable)"
),
date_debut: date | None = Query(None, description="Date de debut (YYYY-MM-DD)"),
date_fin: date | None = Query(None, description="Date de fin (YYYY-MM-DD)"),
limit: int = Query(500, description="Nombre maximum de resultats"),
@@ -194,9 +237,10 @@ async def get_depenses(
Filtres disponibles:
- **immeuble_id**: ID de l'immeuble
- **lot_id**: ID du lot
- **tag_id**: ID du tag
- **tag_id**: ID du tag, ou 0 pour les depenses sans tag
- **categorie**: Categorie de depense
- **fournisseur**: Nom du fournisseur
- **fournisseur**: nom exact d'un fournisseur, repetable pour en retenir
plusieurs (`?fournisseur=A&fournisseur=B`)
- **date_debut**: Date de debut (incluse)
- **date_fin**: Date de fin (incluse)
- **limit**: Nombre max de resultats (defaut: 500)
@@ -219,21 +263,16 @@ async def get_depenses(
.order_by(Document.date.desc(), Depense.id.desc())
)
# Apply filters
if immeuble_id is not None:
stmt = stmt.where(Depense.immeuble_id == immeuble_id)
if lot_id is not None:
stmt = stmt.where(Depense.lot_id == lot_id)
if tag_id is not None:
stmt = stmt.where(Depense.tag_id == tag_id)
if categorie is not None:
stmt = stmt.where(Depense.categorie == categorie)
if fournisseur is not None:
stmt = stmt.where(Depense.fournisseur.ilike(f"%{fournisseur}%"))
if date_debut is not None:
stmt = stmt.where(Document.date >= date_debut)
if date_fin is not None:
stmt = stmt.where(Document.date <= date_fin)
stmt = _appliquer_filtres(
stmt,
immeuble_id=immeuble_id,
lot_id=lot_id,
tag_id=tag_id,
categorie=categorie,
fournisseurs=fournisseur,
date_debut=date_debut,
date_fin=date_fin,
)
stmt = stmt.limit(limit).offset(offset)
@@ -271,9 +310,11 @@ async def get_depenses(
async def get_depenses_summary(
immeuble_id: int | None = Query(None, description="Filtrer par immeuble"),
lot_id: int | None = Query(None, description="Filtrer par lot"),
tag_id: int | None = Query(None, description="Filtrer par tag"),
tag_id: int | None = Query(None, description="Filtrer par tag (0 = sans tag)"),
categorie: str | None = Query(None, description="Filtrer par categorie"),
fournisseur: str | None = Query(None, description="Filtrer par fournisseur"),
fournisseur: list[str] | None = Query(
None, description="Fournisseurs retenus (parametre repetable)"
),
date_debut: date | None = Query(None, description="Date de debut (YYYY-MM-DD)"),
date_fin: date | None = Query(None, description="Date de fin (YYYY-MM-DD)"),
session: Session = Depends(get_session),
@@ -289,22 +330,16 @@ async def get_depenses_summary(
- Top fournisseurs
"""
# Base query with filters
base_stmt = select(Depense).join(Document, Depense.document_id == Document.id)
if immeuble_id is not None:
base_stmt = base_stmt.where(Depense.immeuble_id == immeuble_id)
if lot_id is not None:
base_stmt = base_stmt.where(Depense.lot_id == lot_id)
if tag_id is not None:
base_stmt = base_stmt.where(Depense.tag_id == tag_id)
if categorie is not None:
base_stmt = base_stmt.where(Depense.categorie == categorie)
if fournisseur is not None:
base_stmt = base_stmt.where(Depense.fournisseur.ilike(f"%{fournisseur}%"))
if date_debut is not None:
base_stmt = base_stmt.where(Document.date >= date_debut)
if date_fin is not None:
base_stmt = base_stmt.where(Document.date <= date_fin)
base_stmt = _appliquer_filtres(
select(Depense).join(Document, Depense.document_id == Document.id),
immeuble_id=immeuble_id,
lot_id=lot_id,
tag_id=tag_id,
categorie=categorie,
fournisseurs=fournisseur,
date_debut=date_debut,
date_fin=date_fin,
)
# Get all matching depenses
result = session.execute(base_stmt)

View File

@@ -14,12 +14,43 @@ Deux limites sont assumées plutôt que contournées :
- **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.
Le bloc `loyer` fait exception à ce cumul : il remet le loyer sur un axe de
temps, seule façon de voir une révision, une vacance ou un décrochage que les
totaux écrasent. Ses règles vivent dans `services.loyers`, et la comparaison au
parc les rejoue à l'identique pour les autres lots — situer un chiffre par
rapport à des chiffres obtenus autrement ne voudrait rien dire.
Le loyer au mètre carré vient de la surface saisie sur la fiche du logement.
Aucun compte rendu n'en porte : tant qu'elle manque, le ratio reste `null` et la
page renvoie vers la saisie plutôt que d'afficher un zéro.
Une **fenêtre de temps** optionnelle (`mois`) restreint les chiffres, la
chronologie, les intervenants, les locataires et la courbe du loyer. Trois
règles la gouvernent :
- **elle est calée sur le dernier compte rendu du lot**, pas sur aujourd'hui.
Un lot dont l'extraction s'arrête il y a huit mois afficherait sinon une page
vide sur « 3 derniers mois », ce qui se lirait comme une absence d'activité ;
- **elle coupe la courbe du loyer, sans la recalculer**. Les paliers, la
dernière révision et le mois de comparaison au parc restent lus sur toute la
série : « depuis mai 26 » doit désigner la révision qui a fixé ce loyer, pas
la borne du filtre, et la médiane du parc ne peut pas changer de mois de
référence à chaque changement de période ;
- **elle ne s'applique pas au restant dû**, qui est un stock et non un flux :
le borner ferait disparaître une dette bien réelle dès qu'aucun compte rendu
ne tombe dans la fenêtre (cf. `services.revenus_query`).
Sans `mois`, tout l'historique est rendu — le défaut ne cache rien. Avec, la
réponse porte `periode`, qui dit les bornes retenues et combien de lignes
restent dehors : un filtre doit annoncer ce qu'il masque.
"""
from datetime import date
import calendar
from datetime import date, timedelta
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.orm import Session
@@ -33,6 +64,17 @@ from ...database.models import (
Revenu,
Tag,
)
from ...services.loyers import (
EFFECTIF_MEDIANE_FIABLE,
MoisLoue,
loyer_au_m2,
mediane,
mois_de,
paliers,
parc_du_mois,
serie_du_lot,
variation,
)
from ...services.referentiel import type_effectif
from ...services.revenus_query import (
TYPE_LIGNE_REPORT,
@@ -44,6 +86,26 @@ from ...services.revenus_query import (
router = APIRouter(prefix="/api", tags=["lots"])
class Periode(BaseModel):
"""La fenêtre de temps réellement appliquée, et ce qu'elle laisse dehors.
Renvoyée même quand rien n'est filtré : la page affiche ainsi toujours
l'étendue de ce qu'elle montre, plutôt que de le laisser deviner.
"""
#: Nombre de mois demandé, `None` pour tout l'historique.
mois: int | None = None
#: Bornes retenues, incluses. `None` des deux côtés sans filtre.
debut: date | None = None
fin: date | None = None
#: Date du dernier compte rendu portant une ligne de ce lot — l'origine sur
#: laquelle la fenêtre est calée. `None` pour un lot sans aucune ligne.
ancre: date | None = None
#: Lignes de chronologie que la fenêtre écarte. Une fenêtre qui masque
#: quarante opérations doit le dire, sans quoi le lot paraîtrait calme.
lignes_masquees: int = 0
class LotIdentite(BaseModel):
"""Qui est ce lot : son rattachement, et ce que sa fiche en dit."""
@@ -62,9 +124,14 @@ class LotIdentite(BaseModel):
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.
#: Noms portés par les comptes rendus de la période. Les dates d'entrée et
#: de sortie ne sont pas extraites : le seul rattachement au temps dont on
#: dispose est le compte rendu où le nom figure. L'ordre n'a pas de sens.
locataires: list[str] = []
#: Locataires du lot qu'aucun compte rendu de la période ne porte. Comptés
#: pour qu'une fiche filtrée n'ait pas l'air de n'avoir jamais eu qu'un
#: occupant.
locataires_masques: int = 0
class LotChiffres(BaseModel):
@@ -135,23 +202,257 @@ class Intervenant(BaseModel):
derniere_date: date | None = None
class PointLoyer(BaseModel):
"""Un mois de la courbe du loyer."""
mois: str # "2026-06"
#: Loyer hors charges du mois plein, `None` si aucune ligne ne le couvre.
loyer: float | None = None
charges: float | None = None
#: Facturé au prorata ce mois-là (entrée, sortie, avoir), hors du loyer.
prorata: float | None = None
#: Loyer au m², `None` sans surface saisie.
loyer_m2: float | None = None
#: Le loyer vient d'une ligne pluri-mensuelle répartie (bail trimestriel).
reparti: bool = False
#: Mois facturé seulement au prorata : un changement de locataire, pas une
#: vacance. Sans cette distinction, les deux se ressemblent sur la courbe.
en_transition: bool = False
class LigneHorsCourbe(BaseModel):
"""Une ligne de loyer qu'aucun mois ne peut revendiquer.
Régularisation rétroactive chevauchant plusieurs mois. Rendue à part pour
que la somme de la courbe et de ces lignes retrouve le facturé total.
"""
periode_debut: date | None = None
periode_fin: date | None = None
montant: float = 0.0
class LoyerEnVigueur(BaseModel):
"""Le dernier loyer connu, et depuis quand il tient."""
mois: str
loyer: float
loyer_m2: float | None = None
#: Premier mois du palier courant : la date d'effet de la dernière révision.
depuis: str
#: Loyer d'avant la dernière révision, et l'écart en %. `None` si le lot
#: n'a connu qu'un seul niveau depuis le premier compte rendu.
precedent: float | None = None
variation_pct: float | None = None
#: Le dernier compte rendu de l'immeuble porte encore ce loyer. Faux pour
#: un lot dont le bail s'est arrêté : son dernier loyer est une archive, et
#: l'afficher comme courant ferait croire à une recette qui n'existe plus.
#:
#: Faux aussi pendant une relocation, où le loyer plein d'avant n'a plus
#: cours et celui d'après n'est pas encore facturé : `reloue_depuis` sépare
#: alors ce lot d'un lot réellement sorti de la gestion.
toujours_loue: bool = True
#: Mois d'un prorata d'entrée postérieur à ce loyer : un nouveau bail a
#: commencé, et seuls les jours qu'il couvre sont facturés. Sans ce champ,
#: la fiche annoncerait un loyer « arrêté » sur un lot qui vient d'être
#: reloué.
reloue_depuis: str | None = None
#: Mois d'un prorata de sortie postérieur à ce loyer, quand aucune entrée
#: ne suit : la location s'arrête en cours de mois, pas à la fin du palier.
sortie_en: str | None = None
class PointParc(BaseModel):
"""Un lot du parc, placé par sa surface et son loyer au m²."""
lot_id: int
numero: str
immeuble_code: str | None = None
type_lot: str | None = None
surface: float
loyer_m2: float
#: Le lot dont on regarde la fiche. Il figure dans le nuage — s'y voir situé
#: est tout l'objet — mais reste hors des médianes, qu'il tirerait vers lui.
est_ce_lot: bool = False
class ComparaisonParc(BaseModel):
"""Le loyer au m² du lot situé face aux lots comparables.
Comparé sur le dernier mois loué du lot, et sur ce seul mois : rapprocher
un loyer de 2026 de loyers de 2024 mesurerait l'inflation autant que
l'écart entre deux biens.
Deux lectures cohabitent, et la seconde corrige la première : les médianes
résument le parc en un chiffre, mais mélangent toutes les surfaces ; le
nuage garde la surface en abscisse, seule façon de voir si un lot est cher
*pour sa taille*.
"""
mois: str
loyer_m2: float | None = None
mediane_immeuble: float | None = None
nb_immeuble: int = 0
mediane_type: float | None = None
nb_type: int = 0
type_compare: str | None = None
#: Lots loués ce mois-là dont la fiche n'a pas de surface : ils ne peuvent
#: pas être comparés. Compté et affiché, sans quoi une médiane sur huit
#: lots passerait pour une médiane sur tout le parc.
sans_surface: int = 0
#: Effectif en dessous duquel la médiane décrit surtout le hasard.
effectif_faible: int = EFFECTIF_MEDIANE_FIABLE
#: Tout le parc comparable, surface comprise, lot courant inclus et
#: signalé. Trié par surface : le nuage se lit de gauche à droite.
nuage: list[PointParc] = []
class LotLoyer(BaseModel):
"""Le loyer du lot dans le temps, et ce qu'il vaut au mètre carré.
La courbe suit la fenêtre choisie ; `en_vigueur` et `parc` non. Ces deux-là
décrivent l'état courant : réduits à la fenêtre, « depuis mai 26 » daterait
de la borne du filtre au lieu de la révision qui l'a fixé, et la médiane du
parc changerait de mois de référence à chaque changement de période.
"""
surface: float | None = None
serie: list[PointLoyer] = []
hors_courbe: list[LigneHorsCourbe] = []
en_vigueur: LoyerEnVigueur | None = None
parc: ComparaisonParc | None = None
#: Premier mois tracé quand une fenêtre est active, `None` sans filtre.
depuis_mois: str | None = None
#: Mois de loyer antérieurs à la fenêtre : connus, mais hors de la courbe.
mois_masques: int = 0
#: Lignes hors-courbe antérieures à la fenêtre, également non listées.
hors_courbe_masquees: int = 0
class LotAnalyseResponse(BaseModel):
"""Fiche complète d'un lot."""
identite: LotIdentite
chiffres: LotChiffres
loyer: LotLoyer
chronologie: list[LigneChronologie]
intervenants: list[Intervenant]
#: Fenêtre appliquée aux chiffres, à la chronologie et aux intervenants —
#: jamais au loyer, ni au restant dû.
periode: Periode = Field(default_factory=Periode)
def _identite(session: Session, lot: Lot) -> LotIdentite:
def _recule(reference: date, mois: int) -> date:
"""La même date, `mois` mois plus tôt.
Le 31 mars reculé d'un mois donne le 28 février : un mois calendaire n'a pas
de durée fixe, et l'arithmétique en jours ferait dériver la borne.
"""
total = reference.year * 12 + reference.month - 1 - mois
annee, index = divmod(total, 12)
jour = min(reference.day, calendar.monthrange(annee, index + 1)[1])
return date(annee, index + 1, jour)
def _ancre(session: Session, lot: Lot) -> date | None:
"""Date du dernier compte rendu portant une ligne de ce lot.
Recettes et dépenses comptent toutes deux : un lot sorti de la location
peut n'avoir plus que des travaux, et caler la fenêtre sur ses seuls loyers
la ferait finir avant ses dernières opérations.
"""
dernieres = [
session.execute(
select(func.max(Document.date))
.join(table, table.document_id == Document.id)
.where(table.lot_id == lot.id)
).scalar()
for table in (Revenu, Depense)
]
connues = [date_ for date_ in dernieres if date_ is not None]
return max(connues) if connues else None
def _fenetre(session: Session, lot: Lot, mois: int | None) -> Periode:
"""Fenêtre demandée, ramenée aux dates que la base peut honorer.
Sans `mois`, ou sur un lot dont aucun compte rendu ne parle, il n'y a rien
à borner : la période reste ouverte plutôt que de se rabattre sur
aujourd'hui, qui viderait la fiche sans l'expliquer.
"""
ancre = _ancre(session, lot)
if mois is None or ancre is None:
return Periode(mois=mois, ancre=ancre)
# Le lendemain du même jour `mois` mois plus tôt : trois mois avant le
# 15 février commencent le 16 novembre, sinon le compte rendu du 15 novembre
# entrerait dans une fenêtre de trois mois et en ferait quatre.
debut = _recule(ancre, mois) + timedelta(days=1)
return Periode(mois=mois, debut=debut, fin=ancre, ancre=ancre)
def _borner(stmt, periode: Periode):
"""Restreint une requête à la fenêtre, sur la date du compte rendu.
La requête doit déjà joindre `Document` : les dépenses comme les recettes
ne portent pas de date propre, elles empruntent celle du document qui les
a émises — la seule date qu'un compte rendu garantisse.
"""
if periode.debut is not None:
stmt = stmt.where(Document.date >= periode.debut)
if periode.fin is not None:
stmt = stmt.where(Document.date <= periode.fin)
return stmt
def _locataires(session: Session, lot: Lot, periode: Periode) -> tuple[list[str], int]:
"""Occupants du lot sur la période, et nombre de ceux qu'elle écarte.
Aucune date d'entrée ni de sortie n'est extraite des comptes rendus : le
seul rattachement au temps disponible est le document où le nom figure. Un
locataire appartient donc à la période si un compte rendu de la fenêtre
porte une de ses lignes.
Sans fenêtre, la liste reste celle de la table : un locataire enregistré
dont aucune ligne n'a été rattachée — il en existe en base — disparaîtrait
sinon de la vue par défaut, qui ne filtre justement rien.
"""
tous = list(
session.execute(
select(Locataire.nom)
.where(Locataire.lot_id == lot.id)
.order_by(Locataire.nom)
).scalars()
)
if periode.debut is None and periode.fin is None:
return tous, 0
presents = list(
session.execute(
_borner(
select(Locataire.nom)
.join(Revenu, Revenu.locataire_id == Locataire.id)
.join(Document, Revenu.document_id == Document.id)
.where(Locataire.lot_id == lot.id),
periode,
)
.distinct()
.order_by(Locataire.nom)
).scalars()
)
return presents, len(tous) - len(presents)
def _identite(session: Session, lot: Lot, periode: Periode) -> 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()
noms, masques = _locataires(session, lot, periode)
return LotIdentite(
id=lot.id,
@@ -165,18 +466,24 @@ def _identite(session: Session, lot: Lot) -> LotIdentite:
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),
locataires=noms,
locataires_masques=masques,
)
def _chiffres(session: Session, lot: Lot) -> LotChiffres:
def _chiffres(session: Session, lot: Lot, periode: Periode) -> 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.
La fenêtre borne les flux et rien d'autre. `restant_du` reste lu sur toute
l'histoire : c'est la dette au dernier compte rendu, et la borner
l'annulerait dès qu'aucun document ne tombe dans la fenêtre — un lot
devrait alors 0 € tout en devant 49 000 €.
"""
flux = flux_par(Revenu.lot_id)
flux = flux_par(Revenu.lot_id, periode.debut, periode.fin)
dette = restant_du_par(Revenu.lot_id)
recettes = session.execute(
@@ -194,20 +501,31 @@ def _chiffres(session: Session, lot: Lot) -> LotChiffres:
).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)
_borner(
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),
)
.join(Document, Depense.document_id == Document.id)
.where(Depense.lot_id == lot.id),
periode,
)
).one()
debit, credit, deductible, locatif, nb_operations = depenses
# Charges de l'immeuble laissées hors des lots, pour situer le solde.
# Charges de l'immeuble laissées hors des lots, pour situer le solde. Elles
# suivent la fenêtre : la page les annonce « sur la période ».
commun = session.execute(
select(func.coalesce(func.sum(Depense.debit), 0.0)).where(
Depense.immeuble_id == lot.immeuble_id, Depense.lot_id.is_(None)
_borner(
select(func.coalesce(func.sum(Depense.debit), 0.0))
.join(Document, Depense.document_id == Document.id)
.where(
Depense.immeuble_id == lot.immeuble_id, Depense.lot_id.is_(None)
),
periode,
)
).scalar_one()
@@ -233,6 +551,204 @@ def _chiffres(session: Session, lot: Lot) -> LotChiffres:
)
def _comparaison(
session: Session, lot: Lot, mois: str, loyer_m2: float | None
) -> ComparaisonParc:
"""Situe le loyer au m² du lot parmi les lots comparables du même mois."""
parc = parc_du_mois(session, mois)
type_lot = type_effectif(lot)
meme_immeuble = [
autre.loyer_m2
for autre in parc.comparables
if autre.immeuble_id == lot.immeuble_id and autre.lot_id != lot.id
]
# Le type se compare à travers tout le parc : deux immeubles ne donnent
# jamais assez de T2 pour qu'une médiane par immeuble et par type ait un
# sens. Le lot lui-même est exclu des deux, sans quoi il tirerait vers lui
# la médiane à laquelle on le compare.
meme_type = [
autre.loyer_m2
for autre in parc.comparables
if type_lot is not None
and autre.type_lot == type_lot
and autre.lot_id != lot.id
]
# Le nuage prend tout le parc, sans filtre d'immeuble ni de type : avec une
# dizaine de fiches renseignées, restreindre le viderait, et c'est la
# surface — portée par l'abscisse — qui rend deux lots comparables.
nuage = [
PointParc(
lot_id=autre.lot_id,
numero=autre.numero,
immeuble_code=autre.immeuble_code,
type_lot=autre.type_lot,
surface=autre.surface,
loyer_m2=autre.loyer_m2,
est_ce_lot=autre.lot_id == lot.id,
)
for autre in sorted(parc.comparables, key=lambda autre: autre.surface)
]
return ComparaisonParc(
mois=mois,
loyer_m2=loyer_m2,
mediane_immeuble=mediane(meme_immeuble),
nb_immeuble=len(meme_immeuble),
mediane_type=mediane(meme_type),
nb_type=len(meme_type),
type_compare=type_lot,
sans_surface=parc.sans_surface,
nuage=nuage,
)
def _premier_mois(periode: Periode) -> str | None:
"""Premier mois que la courbe trace, `None` sans fenêtre.
Une fenêtre de trois mois finissant en juillet trace mai, juin, juillet :
le mois de la borne haute compte pour un. Reculer de `mois` pleins en
ajouterait un quatrième, et le graphe démentirait son propre libellé.
"""
if periode.mois is None or periode.fin is None:
return None
return mois_de(_recule(periode.fin, periode.mois - 1))
def _mois_de_fin(ligne) -> str | None:
"""Mois où s'achève une ligne écartée de la courbe, `None` s'il manque."""
borne = ligne.periode_fin or ligne.periode_debut
return mois_de(borne) if borne is not None else None
def _apres_le_palier(
serie: list[MoisLoue], mois_fin: str
) -> tuple[str | None, str | None]:
"""Ce que les mois postérieurs au dernier loyer plein disent du bail.
Un palier qui s'arrête ne dit pas que le lot est vide : entre deux baux, le
compte rendu ne porte qu'un prorata, et un mois de transition n'ouvre pas de
palier. Lu sans lui, un lot reloué le 8 du mois passe pour sorti de la
gestion.
L'entrée l'emporte sur la sortie parce qu'elle vient après : un locataire
part le 10 mars, un autre entre le 8 juillet, et c'est le second qui décrit
l'état du lot. Sans entrée, la dernière sortie donne le mois où la location
s'arrête vraiment — plus tard que la fin du palier, qu'elle déborde.
Returns:
`(reloue_depuis, sortie_en)`, chacun `None` quand rien ne l'établit.
"""
suivants = [point for point in serie if point.mois > mois_fin]
entrees = [point.mois for point in suivants if point.entree]
if entrees:
return entrees[-1], None
sorties = [point.mois for point in suivants if point.sortie]
return None, sorties[-1] if sorties else None
def _loyer(session: Session, lot: Lot, periode: Periode) -> LotLoyer:
"""Le loyer du lot mois par mois, son niveau actuel et sa place au m².
Toute la logique de répartition vit dans `services.loyers` : la comparaison
au parc rejoue exactement le même calcul pour les autres lots, sans quoi
elle situerait un chiffre par rapport à des chiffres obtenus autrement.
La courbe est tracée sur la fenêtre, mais **calculée sur tout l'historique**
puis coupée : les paliers, la dernière révision et le mois de comparaison au
parc sortent de la série entière. Les recalculer sur les seuls mois affichés
ferait dater la révision de la borne du filtre.
Rien n'est coupé après la fenêtre : un bail trimestriel facturé d'avance
porte des mois postérieurs au dernier compte rendu, et les retirer ferait
croire que le lot cesse d'être loué.
"""
fiche = lot.caracteristiques
surface = fiche.surface if fiche else None
serie = serie_du_lot(session, lot.id)
depuis = _premier_mois(periode)
mois_traces = [
point for point in serie.mois if depuis is None or point.mois >= depuis
]
points = [
PointLoyer(
mois=point.mois,
loyer=point.loyer,
charges=point.charges,
prorata=point.prorata,
loyer_m2=loyer_au_m2(point.loyer, surface),
reparti=point.reparti,
en_transition=point.en_transition,
)
for point in mois_traces
]
# Une régularisation suit la courbe : elle est retenue quand la période
# qu'elle couvre atteint la fenêtre. Une ligne de 2024 listée sous une
# courbe qui commence en 2026 n'aurait rien à quoi se rapporter.
retenues = [
ligne
for ligne in serie.ecartees
if depuis is None or _mois_de_fin(ligne) is None or _mois_de_fin(ligne) >= depuis
]
hors_courbe = [
LigneHorsCourbe(
periode_debut=ligne.periode_debut,
periode_fin=ligne.periode_fin,
montant=round(ligne.loyers, 2),
)
for ligne in retenues
]
niveaux = paliers(serie.mois)
en_vigueur = None
parc = None
if niveaux:
courant = niveaux[-1]
precedent = niveaux[-2].loyer if len(niveaux) > 1 else None
# Le dernier compte rendu de l'immeuble donne l'actualité : un lot dont
# le loyer s'arrête avant lui n'est plus loué.
dernier_cr = session.execute(
select(func.max(Document.date)).where(
Document.immeuble_id == lot.immeuble_id
)
).scalar()
reloue_depuis, sortie_en = _apres_le_palier(serie.mois, courant.mois_fin)
en_vigueur = LoyerEnVigueur(
mois=courant.mois_fin,
loyer=courant.loyer,
loyer_m2=loyer_au_m2(courant.loyer, surface),
depuis=courant.mois_debut,
precedent=precedent,
variation_pct=variation(precedent, courant.loyer),
toujours_loue=dernier_cr is None or courant.mois_fin >= mois_de(dernier_cr),
reloue_depuis=reloue_depuis,
sortie_en=sortie_en,
)
parc = _comparaison(session, lot, courant.mois_fin, en_vigueur.loyer_m2)
return LotLoyer(
surface=surface,
serie=points,
hors_courbe=hors_courbe,
en_vigueur=en_vigueur,
parc=parc,
depuis_mois=depuis,
mois_masques=len(serie.mois) - len(mois_traces),
hors_courbe_masquees=len(serie.ecartees) - len(retenues),
)
def _libelle_recette(revenu: Revenu) -> str:
"""Ce que la ligne de recette dit d'elle-même.
@@ -297,7 +813,28 @@ def _chronologie(session: Session, lot: Lot) -> list[LigneChronologie]:
return lignes
def _intervenants(session: Session, lot: Lot) -> list[Intervenant]:
def _restreindre(
lignes: list[LigneChronologie], periode: Periode
) -> tuple[list[LigneChronologie], int]:
"""Lignes de la fenêtre, et nombre de celles qu'elle laisse dehors.
Filtré en Python plutôt qu'en SQL : les lignes sont déjà chargées, un lot
en porte quelques dizaines, et c'est ce qui donne le compte des masquées
sans requête supplémentaire — ce compte est ce qui rend le filtre honnête.
"""
if periode.debut is None and periode.fin is None:
return lignes, 0
gardees = [
ligne
for ligne in lignes
if (periode.debut is None or ligne.date >= periode.debut)
and (periode.fin is None or ligne.date <= periode.fin)
]
return gardees, len(lignes) - len(gardees)
def _intervenants(session: Session, lot: Lot, periode: Periode) -> 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
@@ -316,14 +853,17 @@ def _intervenants(session: Session, lot: Lot) -> list[Intervenant]:
)
rows = session.execute(
select(
Depense.fournisseur,
func.count(Depense.id),
montant_net,
func.max(Document.date),
_borner(
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)),
periode,
)
.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()
@@ -342,22 +882,41 @@ def _intervenants(session: Session, lot: Lot) -> list[Intervenant]:
@router.get("/lots/{lot_id}/analyse", response_model=LotAnalyseResponse)
async def analyser_lot(
lot_id: int,
mois: int | None = Query(
None,
ge=1,
description="Nombre de mois a retenir avant le dernier compte rendu du lot",
),
session: Session = Depends(get_session),
) -> LotAnalyseResponse:
"""Tout ce que les comptes rendus portent sur un lot.
- **lot_id**: ID du lot
- **mois**: fenêtre optionnelle, comptée à rebours du dernier compte rendu
du lot. Absente, tout l'historique est rendu — c'est le défaut, pour
qu'aucun filtre implicite ne cache d'opérations.
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.
La fenêtre borne les chiffres, la chronologie, les intervenants, les
locataires et la courbe du loyer. Le restant dû, le loyer en vigueur et la
comparaison au parc y échappent, pour les raisons données en tête de
module. Ce qu'elle écarte est compté : `periode.lignes_masquees` pour la
chronologie, `loyer.mois_masques` pour la courbe,
`identite.locataires_masques` pour les occupants.
"""
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),
periode = _fenetre(session, lot, mois)
chronologie, periode.lignes_masquees = _restreindre(
_chronologie(session, lot), periode
)
return LotAnalyseResponse(
identite=_identite(session, lot, periode),
chiffres=_chiffres(session, lot, periode),
loyer=_loyer(session, lot, periode),
chronologie=chronologie,
intervenants=_intervenants(session, lot, periode),
periode=periode,
)

View File

@@ -51,6 +51,32 @@ def _wait_until_ready(base_url: str, timeout: float = 30.0) -> bool:
return False
def _activer_lecteur_pdf_qt() -> None:
"""Active le lecteur PDF interne de Qt WebEngine (backend Linux).
L'aperçu du compte rendu s'appuie sur le lecteur PDF du moteur, via une
``<iframe>``. Qt laisse ``PdfViewerEnabled`` à False et le conditionne à
``PluginsEnabled`` : sans les deux, le volet n'affiche qu'un bouton
« Ouvrir » au lieu du document. pywebview n'expose pas ces réglages et
construit son propre profil, donc on les pose sur la vue une fois la
fenêtre chargée — l'``<iframe>`` n'apparaît qu'à l'ouverture d'un document,
donc bien après.
Sans objet sous Windows (WebView2 embarque déjà son lecteur) ; l'appel y
est neutre puisque le backend Qt n'est alors pas importé.
"""
try:
from webview.platforms.qt import BrowserView, QWebEngineSettings
except ImportError:
return
attribut = QWebEngineSettings.WebAttribute
for fenetre in BrowserView.instances.values():
reglages = fenetre.webview.page().settings()
reglages.setAttribute(attribut.PluginsEnabled, True)
reglages.setAttribute(attribut.PdfViewerEnabled, True)
def run() -> None:
"""Lance le serveur puis la fenêtre native. Bloque jusqu'à fermeture."""
port = _find_free_port()
@@ -75,9 +101,16 @@ def run() -> None:
# clic droit -> « Inspecter » (ou « Inspect element ») pour ouvrir la console.
debug = os.environ.get("PLESNA_DEBUG", "").lower() in ("1", "true", "yes")
webview.create_window(WINDOW_TITLE, base_url, width=1280, height=860)
fenetre = webview.create_window(WINDOW_TITLE, base_url, width=1280, height=860)
fenetre.events.loaded += _activer_lecteur_pdf_qt
try:
webview.start(debug=debug)
# private_mode=False : pywebview part sinon sur un profil éphémère qui
# ne conserve ni cookies ni localStorage. Or l'interface y range ses
# réglages — largeur du volet d'aperçu — et le lecteur PDF du moteur y
# garde l'état de sa barre latérale de vignettes. En mode privé, tout
# cela se réinitialise à chaque lancement. L'application ne charge que
# son propre serveur local : rien de tiers n'est stocké au passage.
webview.start(debug=debug, private_mode=False)
finally:
# Fermeture de la fenêtre -> arrêt propre du serveur.
server.should_exit = True

View File

@@ -0,0 +1,421 @@
"""Le loyer d'un lot mois par mois, et ce qu'il vaut au mètre carré.
Le reste de la fiche d'un lot cumule tout l'historique en un chiffre. Ce module
fait l'inverse : il remet le loyer sur un axe de temps, seule façon de voir une
révision, une vacance ou un décrochage.
**L'axe est le mois loué, pas le mois du compte rendu.** Un loyer de mars
facturé en mars et un rappel de mars facturé en avril ne décrivent pas le même
mois ; classer par date de document mettrait le second sur avril et ferait un
faux creux suivi d'un faux pic.
Trois formes de lignes cohabitent dans ``revenus`` sous le même
``type_ligne = "loyer"``, et les confondre fausse la courbe :
- le **loyer d'un mois** (01/03 → 31/03), cas courant ;
- le **loyer d'un trimestre** (01/10 → 31/12), forme réelle des baux
commerciaux du parc. Le laisser sur son seul mois de début creuserait deux
mois sur trois ; il est donc réparti à parts égales sur les mois couverts.
Ce n'est pas une clé de répartition inventée : la période est portée par le
compte rendu, et diviser un montant par le nombre de mois qu'il couvre ne
suppose rien de plus que ce que la ligne dit déjà ;
- le **prorata** (22/06 → 30/06 à l'entrée d'un locataire, 18/10 → 18/10 pour
un avoir), qui ne vaut pas un mois plein. Il est rattaché à son mois mais
compté à part du loyer : additionner les deux ferait passer un mois de
changement de locataire pour un mois à loyer effondré, et un mois annulé par
avoir pour un mois où le loyer aurait baissé.
La frontière tient à la période : commencer un premier de mois et finir un
dernier de mois, c'est couvrir des mois entiers, donc porter un loyer. Tout le
reste est un prorata, rattaché à son mois quand il y tient — et écarté quand il
chevauche plusieurs mois sans les couvrir (14/03 → 31/08, régularisation), car
aucun mois ne peut alors le revendiquer.
Un mois sans loyer plein mais avec un prorata n'est donc pas une vacance : c'est
un mois de transition, et la fiche le distingue au lieu de le confondre avec un
trou. La même frontière dit dans quel sens il penche : un prorata qui court
jusqu'au dernier jour du mois ouvre un bail, un prorata qui part du premier sans
l'atteindre en ferme un. C'est ce qui sépare un lot reloué le 8 d'un lot sorti
de la gestion, là où le palier de loyer s'arrête dans les deux cas.
Le mètre carré vient de la fiche saisie (`lot_caracteristiques.surface`), pas
des comptes rendus qui l'ignorent. Sans surface, le ratio vaut ``None`` et non
zéro : la page montre le trou plutôt que d'afficher un loyer au m² de 0 €.
"""
from calendar import monthrange
from dataclasses import dataclass, field
from datetime import date
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..database.models import Immeuble, Lot, LotCaracteristiques, Revenu
from ..services.referentiel import TYPE_LOT_EFFECTIF, joindre_fiche
#: Type des lignes portant un loyer. Les rappels et les régularisations
#: (`rappel_loyer`, `divers`) décrivent des rattrapages, pas le loyer d'un mois :
#: la chronologie de la fiche les montre déjà, la courbe les laisse dehors.
TYPE_LIGNE_LOYER = "loyer"
#: Sous ce nombre de lots comparables, une médiane décrit surtout le hasard.
#: Elle reste calculée et affichée avec son effectif — le lecteur tranche.
EFFECTIF_MEDIANE_FIABLE = 3
def mois_de(jour: date) -> str:
"""Le mois d'une date, au format ``2026-01``."""
return f"{jour.year:04d}-{jour.month:02d}"
def mois_suivant(mois: str) -> str:
"""Le mois d'après, même format."""
annee, numero = (int(part) for part in mois.split("-"))
return f"{annee + 1:04d}-01" if numero == 12 else f"{annee:04d}-{numero + 1:02d}"
def mois_entiers(debut: date | None, fin: date | None) -> list[str]:
"""Les mois entiers que couvre la période, vide si elle en couvre aucun.
Une période part du premier jour d'un mois et s'arrête au dernier jour d'un
mois : elle couvre alors ces mois-là, un ou plusieurs. Sinon, c'est un
prorata, et aucun mois ne lui appartient entièrement.
"""
if debut is None or fin is None or fin < debut:
return []
if debut.day != 1 or fin.day != monthrange(fin.year, fin.month)[1]:
return []
mois, dernier = mois_de(debut), mois_de(fin)
couverts = [mois]
while mois != dernier:
mois = mois_suivant(mois)
couverts.append(mois)
return couverts
def est_entree(debut: date, fin: date, loyers: float | None) -> bool:
"""Vrai si ce prorata ouvre un bail qui court encore le mois suivant.
Un prorata qui s'arrête au dernier jour du mois sans avoir commencé le
premier facture la fin du mois : quelqu'un est entré en cours de route. La
location ne s'arrête donc pas là, même si aucun loyer plein ne suit encore.
Un montant négatif est écarté : un avoir annule une facturation, il
n'ouvre pas un bail — et il porte parfois la même période qu'elle.
"""
if not loyers or loyers <= 0:
return False
return debut.day != 1 and fin.day == monthrange(fin.year, fin.month)[1]
def est_sortie(debut: date, fin: date, loyers: float | None) -> bool:
"""Vrai si ce prorata ferme un bail en cours de mois.
Miroir de `est_entree` : partir du premier jour sans atteindre le dernier,
c'est facturer le début du mois et s'arrêter.
"""
if not loyers or loyers <= 0:
return False
return debut.day == 1 and fin.day != monthrange(fin.year, fin.month)[1]
@dataclass
class MoisLoue:
"""Ce qu'un mois a été facturé, charges et proratas à part."""
mois: str
#: Loyer hors charges pour un mois plein. `None` quand aucune ligne ne
#: couvre le mois entier : le lot est vacant, sorti de la gestion, en
#: changement de locataire, ou son compte rendu manque. Zéro dirait « loué
#: gratuitement », ce qu'aucun document ne dit.
loyer: float | None = None
#: Provisions sur charges appelées avec le loyer plein.
charges: float | None = None
#: Facturé au prorata sur ce mois : entrée ou sortie en cours de mois,
#: avoir. `None` s'il n'y en a pas. Tenu hors du loyer, qui doit rester
#: comparable d'un mois à l'autre.
prorata: float | None = None
#: Vrai quand le loyer vient d'une ligne pluri-mensuelle répartie.
reparti: bool = False
#: Vrai quand un prorata du mois court jusqu'à son dernier jour sans partir
#: du premier : un bail commence en cours de mois et continue après lui.
entree: bool = False
#: Vrai quand un prorata du mois part de son premier jour sans l'achever :
#: un bail s'arrête en cours de mois.
sortie: bool = False
@property
def en_transition(self) -> bool:
"""Mois sans loyer plein mais facturé au prorata : pas une vacance."""
return self.loyer is None and self.prorata is not None
@dataclass
class LigneEcartee:
"""Une ligne de loyer qu'aucun mois ne peut revendiquer.
Elle chevauche plusieurs mois sans en couvrir aucun entièrement : une
régularisation rétroactive. La rendre telle quelle laisse le lecteur la
rapprocher de la chronologie, où elle figure aussi.
"""
periode_debut: date | None
periode_fin: date | None
loyers: float
charges: float
@dataclass
class Serie:
"""La suite des mois loués d'un lot, et ce qui n'a pas pu y entrer."""
mois: list[MoisLoue] = field(default_factory=list)
ecartees: list[LigneEcartee] = field(default_factory=list)
def repartir(lignes) -> Serie:
"""Range des lignes de loyer sur l'axe des mois.
Args:
lignes: itérable de ``(periode_debut, periode_fin, loyers, provisions)``
Returns:
La série des mois observés, du plus ancien au plus récent, trous
compris ; et les lignes écartées faute de couvrir des mois entiers.
"""
cumul: dict[str, MoisLoue] = {}
ecartees: list[LigneEcartee] = []
for debut, fin, loyers, provisions in lignes:
couverts = mois_entiers(debut, fin)
if not couverts:
# Un prorata tient dans un mois ; au-delà, c'est une régularisation
# rétroactive qu'aucun mois ne peut recevoir.
if debut is not None and fin is not None and mois_de(debut) == mois_de(fin):
point = cumul.setdefault(mois_de(debut), MoisLoue(mois=mois_de(debut)))
point.prorata = (point.prorata or 0.0) + (loyers or 0.0)
point.entree = point.entree or est_entree(debut, fin, loyers)
point.sortie = point.sortie or est_sortie(debut, fin, loyers)
else:
ecartees.append(
LigneEcartee(
periode_debut=debut,
periode_fin=fin,
loyers=loyers or 0.0,
charges=provisions or 0.0,
)
)
continue
part_loyer = (loyers or 0.0) / len(couverts)
part_charges = (provisions or 0.0) / len(couverts)
for mois in couverts:
point = cumul.setdefault(mois, MoisLoue(mois=mois))
point.loyer = (point.loyer or 0.0) + part_loyer
point.charges = (point.charges or 0.0) + part_charges
point.reparti = point.reparti or len(couverts) > 1
if not cumul:
return Serie(ecartees=ecartees)
# Les mois sans aucune ligne restent dans la suite, à `None` : un trou dans
# la courbe se voit, un mois absent de l'axe passerait inaperçu.
serie: list[MoisLoue] = []
mois, dernier = min(cumul), max(cumul)
while True:
point = cumul.get(mois, MoisLoue(mois=mois))
if point.loyer is not None:
point.loyer = round(point.loyer, 2)
point.charges = round(point.charges or 0.0, 2)
if point.prorata is not None:
point.prorata = round(point.prorata, 2)
serie.append(point)
if mois == dernier:
break
mois = mois_suivant(mois)
return Serie(mois=serie, ecartees=ecartees)
@dataclass
class Palier:
"""Une période pendant laquelle le loyer n'a pas bougé."""
mois_debut: str
mois_fin: str
loyer: float
def paliers(serie: list[MoisLoue]) -> list[Palier]:
"""Découpe la série aux changements de loyer.
Un mois sans loyer plein ferme le palier en cours : reprendre au même
montant après une vacance, c'est un nouveau bail qui se trouve tomber au
même prix, pas un loyer qui n'aurait jamais bougé.
Le centime tranche l'égalité — les montants viennent de divisions par le
nombre de mois d'un trimestre, où un tiers d'euro ne retombe pas juste.
"""
trouves: list[Palier] = []
for point in serie:
if point.loyer is None:
continue
en_cours = trouves[-1] if trouves else None
continu = (
en_cours is not None
and abs(en_cours.loyer - point.loyer) < 0.01
and mois_suivant(en_cours.mois_fin) == point.mois
)
if continu:
en_cours.mois_fin = point.mois
else:
trouves.append(
Palier(mois_debut=point.mois, mois_fin=point.mois, loyer=point.loyer)
)
return trouves
def variation(depuis: float | None, vers: float | None) -> float | None:
"""Écart en pourcentage entre deux loyers, `None` si l'un manque."""
if not depuis or vers is None:
return None
return round((vers - depuis) / abs(depuis) * 100, 2)
def loyer_au_m2(loyer: float | None, surface: float | None) -> float | None:
"""Loyer mensuel hors charges rapporté au mètre carré.
`None` dès qu'un des deux manque : la fiche du lot n'est pas saisie, ou le
mois n'a pas de loyer. Une surface nulle ou négative est traitée comme
absente — elle ne peut venir que d'une saisie fautive.
"""
if loyer is None or surface is None or surface <= 0:
return None
return round(loyer / surface, 2)
def mediane(valeurs: list[float]) -> float | None:
"""Médiane d'un échantillon, `None` s'il est vide.
Médiane et non moyenne : sur trente lots, un local commercial suffit à
tirer une moyenne loin de ce que paye un appartement.
"""
if not valeurs:
return None
ordonnees = sorted(valeurs)
milieu = len(ordonnees) // 2
if len(ordonnees) % 2:
return round(ordonnees[milieu], 2)
return round((ordonnees[milieu - 1] + ordonnees[milieu]) / 2, 2)
def _lignes_de_loyer(session: Session, lot_id: int | None = None):
"""Les lignes de loyer, par lot : période et montants.
Sans `lot_id`, tout le parc en une requête — la comparaison a besoin des
trente lots à la fois, et les interroger un par un ferait trente allers.
"""
stmt = select(
Revenu.lot_id,
Revenu.periode_debut,
Revenu.periode_fin,
Revenu.loyers,
Revenu.provisions,
).where(Revenu.type_ligne == TYPE_LIGNE_LOYER)
if lot_id is not None:
stmt = stmt.where(Revenu.lot_id == lot_id)
par_lot: dict[int, list] = {}
for ligne_lot, debut, fin, loyers, provisions in session.execute(stmt):
par_lot.setdefault(ligne_lot, []).append((debut, fin, loyers, provisions))
return par_lot
def serie_du_lot(session: Session, lot_id: int) -> Serie:
"""Les mois loués d'un lot, du premier au dernier connu."""
return repartir(_lignes_de_loyer(session, lot_id).get(lot_id, []))
@dataclass
class LotComparable:
"""Un lot du parc ramené à son loyer au m² sur un mois donné.
Porte sa surface autant que son ratio : le loyer au m² décroît fortement
avec la taille du logement — sur le parc, un studio de 21 m² se loue près du
double au m² d'un T3 de 106 m². Une comparaison qui perd la surface compare
donc des choses qui n'ont pas à l'être.
"""
lot_id: int
immeuble_id: int
immeuble_code: str | None
numero: str
type_lot: str | None
surface: float
loyer_m2: float
@dataclass
class Parc:
"""Ce que le parc permet de comparer, et ce qu'il ne permet pas."""
comparables: list[LotComparable] = field(default_factory=list)
#: Lots loués ce mois-là mais dont la fiche n'a pas de surface. Ils ne
#: peuvent pas entrer dans une comparaison au m² ; les taire ferait passer
#: une médiane sur trois lots pour une médiane sur tout le parc.
sans_surface: int = 0
def parc_du_mois(session: Session, mois: str) -> Parc:
"""Loyer au m² de chaque lot loué le mois donné.
Passe par la même répartition que la fiche d'un lot : une médiane calculée
autrement que les chiffres qu'elle situe ne voudrait rien dire.
"""
lots = session.execute(
joindre_fiche(
select(
Lot.id,
Lot.immeuble_id,
Immeuble.code,
Lot.numero,
TYPE_LOT_EFFECTIF,
LotCaracteristiques.surface,
).join(Immeuble, Immeuble.id == Lot.immeuble_id)
)
).all()
# Toutes les lignes de loyer du parc en une requête : les répartir lot par
# lot depuis la base ferait une trentaine d'allers pour un seul affichage.
lignes_par_lot = _lignes_de_loyer(session)
parc = Parc()
for lot_id, immeuble_id, immeuble_code, numero, type_lot, surface in lots:
serie = repartir(lignes_par_lot.get(lot_id, []))
point = next((p for p in serie.mois if p.mois == mois), None)
if point is None or point.loyer is None or point.loyer <= 0:
continue
ratio = loyer_au_m2(point.loyer, surface)
if ratio is None:
parc.sans_surface += 1
continue
parc.comparables.append(
LotComparable(
lot_id=lot_id,
immeuble_id=immeuble_id,
immeuble_code=immeuble_code,
numero=numero,
type_lot=type_lot,
surface=surface,
loyer_m2=ratio,
)
)
return parc

View File

@@ -28,6 +28,10 @@ from sqlalchemy import and_, func, select
from ..database.models import Document, Revenu
#: Type des lignes qui reportent le solde du compte rendu précédent.
#:
#: Repris tel quel côté frontend (``utils/totauxLocataire.js``), où il sépare le
#: solde antérieur des loyers de la période. Les deux décrivent la sortie du
#: même parser et doivent bouger ensemble.
TYPE_LIGNE_REPORT = "solde_anterieur"

View File

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

View File

@@ -8,7 +8,13 @@ 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.models import (
Document,
Immeuble,
Locataire,
Lot,
LotCaracteristiques,
)
from plesna_gerance.database.service import DatabaseService
@@ -223,3 +229,539 @@ def test_le_total_d_un_intervenant_est_celui_de_ses_lignes(api_client, donnees):
round(sum(ligne["montant"] for ligne in lignes), 2)
== intervenant["montant"]
)
def test_le_loyer_se_lit_mois_par_mois(api_client, donnees):
"""Deux comptes rendus, deux mois : la fiche les remet sur un axe de temps."""
_, lot = donnees
loyer = api_client.get(f"/api/lots/{lot.id}/analyse").json()["loyer"]
assert [point["mois"] for point in loyer["serie"]] == ["2024-01", "2024-02"]
assert [point["loyer"] for point in loyer["serie"]] == [500.0, 500.0]
assert loyer["en_vigueur"]["loyer"] == 500.0
assert loyer["en_vigueur"]["depuis"] == "2024-01"
# Un seul niveau depuis le premier compte rendu : aucune révision à montrer.
assert loyer["en_vigueur"]["precedent"] is None
def test_sans_surface_saisie_le_loyer_au_m2_reste_vide(api_client, donnees):
"""Le ratio manquant se voit ; un zéro laisserait croire à un loyer nul."""
_, lot = donnees
loyer = api_client.get(f"/api/lots/{lot.id}/analyse").json()["loyer"]
assert loyer["surface"] is None
assert all(point["loyer_m2"] is None for point in loyer["serie"])
assert loyer["en_vigueur"]["loyer_m2"] is None
def test_la_surface_saisie_allume_le_loyer_au_m2(api_client, db_session, donnees):
"""La fiche saisie est la seule source de surface : aucun PDF n'en porte."""
_, lot = donnees
db_session.add(LotCaracteristiques(lot_id=lot.id, surface=50.0))
db_session.commit()
loyer = api_client.get(f"/api/lots/{lot.id}/analyse").json()["loyer"]
assert loyer["surface"] == 50.0
assert loyer["en_vigueur"]["loyer_m2"] == 10.0
def test_sans_periode_demandee_rien_n_est_borne(api_client, donnees):
"""Le défaut ne filtre pas : la page s'ouvre sur tout l'historique.
Un filtre par défaut cacherait des opérations dès l'arrivée sur la fiche,
sans que rien ne le signale.
"""
_, lot = donnees
periode = api_client.get(f"/api/lots/{lot.id}/analyse").json()["periode"]
assert periode["mois"] is None
assert periode["debut"] is None
assert periode["fin"] is None
assert periode["lignes_masquees"] == 0
# L'ancre est renvoyée quand même : la page sait sur quoi une fenêtre se
# calerait avant même d'en demander une.
assert periode["ancre"] == "2024-02-15"
def test_la_fenetre_se_cale_sur_le_dernier_compte_rendu_du_lot(api_client, donnees):
"""Comptée à rebours des données, jamais d'aujourd'hui.
Ces comptes rendus datent de 2024 : une fenêtre calée sur la date du jour
viderait la fiche et ferait passer un lot documenté pour un lot sans
activité.
"""
_, lot = donnees
periode = api_client.get(f"/api/lots/{lot.id}/analyse?mois=1").json()["periode"]
assert periode["fin"] == "2024-02-15"
assert periode["debut"] == "2024-01-16"
def test_une_fenetre_d_un_mois_ne_retient_qu_un_compte_rendu(api_client, donnees):
"""Un mois de fenêtre, un compte rendu : le précédent tombe dehors.
Bornes incluses des deux côtés, le compte rendu du 15 janvier entrerait
dans une fenêtre d'un mois finissant le 15 février — elle en couvrirait
deux.
"""
_, lot = donnees
analyse = api_client.get(f"/api/lots/{lot.id}/analyse?mois=1").json()
dates = {ligne["date"] for ligne in analyse["chronologie"]}
assert dates == {"2024-02-15"}
# Le premier compte rendu portait un loyer et une opération de nettoyage
# d'immeuble ; seules ses lignes de lot comptent ici.
assert analyse["periode"]["lignes_masquees"] == 1
def test_les_chiffres_suivent_la_fenetre(api_client, donnees):
"""Facturé, dépenses et charges communes se recalculent sur la période."""
_, lot = donnees
chiffres = api_client.get(f"/api/lots/{lot.id}/analyse?mois=1").json()["chiffres"]
# Le seul loyer du second compte rendu, sans celui de janvier.
assert chiffres["facture"] == 500.0
assert chiffres["encaisse"] == 200.0
assert chiffres["nb_operations"] == 3
# Le nettoyage de l'immeuble datait du premier compte rendu.
assert chiffres["depenses_immeuble_non_reparties"] == 0.0
def test_le_restant_du_echappe_a_la_fenetre(api_client, donnees):
"""Un stock ne se borne pas : la dette reste celle du dernier compte rendu.
La ramener à la fenêtre l'annulerait dès qu'aucun compte rendu n'y tombe —
un lot afficherait 0 € dû tout en devant plusieurs milliers.
"""
_, lot = donnees
chiffres = api_client.get(f"/api/lots/{lot.id}/analyse?mois=1").json()["chiffres"]
assert chiffres["restant_du"] == 600.0
def test_les_intervenants_suivent_la_fenetre(api_client, donnees):
"""Le tableau des entreprises décrit la même période que la chronologie."""
_, lot = donnees
analyse = api_client.get(f"/api/lots/{lot.id}/analyse?mois=1").json()
# Invariant du dépliage, sous fenêtre comme sans : le détail doit retrouver
# le total. Les deux viennent de calculs séparés — un agrégat SQL borné
# d'un côté, les lignes filtrées de l'autre — et une fenêtre appliquée d'un
# seul côté les ferait diverger sans que rien ne le signale.
assert analyse["intervenants"]
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"]
)
def test_la_courbe_du_loyer_suit_la_fenetre(api_client, donnees):
"""Le graphe se limite aux mois de la période, et dit ce qu'il ne trace pas.
Le mois de la borne haute compte pour un : une fenêtre d'un mois finissant
en février trace février seul, sans quoi le graphe démentirait son libellé.
"""
_, lot = donnees
loyer = api_client.get(f"/api/lots/{lot.id}/analyse?mois=1").json()["loyer"]
assert [point["mois"] for point in loyer["serie"]] == ["2024-02"]
assert loyer["depuis_mois"] == "2024-02"
assert loyer["mois_masques"] == 1
def test_le_loyer_en_vigueur_ignore_la_fenetre(api_client, donnees):
"""Les paliers restent lus sur toute la série, même courbe tronquée.
Recalculés sur les seuls mois tracés, « depuis » daterait de la borne du
filtre : ce loyer semblerait révisé en février alors qu'il n'a jamais
bougé depuis janvier.
"""
_, lot = donnees
loyer = api_client.get(f"/api/lots/{lot.id}/analyse?mois=1").json()["loyer"]
assert loyer["en_vigueur"]["depuis"] == "2024-01"
assert loyer["en_vigueur"]["loyer"] == 500.0
# La comparaison au parc garde son mois de référence : le dernier mois loué.
assert loyer["parc"]["mois"] == "2024-02"
def test_sans_fenetre_la_courbe_reste_entiere(api_client, donnees):
"""Le défaut ne coupe rien, et ne prétend pas avoir coupé."""
_, lot = donnees
loyer = api_client.get(f"/api/lots/{lot.id}/analyse").json()["loyer"]
assert [point["mois"] for point in loyer["serie"]] == ["2024-01", "2024-02"]
assert loyer["depuis_mois"] is None
assert loyer["mois_masques"] == 0
def test_la_fenetre_ne_garde_que_les_occupants_de_la_periode(
api_client, db_session, donnees, sample_data
):
"""Un locataire appartient à la période si un compte rendu l'y porte.
Aucune date d'entrée ni de sortie n'est extraite : le document où le nom
figure est le seul rattachement au temps dont on dispose.
"""
_, lot = donnees
DatabaseService(db_session).save_document(
data={
**sample_data,
"metadata": {
**sample_data["metadata"],
"document": {
"reference": "REF003",
"date": "2024-03-15",
"type": "COMPTE RENDU DE GESTION",
},
},
"situation_locataires": [
{
"lot": {"numero": "01", "type": "Appartement"},
"locataire": {"nom": "MARTIN"},
"lignes": [
{
"type": "loyer",
"periode": {"debut": "2024-03-01", "fin": "2024-03-31"},
"loyers": 500.0,
"total": 500.0,
"regles": 500.0,
"impayes": 0.0,
}
],
}
],
}
)
identite = api_client.get(f"/api/lots/{lot.id}/analyse?mois=1").json()["identite"]
assert identite["locataires"] == ["MARTIN"]
# DUPONT n'est pas effacé pour autant : la fiche dit qu'il en manque un.
assert identite["locataires_masques"] == 1
entier = api_client.get(f"/api/lots/{lot.id}/analyse").json()["identite"]
assert entier["locataires"] == ["DUPONT", "MARTIN"]
assert entier["locataires_masques"] == 0
def test_sans_fenetre_un_locataire_sans_ligne_reste_liste(
api_client, db_session, donnees
):
"""La vue par défaut ne filtre rien, pas même par les lignes rattachées.
La base porte des locataires dont aucune ligne ne dépend ; les faire
disparaître de la fiche entière serait un filtre que personne n'a demandé.
"""
_, lot = donnees
db_session.add(Locataire(lot_id=lot.id, nom="ORPHELIN"))
db_session.commit()
identite = api_client.get(f"/api/lots/{lot.id}/analyse").json()["identite"]
assert identite["locataires"] == ["DUPONT", "ORPHELIN"]
def test_un_lot_sans_ligne_garde_une_periode_ouverte(api_client, db_session, donnees):
"""Rien à quoi caler la fenêtre : la fiche s'ouvre au lieu d'échouer."""
immeuble, _ = donnees
vide = Lot(immeuble_id=immeuble.id, numero="99")
db_session.add(vide)
db_session.commit()
analyse = api_client.get(f"/api/lots/{vide.id}/analyse?mois=3").json()
assert analyse["periode"]["ancre"] is None
assert analyse["periode"]["debut"] is None
assert analyse["chronologie"] == []
@pytest.fixture
def parc(db_session, sample_data):
"""Un compte rendu portant trois lots, dont un sans surface saisie.
Se situer suppose des voisins : le nuage n'a de sens qu'à plusieurs. Les
surfaces sont volontairement contrastées (20 m² à 15 €/m², 50 m² à 10 €/m²)
pour reproduire la pente du parc réel, où le petit se loue plus cher au m².
"""
def locataire(numero, nom, loyer):
return {
"lot": {"numero": numero, "type": "Appartement"},
"locataire": {"nom": nom},
"lignes": [
{
"type": "loyer",
"periode": {"debut": "2024-01-01", "fin": "2024-01-31"},
"loyers": loyer,
"total": loyer,
"regles": loyer,
"impayes": 0.0,
}
],
}
DatabaseService(db_session).save_document(
data={
**sample_data,
"situation_locataires": [
locataire("01", "DUPONT", 500.0),
locataire("02", "MARTIN", 300.0),
locataire("03", "DURAND", 700.0),
],
}
)
immeuble = db_session.query(Immeuble).filter(Immeuble.code == "IMM1").one()
lots = {
lot.numero: lot
for lot in db_session.query(Lot).filter(Lot.immeuble_id == immeuble.id)
}
db_session.add(LotCaracteristiques(lot_id=lots["01"].id, surface=50.0))
db_session.add(LotCaracteristiques(lot_id=lots["02"].id, surface=20.0))
# Le lot 03 reste sans fiche : c'est le cas majoritaire en base.
db_session.commit()
return lots
def test_le_nuage_situe_le_lot_parmi_ses_voisins(api_client, parc):
"""Trié par surface, le lot courant présent et signalé.
Il figure dans le nuage — s'y voir situé est tout l'objet — alors qu'il est
exclu des médianes, qu'il tirerait vers lui.
"""
nuage = api_client.get(f"/api/lots/{parc['01'].id}/analyse").json()["loyer"][
"parc"
]["nuage"]
assert [(point["surface"], point["loyer_m2"]) for point in nuage] == [
(20.0, 15.0),
(50.0, 10.0),
]
assert [point["est_ce_lot"] for point in nuage] == [False, True]
assert nuage[0]["numero"] == "02"
def test_un_lot_sans_surface_n_entre_pas_dans_le_nuage(api_client, parc):
"""Sans surface, aucune abscisse : le lot ne peut pas être placé.
Il n'est pas pour autant oublié — `sans_surface` le compte, et la page le
dit sous les médianes.
"""
comparaison = api_client.get(f"/api/lots/{parc['01'].id}/analyse").json()["loyer"][
"parc"
]
assert len(comparaison["nuage"]) == 2
assert parc["03"].id not in [point["lot_id"] for point in comparaison["nuage"]]
assert comparaison["sans_surface"] == 1
def test_le_nuage_garde_le_lot_courant_meme_seul(api_client, db_session, donnees):
"""Seul lot mesuré du parc : le nuage le porte quand même.
Le vider dans ce cas ferait disparaître le point qu'on cherche justement à
situer, et la page ne dirait plus rien du lot ouvert.
"""
_, lot = donnees
db_session.add(LotCaracteristiques(lot_id=lot.id, surface=50.0))
db_session.commit()
nuage = api_client.get(f"/api/lots/{lot.id}/analyse").json()["loyer"]["parc"][
"nuage"
]
assert len(nuage) == 1
assert nuage[0]["est_ce_lot"] is True
def test_la_comparaison_compte_les_lots_qu_elle_ne_peut_pas_voir(
api_client, db_session, donnees
):
"""Un lot sans surface ne peut pas entrer dans une médiane au m².
Taire ces lots ferait passer une médiane sur une poignée de lots pour une
médiane sur tout le parc — c'est le chiffre, et non son effectif, qui
tromperait.
"""
_, lot = donnees
db_session.add(LotCaracteristiques(lot_id=lot.id, surface=50.0))
db_session.commit()
parc = api_client.get(f"/api/lots/{lot.id}/analyse").json()["loyer"]["parc"]
assert parc["mois"] == "2024-02"
assert parc["loyer_m2"] == 10.0
# Seul lot de la base : rien à quoi le comparer, et la médiane ne se
# rabat pas sur lui-même.
assert parc["mediane_immeuble"] is None
assert parc["nb_immeuble"] == 0
assert parc["sans_surface"] == 0
@pytest.fixture
def bail_qui_change(db_session, sample_data):
"""Un lot dont le bail s'arrête en cours de mois, puis reprend au suivant.
C'est la chronologie du lot 15 du parc : deux mois pleins, une sortie le
10 mars, quatre mois vides, puis une entrée le 8 juillet facturée au seul
prorata. Le dernier compte rendu ne porte donc aucun loyer plein, alors que
le lot est bel et bien reloué.
"""
def compte_rendu(reference, date_cr, lignes):
return {
**sample_data,
"metadata": {
**sample_data["metadata"],
"document": {
"reference": reference,
"date": date_cr,
"type": "COMPTE RENDU DE GESTION",
},
},
"situation_locataires": [
{
"lot": {"numero": "01", "type": "Appartement"},
"locataire": {"nom": lignes["nom"]},
"lignes": [
{
"type": "loyer",
"periode": {
"debut": lignes["debut"],
"fin": lignes["fin"],
},
"loyers": lignes["loyers"],
"total": lignes["loyers"],
"regles": lignes["loyers"],
"impayes": 0.0,
}
],
}
],
"recapitulatif_operations": [],
}
service = DatabaseService(db_session)
for reference, date_cr, lignes in [
(
"CR01",
"2026-01-26",
{
"nom": "SORTANT",
"debut": "2026-01-01",
"fin": "2026-01-31",
"loyers": 1447.05,
},
),
(
"CR02",
"2026-02-26",
{
"nom": "SORTANT",
"debut": "2026-02-01",
"fin": "2026-02-28",
"loyers": 1447.05,
},
),
(
"CR03",
"2026-03-23",
{
"nom": "SORTANT",
"debut": "2026-03-01",
"fin": "2026-03-10",
"loyers": 482.35,
},
),
(
"CR04",
"2026-07-28",
{
"nom": "ENTRANT",
"debut": "2026-07-08",
"fin": "2026-07-31",
"loyers": 1111.67,
},
),
]:
service.save_document(data=compte_rendu(reference, date_cr, lignes))
immeuble = db_session.query(Immeuble).filter(Immeuble.code == "IMM1").one()
lot = db_session.query(Lot).filter(Lot.immeuble_id == immeuble.id).one()
return lot
def test_un_lot_reloue_au_prorata_n_est_pas_un_lot_arrete(api_client, bail_qui_change):
"""Le palier s'arrête en février, le lot est reloué en juillet.
Sans cette lecture, la fiche annonce « arrêté après févr. » sur un lot qui
vient de retrouver un locataire — et la courbe juste dessous, où la barre
de juillet est bien là, la contredit.
"""
vigueur = api_client.get(f"/api/lots/{bail_qui_change.id}/analyse").json()["loyer"][
"en_vigueur"
]
assert vigueur["reloue_depuis"] == "2026-07"
assert vigueur["sortie_en"] is None
# Le montant reste celui du bail précédent : le nouveau n'a été facturé
# qu'au prorata, et en tirer un loyer mensuel l'inventerait.
assert vigueur["mois"] == "2026-02"
assert vigueur["loyer"] == 1447.05
assert vigueur["toujours_loue"] is False
def test_une_sortie_en_cours_de_mois_deborde_le_dernier_palier(
api_client, db_session, bail_qui_change
):
"""Sans relocation, c'est la sortie qui date la fin de la location.
Le dernier loyer plein est celui de février, mais le lot est resté loué
jusqu'au 10 mars : dire « arrêté après févr. » avancerait la sortie d'un
mois.
"""
dernier = (
db_session.query(Document).order_by(Document.date.desc()).first()
)
db_session.delete(dernier)
db_session.commit()
vigueur = api_client.get(f"/api/lots/{bail_qui_change.id}/analyse").json()["loyer"][
"en_vigueur"
]
assert vigueur["sortie_en"] == "2026-03"
assert vigueur["reloue_depuis"] is None
def test_un_loyer_qui_s_arrete_net_reste_un_loyer_arrete(api_client, donnees):
"""Aucun prorata après le dernier palier : rien ne nuance l'arrêt."""
_, lot = donnees
vigueur = api_client.get(f"/api/lots/{lot.id}/analyse").json()["loyer"][
"en_vigueur"
]
assert vigueur["reloue_depuis"] is None
assert vigueur["sortie_en"] is None

304
tests/test_loyers.py Normal file
View File

@@ -0,0 +1,304 @@
"""Tests de la mise en temps du loyer.
Ce que ces tests protègent, c'est la lecture d'une courbe : un mois vacant, un
mois de changement de locataire et un mois à loyer plein doivent rester trois
choses différentes. Les cas ne sont pas inventés — ils viennent tous du parc
réel (bail commercial trimestriel, relocation en cours de mois, avoir annulant
un loyer, régularisation rétroactive à cheval sur six mois).
"""
from datetime import date
import pytest
from plesna_gerance.services.loyers import (
loyer_au_m2,
mediane,
mois_entiers,
mois_suivant,
paliers,
repartir,
variation,
)
def ligne(debut: str, fin: str, loyers: float, provisions: float = 0.0):
"""Une ligne de loyer telle que la base la porte."""
return (date.fromisoformat(debut), date.fromisoformat(fin), loyers, provisions)
class TestMoisEntiers:
"""Ce qui fait qu'une période porte un loyer plutôt qu'un prorata."""
def test_un_mois_plein_couvre_son_mois(self):
assert mois_entiers(date(2026, 3, 1), date(2026, 3, 31)) == ["2026-03"]
def test_fevrier_se_termine_le_28_ou_le_29(self):
assert mois_entiers(date(2025, 2, 1), date(2025, 2, 28)) == ["2025-02"]
assert mois_entiers(date(2024, 2, 1), date(2024, 2, 29)) == ["2024-02"]
def test_un_trimestre_couvre_ses_trois_mois(self):
assert mois_entiers(date(2025, 10, 1), date(2025, 12, 31)) == [
"2025-10",
"2025-11",
"2025-12",
]
def test_une_periode_partielle_ne_couvre_aucun_mois(self):
assert mois_entiers(date(2024, 6, 22), date(2024, 6, 30)) == []
assert mois_entiers(date(2025, 3, 14), date(2025, 8, 31)) == []
def test_une_periode_absente_ou_inversee_ne_couvre_rien(self):
assert mois_entiers(None, date(2026, 3, 31)) == []
assert mois_entiers(date(2026, 3, 31), date(2026, 3, 1)) == []
def test_le_passage_a_l_annee_suivante(self):
assert mois_suivant("2025-12") == "2026-01"
class TestRepartition:
"""Comment les lignes se rangent sur l'axe des mois."""
def test_un_loyer_mensuel_va_dans_son_mois(self):
serie = repartir([ligne("2026-03-01", "2026-03-31", 640.0, 31.0)])
assert [point.mois for point in serie.mois] == ["2026-03"]
assert serie.mois[0].loyer == 640.0
assert serie.mois[0].charges == 31.0
assert serie.mois[0].reparti is False
def test_un_bail_trimestriel_se_repartit_sur_ses_mois(self):
"""Sans répartition, deux mois sur trois d'un bail commercial seraient
montrés vides alors que le local est loué."""
serie = repartir([ligne("2025-10-01", "2025-12-31", 3963.27)])
assert [point.mois for point in serie.mois] == [
"2025-10",
"2025-11",
"2025-12",
]
assert [point.loyer for point in serie.mois] == [1321.09, 1321.09, 1321.09]
assert all(point.reparti for point in serie.mois)
def test_un_mois_sans_ligne_reste_vide_et_non_a_zero(self):
"""Un loyer à zéro se lirait comme un logement prêté ; le trou dit la
vacance, qui est ce que les comptes rendus montrent."""
serie = repartir(
[
ligne("2024-01-01", "2024-01-31", 850.0),
ligne("2024-04-01", "2024-04-30", 900.0),
]
)
assert [point.mois for point in serie.mois] == [
"2024-01",
"2024-02",
"2024-03",
"2024-04",
]
assert [point.loyer for point in serie.mois] == [850.0, None, None, 900.0]
def test_un_prorata_se_range_a_part_du_loyer(self):
"""Le locataire entre le 22 : le mois est facturé, mais pas au prix
d'un mois plein. Additionner les deux inventerait une baisse de loyer."""
serie = repartir([ligne("2024-06-22", "2024-06-30", 417.0)])
point = serie.mois[0]
assert point.mois == "2024-06"
assert point.loyer is None
assert point.prorata == 417.0
assert point.en_transition is True
def test_un_mois_de_transition_n_est_pas_une_vacance(self):
"""Sortie le 3, entrée le 14 : le mois est loué deux fois en morceaux.
Le montrer vide le confondrait avec un mois sans locataire."""
serie = repartir(
[
ligne("2025-03-01", "2025-03-03", 109.68),
ligne("2025-03-14", "2025-03-31", 622.2),
]
)
point = serie.mois[0]
assert point.loyer is None
assert point.prorata == pytest.approx(731.88)
assert point.en_transition is True
def test_un_avoir_ne_deforme_pas_le_loyer_du_mois(self):
"""Le loyer d'octobre est annulé par un avoir. Le loyer contractuel
reste 1390 ; l'avoir se lit à côté, sans creuser la courbe."""
serie = repartir(
[
ligne("2024-10-01", "2024-10-31", 1390.0),
ligne("2024-10-18", "2024-10-18", -1390.0),
]
)
point = serie.mois[0]
assert point.loyer == 1390.0
assert point.prorata == -1390.0
assert point.en_transition is False
def test_un_prorata_qui_finit_le_mois_est_une_entree(self):
"""Lot 15 du parc : le locataire entre le 8 juillet. La location ne
s'arrête donc pas au dernier loyer plein, elle recommence."""
serie = repartir([ligne("2026-07-08", "2026-07-31", 1111.67)])
point = serie.mois[0]
assert point.entree is True
assert point.sortie is False
def test_un_prorata_qui_ouvre_le_mois_est_une_sortie(self):
"""Même lot, quatre mois plus tôt : le locataire part le 10 mars."""
serie = repartir([ligne("2026-03-01", "2026-03-10", 482.35)])
point = serie.mois[0]
assert point.sortie is True
assert point.entree is False
def test_un_mois_qui_change_de_locataire_porte_les_deux(self):
"""Sortie le 3, entrée le 14 : le mois ferme un bail et en ouvre un
autre, et c'est l'entrée qui dit que le lot reste loué."""
serie = repartir(
[
ligne("2025-03-01", "2025-03-03", 109.68),
ligne("2025-03-14", "2025-03-31", 622.2),
]
)
point = serie.mois[0]
assert point.entree is True
assert point.sortie is True
def test_un_avoir_n_ouvre_ni_ne_ferme_de_bail(self):
"""Un avoir porte la période qu'il annule : lu comme une entrée, il
ferait croire à une relocation là où rien n'a été loué."""
serie = repartir(
[
ligne("2024-10-15", "2024-10-31", -700.0),
ligne("2024-11-18", "2024-11-18", -1390.0),
]
)
assert [point.entree for point in serie.mois] == [False, False]
assert [point.sortie for point in serie.mois] == [False, False]
def test_un_mois_plein_n_est_ni_entree_ni_sortie(self):
serie = repartir([ligne("2026-01-01", "2026-01-31", 1447.05)])
point = serie.mois[0]
assert point.entree is False
assert point.sortie is False
def test_une_regularisation_a_cheval_reste_hors_de_la_courbe(self):
"""De mars à août sans couvrir un mois entier : l'étaler inventerait
six demi-mois de loyer."""
serie = repartir(
[
ligne("2025-09-01", "2025-09-30", 1023.0),
ligne("2025-03-14", "2025-08-31", -418.55),
]
)
assert [point.mois for point in serie.mois] == ["2025-09"]
assert len(serie.ecartees) == 1
assert serie.ecartees[0].loyers == -418.55
def test_sans_aucune_ligne_la_serie_est_vide(self):
assert repartir([]).mois == []
class TestPaliers:
"""Les révisions de loyer, lues dans la suite des mois."""
def test_un_loyer_stable_ne_fait_qu_un_palier(self):
serie = repartir(
[
ligne("2026-01-01", "2026-01-31", 900.0),
ligne("2026-02-01", "2026-02-28", 900.0),
ligne("2026-03-01", "2026-03-31", 900.0),
]
)
niveaux = paliers(serie.mois)
assert len(niveaux) == 1
assert (niveaux[0].mois_debut, niveaux[0].mois_fin) == ("2026-01", "2026-03")
def test_une_revision_ouvre_un_palier(self):
serie = repartir(
[
ligne("2025-12-01", "2025-12-31", 900.0),
ligne("2026-01-01", "2026-01-31", 907.85),
ligne("2026-02-01", "2026-02-28", 907.85),
]
)
niveaux = paliers(serie.mois)
assert [n.loyer for n in niveaux] == [900.0, 907.85]
assert niveaux[-1].mois_debut == "2026-01"
def test_une_vacance_coupe_le_palier_meme_a_loyer_egal(self):
"""Relouer au même prix après trois mois vides, c'est un nouveau bail :
dire que le loyer « n'a pas bougé depuis 2024 » serait faux."""
serie = repartir(
[
ligne("2024-01-01", "2024-01-31", 850.0),
ligne("2024-05-01", "2024-05-31", 850.0),
]
)
niveaux = paliers(serie.mois)
assert len(niveaux) == 2
assert niveaux[-1].mois_debut == "2024-05"
def test_les_centimes_d_un_trimestre_ne_creent_pas_de_faux_paliers(self):
"""3963,27 divisé par trois ne retombe pas juste ; l'égalité se juge au
centime, sinon chaque mois ouvrirait son propre palier."""
serie = repartir(
[
ligne("2025-01-01", "2025-03-31", 3963.27),
ligne("2025-04-01", "2025-06-30", 3963.27),
]
)
assert len(paliers(serie.mois)) == 1
class TestLoyerAuM2:
"""Le ratio, et ce qu'il refuse de calculer."""
def test_le_ratio_se_calcule_hors_charges(self):
assert loyer_au_m2(640.0, 47.84) == 13.38
def test_sans_surface_il_n_y_a_pas_de_ratio(self):
"""Zéro serait un loyer au m² nul ; `None` est un trou à combler."""
assert loyer_au_m2(640.0, None) is None
def test_une_surface_absurde_vaut_une_surface_absente(self):
assert loyer_au_m2(640.0, 0) is None
assert loyer_au_m2(640.0, -10) is None
def test_un_mois_sans_loyer_n_a_pas_de_ratio(self):
assert loyer_au_m2(None, 47.84) is None
class TestMedianeEtVariation:
"""Les deux agrégats qui situent un lot."""
def test_la_mediane_resiste_a_un_local_commercial(self):
"""Une moyenne serait tirée par la valeur extrême ; c'est tout
l'intérêt de la médiane sur un parc de trente lots."""
assert mediane([11.0, 12.0, 13.0, 14.0, 90.0]) == 13.0
def test_la_mediane_d_un_effectif_pair_prend_le_milieu(self):
assert mediane([10.0, 12.0, 14.0, 16.0]) == 13.0
def test_sans_lot_comparable_il_n_y_a_pas_de_mediane(self):
assert mediane([]) is None
def test_la_variation_se_lit_en_pourcentage(self):
assert variation(1023.0, 1031.06) == 0.79
def test_une_variation_sans_reference_n_existe_pas(self):
assert variation(None, 900.0) is None
assert variation(0.0, 900.0) is None

2
uv.lock generated
View File

@@ -672,7 +672,7 @@ wheels = [
[[package]]
name = "plesna-gerance"
version = "0.1.0"
version = "0.1.1"
source = { editable = "." }
dependencies = [
{ name = "click" },