Compare commits

..

5 Commits

Author SHA1 Message Date
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
15 changed files with 1096 additions and 206 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,80 @@ 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 quatre fichiers, que l'outillage tient d'accord entre eux :
`pyproject.toml`, `src/plesna_gerance/__init__.py`, `frontend/package.json` 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 +116,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

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

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

View File

@@ -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,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,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({})
})
})

155
scripts/release.py Normal file
View File

@@ -0,0 +1,155 @@
"""Pose une version : aligne les fichiers, commite, tague.
Le numéro de version est écrit à quatre endroits qui doivent rester d'accord
(paquet Python, module, frontend, 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, et le remplacement. Le motif doit
# capturer la version dans le groupe 1 pour que l'on puisse relire l'ancienne
# valeur, et matcher **exactement une fois** (vérifié plus bas).
PORTEURS_DE_VERSION = [
("pyproject.toml", re.compile(r'^version = "([^"]+)"$', re.M), 'version = "{v}"'),
(
"src/plesna_gerance/__init__.py",
re.compile(r'^__version__ = "([^"]+)"$', re.M),
'__version__ = "{v}"',
),
(
"frontend/package.json",
re.compile(r'^ "version": "([^"]+)",$', re.M),
' "version": "{v}",',
),
(
"packaging/installer.iss",
re.compile(r'^#define AppVersion "([^"]+)"$', re.M),
'#define AppVersion "{v}"',
),
]
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 in PORTEURS_DE_VERSION:
chemin = RACINE / chemin_relatif
contenu = chemin.read_text(encoding="utf-8")
occurrences = motif.findall(contenu)
if len(occurrences) != 1:
raise Refus(
f"{chemin_relatif} : {len(occurrences)} ligne(s) de version trouvée(s), "
"une seule attendue. Le fichier a changé de forme : "
"corriger le motif dans scripts/release.py."
)
if occurrences[0] == version:
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

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