Compare commits

12 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
35 changed files with 2400 additions and 617 deletions

View File

@@ -1,15 +1,14 @@
{
"name": "plesna-gerance-frontend",
"version": "0.1.1",
"version": "0.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "plesna-gerance-frontend",
"version": "0.1.1",
"version": "0.1.2",
"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"
@@ -568,271 +567,6 @@
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@napi-rs/canvas": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.0.tgz",
"integrity": "sha512-Jqxcy1XOIqj+lH9sl1GT+il6GR3uQv13vI2mrwubP3uT8Olak2ClDrK2RnxlQKjwv8BRr4b3ug0YR7c6hBX8wg==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "1.0.0",
"@napi-rs/canvas-darwin-arm64": "1.0.0",
"@napi-rs/canvas-darwin-x64": "1.0.0",
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.0",
"@napi-rs/canvas-linux-arm64-gnu": "1.0.0",
"@napi-rs/canvas-linux-arm64-musl": "1.0.0",
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.0",
"@napi-rs/canvas-linux-x64-gnu": "1.0.0",
"@napi-rs/canvas-linux-x64-musl": "1.0.0",
"@napi-rs/canvas-win32-arm64-msvc": "1.0.0",
"@napi-rs/canvas-win32-x64-msvc": "1.0.0"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.0.tgz",
"integrity": "sha512-3hNKJObUK7JsCF9aJlVCs1J0/KE/gGfZNeK8MO1ge6bB3aicr5walGme9t9No1f/oyk9GgvdAT/rjSdsx3gbIw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.0.tgz",
"integrity": "sha512-ZIja19/BiGz2puhki+WUYSRriwFeFJ8Mi9eK3hZdSS85w4Y60cuEAJVhMCfKwswQkKkUtrnzdKMBuO7TupvexA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.0.tgz",
"integrity": "sha512-hImggWc82jqZVpEsFR9S7PE9OQYjq/H/D7vwCGB6X1jRH+UVBP1+1niJTPBOat1B154T6GKK7/kcFtoWgjgFzQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.0.tgz",
"integrity": "sha512-hlJRy6d+kWLKVOG/+1rEvNQVURZ0DxxRPJsLmEWwhwiXZUJc0BF5o9esALHSEP4CoJK4wChRtj3hnyBgVx2oWA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.0.tgz",
"integrity": "sha512-5Hru4T3RXkosRQafcjelv7AUzw9mXqmGYsxnzeDDOWveFCJyEPMSJltvGCM+jfH98seOCbfwm9KyFg6Jm5FhAA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.0.tgz",
"integrity": "sha512-LTUl9jS8WsLSUGaxQZKQkxfluOJRpgvBuxxdM4pYcjib+di8AU4OzQc6+L6SzGMLcKc9H0RAjojRatBhTMqYdg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.0.tgz",
"integrity": "sha512-Iz931SAZf+WVDzpjk52Q3ffW3zw0YflFwEZMgs036Wfu1kX/LrwT9wGjsuSqyduqefUkl91/vTdAjn8hQu5ezA==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.0.tgz",
"integrity": "sha512-pFEQ5eFK4JusgN1K6KkO9DKP/Hi1WMJOkF8Ch03/khTc4bFbCKkCCsJG4YcOMOW9bI4XbT2/eMAWxhO0xaWgPA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.0.tgz",
"integrity": "sha512-jnvr8NrLHiZ3NCiOKWqDbkI4Ah+QDrqtZ+sddPZBltEb1mQ2coSvCSJYfict+oAwcm0c970oTmVySpjKP/lnaA==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.0.tgz",
"integrity": "sha512-y2j9/Gfd5joqiqxdP/L1smqjQ+uAx3C4N0EC7bDHrnZEEH8ToM/OC5p3uHvtj4Lq591aHj+ArL01UDLNwT5HgQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.0.tgz",
"integrity": "sha512-qwdhh9N6Gge/hC4pL9S1tQp0iKwhSl/dYjg7+RGp9k26iRGRi5MqqUyKGOXIWli0zOcuy5Y2wIH/jk2ry6i/jA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -2187,18 +1921,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/pdfjs-dist": {
"version": "6.0.227",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.0.227.tgz",
"integrity": "sha512-/P6M4SXw+70waMVLUM7rdRtvo+dEzqE1t6W/zQNvBETo2MaRa5rrvCcAYdfWGiUzadTgM0lJmRApUrW0d9zgKg==",
"license": "Apache-2.0",
"engines": {
"node": ">=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^1.0.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "plesna-gerance-frontend",
"version": "0.1.1",
"version": "0.1.2",
"private": true,
"type": "module",
"scripts": {
@@ -11,7 +11,6 @@
},
"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"

View File

@@ -249,7 +249,7 @@ const copyLabel = ref('Copier')
const expandedSections = reactive({
metadata: true,
locataires: true,
operations: false
operations: true
})
// Highlight state

View File

@@ -217,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)

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

@@ -24,6 +24,17 @@
<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
@@ -68,17 +79,35 @@
</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. -->
<div v-if="loyer.hors_courbe.length" class="card-body pt-0">
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 · {{ loyer.hors_courbe.length }}
Hors de la courbe<template v-if="loyer.hors_courbe.length"> · {{ loyer.hors_courbe.length }}</template>
</h3>
<p class="form-hint mb-3">
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.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 class="table">
<table v-if="loyer.hors_courbe.length" class="table">
<thead>
<tr>
<th>Période</th>
@@ -123,6 +152,40 @@ function formatM2(valeur) {
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
@@ -137,14 +200,12 @@ const tuiles = computed(() => {
return [
{
libelle: vigueur.toujours_loue ? 'Loyer en vigueur' : 'Dernier loyer connu',
libelle: etatDuBail.value.libelle,
valeur: formatMontant(vigueur.loyer),
classe: vigueur.toujours_loue ? 'text-green-400' : 'text-gray-400',
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: vigueur.toujours_loue
? `depuis ${formatMoisAnnee(vigueur.depuis)}`
: `arrêté après ${formatMoisAnnee(vigueur.mois)}`
detail: etatDuBail.value.detail
},
{
libelle: 'Au m²',

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">
@@ -244,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²' },
@@ -357,39 +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,
(id) => {
[() => 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

@@ -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

@@ -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,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,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,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.1"
#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.1"
version = "0.1.2"
description = "Extracteur de comptes rendus de gérance Oralia/ICS"
requires-python = ">=3.10"
dependencies = [

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.1"
__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

@@ -24,12 +24,33 @@ 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
@@ -45,6 +66,7 @@ from ...database.models import (
)
from ...services.loyers import (
EFFECTIF_MEDIANE_FIABLE,
MoisLoue,
loyer_au_m2,
mediane,
mois_de,
@@ -64,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."""
@@ -82,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):
@@ -200,7 +247,19 @@ class LoyerEnVigueur(BaseModel):
#: 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):
@@ -252,7 +311,13 @@ class ComparaisonParc(BaseModel):
class LotLoyer(BaseModel):
"""Le loyer du lot dans le temps, et ce qu'il vaut au mètre carré."""
"""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] = []
@@ -260,6 +325,13 @@ class LotLoyer(BaseModel):
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."""
@@ -269,16 +341,118 @@ class LotAnalyseResponse(BaseModel):
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,
@@ -292,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(
@@ -321,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()
@@ -413,17 +604,76 @@ def _comparaison(
)
def _loyer(session: Session, lot: Lot) -> LotLoyer:
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(
@@ -435,7 +685,16 @@ def _loyer(session: Session, lot: Lot) -> LotLoyer:
reparti=point.reparti,
en_transition=point.en_transition,
)
for point in serie.mois
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 = [
@@ -444,7 +703,7 @@ def _loyer(session: Session, lot: Lot) -> LotLoyer:
periode_fin=ligne.periode_fin,
montant=round(ligne.loyers, 2),
)
for ligne in serie.ecartees
for ligne in retenues
]
niveaux = paliers(serie.mois)
@@ -463,6 +722,8 @@ def _loyer(session: Session, lot: Lot) -> LotLoyer:
)
).scalar()
reloue_depuis, sortie_en = _apres_le_palier(serie.mois, courant.mois_fin)
en_vigueur = LoyerEnVigueur(
mois=courant.mois_fin,
loyer=courant.loyer,
@@ -471,6 +732,8 @@ def _loyer(session: Session, lot: Lot) -> LotLoyer:
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)
@@ -480,6 +743,9 @@ def _loyer(session: Session, lot: Lot) -> LotLoyer:
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),
)
@@ -547,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
@@ -566,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()
@@ -592,23 +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),
loyer=_loyer(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

@@ -33,7 +33,10 @@ 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.
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
@@ -91,6 +94,32 @@ def mois_entiers(debut: date | None, fin: date | None) -> list[str]:
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."""
@@ -109,6 +138,12 @@ class MoisLoue:
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:
@@ -161,6 +196,8 @@ def repartir(lignes) -> Serie:
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(

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, LotCaracteristiques
from plesna_gerance.database.models import (
Document,
Immeuble,
Locataire,
Lot,
LotCaracteristiques,
)
from plesna_gerance.database.service import DatabaseService
@@ -262,6 +268,233 @@ def test_la_surface_saisie_allume_le_loyer_au_m2(api_client, db_session, donnees
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.
@@ -384,3 +617,151 @@ def test_la_comparaison_compte_les_lots_qu_elle_ne_peut_pas_voir(
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

View File

@@ -139,6 +139,57 @@ class TestRepartition:
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."""