Compare commits
5 Commits
8d869a3dde
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fc6f66955 | |||
| 1d138b8ca9 | |||
| 8c959f5d5f | |||
| 6a638df1ab | |||
| 7b033a0184 |
9
.env.example
Normal file
9
.env.example
Normal 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
|
||||
@@ -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"
|
||||
|
||||
14
Makefile
14
Makefile
@@ -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)
|
||||
|
||||
76
README.md
76
README.md
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'où vient la valeur, pour l'infobulle. Les trois écrans qui emploient ce
|
||||
* champ éditent aujourd'hui de l'extraction, d'où 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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,155 +83,217 @@
|
||||
</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,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
|
||||
}
|
||||
setNestedValue(updated.lignes[ligneIndex], path, value)
|
||||
|
||||
emit('update:locataire', updated)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
20
frontend/src/utils/chemin.js
Normal file
20
frontend/src/utils/chemin.js
Normal 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
|
||||
}
|
||||
126
frontend/src/utils/totauxLocataire.js
Normal file
126
frontend/src/utils/totauxLocataire.js
Normal 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
|
||||
}
|
||||
157
frontend/tests/locataireCard.test.js
Normal file
157
frontend/tests/locataireCard.test.js
Normal 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('n’affiche aucune valeur déduite quand l’extraction 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 n’a 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 n’a é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 l’impayé 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 d’un impayé', async () => {
|
||||
const lot = lotCharlot()
|
||||
lot.totaux.impayes = -32.55
|
||||
|
||||
const html = await rendre(lot)
|
||||
expect(html).toContain('Trop-percu: 32,55 €')
|
||||
})
|
||||
})
|
||||
183
frontend/tests/totauxLocataire.test.js
Normal file
183
frontend/tests/totauxLocataire.test.js
Normal 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 n’a é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 l’extrait, 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 d’arrondi 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
155
scripts/release.py
Normal 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())
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user