Compare commits

15 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 11:24:32 +02:00
37bddae6ca feat: ouvre la fiche d'un lot dans l'interface
All checks were successful
Build and Publish Docker Image / Build App Image (push) Successful in 1m24s
Build and Publish Docker Image / Build Summary (push) Successful in 3s
Nouvel onglet « Lots » : identité, chiffres, chronologie et intervenants
d'un lot, choisi dans un sélecteur. Le lot vit dans l'URL (`/lots/26`) pour
qu'une fiche se partage et survive au rechargement.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

9
.env.example Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "plesna-gerance-frontend",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"type": "module",
"scripts": {
@@ -17,11 +17,11 @@
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"@vitejs/plugin-vue": "^6.0.8",
"autoprefixer": "^10.4.18",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"vite": "^5.1.6",
"vite": "^7.3.6",
"vitest": "^4.1.10"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -194,6 +194,7 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import EditableField from './EditableField.vue'
import { setNestedValue } from '../utils/chemin'
const props = defineProps({
categorie: {
@@ -257,19 +258,6 @@ function formatCurrency(value) {
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value)
}
// Helper pour setter une valeur nested
function setNestedValue(obj, path, value) {
const parts = path.split('.')
let current = obj
for (let i = 0; i < parts.length - 1; i++) {
if (!current[parts[i]]) {
current[parts[i]] = {}
}
current = current[parts[i]]
}
current[parts[parts.length - 1]] = value
}
// Mettre a jour un champ d'une operation
function updateOperationField(opIndex, path, value) {
const updatedOperations = JSON.parse(JSON.stringify(props.operations))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,6 +40,13 @@
@apply text-sm text-gray-400 mt-1;
}
/* --- Navigation laterale ---------------------------------------------- */
/* Entree de menu : la couleur (actif / inactif) est posee par App.vue. */
.nav-link {
@apply flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg transition-colors;
}
/* --- Cartes ----------------------------------------------------------- */
.card {
@@ -112,6 +119,15 @@
@apply cursor-pointer;
}
/* Champ de saisie dans une cellule de tableau. Bordure invisible au repos :
une grille de 14 colonnes encadrees serait illisible, alors qu'un tableau
de saisie doit d'abord se lire. */
.input-cell {
@apply bg-transparent border border-transparent rounded px-2 py-1 text-sm text-white
placeholder-gray-600 hover:border-gray-700 focus:outline-none focus:border-blue-500
focus:bg-gray-950 transition-colors [color-scheme:dark];
}
.form-hint {
@apply text-xs text-gray-500 mt-1;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

182
scripts/release.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ from ...database.models import (
Lot,
Revenu,
)
from ...services.referentiel import TYPE_LOT_EFFECTIF, joindre_fiche
from ...services.revenus_query import (
est_flux,
flux_par,
@@ -390,10 +391,11 @@ async def get_revenus_by_lot(
flux = flux_par(Revenu.lot_id)
dette = restant_du_par(Revenu.lot_id)
stmt = (
joindre_fiche(
select(
Lot.id,
Lot.numero,
Lot.type,
TYPE_LOT_EFFECTIF.label("type"),
Immeuble.code,
func.max(Locataire.nom).label("locataire_nom"),
flux.c.facture,
@@ -410,6 +412,7 @@ async def get_revenus_by_lot(
)
.outerjoin(flux, flux.c.cle == Lot.id)
.outerjoin(dette, dette.c.cle == Lot.id)
)
.group_by(Lot.id)
.order_by(desc("restant_du"), desc("facture"))
.limit(limit)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

57
tests/test_logements.py Normal file
View File

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

225
tests/test_lot_analyse.py Normal file
View File

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

View File

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

View File

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