Compare commits
8 Commits
8d869a3dde
...
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fe2bf75c0 | |||
| c42e190aa7 | |||
| 62d6eb169c | |||
| 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 }}
|
NAMESPACE: ${{ secrets.REGISTRY_NAMESPACE }}
|
||||||
|
|
||||||
jobs:
|
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:
|
build:
|
||||||
name: Build App Image
|
name: Build App Image
|
||||||
|
needs: [test]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -78,14 +117,16 @@ jobs:
|
|||||||
name: Build Summary
|
name: Build Summary
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [build]
|
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:
|
steps:
|
||||||
- name: Build summary
|
- name: Build summary
|
||||||
run: |
|
run: |
|
||||||
echo "## 🐳 Docker Image Built Successfully"
|
echo "## 🐳 Docker Image Built Successfully"
|
||||||
echo ""
|
echo ""
|
||||||
echo "- Registry: ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/plesna-gerance"
|
echo "- Registry: ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/plesna-gerance"
|
||||||
echo "- Tags: latest, ${{ gitea.ref_name }}"
|
echo "- Ref: ${{ gitea.ref_name }}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "### 🚀 Deployment"
|
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)
|
# Installe les dependances backend + frontend (a refaire par worktree)
|
||||||
install:
|
install:
|
||||||
@@ -17,6 +17,12 @@ dev_front:
|
|||||||
dev:
|
dev:
|
||||||
$(MAKE) dev_back & $(MAKE) dev_front & wait
|
$(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
|
# Tire l'image publiee (registre Gitea) et lance via podman-compose
|
||||||
docker:
|
docker:
|
||||||
podman-compose pull
|
podman-compose pull
|
||||||
@@ -25,3 +31,9 @@ docker:
|
|||||||
# Construit l'image en local (test avant publication)
|
# Construit l'image en local (test avant publication)
|
||||||
docker-build:
|
docker-build:
|
||||||
podman build -f backend.Dockerfile -t plesna-gerance:local .
|
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)
|
||||||
|
|||||||
78
README.md
78
README.md
@@ -35,6 +35,82 @@ make dev
|
|||||||
|
|
||||||
Ouvrir **http://localhost:5173** — le proxy Vite redirige `/api` vers le backend.
|
Ouvrir **http://localhost:5173** — le proxy Vite redirige `/api` vers le backend.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test # ruff check + pytest + vitest, soit exactement ce que la CI verifie
|
||||||
|
```
|
||||||
|
|
||||||
|
Les tests de non-regression des parseurs comparent l'extraction de PDF reels a
|
||||||
|
des references figees. PDF (`data/`) et references (`tests/golden/`) contiennent
|
||||||
|
des donnees personnelles et ne sont pas versionnes : ces tests se **sautent**
|
||||||
|
d'eux-memes la ou le corpus est absent, CI comprise. Les lancer pour de vrai
|
||||||
|
suppose donc un corpus local (cf. `tests/test_parsers_golden.py`).
|
||||||
|
|
||||||
|
## Versions et cycle de vie
|
||||||
|
|
||||||
|
La production heberge des donnees reelles : elle suit des **versions**, pas la
|
||||||
|
pointe de `main`.
|
||||||
|
|
||||||
|
### Ce qu'est une version
|
||||||
|
|
||||||
|
Un tag git `vMAJEUR.MINEUR.CORRECTIF` (semver), pose sur `main`. Le meme numero
|
||||||
|
est ecrit dans cinq fichiers, que l'outillage tient d'accord entre eux :
|
||||||
|
`pyproject.toml`, `src/plesna_gerance/__init__.py`, `frontend/package.json`,
|
||||||
|
`frontend/package-lock.json` (qui porte lui aussi la version du paquet racine,
|
||||||
|
sans quoi `npm ci` peut refuser de tourner en CI) et `packaging/installer.iss`
|
||||||
|
(version affichee par l'installeur Windows).
|
||||||
|
|
||||||
|
Quand incrementer quoi :
|
||||||
|
|
||||||
|
| Segment | Quand | Exemple |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| CORRECTIF (`0.1.1`) | correction sans changement d'usage | un montant mal lu |
|
||||||
|
| MINEUR (`0.2.0`) | nouvelle fonctionnalite, donnees existantes intactes | une nouvelle page |
|
||||||
|
| MAJEUR (`1.0.0`) | rupture : migration de base ou changement d'usage a annoncer | refonte du referentiel |
|
||||||
|
|
||||||
|
### Publier une version
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test # ce que la CI verifiera de toute facon
|
||||||
|
make release VERSION=0.2.0 # aligne les 4 fichiers, commite, pose le tag
|
||||||
|
git push origin main v0.2.0 # <- c'est ce push qui publie
|
||||||
|
```
|
||||||
|
|
||||||
|
`make release` **ne pousse pas** : le tag reste local tant qu'on ne l'a pas
|
||||||
|
envoye, ce qui laisse le temps de relire le commit de version. Le script
|
||||||
|
(`scripts/release.py`) refuse d'avancer hors de `main`, sur un arbre sale, ou
|
||||||
|
si le tag existe deja — un tag publie ne se reecrit pas.
|
||||||
|
|
||||||
|
### Ce que declenche le push d'un tag
|
||||||
|
|
||||||
|
- **`.gitea/workflows/docker-publish.yml`** : lance d'abord les tests
|
||||||
|
(`ruff`, `pytest`, `vitest`), et seulement s'ils passent construit et publie
|
||||||
|
l'image sous **trois** tags — `0.2.0`, `0.2` et `0`. Epingler `0.2.0` fige le
|
||||||
|
deploiement a l'octet pres ; `0.2` laisse entrer les correctifs de la serie.
|
||||||
|
- **`.github/workflows/build-windows.yml`** : construit l'executable et
|
||||||
|
l'installeur Windows (necessite un runner `windows-latest`).
|
||||||
|
|
||||||
|
Un push sur `main` **sans tag** publie `latest` et `main` : utile pour essayer
|
||||||
|
la pointe, jamais pour la production.
|
||||||
|
|
||||||
|
### Deployer une version
|
||||||
|
|
||||||
|
La version deployee est epinglee dans `.env`, lu par `docker-compose.yml` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # une seule fois
|
||||||
|
# editer PLESNA_VERSION=0.2.0
|
||||||
|
make docker # pull + up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Revenir en arriere, c'est la meme manoeuvre : remettre le numero precedent dans
|
||||||
|
`.env` et relancer `make docker`. Les donnees vivent dans le volume nomme
|
||||||
|
`plesna-data`, independamment de l'image — un retour arriere d'image ne les
|
||||||
|
touche pas. En revanche une version qui a **migre le schema** de la base ne se
|
||||||
|
defait pas en changeant le numero : sauvegarder le volume avant une montee de
|
||||||
|
version majeure.
|
||||||
|
|
||||||
## Production (Docker)
|
## Production (Docker)
|
||||||
|
|
||||||
Un **conteneur unique** : l'image embarque le frontend Vue builde, servi par
|
Un **conteneur unique** : l'image embarque le frontend Vue builde, servi par
|
||||||
@@ -42,7 +118,7 @@ le backend FastAPI (API + interface web sur le meme port). Pas de nginx.
|
|||||||
|
|
||||||
L'image est **publiee par la CI Gitea** (`.gitea/workflows/docker-publish.yml`)
|
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**
|
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
|
```bash
|
||||||
make docker # podman-compose pull && podman-compose up -d
|
make docker # podman-compose pull && podman-compose up -d
|
||||||
|
|||||||
@@ -7,8 +7,11 @@
|
|||||||
FROM node:22-alpine AS frontend
|
FROM node:22-alpine AS frontend
|
||||||
|
|
||||||
WORKDIR /frontend
|
WORKDIR /frontend
|
||||||
COPY frontend/package.json frontend/package-lock.json* ./
|
# `npm ci` (et non `npm install`) : l'image doit installer exactement l'arbre
|
||||||
RUN npm install
|
# du lock, sinon deux builds du même tag peuvent embarquer des dépendances
|
||||||
|
# différentes. Le lock n'est donc plus optionnel.
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
# Image publiée par la CI Gitea (pas de build local).
|
# Image publiée par la CI Gitea (pas de build local). La version déployée
|
||||||
image: git.opytex.org/lafrite/plesna-gerance:latest
|
# 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:
|
ports:
|
||||||
# hôte:conteneur — l'app (API + interface web) écoute sur 8000
|
# hôte:conteneur — l'app (API + interface web) écoute sur 8000
|
||||||
- "8080:8000"
|
- "8080:8000"
|
||||||
|
|||||||
1139
frontend/package-lock.json
generated
1139
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "plesna-gerance-frontend",
|
"name": "plesna-gerance-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -17,11 +17,11 @@
|
|||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.0.4",
|
"@vitejs/plugin-vue": "^6.0.8",
|
||||||
"autoprefixer": "^10.4.18",
|
"autoprefixer": "^10.4.18",
|
||||||
"postcss": "^8.4.35",
|
"postcss": "^8.4.35",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"vite": "^5.1.6",
|
"vite": "^7.3.6",
|
||||||
"vitest": "^4.1.10"
|
"vitest": "^4.1.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,20 @@
|
|||||||
:class="{ 'w-full': fullWidth }"
|
:class="{ 'w-full': fullWidth }"
|
||||||
>
|
>
|
||||||
<!-- Mode lecture -->
|
<!-- 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
|
<span
|
||||||
v-if="!isEditing"
|
v-if="!isEditing"
|
||||||
@click="startEditing"
|
@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="[
|
:class="[
|
||||||
displayClass,
|
displayClass,
|
||||||
{ 'text-gray-500 italic': isEmpty }
|
{ 'text-gray-500 italic': isEmpty }
|
||||||
]"
|
]"
|
||||||
:title="'Cliquer pour modifier'"
|
:title="titreSurvol"
|
||||||
>
|
>
|
||||||
{{ displayValue }}
|
{{ displayValue }}
|
||||||
</span>
|
</span>
|
||||||
@@ -74,6 +79,15 @@ const props = defineProps({
|
|||||||
inputClass: {
|
inputClass: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
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
|
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(() => {
|
const inputType = computed(() => {
|
||||||
if (props.type === 'currency' || props.type === 'number') return 'number'
|
if (props.type === 'currency' || props.type === 'number') return 'number'
|
||||||
if (props.type === 'date') return 'date'
|
if (props.type === 'date') return 'date'
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ import DataCard from './DataCard.vue'
|
|||||||
import DataRow from './DataRow.vue'
|
import DataRow from './DataRow.vue'
|
||||||
import LocataireCard from './LocataireCard.vue'
|
import LocataireCard from './LocataireCard.vue'
|
||||||
import OperationCard from './OperationCard.vue'
|
import OperationCard from './OperationCard.vue'
|
||||||
|
import { setNestedValue } from '../utils/chemin'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
data: {
|
data: {
|
||||||
@@ -348,19 +349,6 @@ function cloneData() {
|
|||||||
return JSON.parse(JSON.stringify(props.data))
|
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
|
// Mettre a jour les metadonnees
|
||||||
function updateMetadata(path, value) {
|
function updateMetadata(path, value) {
|
||||||
const updated = cloneData()
|
const updated = cloneData()
|
||||||
|
|||||||
@@ -43,18 +43,31 @@
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<div class="flex items-center gap-2">
|
<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
|
<span
|
||||||
v-if="changed"
|
v-if="changed"
|
||||||
class="badge badge-warning text-[10px] uppercase tracking-wide"
|
class="badge badge-warning text-[10px] uppercase tracking-wide"
|
||||||
>
|
>
|
||||||
Modifié
|
Modifié
|
||||||
</span>
|
</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">
|
<div class="text-sm font-semibold" :class="totalClass">
|
||||||
{{ formatCurrency(locataire.totaux?.total) }}
|
{{ formatMontantPrecis(locataire.totaux?.total) }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="locataire.totaux?.impayes > 0" class="text-xs text-red-400">
|
<div v-if="locataire.totaux?.impayes" class="text-xs" :class="locataire.totaux.impayes > 0 ? 'text-red-400' : 'text-blue-400'">
|
||||||
Impayes: {{ formatCurrency(locataire.totaux?.impayes) }}
|
{{ locataire.totaux.impayes > 0 ? 'Impayes' : 'Trop-percu' }}:
|
||||||
|
{{ formatMontantPrecis(Math.abs(locataire.totaux.impayes)) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Bouton supprimer -->
|
<!-- Bouton supprimer -->
|
||||||
@@ -69,156 +82,218 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Content : le tableau du compte rendu, colonne pour colonne.
|
||||||
<div v-if="locataire.lignes?.length" class="space-y-1">
|
Monte a l'ouverture (`v-if`) : un document compte une vingtaine de lots,
|
||||||
<div class="flex items-center justify-between mb-1">
|
et garder replies une vingtaine de tableaux d'une centaine de champs
|
||||||
<span class="text-xs text-gray-500 font-medium">Detail des lignes</span>
|
editables les ferait re-rendre a chaque frappe sans qu'on les voie. -->
|
||||||
<button
|
<div v-if="expanded" class="border-t border-gray-700 bg-gray-900/60 p-3">
|
||||||
@click="addLigne"
|
<div class="flex items-center justify-between mb-1">
|
||||||
class="flex items-center gap-1 px-2 py-1 text-xs text-blue-400 hover:bg-blue-500/10 rounded transition-colors"
|
<span class="text-xs text-gray-500 font-medium">Detail des lignes</span>
|
||||||
>
|
|
||||||
<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">
|
|
||||||
<button
|
<button
|
||||||
@click="addLigne"
|
@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">
|
<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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
</svg>
|
</svg>
|
||||||
Ajouter une ligne
|
Ajouter
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-xs border-separate border-spacing-0">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-[10px] uppercase tracking-wide text-gray-500">
|
||||||
|
<th class="text-left font-medium pb-1 pr-2">Type</th>
|
||||||
|
<!-- Le compte rendu loge dans cette colonne aussi bien une periode
|
||||||
|
qu'un libelle de report : l'en-tete le dit. -->
|
||||||
|
<th class="text-left font-medium pb-1 pr-2">Periode / Libelle</th>
|
||||||
|
<th
|
||||||
|
v-for="colonne in COLONNES_TABLEAU"
|
||||||
|
:key="colonne.champ"
|
||||||
|
class="font-medium pb-1 px-2"
|
||||||
|
:class="alignement(colonne)"
|
||||||
|
>
|
||||||
|
{{ colonne.libelle }}
|
||||||
|
</th>
|
||||||
|
<th class="pb-1"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="(ligne, idx) in locataire.lignes"
|
||||||
|
:key="idx"
|
||||||
|
class="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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -226,6 +301,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, nextTick } from 'vue'
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
import EditableField from './EditableField.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({
|
const props = defineProps({
|
||||||
locataire: {
|
locataire: {
|
||||||
@@ -248,6 +331,10 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['update:locataire', 'remove'])
|
const emit = defineEmits(['update:locataire', 'remove'])
|
||||||
|
|
||||||
|
const LIBELLES = Object.fromEntries(
|
||||||
|
COLONNES_CRG.map(({ champ, libelle }) => [champ, libelle])
|
||||||
|
)
|
||||||
|
|
||||||
const expanded = ref(false)
|
const expanded = ref(false)
|
||||||
const rootEl = ref(null)
|
const rootEl = ref(null)
|
||||||
const isHighlighted = ref(false)
|
const isHighlighted = ref(false)
|
||||||
@@ -265,6 +352,32 @@ watch(() => props.highlighted, (val) => {
|
|||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { 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 totalClass = computed(() => {
|
||||||
const total = props.locataire.totaux?.total || 0
|
const total = props.locataire.totaux?.total || 0
|
||||||
if (total > 0) return 'text-green-400'
|
if (total > 0) return 'text-green-400'
|
||||||
@@ -272,9 +385,20 @@ const totalClass = computed(() => {
|
|||||||
return 'text-gray-400'
|
return 'text-gray-400'
|
||||||
})
|
})
|
||||||
|
|
||||||
function formatCurrency(value) {
|
// Les regles apparaissent en vert sur le compte rendu, les impayes en rouge.
|
||||||
if (value === null || value === undefined) return '-'
|
function classeMontant(champ, valeur) {
|
||||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value)
|
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")
|
// Mettre a jour un champ nested (ex: "lot.numero", "totaux.loyers")
|
||||||
@@ -284,30 +408,14 @@ function updateField(path, value) {
|
|||||||
emit('update:locataire', updated)
|
emit('update:locataire', updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper pour setter une valeur nested
|
// Mettre a jour un champ d'une ligne. Aucun autre champ n'est touche : une
|
||||||
function setNestedValue(obj, path, value) {
|
// correction de l'utilisateur ne doit rien declencher qu'il n'ait pas demande.
|
||||||
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
|
|
||||||
function updateLigneField(ligneIndex, path, value) {
|
function updateLigneField(ligneIndex, path, value) {
|
||||||
const updated = JSON.parse(JSON.stringify(props.locataire))
|
const updated = JSON.parse(JSON.stringify(props.locataire))
|
||||||
if (!updated.lignes) updated.lignes = []
|
if (!updated.lignes) updated.lignes = []
|
||||||
|
|
||||||
if (path.includes('.')) {
|
setNestedValue(updated.lignes[ligneIndex], path, value)
|
||||||
setNestedValue(updated.lignes[ligneIndex], path, value)
|
|
||||||
} else {
|
|
||||||
updated.lignes[ligneIndex][path] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
emit('update:locataire', updated)
|
emit('update:locataire', updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,7 +423,7 @@ function updateLigneField(ligneIndex, path, value) {
|
|||||||
function addLigne() {
|
function addLigne() {
|
||||||
const updated = JSON.parse(JSON.stringify(props.locataire))
|
const updated = JSON.parse(JSON.stringify(props.locataire))
|
||||||
if (!updated.lignes) updated.lignes = []
|
if (!updated.lignes) updated.lignes = []
|
||||||
|
|
||||||
updated.lignes.push({
|
updated.lignes.push({
|
||||||
type: 'loyer',
|
type: 'loyer',
|
||||||
periode: { debut: null, fin: null },
|
periode: { debut: null, fin: null },
|
||||||
@@ -327,7 +435,7 @@ function addLigne() {
|
|||||||
regles: 0,
|
regles: 0,
|
||||||
impayes: 0
|
impayes: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
emit('update:locataire', updated)
|
emit('update:locataire', updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -194,6 +194,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, nextTick } from 'vue'
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
import EditableField from './EditableField.vue'
|
import EditableField from './EditableField.vue'
|
||||||
|
import { setNestedValue } from '../utils/chemin'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
categorie: {
|
categorie: {
|
||||||
@@ -257,19 +258,6 @@ function formatCurrency(value) {
|
|||||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(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
|
// Mettre a jour un champ d'une operation
|
||||||
function updateOperationField(opIndex, path, value) {
|
function updateOperationField(opIndex, path, value) {
|
||||||
const updatedOperations = JSON.parse(JSON.stringify(props.operations))
|
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({})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
; idéal pour un poste personnel. Crée un raccourci Bureau et menu Démarrer.
|
; idéal pour un poste personnel. Crée un raccourci Bureau et menu Démarrer.
|
||||||
|
|
||||||
#define AppName "Plesna Gérance"
|
#define AppName "Plesna Gérance"
|
||||||
#define AppVersion "0.1.0"
|
#define AppVersion "0.1.1"
|
||||||
#define AppPublisher "Plesna"
|
#define AppPublisher "Plesna"
|
||||||
#define AppExeName "PlesnaGerance.exe"
|
#define AppExeName "PlesnaGerance.exe"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "plesna-gerance"
|
name = "plesna-gerance"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
description = "Extracteur de comptes rendus de gérance Oralia/ICS"
|
description = "Extracteur de comptes rendus de gérance Oralia/ICS"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
182
scripts/release.py
Normal file
182
scripts/release.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
"""Pose une version : aligne les fichiers, commite, tague.
|
||||||
|
|
||||||
|
Le numéro de version est écrit dans cinq fichiers qui doivent rester d'accord
|
||||||
|
(paquet Python, module, frontend, son lock, installeur Windows). Les tenir à
|
||||||
|
jour à la main, c'est publier tôt ou tard un tag `v0.3.0` sur un code qui se
|
||||||
|
déclare `0.1.0`. Ce script fait la mise à jour d'un bloc et refuse d'avancer au
|
||||||
|
moindre doute plutôt que de produire une version à moitié cohérente.
|
||||||
|
|
||||||
|
uv run python scripts/release.py 0.2.0
|
||||||
|
|
||||||
|
Il s'arrête avant le `git push` : le tag reste local tant qu'il n'est pas
|
||||||
|
poussé, et c'est le push qui déclenche la CI (images Docker + build Windows).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
RACINE = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# Un fichier, le motif qui y porte la version, le remplacement, et le nombre
|
||||||
|
# d'occurrences attendues. Le motif doit capturer la version dans son dernier
|
||||||
|
# groupe pour que l'on puisse relire l'ancienne valeur ; le compte est verifie
|
||||||
|
# a chaque passage, pour que le script s'arrete si un fichier change de forme
|
||||||
|
# plutot que de laisser filer une version a moitie posee.
|
||||||
|
PORTEURS_DE_VERSION = [
|
||||||
|
(
|
||||||
|
"pyproject.toml",
|
||||||
|
re.compile(r'^version = "([^"]+)"$', re.M),
|
||||||
|
'version = "{v}"',
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"src/plesna_gerance/__init__.py",
|
||||||
|
re.compile(r'^__version__ = "([^"]+)"$', re.M),
|
||||||
|
'__version__ = "{v}"',
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"frontend/package.json",
|
||||||
|
re.compile(r'^ "version": "([^"]+)",$', re.M),
|
||||||
|
' "version": "{v}",',
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
# Le lock porte la version du paquet racine a deux endroits (en-tete et
|
||||||
|
# packages[""]). Les laisser en arriere fait diverger lock et package.json,
|
||||||
|
# ce que `npm ci` peut refuser en CI selon la version de npm. On s'ancre sur
|
||||||
|
# le nom du paquet, qui n'apparait qu'a ces deux endroits.
|
||||||
|
(
|
||||||
|
"frontend/package-lock.json",
|
||||||
|
re.compile(
|
||||||
|
r'(^(\s*)"name": "plesna-gerance-frontend",\n\s*"version": ")([^"]+)',
|
||||||
|
re.M,
|
||||||
|
),
|
||||||
|
r"\g<1>{v}",
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"packaging/installer.iss",
|
||||||
|
re.compile(r'^#define AppVersion "([^"]+)"$', re.M),
|
||||||
|
'#define AppVersion "{v}"',
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
SEMVER = re.compile(r"^\d+\.\d+\.\d+$")
|
||||||
|
|
||||||
|
|
||||||
|
class Refus(Exception):
|
||||||
|
"""Condition non remplie : on s'arrête sans rien modifier."""
|
||||||
|
|
||||||
|
|
||||||
|
def git(*args: str) -> str:
|
||||||
|
"""Lance une commande git dans le dépôt et renvoie sa sortie."""
|
||||||
|
resultat = subprocess.run(
|
||||||
|
["git", "-C", str(RACINE), *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if resultat.returncode != 0:
|
||||||
|
raise Refus(f"git {' '.join(args)} a échoué :\n{resultat.stderr.strip()}")
|
||||||
|
return resultat.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def verifier_le_depot(version: str, autoriser_hors_main: bool) -> None:
|
||||||
|
"""Refuse de poser une version depuis un état de dépôt douteux."""
|
||||||
|
branche = git("rev-parse", "--abbrev-ref", "HEAD")
|
||||||
|
if branche != "main" and not autoriser_hors_main:
|
||||||
|
raise Refus(
|
||||||
|
f"branche courante « {branche} » : une version se pose sur main.\n"
|
||||||
|
"Fusionner d'abord, ou forcer avec --autoriser-hors-main."
|
||||||
|
)
|
||||||
|
|
||||||
|
if git("status", "--porcelain"):
|
||||||
|
raise Refus(
|
||||||
|
"l'arbre de travail n'est pas propre : commiter ou remiser d'abord.\n"
|
||||||
|
"Une version doit correspondre à un état de code identifiable."
|
||||||
|
)
|
||||||
|
|
||||||
|
if git("tag", "--list", f"v{version}"):
|
||||||
|
raise Refus(
|
||||||
|
f"le tag v{version} existe déjà. Choisir un numéro supérieur "
|
||||||
|
"(un tag publié ne se réécrit pas)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def appliquer_la_version(version: str) -> list[str]:
|
||||||
|
"""Écrit la version dans les fichiers porteurs. Renvoie ceux qui ont changé."""
|
||||||
|
modifies = []
|
||||||
|
for chemin_relatif, motif, remplacement, attendues in PORTEURS_DE_VERSION:
|
||||||
|
chemin = RACINE / chemin_relatif
|
||||||
|
contenu = chemin.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# Le dernier groupe capture la version elle-même, quel que soit le
|
||||||
|
# nombre de groupes servant à l'ancrage.
|
||||||
|
trouvees = [
|
||||||
|
correspondance.groups()[-1] for correspondance in motif.finditer(contenu)
|
||||||
|
]
|
||||||
|
if len(trouvees) != attendues:
|
||||||
|
raise Refus(
|
||||||
|
f"{chemin_relatif} : {len(trouvees)} version(s) trouvée(s), "
|
||||||
|
f"{attendues} attendue(s). Le fichier a changé de forme : "
|
||||||
|
"corriger le motif dans scripts/release.py."
|
||||||
|
)
|
||||||
|
|
||||||
|
if all(trouvee == version for trouvee in trouvees):
|
||||||
|
continue
|
||||||
|
|
||||||
|
chemin.write_text(
|
||||||
|
motif.sub(remplacement.format(v=version), contenu), encoding="utf-8"
|
||||||
|
)
|
||||||
|
modifies.append(chemin_relatif)
|
||||||
|
|
||||||
|
return modifies
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parseur = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parseur.add_argument("version", help="numéro de version, sans le v (ex. 0.2.0)")
|
||||||
|
parseur.add_argument(
|
||||||
|
"--autoriser-hors-main",
|
||||||
|
action="store_true",
|
||||||
|
help="pose la version depuis une autre branche (rattrapage)",
|
||||||
|
)
|
||||||
|
arguments = parseur.parse_args()
|
||||||
|
|
||||||
|
version = arguments.version.lstrip("v")
|
||||||
|
if not SEMVER.match(version):
|
||||||
|
print(
|
||||||
|
f"Version « {arguments.version} » invalide : attendu MAJEUR.MINEUR.CORRECTIF "
|
||||||
|
"(ex. 0.2.0).",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
verifier_le_depot(version, arguments.autoriser_hors_main)
|
||||||
|
modifies = appliquer_la_version(version)
|
||||||
|
|
||||||
|
if modifies:
|
||||||
|
git("add", *modifies)
|
||||||
|
git("commit", "-m", f"chore: passe en version {version}")
|
||||||
|
print(f"Version écrite dans : {', '.join(modifies)}")
|
||||||
|
else:
|
||||||
|
print(f"Les fichiers déclarent déjà {version} : aucun commit de version.")
|
||||||
|
|
||||||
|
git("tag", "-a", f"v{version}", "-m", f"Version {version}")
|
||||||
|
except Refus as refus:
|
||||||
|
print(f"Publication interrompue : {refus}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"Tag v{version} posé sur {git('rev-parse', '--short', 'HEAD')}.")
|
||||||
|
print("\nRien n'est publié tant que le tag n'est pas poussé :")
|
||||||
|
print(f" git push origin main v{version}")
|
||||||
|
print("\nLe push déclenche la CI : images Docker taguées puis build Windows.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -5,7 +5,7 @@ Ce package extrait les informations structurées des PDFs de comptes rendus
|
|||||||
de gérance générés par le logiciel de gestion immobilière Oralia/ICS.
|
de gérance générés par le logiciel de gestion immobilière Oralia/ICS.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.1.1"
|
||||||
|
|
||||||
from .extractor import extract_compte_rendu
|
from .extractor import extract_compte_rendu
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ from sqlalchemy import and_, func, select
|
|||||||
from ..database.models import Document, Revenu
|
from ..database.models import Document, Revenu
|
||||||
|
|
||||||
#: Type des lignes qui reportent le solde du compte rendu précédent.
|
#: 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"
|
TYPE_LIGNE_REPORT = "solde_anterieur"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user