Compare commits
15 Commits
ac63922896
...
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fe2bf75c0 | |||
| c42e190aa7 | |||
| 62d6eb169c | |||
| 5fc6f66955 | |||
| 1d138b8ca9 | |||
| 8c959f5d5f | |||
| 6a638df1ab | |||
| 7b033a0184 | |||
| 8d869a3dde | |||
| 37bddae6ca | |||
| 6a014cdb71 | |||
| dc5e8b995f | |||
| fdffe27f06 | |||
| 9f5f46a93a | |||
| 5a08b0c7e5 |
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)
|
||||
|
||||
78
README.md
78
README.md
@@ -35,6 +35,82 @@ make dev
|
||||
|
||||
Ouvrir **http://localhost:5173** — le proxy Vite redirige `/api` vers le backend.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
make test # ruff check + pytest + vitest, soit exactement ce que la CI verifie
|
||||
```
|
||||
|
||||
Les tests de non-regression des parseurs comparent l'extraction de PDF reels a
|
||||
des references figees. PDF (`data/`) et references (`tests/golden/`) contiennent
|
||||
des donnees personnelles et ne sont pas versionnes : ces tests se **sautent**
|
||||
d'eux-memes la ou le corpus est absent, CI comprise. Les lancer pour de vrai
|
||||
suppose donc un corpus local (cf. `tests/test_parsers_golden.py`).
|
||||
|
||||
## Versions et cycle de vie
|
||||
|
||||
La production heberge des donnees reelles : elle suit des **versions**, pas la
|
||||
pointe de `main`.
|
||||
|
||||
### Ce qu'est une version
|
||||
|
||||
Un tag git `vMAJEUR.MINEUR.CORRECTIF` (semver), pose sur `main`. Le meme numero
|
||||
est ecrit dans cinq fichiers, que l'outillage tient d'accord entre eux :
|
||||
`pyproject.toml`, `src/plesna_gerance/__init__.py`, `frontend/package.json`,
|
||||
`frontend/package-lock.json` (qui porte lui aussi la version du paquet racine,
|
||||
sans quoi `npm ci` peut refuser de tourner en CI) et `packaging/installer.iss`
|
||||
(version affichee par l'installeur Windows).
|
||||
|
||||
Quand incrementer quoi :
|
||||
|
||||
| Segment | Quand | Exemple |
|
||||
| --- | --- | --- |
|
||||
| CORRECTIF (`0.1.1`) | correction sans changement d'usage | un montant mal lu |
|
||||
| MINEUR (`0.2.0`) | nouvelle fonctionnalite, donnees existantes intactes | une nouvelle page |
|
||||
| MAJEUR (`1.0.0`) | rupture : migration de base ou changement d'usage a annoncer | refonte du referentiel |
|
||||
|
||||
### Publier une version
|
||||
|
||||
```bash
|
||||
make test # ce que la CI verifiera de toute facon
|
||||
make release VERSION=0.2.0 # aligne les 4 fichiers, commite, pose le tag
|
||||
git push origin main v0.2.0 # <- c'est ce push qui publie
|
||||
```
|
||||
|
||||
`make release` **ne pousse pas** : le tag reste local tant qu'on ne l'a pas
|
||||
envoye, ce qui laisse le temps de relire le commit de version. Le script
|
||||
(`scripts/release.py`) refuse d'avancer hors de `main`, sur un arbre sale, ou
|
||||
si le tag existe deja — un tag publie ne se reecrit pas.
|
||||
|
||||
### Ce que declenche le push d'un tag
|
||||
|
||||
- **`.gitea/workflows/docker-publish.yml`** : lance d'abord les tests
|
||||
(`ruff`, `pytest`, `vitest`), et seulement s'ils passent construit et publie
|
||||
l'image sous **trois** tags — `0.2.0`, `0.2` et `0`. Epingler `0.2.0` fige le
|
||||
deploiement a l'octet pres ; `0.2` laisse entrer les correctifs de la serie.
|
||||
- **`.github/workflows/build-windows.yml`** : construit l'executable et
|
||||
l'installeur Windows (necessite un runner `windows-latest`).
|
||||
|
||||
Un push sur `main` **sans tag** publie `latest` et `main` : utile pour essayer
|
||||
la pointe, jamais pour la production.
|
||||
|
||||
### Deployer une version
|
||||
|
||||
La version deployee est epinglee dans `.env`, lu par `docker-compose.yml` :
|
||||
|
||||
```bash
|
||||
cp .env.example .env # une seule fois
|
||||
# editer PLESNA_VERSION=0.2.0
|
||||
make docker # pull + up -d
|
||||
```
|
||||
|
||||
Revenir en arriere, c'est la meme manoeuvre : remettre le numero precedent dans
|
||||
`.env` et relancer `make docker`. Les donnees vivent dans le volume nomme
|
||||
`plesna-data`, independamment de l'image — un retour arriere d'image ne les
|
||||
touche pas. En revanche une version qui a **migre le schema** de la base ne se
|
||||
defait pas en changeant le numero : sauvegarder le volume avant une montee de
|
||||
version majeure.
|
||||
|
||||
## Production (Docker)
|
||||
|
||||
Un **conteneur unique** : l'image embarque le frontend Vue builde, servi par
|
||||
@@ -42,7 +118,7 @@ le backend FastAPI (API + interface web sur le meme port). Pas de nginx.
|
||||
|
||||
L'image est **publiee par la CI Gitea** (`.gitea/workflows/docker-publish.yml`)
|
||||
sur `git.opytex.org/lafrite/plesna-gerance`. Le `docker-compose.yml` la **tire**
|
||||
directement (pas de build local) :
|
||||
directement (pas de build local), a la version epinglee dans `.env` :
|
||||
|
||||
```bash
|
||||
make docker # podman-compose pull && podman-compose up -d
|
||||
|
||||
@@ -7,8 +7,11 @@
|
||||
FROM node:22-alpine AS frontend
|
||||
|
||||
WORKDIR /frontend
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
RUN npm install
|
||||
# `npm ci` (et non `npm install`) : l'image doit installer exactement l'arbre
|
||||
# du lock, sinon deux builds du même tag peuvent embarquer des dépendances
|
||||
# différentes. Le lock n'est donc plus optionnel.
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
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",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -17,11 +17,11 @@
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"vite": "^5.1.6",
|
||||
"vite": "^7.3.6",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,69 @@
|
||||
<template>
|
||||
<div class="h-screen flex flex-col bg-gray-950">
|
||||
<!-- Header compact -->
|
||||
<header class="flex-shrink-0 bg-gray-900 border-b border-gray-700 px-6 py-2">
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<router-link to="/" class="text-lg font-semibold text-white hover:text-blue-400 transition-colors whitespace-nowrap">
|
||||
Plesna Gerance
|
||||
<span class="text-gray-400 font-normal text-sm ml-2">Extracteur de comptes rendus</span>
|
||||
</router-link>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex items-center gap-1">
|
||||
<router-link
|
||||
v-for="link in navLinks"
|
||||
:key="link.to"
|
||||
:to="link.to"
|
||||
class="px-3 py-1.5 text-sm rounded-lg transition-colors"
|
||||
:class="$route.path === link.to ? 'bg-gray-800 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-800'"
|
||||
<div class="h-screen flex bg-gray-950">
|
||||
<!-- Navigation laterale -->
|
||||
<aside
|
||||
class="flex-shrink-0 bg-gray-900 border-r border-gray-700 flex flex-col transition-all duration-200"
|
||||
:class="replie ? 'w-16' : 'w-52'"
|
||||
>
|
||||
{{ link.label }}
|
||||
<router-link
|
||||
to="/"
|
||||
class="block border-b border-gray-800 hover:bg-gray-800 transition-colors"
|
||||
:class="replie ? 'px-2 py-3 text-center' : 'px-4 py-3'"
|
||||
:title="replie ? 'Plesna Gerance' : null"
|
||||
>
|
||||
<template v-if="replie">
|
||||
<div class="text-base font-semibold text-white">PG</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="text-base font-semibold text-white leading-tight">Plesna Gerance</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5">Extracteur de comptes rendus</div>
|
||||
</template>
|
||||
</router-link>
|
||||
|
||||
<!-- Import button with file input -->
|
||||
<label class="btn btn-primary ml-2 cursor-pointer">
|
||||
Importer un PDF
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
class="hidden"
|
||||
@change="onFileSelect"
|
||||
/>
|
||||
</label>
|
||||
</nav>
|
||||
<nav class="flex-1 overflow-y-auto px-2 py-3 space-y-4">
|
||||
<NavLien v-bind="accueil" :replie="replie" />
|
||||
|
||||
<div v-for="groupe in groupes" :key="groupe.titre">
|
||||
<!-- Replie, le titre de groupe n'a plus la place de s'ecrire : un
|
||||
filet garde la separation entre saisie et analyse. -->
|
||||
<div
|
||||
v-if="replie"
|
||||
class="mx-3 mb-2 border-t border-gray-800"
|
||||
:aria-label="groupe.titre"
|
||||
></div>
|
||||
<div
|
||||
v-else
|
||||
class="px-3 pb-1 text-[11px] font-semibold uppercase tracking-wider text-gray-600"
|
||||
>
|
||||
{{ groupe.titre }}
|
||||
</div>
|
||||
</header>
|
||||
<NavLien v-for="lien in groupe.liens" :key="lien.to" v-bind="lien" :replie="replie" />
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Config n'est pas une destination de travail : elle vit a l'ecart, en bas -->
|
||||
<div class="px-2 py-3 border-t border-gray-800 space-y-1">
|
||||
<NavLien v-bind="config" :replie="replie" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="nav-link w-full text-gray-500 hover:text-white hover:bg-gray-800"
|
||||
:class="replie ? 'justify-center px-0' : ''"
|
||||
:title="replie ? 'Déplier le menu' : 'Replier le menu'"
|
||||
@click="replie = !replie"
|
||||
>
|
||||
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
:d="replie ? 'M13 5l7 7-7 7M5 5l7 7-7 7' : 'M11 19l-7-7 7-7m8 14l-7-7 7-7'"
|
||||
/>
|
||||
</svg>
|
||||
<span v-if="!replie" class="truncate">Replier</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="flex-1 flex overflow-hidden">
|
||||
@@ -43,32 +73,88 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { pendingFile } from './store'
|
||||
import { ref, watch } from 'vue'
|
||||
import NavLien from './components/NavLien.vue'
|
||||
import { FEATURE_IA } from './features'
|
||||
|
||||
const router = useRouter()
|
||||
const fileInput = ref(null)
|
||||
// Le repli survit au rechargement : c'est un reglage d'espace de travail, pas
|
||||
// un etat de page — le retrouver deplie a chaque F5 serait une corvee.
|
||||
const replie = ref(localStorage.getItem('nav-repliee') === '1')
|
||||
watch(replie, (valeur) => localStorage.setItem('nav-repliee', valeur ? '1' : '0'))
|
||||
|
||||
// Les libelles suivent le vocabulaire de l'accueil : recettes et depenses. Les
|
||||
const accueil = {
|
||||
to: '/',
|
||||
label: 'Accueil',
|
||||
paths: [
|
||||
'M3 12l9-9 9 9M5 10v10a1 1 0 001 1h3a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1h3a1 1 0 001-1V10'
|
||||
]
|
||||
}
|
||||
|
||||
const config = {
|
||||
to: '/config',
|
||||
label: 'Config',
|
||||
paths: [
|
||||
'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z',
|
||||
'M15 12a3 3 0 11-6 0 3 3 0 016 0z'
|
||||
]
|
||||
}
|
||||
|
||||
// Deux natures de travail, deux groupes : on alimente la base (Saisie) ou on
|
||||
// lit ce qu'elle contient (Analyse). « Fiches logements » et « Par lot »
|
||||
// portent des noms distincts parce que les deux pages parlent du même objet :
|
||||
// l'une décrit le logement, l'autre analyse ce qui s'y est passé.
|
||||
//
|
||||
// Les libellés suivent le vocabulaire de l'accueil : recettes et depenses. Les
|
||||
// URL gardent leurs noms d'origine (/revenus, /analytics), qui circulent dans
|
||||
// des liens deja partages.
|
||||
const navLinks = [
|
||||
{ to: '/', label: 'Accueil' },
|
||||
{ to: '/documents', label: 'Documents' },
|
||||
{ to: '/revenus', label: 'Recettes' },
|
||||
{ to: '/analytics', label: 'Dépenses' },
|
||||
...(FEATURE_IA ? [{ to: '/ia', label: 'IA' }] : []),
|
||||
{ to: '/config', label: 'Config' }
|
||||
]
|
||||
|
||||
function onFileSelect(e) {
|
||||
const file = e.target.files[0]
|
||||
if (file && file.name.toLowerCase().endsWith('.pdf')) {
|
||||
pendingFile.value = file
|
||||
router.push('/extract')
|
||||
//
|
||||
// Les fleches de Recettes et Depenses sont celles des actions rapides de
|
||||
// l'accueil : replie, le menu ne montre plus que ces icones, elles doivent
|
||||
// designer la meme chose d'un ecran a l'autre.
|
||||
const groupes = [
|
||||
{
|
||||
titre: 'Saisie',
|
||||
liens: [
|
||||
{
|
||||
to: '/documents',
|
||||
label: 'Documents',
|
||||
paths: [
|
||||
'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'
|
||||
]
|
||||
},
|
||||
{
|
||||
to: '/logements',
|
||||
label: 'Fiches logements',
|
||||
paths: [
|
||||
'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4'
|
||||
]
|
||||
}
|
||||
e.target.value = '' // Reset input
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
titre: 'Analyse',
|
||||
liens: [
|
||||
{ to: '/revenus', label: 'Recettes', paths: ['M12 19V5m0 0l-6 6m6-6l6 6'] },
|
||||
{ to: '/analytics', label: 'Dépenses', paths: ['M12 5v14m0 0l6-6m-6 6l-6-6'] },
|
||||
{
|
||||
to: '/lots',
|
||||
label: 'Par lot',
|
||||
paths: [
|
||||
'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z'
|
||||
]
|
||||
},
|
||||
...(FEATURE_IA
|
||||
? [
|
||||
{
|
||||
to: '/ia',
|
||||
label: 'IA',
|
||||
paths: [
|
||||
'M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z'
|
||||
]
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -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,50 +83,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div v-show="expanded" class="border-t border-gray-700 bg-gray-900/60 p-3">
|
||||
<!-- Totaux editables -->
|
||||
<div class="grid grid-cols-4 gap-2 text-xs mb-3">
|
||||
<div class="text-center p-2 bg-gray-800 border border-gray-700 rounded">
|
||||
<div class="text-gray-500 mb-1">Loyers</div>
|
||||
<EditableField
|
||||
:modelValue="locataire.totaux?.loyers"
|
||||
@update:modelValue="updateField('totaux.loyers', $event)"
|
||||
type="currency"
|
||||
displayClass="font-semibold text-gray-200"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-center p-2 bg-gray-800 border border-gray-700 rounded">
|
||||
<div class="text-gray-500 mb-1">Taxes</div>
|
||||
<EditableField
|
||||
:modelValue="locataire.totaux?.taxes"
|
||||
@update:modelValue="updateField('totaux.taxes', $event)"
|
||||
type="currency"
|
||||
displayClass="font-semibold text-gray-200"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-center p-2 bg-gray-800 border border-gray-700 rounded">
|
||||
<div class="text-gray-500 mb-1">Provisions</div>
|
||||
<EditableField
|
||||
:modelValue="locataire.totaux?.provisions"
|
||||
@update:modelValue="updateField('totaux.provisions', $event)"
|
||||
type="currency"
|
||||
displayClass="font-semibold text-gray-200"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-center p-2 bg-gray-800 border border-gray-700 rounded">
|
||||
<div class="text-gray-500 mb-1">Regles</div>
|
||||
<EditableField
|
||||
:modelValue="locataire.totaux?.regles"
|
||||
@update:modelValue="updateField('totaux.regles', $event)"
|
||||
type="currency"
|
||||
displayClass="font-semibold text-green-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lignes detail -->
|
||||
<div v-if="locataire.lignes?.length" class="space-y-1">
|
||||
<!-- Content : le tableau du compte rendu, colonne pour colonne.
|
||||
Monte a l'ouverture (`v-if`) : un document compte une vingtaine de lots,
|
||||
et garder replies une vingtaine de tableaux d'une centaine de champs
|
||||
editables les ferait re-rendre a chaque frappe sans qu'on les voie. -->
|
||||
<div v-if="expanded" class="border-t border-gray-700 bg-gray-900/60 p-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-gray-500 font-medium">Detail des lignes</span>
|
||||
<button
|
||||
@@ -126,14 +100,34 @@
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-xs border-separate border-spacing-0">
|
||||
<thead>
|
||||
<tr class="text-[10px] uppercase tracking-wide text-gray-500">
|
||||
<th class="text-left font-medium pb-1 pr-2">Type</th>
|
||||
<!-- Le compte rendu loge dans cette colonne aussi bien une periode
|
||||
qu'un libelle de report : l'en-tete le dit. -->
|
||||
<th class="text-left font-medium pb-1 pr-2">Periode / Libelle</th>
|
||||
<th
|
||||
v-for="colonne in COLONNES_TABLEAU"
|
||||
:key="colonne.champ"
|
||||
class="font-medium pb-1 px-2"
|
||||
:class="alignement(colonne)"
|
||||
>
|
||||
{{ colonne.libelle }}
|
||||
</th>
|
||||
<th class="pb-1"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(ligne, idx) in locataire.lignes"
|
||||
:key="idx"
|
||||
class="text-xs bg-gray-800 border border-gray-700 rounded p-2"
|
||||
class="bg-gray-800/60 hover:bg-gray-800"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<!-- Type de ligne -->
|
||||
<td class="py-1 pr-2 border-t border-gray-700/60">
|
||||
<select
|
||||
:value="ligne.type"
|
||||
@change="updateLigneField(idx, 'type', $event.target.value)"
|
||||
@@ -144,9 +138,22 @@
|
||||
<option value="rappel_loyer">rappel_loyer</option>
|
||||
<option value="divers">divers</option>
|
||||
</select>
|
||||
</td>
|
||||
|
||||
<!-- Periode -->
|
||||
<div class="flex items-center gap-1 text-gray-500">
|
||||
<!-- Un report de solde porte son montant dans cette colonne sur le
|
||||
compte rendu, pas dans « Loyers » : le tableau fait de meme,
|
||||
sans quoi la colonne Loyers semblerait ne pas sommer. Le libelle
|
||||
du compte rendu n'est pas repris, la colonne « Type » le donne. -->
|
||||
<td class="py-1 pr-2 border-t border-gray-700/60">
|
||||
<div v-if="ligne.type === 'solde_anterieur'" class="flex items-center gap-1 whitespace-nowrap">
|
||||
<EditableField
|
||||
:modelValue="ligne.loyers"
|
||||
@update:modelValue="updateLigneField(idx, 'loyers', $event)"
|
||||
type="currency"
|
||||
displayClass="text-xs text-gray-200"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-1 text-gray-500 whitespace-nowrap">
|
||||
<EditableField
|
||||
:modelValue="ligne.periode?.debut"
|
||||
@update:modelValue="updateLigneField(idx, 'periode.debut', $event)"
|
||||
@@ -163,36 +170,45 @@
|
||||
displayClass="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Libelle divers -->
|
||||
<td
|
||||
v-for="colonne in COLONNES_TABLEAU"
|
||||
:key="colonne.champ"
|
||||
class="py-1 px-2 border-t border-gray-700/60"
|
||||
>
|
||||
<!-- Divers : libelle puis montant, comme sur le compte rendu.
|
||||
Le libelle reste vide tant qu'il n'y en a pas, mais cliquable. -->
|
||||
<div v-if="colonne.champ === 'divers'" class="flex items-center justify-between gap-2">
|
||||
<EditableField
|
||||
v-if="ligne.type === 'divers'"
|
||||
:modelValue="ligne.divers?.libelle"
|
||||
@update:modelValue="updateLigneField(idx, 'divers.libelle', $event)"
|
||||
type="text"
|
||||
placeholder="libellé"
|
||||
displayClass="text-xs text-gray-400 italic truncate"
|
||||
placeholder=""
|
||||
displayClass="text-xs text-gray-400 italic"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Montant : divers -> montant divers ; sinon total (ou loyers) -->
|
||||
<EditableField
|
||||
v-if="ligne.type === 'divers'"
|
||||
:modelValue="ligne.divers?.montant"
|
||||
@update:modelValue="updateLigneField(idx, 'divers.montant', $event)"
|
||||
type="currency"
|
||||
displayClass="font-medium text-gray-200"
|
||||
displayClass="text-xs text-gray-200"
|
||||
/>
|
||||
</div>
|
||||
<!-- Le montant d'un report est rendu dans la colonne « Periode » -->
|
||||
<div
|
||||
v-else-if="colonne.champ !== 'loyers' || ligne.type !== 'solde_anterieur'"
|
||||
class="flex justify-end"
|
||||
>
|
||||
<EditableField
|
||||
v-else
|
||||
:modelValue="ligne.total || ligne.loyers"
|
||||
@update:modelValue="updateLigneField(idx, 'total', $event)"
|
||||
:modelValue="ligne[colonne.champ]"
|
||||
@update:modelValue="updateLigneField(idx, colonne.champ, $event)"
|
||||
type="currency"
|
||||
displayClass="font-medium text-gray-200"
|
||||
:displayClass="`text-xs ${classeMontant(colonne.champ, ligne[colonne.champ])}`"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Bouton supprimer ligne -->
|
||||
<td class="py-1 border-t border-gray-700/60">
|
||||
<button
|
||||
@click="removeLigne(idx)"
|
||||
class="p-1 text-gray-500 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
|
||||
@@ -202,23 +218,82 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr v-if="!locataire.lignes?.length">
|
||||
<td colspan="10" class="py-3 text-center text-gray-500 border-t border-gray-700/60">
|
||||
Aucune ligne extraite pour ce lot
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
<!-- Ligne « Totaux » du compte rendu : extraite elle aussi, donc editable -->
|
||||
<tfoot>
|
||||
<tr class="bg-gray-800">
|
||||
<td class="py-1 pr-2 border-t-2 border-gray-600 text-gray-300 font-medium">Totaux</td>
|
||||
|
||||
<!-- Le solde anterieur occupe la colonne « Periode », comme sur le
|
||||
compte rendu, mais se recoupe comme les autres colonnes. -->
|
||||
<td
|
||||
class="py-1 pr-2 border-t-2 border-gray-600"
|
||||
:class="classeCellule('solde_anterieur')"
|
||||
>
|
||||
<div class="flex items-center gap-1 whitespace-nowrap">
|
||||
<span class="text-[10px] uppercase tracking-wide text-gray-500">Solde ant.</span>
|
||||
<EditableField
|
||||
:modelValue="locataire.totaux?.solde_anterieur"
|
||||
@update:modelValue="updateField('totaux.solde_anterieur', $event)"
|
||||
type="currency"
|
||||
displayClass="text-xs text-gray-200"
|
||||
/>
|
||||
<span
|
||||
v-if="ecarts.solde_anterieur"
|
||||
class="text-[10px] text-amber-500/90 italic cursor-help whitespace-nowrap"
|
||||
:title="detailEcart('solde_anterieur')"
|
||||
>
|
||||
calculé : {{ formatMontantPrecis(ecarts.solde_anterieur.calcule) }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Une colonne qui ne somme pas est signalee sur toute la cellule :
|
||||
c'est le premier endroit ou l'oeil verifie le compte rendu. -->
|
||||
<td
|
||||
v-for="colonne in COLONNES_TABLEAU"
|
||||
:key="colonne.champ"
|
||||
class="py-1 px-2 border-t-2 border-gray-600"
|
||||
:class="classeCellule(colonne.champ)"
|
||||
>
|
||||
<div class="flex flex-col items-end">
|
||||
<EditableField
|
||||
:modelValue="locataire.totaux?.[colonne.champ]"
|
||||
@update:modelValue="updateField(`totaux.${colonne.champ}`, $event)"
|
||||
type="currency"
|
||||
:displayClass="`text-xs font-semibold ${classeMontant(colonne.champ, locataire.totaux?.[colonne.champ])}`"
|
||||
/>
|
||||
<!-- Somme de la colonne. Ni pointille ni contraste : non modifiable. -->
|
||||
<span
|
||||
v-if="ecarts[colonne.champ]"
|
||||
class="text-[10px] text-amber-500/90 italic mt-0.5 cursor-help px-1 whitespace-nowrap"
|
||||
:title="detailEcart(colonne.champ)"
|
||||
>
|
||||
calculé : {{ formatMontantPrecis(ecarts[colonne.champ].calcule) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="border-t-2 border-gray-600"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Bouton ajouter ligne si aucune -->
|
||||
<div v-else class="text-center py-2">
|
||||
<button
|
||||
@click="addLigne"
|
||||
class="flex items-center gap-1 px-3 py-1.5 text-xs text-blue-400 hover:bg-blue-500/10 rounded transition-colors mx-auto"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Ajouter une ligne
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="aDesEcarts" class="mt-2 text-[10px] text-gray-500 italic">
|
||||
Colonne(s) en surbrillance : le montant du compte rendu ne correspond pas a ce que
|
||||
totalisent les lignes ci-dessus (« calculé : … »), signe qu'une valeur n'a pas ete
|
||||
extraite. Seuls les champs soulignes sont modifiables, et eux seuls partent en base.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -226,6 +301,14 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import EditableField from './EditableField.vue'
|
||||
import { setNestedValue } from '../utils/chemin'
|
||||
import { formatMontantPrecis } from '../utils/format'
|
||||
import {
|
||||
COLONNES_CRG,
|
||||
COLONNES_TABLEAU,
|
||||
ecartsAvecLignes,
|
||||
totauxCalcules,
|
||||
} from '../utils/totauxLocataire'
|
||||
|
||||
const props = defineProps({
|
||||
locataire: {
|
||||
@@ -248,6 +331,10 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['update:locataire', 'remove'])
|
||||
|
||||
const LIBELLES = Object.fromEntries(
|
||||
COLONNES_CRG.map(({ champ, libelle }) => [champ, libelle])
|
||||
)
|
||||
|
||||
const expanded = ref(false)
|
||||
const rootEl = ref(null)
|
||||
const isHighlighted = ref(false)
|
||||
@@ -265,6 +352,32 @@ watch(() => props.highlighted, (val) => {
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Tout ce qui est affiche vient de l'extraction. Les lignes sont neanmoins
|
||||
// reagregees pour un seul usage : reperer les lots ou l'extraction se contredit.
|
||||
// Ces valeurs ne sont ni enregistrees, ni reportees dans les champs.
|
||||
const calcules = computed(() => totauxCalcules(props.locataire.lignes))
|
||||
|
||||
const ecarts = computed(() => ecartsAvecLignes(props.locataire.totaux, calcules.value))
|
||||
|
||||
const aDesEcarts = computed(() => Object.keys(ecarts.value).length > 0)
|
||||
|
||||
const resumeEcarts = computed(() =>
|
||||
Object.keys(ecarts.value)
|
||||
.map((champ) => detailEcart(champ))
|
||||
.join('\n\n')
|
||||
)
|
||||
|
||||
function detailEcart(champ) {
|
||||
const nb = props.locataire.lignes?.length || 0
|
||||
const { extrait, calcule, manquant } = ecarts.value[champ]
|
||||
return (
|
||||
`${LIBELLES[champ]}\n` +
|
||||
`Extrait du compte rendu : ${formatMontantPrecis(extrait)}\n` +
|
||||
`Calculé sur les ${nb} ligne(s) : ${formatMontantPrecis(calcule)}\n` +
|
||||
`Écart de ${formatMontantPrecis(manquant)} : aucune ligne ne porte ce montant.`
|
||||
)
|
||||
}
|
||||
|
||||
const totalClass = computed(() => {
|
||||
const total = props.locataire.totaux?.total || 0
|
||||
if (total > 0) return 'text-green-400'
|
||||
@@ -272,9 +385,20 @@ const totalClass = computed(() => {
|
||||
return 'text-gray-400'
|
||||
})
|
||||
|
||||
function formatCurrency(value) {
|
||||
if (value === null || value === undefined) return '-'
|
||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value)
|
||||
// Les regles apparaissent en vert sur le compte rendu, les impayes en rouge.
|
||||
function classeMontant(champ, valeur) {
|
||||
if (champ === 'regles') return 'text-green-400'
|
||||
if (champ === 'impayes') return valeur ? 'text-red-400' : 'text-gray-200'
|
||||
return 'text-gray-200'
|
||||
}
|
||||
|
||||
function classeCellule(champ) {
|
||||
return ecarts.value[champ] ? 'bg-amber-500/10 ring-1 ring-inset ring-amber-500/40' : ''
|
||||
}
|
||||
|
||||
// Le compte rendu aligne ses montants a droite et le libelle « Divers » a gauche.
|
||||
function alignement(colonne) {
|
||||
return colonne.champ === 'divers' ? 'text-left' : 'text-right'
|
||||
}
|
||||
|
||||
// Mettre a jour un champ nested (ex: "lot.numero", "totaux.loyers")
|
||||
@@ -284,29 +408,13 @@ function updateField(path, value) {
|
||||
emit('update:locataire', updated)
|
||||
}
|
||||
|
||||
// Helper pour setter une valeur nested
|
||||
function setNestedValue(obj, path, value) {
|
||||
const parts = path.split('.')
|
||||
let current = obj
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (!current[parts[i]]) {
|
||||
current[parts[i]] = {}
|
||||
}
|
||||
current = current[parts[i]]
|
||||
}
|
||||
current[parts[parts.length - 1]] = value
|
||||
}
|
||||
|
||||
// Mettre a jour un champ d'une ligne
|
||||
// Mettre a jour un champ d'une ligne. Aucun autre champ n'est touche : une
|
||||
// correction de l'utilisateur ne doit rien declencher qu'il n'ait pas demande.
|
||||
function updateLigneField(ligneIndex, path, value) {
|
||||
const updated = JSON.parse(JSON.stringify(props.locataire))
|
||||
if (!updated.lignes) updated.lignes = []
|
||||
|
||||
if (path.includes('.')) {
|
||||
setNestedValue(updated.lignes[ligneIndex], path, value)
|
||||
} else {
|
||||
updated.lignes[ligneIndex][path] = value
|
||||
}
|
||||
|
||||
emit('update:locataire', updated)
|
||||
}
|
||||
|
||||
45
frontend/src/components/NavLien.vue
Normal file
45
frontend/src/components/NavLien.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<router-link
|
||||
:to="to"
|
||||
class="nav-link"
|
||||
:class="[
|
||||
actif ? 'bg-gray-800 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-800',
|
||||
replie ? 'justify-center px-0' : ''
|
||||
]"
|
||||
:title="replie ? label : null"
|
||||
:aria-label="label"
|
||||
>
|
||||
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
v-for="d in paths"
|
||||
:key="d"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
:d="d"
|
||||
/>
|
||||
</svg>
|
||||
<span v-if="!replie" class="truncate">{{ label }}</span>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const props = defineProps({
|
||||
to: { type: String, required: true },
|
||||
label: { type: String, required: true },
|
||||
paths: { type: Array, required: true },
|
||||
replie: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// Une fiche de lot (/lots/26) doit garder son entrée allumée : comparer les
|
||||
// chemins à l'identique éteindrait la navigation dès qu'une page a des
|
||||
// sous-routes.
|
||||
const actif = computed(
|
||||
() => route.path === props.to || route.path.startsWith(`${props.to}/`)
|
||||
)
|
||||
</script>
|
||||
@@ -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))
|
||||
|
||||
227
frontend/src/components/referentiel/LigneLogement.vue
Normal file
227
frontend/src/components/referentiel/LigneLogement.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<tr :class="{ 'bg-gray-800/30': etat === 'enregistrement' }">
|
||||
<!-- Nom d'usage de l'immeuble : saisi ici faute d'un écran à lui, et
|
||||
répercuté sur toutes ses lignes puisqu'il ne décrit pas le lot. -->
|
||||
<td class="whitespace-nowrap">
|
||||
<input
|
||||
v-model="nomImmeuble"
|
||||
class="input-cell w-32 text-white"
|
||||
:placeholder="ligne.immeuble_code"
|
||||
:title="`Nom d'usage de l'immeuble ${ligne.immeuble_code} : le modifier vaut pour tous ses lots`"
|
||||
@change="renommerImmeuble"
|
||||
/>
|
||||
</td>
|
||||
|
||||
<!-- Porte : identifiant du lot, non modifiable ici -->
|
||||
<td class="font-mono text-white whitespace-nowrap">
|
||||
{{ ligne.numero }}
|
||||
|
||||
<!-- Un lot que rien ne rattache à un compte rendu ne décrit rien : il
|
||||
s'efface d'ici, sinon il encombre la saisie à chaque ligne. -->
|
||||
<button v-if="inutilise"
|
||||
class="badge badge-neutral ml-2 hover:bg-red-500/20 hover:text-red-400 transition-colors"
|
||||
:title="`Aucune ligne de compte rendu ne renvoie au lot ${ligne.numero}.\nCliquer pour le supprimer du référentiel.`"
|
||||
@click="demanderSuppression">
|
||||
inutilisé ✕
|
||||
</button>
|
||||
<span v-else class="badge badge-info ml-2 whitespace-nowrap" :title="detailRattachements">
|
||||
{{ resumeRattachements }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td><input v-model="fiche.bat" class="input-cell w-20" placeholder="—" @change="enregistrer" /></td>
|
||||
|
||||
<td>
|
||||
<div class="flex items-center gap-1">
|
||||
<input v-model="fiche.type" class="input-cell w-40" :placeholder="ligne.type_extrait || '—'" @change="enregistrer" />
|
||||
<span v-if="ligne.type_ecart"
|
||||
class="badge badge-warning whitespace-nowrap"
|
||||
:title="`Le PDF annonce « ${ligne.type_extrait} »`">
|
||||
PDF : {{ ligne.type_extrait }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td><input v-model="fiche.etage" class="input-cell w-16" placeholder="—" @change="enregistrer" /></td>
|
||||
|
||||
<td>
|
||||
<input v-model.number="fiche.surface" type="number" min="0" step="0.01"
|
||||
class="input-cell w-24 text-right" placeholder="—" @change="enregistrer" />
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input v-model="fiche.surface_date_diag" type="date" class="input-cell w-36" @change="enregistrer" />
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input v-model="fiche.numero_fiscal" class="input-cell w-36" placeholder="—" @change="enregistrer" />
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<select v-model="fiche.dpe_classe" class="input-cell w-16" @change="enregistrer">
|
||||
<option :value="null">—</option>
|
||||
<option v-for="classe in DPE_CLASSES" :key="classe" :value="classe">{{ classe }}</option>
|
||||
</select>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input v-model="fiche.dpe_date_realisation" type="date" class="input-cell w-36" @change="enregistrer" />
|
||||
</td>
|
||||
|
||||
<!-- Échéance : déduite de la date de réalisation, jamais saisie -->
|
||||
<td class="whitespace-nowrap">
|
||||
<span v-if="!derives.dpe_echeance" class="text-gray-600">—</span>
|
||||
<span v-else class="badge" :class="badgeEcheance">{{ formatDate(derives.dpe_echeance) }}</span>
|
||||
</td>
|
||||
|
||||
<td><input v-model="fiche.chauffage" class="input-cell w-32" placeholder="—" @change="enregistrer" /></td>
|
||||
|
||||
<td>
|
||||
<input v-model.number="fiche.surface_impots" type="number" min="0" step="0.01"
|
||||
class="input-cell w-24 text-right" placeholder="—" @change="enregistrer" />
|
||||
</td>
|
||||
|
||||
<!-- Écart de surface : calculé, affiché même nul pour dire que le rapprochement est fait -->
|
||||
<td class="text-right whitespace-nowrap">
|
||||
<span v-if="derives.delta_surface == null" class="text-gray-600">—</span>
|
||||
<span v-else class="badge" :class="derives.delta_surface === 0 ? 'badge-success' : 'badge-warning'">
|
||||
{{ formatDelta(derives.delta_surface) }} m²
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input v-model="fiche.note_impots" class="input-cell w-56" placeholder="—" @change="enregistrer" />
|
||||
</td>
|
||||
|
||||
<td class="w-8 text-center">
|
||||
<span v-if="etat === 'enregistrement'" class="spinner w-3 h-3 align-middle" title="Enregistrement"></span>
|
||||
<span v-else-if="etat === 'ok'" class="text-green-400" title="Enregistré">✓</span>
|
||||
<span v-else-if="etat === 'erreur'" class="text-red-400 cursor-help" :title="erreur">!</span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, computed, watch } from 'vue'
|
||||
import { formatDate } from '../../utils/format'
|
||||
|
||||
const API = import.meta.env.VITE_API_URL || ''
|
||||
|
||||
const DPE_CLASSES = ['A', 'B', 'C', 'D', 'E', 'F', 'G']
|
||||
|
||||
//: Champs envoyés au serveur. L'ordre suit celui du tableau de saisie.
|
||||
const CHAMPS = [
|
||||
'bat', 'etage', 'type', 'surface', 'surface_date_diag', 'chauffage',
|
||||
'dpe_classe', 'dpe_date_realisation', 'numero_fiscal', 'surface_impots', 'note_impots'
|
||||
]
|
||||
|
||||
const props = defineProps({
|
||||
ligne: { type: Object, required: true }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['enregistree', 'supprimer', 'renommer-immeuble'])
|
||||
|
||||
const nomImmeuble = ref(props.ligne.immeuble_denomination || '')
|
||||
|
||||
watch(
|
||||
() => props.ligne.immeuble_denomination,
|
||||
(denomination) => { nomImmeuble.value = denomination || '' }
|
||||
)
|
||||
|
||||
// Le renommage remonte : il touche toutes les lignes du même immeuble, donc il
|
||||
// appartient au tableau et non à celle qui l'a déclenché.
|
||||
function renommerImmeuble() {
|
||||
emit('renommer-immeuble', { immeubleId: props.ligne.immeuble_id, denomination: nomImmeuble.value })
|
||||
}
|
||||
|
||||
const inutilise = computed(() => props.ligne.nb_revenus === 0 && props.ligne.nb_depenses === 0)
|
||||
|
||||
function accorder(nombre, singulier, pluriel = `${singulier}s`) {
|
||||
return `${nombre} ${nombre > 1 ? pluriel : singulier}`
|
||||
}
|
||||
|
||||
// Le badge dit ce que les comptes rendus rattachent à ce lot. Abrégé, il ne
|
||||
// voulait rien dire : « 11 rev. » se lit aussi bien comme onze euros.
|
||||
const resumeRattachements = computed(() => {
|
||||
const parties = [accorder(props.ligne.nb_revenus, 'revenu')]
|
||||
if (props.ligne.nb_depenses) parties.push(accorder(props.ligne.nb_depenses, 'dépense'))
|
||||
return parties.join(' · ')
|
||||
})
|
||||
|
||||
const detailRattachements = computed(() => {
|
||||
const revenus = accorder(props.ligne.nb_revenus, 'ligne de revenus', 'lignes de revenus')
|
||||
const depenses = accorder(props.ligne.nb_depenses, 'dépense')
|
||||
return (
|
||||
`Les comptes rendus rattachent ${revenus} et ${depenses} au lot ${props.ligne.numero}.\n` +
|
||||
'Un lot rattaché ne peut pas être supprimé : ses montants partiraient avec lui.'
|
||||
)
|
||||
})
|
||||
|
||||
// La suppression remonte : la confirmation et l'appel appartiennent au tableau,
|
||||
// qui seul peut retirer la ligne de la liste.
|
||||
function demanderSuppression() {
|
||||
emit('supprimer', props.ligne)
|
||||
}
|
||||
|
||||
const fiche = reactive(Object.fromEntries(CHAMPS.map((champ) => [champ, null])))
|
||||
const etat = ref('')
|
||||
const erreur = ref('')
|
||||
|
||||
// Valeurs calculées par le serveur : elles ne se devinent pas côté client, on
|
||||
// affiche celles de la dernière réponse.
|
||||
const derives = computed(() => ({
|
||||
dpe_echeance: props.ligne.caracteristiques?.dpe_echeance ?? null,
|
||||
delta_surface: props.ligne.caracteristiques?.delta_surface ?? null
|
||||
}))
|
||||
|
||||
watch(
|
||||
() => props.ligne.caracteristiques,
|
||||
(caracteristiques) => {
|
||||
for (const champ of CHAMPS) fiche[champ] = caracteristiques?.[champ] ?? null
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Un DPE périmé interdit de relouer sans le refaire : il doit sauter aux yeux
|
||||
// avant l'échéance, pas le jour où le locataire part.
|
||||
const badgeEcheance = computed(() => {
|
||||
const echeance = new Date(derives.value.dpe_echeance)
|
||||
const dans12Mois = new Date()
|
||||
dans12Mois.setFullYear(dans12Mois.getFullYear() + 1)
|
||||
|
||||
if (echeance < new Date()) return 'badge-danger'
|
||||
if (echeance < dans12Mois) return 'badge-warning'
|
||||
return 'badge-neutral'
|
||||
})
|
||||
|
||||
function formatDelta(valeur) {
|
||||
const signe = valeur > 0 ? '+' : ''
|
||||
return `${signe}${valeur}`
|
||||
}
|
||||
|
||||
async function enregistrer() {
|
||||
etat.value = 'enregistrement'
|
||||
erreur.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API}/api/lots/${props.ligne.id}/caracteristiques`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(Object.fromEntries(CHAMPS.map((champ) => [champ, fiche[champ] === '' ? null : fiche[champ]])))
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => null)
|
||||
throw new Error(detail?.detail?.[0]?.msg || detail?.detail || 'Enregistrement refusé')
|
||||
}
|
||||
|
||||
emit('enregistree', await response.json())
|
||||
etat.value = 'ok'
|
||||
setTimeout(() => { if (etat.value === 'ok') etat.value = '' }, 2000)
|
||||
} catch (e) {
|
||||
// La saisie reste à l'écran : l'effacer ferait perdre la valeur tapée.
|
||||
etat.value = 'erreur'
|
||||
erreur.value = e.message
|
||||
}
|
||||
}
|
||||
</script>
|
||||
251
frontend/src/components/referentiel/colonnes.js
Normal file
251
frontend/src/components/referentiel/colonnes.js
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Colonnes du tableau des logements : en-têtes, tri et filtres.
|
||||
*
|
||||
* L'ordre de cette liste est celui des cellules de `LigneLogement.vue` — les
|
||||
* deux se lisent ensemble, une colonne ajoutée ici doit l'être là aussi.
|
||||
*
|
||||
* `valeur` sert au tri et aux filtres ; ce que la cellule affiche peut être
|
||||
* plus riche (badges, champs de saisie), mais c'est cette valeur brute qui est
|
||||
* comparée, pour que trier « Écart » range des nombres et non des libellés.
|
||||
*
|
||||
* Chaque colonne porte son type de filtre, posé dans son en-tête :
|
||||
* - `choix` : liste des valeurs présentes (plus « (vide) »)
|
||||
* - `texte` : sous-chaîne, insensible à la casse et aux accents
|
||||
* - `nombre` : comparaison, `>100`, `<=50`, `!=0`, ou une valeur exacte
|
||||
* - `echeance` : où en est le DPE par rapport à sa péremption
|
||||
*
|
||||
* Une liste de choix peut proposer, au-dessus des valeurs, des entrées qui
|
||||
* n'en sont pas — le rattachement d'un lot, un désaccord avec le PDF. Elles
|
||||
* vivent dans leur propre groupe pour rester distinctes de ce que la colonne
|
||||
* contient vraiment.
|
||||
*/
|
||||
|
||||
const fiche = (ligne) => ligne.caracteristiques ?? {}
|
||||
|
||||
/** Marque le désaccord entre le type saisi et celui du PDF, filtrable comme une valeur. */
|
||||
export const TYPE_EN_DESACCORD = '⚠ en désaccord avec le PDF'
|
||||
|
||||
/** Option des listes de choix pour les lots dont la colonne est vide. */
|
||||
export const VIDE = '(vide)'
|
||||
|
||||
/** Lots rattachés à au moins une ligne de compte rendu. */
|
||||
export const ETAT_UTILISES = 'Lots utilisés'
|
||||
|
||||
/** Lots qu'aucune ligne de compte rendu ne mentionne : les supprimables. */
|
||||
export const ETAT_INUTILISES = 'Lots inutilisés'
|
||||
|
||||
export const COLONNES = [
|
||||
{
|
||||
cle: 'immeuble',
|
||||
libelle: 'Immeuble',
|
||||
valeur: (l) => l.immeuble_denomination || l.immeuble_code,
|
||||
filtre: 'choix'
|
||||
},
|
||||
{ cle: 'numero', libelle: 'Porte', valeur: (l) => l.numero, filtre: 'choix' },
|
||||
{ cle: 'bat', libelle: 'Bât', valeur: (l) => fiche(l).bat, filtre: 'choix' },
|
||||
{ cle: 'type', libelle: 'Type', valeur: (l) => l.type_effectif, filtre: 'choix' },
|
||||
{ cle: 'etage', libelle: 'Étage', valeur: (l) => fiche(l).etage, filtre: 'choix' },
|
||||
{
|
||||
cle: 'surface',
|
||||
libelle: 'Surface',
|
||||
valeur: (l) => fiche(l).surface,
|
||||
filtre: 'nombre',
|
||||
align: 'text-right'
|
||||
},
|
||||
{
|
||||
cle: 'surface_date_diag',
|
||||
libelle: 'Diag. surface',
|
||||
valeur: (l) => fiche(l).surface_date_diag,
|
||||
filtre: 'texte'
|
||||
},
|
||||
{
|
||||
cle: 'numero_fiscal',
|
||||
libelle: 'N° fiscal',
|
||||
valeur: (l) => fiche(l).numero_fiscal,
|
||||
filtre: 'texte'
|
||||
},
|
||||
{ cle: 'dpe_classe', libelle: 'DPE', valeur: (l) => fiche(l).dpe_classe, filtre: 'choix' },
|
||||
{
|
||||
cle: 'dpe_date_realisation',
|
||||
libelle: 'Réalisé le',
|
||||
valeur: (l) => fiche(l).dpe_date_realisation,
|
||||
filtre: 'texte'
|
||||
},
|
||||
{
|
||||
cle: 'dpe_echeance',
|
||||
libelle: 'Échéance',
|
||||
valeur: (l) => fiche(l).dpe_echeance,
|
||||
filtre: 'echeance'
|
||||
},
|
||||
{ cle: 'chauffage', libelle: 'Chauffage', valeur: (l) => fiche(l).chauffage, filtre: 'choix' },
|
||||
{
|
||||
cle: 'surface_impots',
|
||||
libelle: 'Surf. impôts',
|
||||
valeur: (l) => fiche(l).surface_impots,
|
||||
filtre: 'nombre',
|
||||
align: 'text-right'
|
||||
},
|
||||
{
|
||||
cle: 'delta_surface',
|
||||
libelle: 'Écart',
|
||||
valeur: (l) => fiche(l).delta_surface,
|
||||
filtre: 'nombre',
|
||||
align: 'text-right'
|
||||
},
|
||||
{
|
||||
cle: 'note_impots',
|
||||
libelle: 'Info impôts',
|
||||
valeur: (l) => fiche(l).note_impots,
|
||||
filtre: 'texte'
|
||||
}
|
||||
]
|
||||
|
||||
export const OPTIONS_ECHEANCE = [
|
||||
{ valeur: 'perime', libelle: 'Périmé' },
|
||||
{ valeur: 'bientot', libelle: 'Moins d’un an' },
|
||||
{ valeur: 'valide', libelle: 'Valide' },
|
||||
{ valeur: 'absent', libelle: 'Sans DPE' }
|
||||
]
|
||||
|
||||
const estVide = (valeur) => valeur == null || valeur === ''
|
||||
|
||||
// « Electrique » doit trouver « Électrique » : après décomposition NFD, les
|
||||
// accents deviennent des marques combinantes (\p{Mn}) qu'on retire par leur
|
||||
// catégorie Unicode — les écrire en clair donnerait un littéral invisible à la
|
||||
// relecture.
|
||||
function sansAccents(valeur) {
|
||||
return String(valeur)
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Mn}/gu, '')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Valeurs présentes dans une colonne, pour peupler sa liste de choix.
|
||||
*
|
||||
* Seules les valeurs réellement là sont proposées : une liste figée
|
||||
* afficherait des chauffages qu'aucun lot n'a.
|
||||
*/
|
||||
export function valeursDistinctes(lignes, colonne) {
|
||||
const valeurs = new Set()
|
||||
|
||||
for (const ligne of lignes) {
|
||||
const valeur = colonne.valeur(ligne)
|
||||
valeurs.add(estVide(valeur) ? VIDE : String(valeur))
|
||||
}
|
||||
|
||||
return [...valeurs].sort((a, b) => {
|
||||
if (a === VIDE) return 1
|
||||
if (b === VIDE) return -1
|
||||
return a.localeCompare(b, 'fr', { numeric: true })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Contenu de la liste déroulante d'une colonne, en groupes.
|
||||
*
|
||||
* Les entrées qui ne sont pas des valeurs de la colonne (rattachement d'un lot,
|
||||
* désaccord avec le PDF) sont proposées à part : mélangées aux numéros de
|
||||
* porte, elles se liraient comme des portes.
|
||||
*/
|
||||
export function groupesFiltre(lignes, colonne) {
|
||||
const valeurs = valeursDistinctes(lignes, colonne)
|
||||
|
||||
if (colonne.cle === 'numero') {
|
||||
return [
|
||||
{ libelle: 'Rattachement', options: [ETAT_UTILISES, ETAT_INUTILISES] },
|
||||
{ libelle: 'Porte', options: valeurs }
|
||||
]
|
||||
}
|
||||
|
||||
if (colonne.cle === 'type' && lignes.some((ligne) => ligne.type_ecart)) {
|
||||
return [
|
||||
{ libelle: 'Contrôle', options: [TYPE_EN_DESACCORD] },
|
||||
{ libelle: 'Type', options: valeurs }
|
||||
]
|
||||
}
|
||||
|
||||
return [{ libelle: '', options: valeurs }]
|
||||
}
|
||||
|
||||
function passeNombre(valeur, saisie) {
|
||||
const match = saisie.match(/^\s*(>=|<=|!=|<>|≠|>|<|=)?\s*(-?[\d.,]+)\s*$/)
|
||||
if (!match) return true // saisie incomplète : ne rien masquer
|
||||
|
||||
const seuil = parseFloat(match[2].replace(',', '.'))
|
||||
if (Number.isNaN(seuil)) return true
|
||||
if (valeur == null) return false
|
||||
|
||||
switch (match[1]) {
|
||||
case '>': return valeur > seuil
|
||||
case '<': return valeur < seuil
|
||||
case '>=': return valeur >= seuil
|
||||
case '<=': return valeur <= seuil
|
||||
case '!=':
|
||||
case '<>':
|
||||
case '≠': return valeur !== seuil
|
||||
default: return valeur === seuil
|
||||
}
|
||||
}
|
||||
|
||||
function passeEcheance(echeance, choix) {
|
||||
if (choix === 'absent') return estVide(echeance)
|
||||
if (estVide(echeance)) return false
|
||||
|
||||
const date = new Date(echeance)
|
||||
const aujourdhui = new Date()
|
||||
const dans12Mois = new Date()
|
||||
dans12Mois.setFullYear(dans12Mois.getFullYear() + 1)
|
||||
|
||||
if (choix === 'perime') return date < aujourdhui
|
||||
if (choix === 'bientot') return date >= aujourdhui && date < dans12Mois
|
||||
return date >= dans12Mois
|
||||
}
|
||||
|
||||
/** Une ligne survit-elle au filtre posé sur cette colonne ? */
|
||||
export function passeFiltre(ligne, colonne, saisie) {
|
||||
if (estVide(saisie)) return true
|
||||
|
||||
const valeur = colonne.valeur(ligne)
|
||||
|
||||
switch (colonne.filtre) {
|
||||
case 'echeance':
|
||||
return passeEcheance(valeur, saisie)
|
||||
case 'choix': {
|
||||
if (saisie === ETAT_UTILISES || saisie === ETAT_INUTILISES) {
|
||||
const inutilise = ligne.nb_revenus === 0 && ligne.nb_depenses === 0
|
||||
return saisie === ETAT_INUTILISES ? inutilise : !inutilise
|
||||
}
|
||||
if (saisie === TYPE_EN_DESACCORD) return ligne.type_ecart
|
||||
if (saisie === VIDE) return estVide(valeur)
|
||||
return String(valeur) === saisie
|
||||
}
|
||||
case 'nombre':
|
||||
return passeNombre(valeur, saisie)
|
||||
default:
|
||||
return !estVide(valeur) && sansAccents(valeur).includes(sansAccents(saisie))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparateur d'une colonne, dans le sens demandé.
|
||||
*
|
||||
* Les cases vides vont toujours en fin de tri, quel que soit le sens : trier
|
||||
* par surface pour voir les plus grandes ne doit pas d'abord dérouler tous les
|
||||
* lots non renseignés.
|
||||
*/
|
||||
export function comparer(colonne, sens) {
|
||||
const signe = sens === 'desc' ? -1 : 1
|
||||
|
||||
return (a, b) => {
|
||||
const va = colonne.valeur(a)
|
||||
const vb = colonne.valeur(b)
|
||||
|
||||
if (estVide(va) && estVide(vb)) return 0
|
||||
if (estVide(va)) return 1
|
||||
if (estVide(vb)) return -1
|
||||
|
||||
if (typeof va === 'number' && typeof vb === 'number') return signe * (va - vb)
|
||||
return signe * String(va).localeCompare(String(vb), 'fr', { numeric: true })
|
||||
}
|
||||
}
|
||||
247
frontend/src/pages/LogementsPage.vue
Normal file
247
frontend/src/pages/LogementsPage.vue
Normal file
@@ -0,0 +1,247 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-content">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Logements</h1>
|
||||
<p class="page-subtitle">
|
||||
Ce que les comptes rendus ne disent pas d'un lot : surface, étage, DPE, chauffage.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="chargement" class="empty-state">
|
||||
<span class="spinner w-6 h-6"></span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!lots.length" class="empty-state">
|
||||
Aucun lot en base : importez d'abord un compte rendu.
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="card">
|
||||
<div class="card-header flex-wrap">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="card-title">{{ lignesAffichees.length }} / {{ lots.length }} lots · {{ nbFiches }} décrits</h2>
|
||||
<span v-if="nbEcartsType" class="badge badge-warning">
|
||||
{{ nbEcartsType }} type{{ nbEcartsType > 1 ? 's' : '' }} en désaccord avec le PDF
|
||||
</span>
|
||||
<span v-if="nbEcartsSurface" class="badge badge-warning">
|
||||
{{ nbEcartsSurface }} écart{{ nbEcartsSurface > 1 ? 's' : '' }} de surface
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="filtresActifs"
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="reinitialiserFiltres"
|
||||
>Effacer les filtres</button>
|
||||
</div>
|
||||
|
||||
<!-- Hauteur bornée : sans elle, le defilement appartiendrait à la
|
||||
page et l'en-tête collant n'aurait rien à quoi se coller. -->
|
||||
<div class="table-wrap custom-scrollbar overflow-y-auto max-h-[calc(100vh-14rem)]">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<!-- Libellé et filtre dans la même cellule : le filtre se
|
||||
cherche dans sa colonne, et l'en-tête reste d'un bloc quand
|
||||
il se fige en haut du tableau. -->
|
||||
<tr>
|
||||
<th
|
||||
v-for="colonne in COLONNES"
|
||||
:key="colonne.cle"
|
||||
class="sticky top-0 z-20 bg-gray-800 align-bottom"
|
||||
>
|
||||
<button
|
||||
class="select-none hover:text-white uppercase"
|
||||
:class="colonne.align === 'text-right' ? 'w-full text-right' : ''"
|
||||
:title="`Trier par ${colonne.libelle}`"
|
||||
@click="trierPar(colonne.cle)"
|
||||
>
|
||||
{{ colonne.libelle }}
|
||||
<span class="text-blue-400">{{ triCle === colonne.cle ? (triSens === 'asc' ? '▲' : '▼') : '' }}</span>
|
||||
</button>
|
||||
|
||||
<div class="mt-1 font-normal normal-case tracking-normal">
|
||||
<select
|
||||
v-if="colonne.filtre === 'choix'"
|
||||
v-model="filtres[colonne.cle]"
|
||||
class="input-cell w-full border-gray-700 text-xs"
|
||||
>
|
||||
<option value="">Tous</option>
|
||||
<template v-for="groupe in choix[colonne.cle]" :key="groupe.libelle">
|
||||
<optgroup v-if="groupe.libelle" :label="groupe.libelle">
|
||||
<option v-for="valeur in groupe.options" :key="valeur" :value="valeur">{{ valeur }}</option>
|
||||
</optgroup>
|
||||
<template v-else>
|
||||
<option v-for="valeur in groupe.options" :key="valeur" :value="valeur">{{ valeur }}</option>
|
||||
</template>
|
||||
</template>
|
||||
</select>
|
||||
|
||||
<select
|
||||
v-else-if="colonne.filtre === 'echeance'"
|
||||
v-model="filtres[colonne.cle]"
|
||||
class="input-cell w-full border-gray-700 text-xs"
|
||||
>
|
||||
<option value="">Tous</option>
|
||||
<option v-for="option in OPTIONS_ECHEANCE" :key="option.valeur" :value="option.valeur">{{ option.libelle }}</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
v-else
|
||||
v-model="filtres[colonne.cle]"
|
||||
class="input-cell w-full border-gray-700 text-xs"
|
||||
:class="colonne.align"
|
||||
:placeholder="colonne.filtre === 'nombre' ? '> 100' : 'filtrer…'"
|
||||
:title="colonne.filtre === 'nombre' ? 'Comparaison acceptée : > 100, <= 50, != 0, ou une valeur exacte' : ''"
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
<th class="sticky top-0 z-20 bg-gray-800"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<LigneLogement
|
||||
v-for="ligne in lignesAffichees"
|
||||
:key="ligne.id"
|
||||
:ligne="ligne"
|
||||
@enregistree="majLigne"
|
||||
@supprimer="supprimerLot"
|
||||
@renommer-immeuble="renommerImmeuble"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="!lignesAffichees.length" class="empty-state">
|
||||
Aucun lot ne correspond aux filtres.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="form-hint">
|
||||
Chaque cellule s'enregistre dès qu'elle est quittée. L'échéance du DPE et l'écart
|
||||
de surface sont calculés, jamais saisis.
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import LigneLogement from '../components/referentiel/LigneLogement.vue'
|
||||
import {
|
||||
COLONNES,
|
||||
OPTIONS_ECHEANCE,
|
||||
comparer,
|
||||
groupesFiltre,
|
||||
passeFiltre
|
||||
} from '../components/referentiel/colonnes'
|
||||
|
||||
const API = import.meta.env.VITE_API_URL || ''
|
||||
|
||||
const lots = ref([])
|
||||
const chargement = ref(true)
|
||||
|
||||
const filtres = ref(Object.fromEntries(COLONNES.map((colonne) => [colonne.cle, ''])))
|
||||
const triCle = ref('numero')
|
||||
const triSens = ref('asc')
|
||||
|
||||
const nbFiches = computed(() => lots.value.filter((ligne) => ligne.caracteristiques).length)
|
||||
const nbEcartsType = computed(() => lots.value.filter((ligne) => ligne.type_ecart).length)
|
||||
const nbEcartsSurface = computed(
|
||||
() => lots.value.filter((ligne) => (ligne.caracteristiques?.delta_surface ?? 0) !== 0).length
|
||||
)
|
||||
|
||||
// Listes de choix construites sur les lots chargés : proposer un chauffage
|
||||
// qu'aucun lot n'a donnerait un filtre qui ne renvoie jamais rien.
|
||||
const choix = computed(() =>
|
||||
Object.fromEntries(
|
||||
COLONNES.filter((colonne) => colonne.filtre === 'choix').map((colonne) => [
|
||||
colonne.cle,
|
||||
groupesFiltre(lots.value, colonne)
|
||||
])
|
||||
)
|
||||
)
|
||||
|
||||
const filtresActifs = computed(() => Object.values(filtres.value).some((valeur) => valeur !== ''))
|
||||
|
||||
const lignesAffichees = computed(() => {
|
||||
const colonneTri = COLONNES.find((colonne) => colonne.cle === triCle.value) ?? COLONNES[0]
|
||||
|
||||
return lots.value
|
||||
.filter((ligne) =>
|
||||
COLONNES.every((colonne) => passeFiltre(ligne, colonne, filtres.value[colonne.cle]))
|
||||
)
|
||||
.sort(comparer(colonneTri, triSens.value))
|
||||
})
|
||||
|
||||
function reinitialiserFiltres() {
|
||||
filtres.value = Object.fromEntries(COLONNES.map((colonne) => [colonne.cle, '']))
|
||||
}
|
||||
|
||||
function trierPar(cle) {
|
||||
if (triCle.value === cle) {
|
||||
triSens.value = triSens.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
triCle.value = cle
|
||||
triSens.value = 'asc'
|
||||
}
|
||||
}
|
||||
|
||||
async function chargerLots() {
|
||||
const response = await fetch(`${API}/api/lots/referentiel`)
|
||||
lots.value = await response.json()
|
||||
}
|
||||
|
||||
// Le nom d'usage appartient à l'immeuble : toutes ses lignes le portent, elles
|
||||
// changent donc ensemble. Ne rafraîchir que la ligne éditée afficherait deux
|
||||
// noms pour un même immeuble jusqu'au rechargement.
|
||||
async function renommerImmeuble({ immeubleId, denomination }) {
|
||||
const response = await fetch(`${API}/api/immeubles/${immeubleId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ denomination })
|
||||
})
|
||||
if (!response.ok) return
|
||||
|
||||
const immeuble = await response.json()
|
||||
lots.value = lots.value.map((ligne) =>
|
||||
ligne.immeuble_id === immeuble.id
|
||||
? { ...ligne, immeuble_denomination: immeuble.denomination }
|
||||
: ligne
|
||||
)
|
||||
}
|
||||
|
||||
// Le serveur renvoie la ligne telle qu'il vient de l'enregistrer, dérivés
|
||||
// compris : la remplacer évite de recharger tout le tableau à chaque frappe.
|
||||
function majLigne(ligne) {
|
||||
const index = lots.value.findIndex((existante) => existante.id === ligne.id)
|
||||
if (index !== -1) lots.value[index] = ligne
|
||||
}
|
||||
|
||||
async function supprimerLot(ligne) {
|
||||
const decrit = ligne.caracteristiques ? ' Sa fiche sera perdue.' : ''
|
||||
if (!confirm(`Supprimer le lot ${ligne.numero} ?${decrit}`)) return
|
||||
|
||||
const response = await fetch(`${API}/api/lots/${ligne.id}`, { method: 'DELETE' })
|
||||
if (response.ok) {
|
||||
lots.value = lots.value.filter((existante) => existante.id !== ligne.id)
|
||||
return
|
||||
}
|
||||
|
||||
// Le refus vient du serveur (lot rattaché à un compte rendu) : le montrer tel
|
||||
// quel plutôt que de faire disparaître la ligne à tort.
|
||||
const detail = await response.json().catch(() => null)
|
||||
alert(detail?.detail || 'Suppression refusée.')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await chargerLots()
|
||||
} finally {
|
||||
chargement.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
384
frontend/src/pages/LotPage.vue
Normal file
384
frontend/src/pages/LotPage.vue
Normal file
@@ -0,0 +1,384 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-content">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Lot</h1>
|
||||
<p class="page-subtitle">
|
||||
Ce que les comptes rendus portent sur un lot, dans l'ordre où ils l'ont porté.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<select v-model="lotChoisi" class="input w-64">
|
||||
<option v-for="lot in lots" :key="lot.id" :value="lot.id">
|
||||
{{ lot.numero }} — {{ lot.immeuble_denomination || lot.immeuble_code }}
|
||||
<template v-if="lot.type_effectif"> · {{ lot.type_effectif }}</template>
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="chargement" class="empty-state">
|
||||
<span class="spinner w-6 h-6"></span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!lots.length" class="empty-state">
|
||||
Aucun lot en base : importez d'abord un compte rendu.
|
||||
</div>
|
||||
|
||||
<template v-else-if="analyse">
|
||||
<!-- Identité : les cases vides de la fiche restent vides et renvoient
|
||||
vers la saisie, plutôt que d'être comblées ou masquées. -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">
|
||||
Lot {{ analyse.identite.numero }}
|
||||
<span class="text-gray-400 font-normal">
|
||||
· {{ analyse.identite.immeuble_denomination || analyse.identite.immeuble_code }}
|
||||
</span>
|
||||
</h2>
|
||||
<router-link to="/logements" class="btn btn-secondary btn-sm">
|
||||
Compléter la fiche
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="card-body grid grid-cols-2 md:grid-cols-6 gap-4 text-sm">
|
||||
<div v-for="champ in CHAMPS_IDENTITE" :key="champ.cle">
|
||||
<div class="text-xs text-gray-400 uppercase tracking-wide">{{ champ.libelle }}</div>
|
||||
<div :class="valeurIdentite(champ) === '—' ? 'text-gray-600' : 'text-white'">
|
||||
{{ valeurIdentite(champ) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body pt-0 text-sm">
|
||||
<span class="text-xs text-gray-400 uppercase tracking-wide">Locataires</span>
|
||||
<span v-if="!analyse.identite.locataires.length" class="ml-2 text-gray-600">—</span>
|
||||
<span
|
||||
v-for="nom in analyse.identite.locataires"
|
||||
:key="nom"
|
||||
class="badge badge-neutral ml-2"
|
||||
>{{ nom }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chiffres -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<div v-for="tuile in tuiles" :key="tuile.libelle" class="card card-body">
|
||||
<div class="text-xs text-gray-400 uppercase tracking-wide">{{ tuile.libelle }}</div>
|
||||
<div class="text-2xl font-bold" :class="tuile.classe">{{ tuile.valeur }}</div>
|
||||
<div v-if="tuile.detail" class="text-xs text-gray-500 mt-1">{{ tuile.detail }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Le solde n'est pas un résultat : le dire là où il s'affiche, pas
|
||||
dans une documentation que personne n'ouvre. -->
|
||||
<p class="form-hint">
|
||||
Le solde ne retient que les dépenses imputées à ce lot par le compte rendu.
|
||||
Les charges de l'immeuble ({{ formatMontant(analyse.chiffres.depenses_immeuble_non_reparties) }}
|
||||
sur la période, tous lots confondus) n'y sont pas réparties : aucun document ne dit
|
||||
quelle part revient à quel lot.
|
||||
</p>
|
||||
|
||||
<!-- Chronologie -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Chronologie · {{ analyse.chronologie.length }} lignes</h2>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
v-model="depensesSeules"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 rounded border-gray-700 accent-blue-500 [color-scheme:dark]"
|
||||
/>
|
||||
<span class="text-sm text-gray-300">Dépenses seulement</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap custom-scrollbar">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Nature</th>
|
||||
<th>Libellé</th>
|
||||
<th>Fournisseur</th>
|
||||
<th class="text-right">Montant</th>
|
||||
<th class="text-right">Réglé</th>
|
||||
<th class="text-right">Impayé</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(ligne, index) in chronologieAffichee" :key="index">
|
||||
<td class="whitespace-nowrap">{{ formatDate(ligne.date) }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="badgeNature(ligne)">{{ libelleNature(ligne) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{{ ligne.libelle || '—' }}
|
||||
<div v-if="ligne.categorie" class="text-xs text-gray-500">{{ ligne.categorie }}</div>
|
||||
<div v-else-if="ligne.periode_debut" class="text-xs text-gray-500">
|
||||
{{ formatDate(ligne.periode_debut) }} → {{ formatDate(ligne.periode_fin) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-gray-400">{{ ligne.fournisseur || '—' }}</td>
|
||||
<td class="text-right" :class="ligne.nature === 'depense' ? 'text-red-400' : 'text-gray-300'">
|
||||
{{ formatMontantPrecis(ligne.montant) }}
|
||||
</td>
|
||||
<td class="text-right" :class="ligne.regle ? 'text-blue-400' : 'text-gray-500'">
|
||||
{{ ligne.regle == null ? '—' : formatMontantPrecis(ligne.regle) }}
|
||||
</td>
|
||||
<td class="text-right" :class="ligne.impaye ? 'text-amber-400' : 'text-gray-500'">
|
||||
{{ ligne.impaye == null ? '—' : formatMontantPrecis(ligne.impaye) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="!chronologieAffichee.length" class="empty-state">
|
||||
Aucune ligne pour ce lot.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Intervenants -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Intervenants · {{ analyse.intervenants.length }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap custom-scrollbar">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Entreprise</th>
|
||||
<th class="text-right">Interventions</th>
|
||||
<th class="text-right">Montant</th>
|
||||
<th>Dernière</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template
|
||||
v-for="intervenant in analyse.intervenants"
|
||||
:key="intervenant.fournisseur"
|
||||
>
|
||||
<tr
|
||||
class="cursor-pointer"
|
||||
@click="basculerDetail(intervenant.fournisseur)"
|
||||
>
|
||||
<td class="text-white">{{ intervenant.fournisseur }}</td>
|
||||
<td class="text-right">{{ intervenant.nb_interventions }}</td>
|
||||
<td class="text-right text-red-400">{{ formatMontantPrecis(intervenant.montant) }}</td>
|
||||
<td>{{ formatDate(intervenant.derniere_date) }}</td>
|
||||
<td class="text-right">
|
||||
<button class="btn btn-sm btn-secondary">
|
||||
{{ detailsOuverts[intervenant.fournisseur] ? 'Masquer' : 'Détail' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Le détail reprend les lignes déjà chargées : ce que la
|
||||
chronologie montre pour cette entreprise, sans nouvel
|
||||
appel ni second calcul qui pourrait diverger du total. -->
|
||||
<tr v-if="detailsOuverts[intervenant.fournisseur]" class="hover:bg-transparent">
|
||||
<td colspan="5" class="bg-gray-950 p-0">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(ligne, index) in interventions(intervenant.fournisseur)"
|
||||
:key="index"
|
||||
>
|
||||
<td class="whitespace-nowrap w-32">{{ formatDate(ligne.date) }}</td>
|
||||
<td>
|
||||
{{ ligne.libelle || '—' }}
|
||||
<div v-if="ligne.categorie" class="text-xs text-gray-500">
|
||||
{{ ligne.categorie }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-right text-red-400 w-32">
|
||||
{{ formatMontantPrecis(ligne.montant) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="!analyse.intervenants.length" class="empty-state">
|
||||
Aucune entreprise n'est intervenue sur ce lot.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Changer de lot recharge la fiche : sans cet état, la page se viderait
|
||||
en silence entre deux lots. -->
|
||||
<div v-else class="empty-state">
|
||||
<span class="spinner w-6 h-6"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { formatDate, formatMontant, formatMontantPrecis } from '../utils/format'
|
||||
|
||||
const API = import.meta.env.VITE_API_URL || ''
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const lots = ref([])
|
||||
const lotChoisi = ref(null)
|
||||
const analyse = ref(null)
|
||||
const chargement = ref(true)
|
||||
const depensesSeules = ref(false)
|
||||
const detailsOuverts = reactive({})
|
||||
|
||||
const CHAMPS_IDENTITE = [
|
||||
{ cle: 'type_effectif', libelle: 'Type' },
|
||||
{ cle: 'surface', libelle: 'Surface', unite: ' m²' },
|
||||
{ cle: 'etage', libelle: 'Étage' },
|
||||
{ cle: 'bat', libelle: 'Bâtiment' },
|
||||
{ cle: 'chauffage', libelle: 'Chauffage' },
|
||||
{ cle: 'dpe_classe', libelle: 'DPE' }
|
||||
]
|
||||
|
||||
function valeurIdentite(champ) {
|
||||
const valeur = analyse.value?.identite?.[champ.cle]
|
||||
if (valeur == null || valeur === '') return '—'
|
||||
return `${valeur}${champ.unite || ''}`
|
||||
}
|
||||
|
||||
// Palette reprise des autres tuiles de l'application : le facturé au vert des
|
||||
// recettes, l'encaissé au bleu des règlements, l'impayé à l'ambre des alertes,
|
||||
// la dépense au rouge du débit. Chaque valeur porte toujours une couleur : sans
|
||||
// classe, elle hériterait du noir et disparaîtrait sur le fond sombre.
|
||||
function couleurTaux(taux) {
|
||||
if (taux >= 95) return 'text-green-400'
|
||||
if (taux >= 80) return 'text-yellow-400'
|
||||
return 'text-red-400'
|
||||
}
|
||||
|
||||
const tuiles = computed(() => {
|
||||
const c = analyse.value.chiffres
|
||||
const rienFacture = c.facture <= 0
|
||||
|
||||
return [
|
||||
{
|
||||
libelle: 'Facturé',
|
||||
valeur: formatMontant(c.facture),
|
||||
classe: 'text-green-400',
|
||||
detail: 'reports exclus'
|
||||
},
|
||||
{
|
||||
libelle: 'Encaissé',
|
||||
valeur: formatMontant(c.encaisse),
|
||||
classe: 'text-blue-400'
|
||||
},
|
||||
{
|
||||
libelle: 'Restant dû',
|
||||
valeur: formatMontant(c.restant_du),
|
||||
classe: c.restant_du > 0 ? 'text-amber-400' : 'text-gray-400',
|
||||
detail: 'au dernier compte rendu'
|
||||
},
|
||||
{
|
||||
libelle: 'Recouvrement',
|
||||
// Sans rien de facturé, un taux de 100 % ferait passer pour sain un lot
|
||||
// qui ne facture plus rien tout en devant 49 000 € : mieux vaut ne pas
|
||||
// afficher de taux que d'en afficher un qui rassure à tort.
|
||||
valeur: rienFacture ? '—' : `${c.taux_recouvrement} %`,
|
||||
classe: rienFacture ? 'text-gray-500' : couleurTaux(c.taux_recouvrement),
|
||||
detail: rienFacture ? 'rien de facturé' : null
|
||||
},
|
||||
{
|
||||
libelle: 'Dépenses',
|
||||
valeur: formatMontant(c.depenses_debit - c.depenses_credit),
|
||||
classe: 'text-red-400',
|
||||
detail: `${c.nb_operations} opération${c.nb_operations > 1 ? 's' : ''}`
|
||||
},
|
||||
{
|
||||
libelle: 'Solde',
|
||||
valeur: formatMontant(c.solde),
|
||||
classe: c.solde < 0 ? 'text-red-400' : 'text-green-400',
|
||||
detail: 'hors charges communes'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
/** Nature d'une ligne : dépense, report de solde, ou recette de la période. */
|
||||
function libelleNature(ligne) {
|
||||
if (ligne.nature === 'depense') return ligne.tag || 'Dépense'
|
||||
return ligne.est_report ? 'Report' : 'Recette'
|
||||
}
|
||||
|
||||
function badgeNature(ligne) {
|
||||
if (ligne.nature === 'depense') return 'badge-danger'
|
||||
return ligne.est_report ? 'badge-neutral' : 'badge-success'
|
||||
}
|
||||
|
||||
// Interventions regroupées par entreprise, dans l'ordre de la chronologie dont
|
||||
// elles sortent. Le détail d'une entreprise est donc exactement le sous-ensemble
|
||||
// de lignes que le tableau du dessus montre déjà.
|
||||
const interventionsParFournisseur = computed(() => {
|
||||
const groupes = {}
|
||||
for (const ligne of analyse.value.chronologie) {
|
||||
if (ligne.nature !== 'depense' || !ligne.fournisseur) continue
|
||||
;(groupes[ligne.fournisseur] ??= []).push(ligne)
|
||||
}
|
||||
return groupes
|
||||
})
|
||||
|
||||
function interventions(fournisseur) {
|
||||
return interventionsParFournisseur.value[fournisseur] ?? []
|
||||
}
|
||||
|
||||
function basculerDetail(fournisseur) {
|
||||
detailsOuverts[fournisseur] = !detailsOuverts[fournisseur]
|
||||
}
|
||||
|
||||
const chronologieAffichee = computed(() =>
|
||||
depensesSeules.value
|
||||
? analyse.value.chronologie.filter((ligne) => ligne.nature === 'depense')
|
||||
: analyse.value.chronologie
|
||||
)
|
||||
|
||||
async function chargerLots() {
|
||||
const response = await fetch(`${API}/api/lots/referentiel`)
|
||||
lots.value = await response.json()
|
||||
}
|
||||
|
||||
async function chargerAnalyse(lotId) {
|
||||
analyse.value = null
|
||||
// Les dépliages appartiennent au lot affiché : gardés, une entreprise
|
||||
// présente sur deux lots (PPR par exemple) arriverait déjà ouverte.
|
||||
for (const fournisseur of Object.keys(detailsOuverts)) delete detailsOuverts[fournisseur]
|
||||
|
||||
const response = await fetch(`${API}/api/lots/${lotId}/analyse`)
|
||||
if (response.ok) analyse.value = await response.json()
|
||||
}
|
||||
|
||||
// L'URL porte le lot : une fiche se partage et se recharge sans repasser par
|
||||
// le sélecteur.
|
||||
watch(lotChoisi, (lotId) => {
|
||||
if (lotId == null) return
|
||||
if (String(lotId) !== route.params.id) router.replace(`/lots/${lotId}`)
|
||||
chargerAnalyse(lotId)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await chargerLots()
|
||||
const demande = Number(route.params.id)
|
||||
lotChoisi.value = lots.value.some((lot) => lot.id === demande)
|
||||
? demande
|
||||
: (lots.value[0]?.id ?? null)
|
||||
} finally {
|
||||
chargement.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -4,6 +4,8 @@ import ExtractPage from './pages/ExtractPage.vue'
|
||||
import AnalyticsPage from './pages/AnalyticsPage.vue'
|
||||
import RevenusPage from './pages/RevenusPage.vue'
|
||||
import DocumentsPage from './pages/DocumentsPage.vue'
|
||||
import LogementsPage from './pages/LogementsPage.vue'
|
||||
import LotPage from './pages/LotPage.vue'
|
||||
import EditDocumentPage from './pages/EditDocumentPage.vue'
|
||||
import ReExtractionPage from './pages/ReExtractionPage.vue'
|
||||
import ConfigPage from './pages/ConfigPage.vue'
|
||||
@@ -55,6 +57,18 @@ const routes = [
|
||||
name: 'documents',
|
||||
component: DocumentsPage
|
||||
},
|
||||
{
|
||||
path: '/logements',
|
||||
name: 'logements',
|
||||
component: LogementsPage
|
||||
},
|
||||
// Le lot vit dans l'URL : une fiche se partage et survit au rechargement.
|
||||
// `/lots` sans identifiant ouvre le premier lot plutôt qu'une page vide.
|
||||
{
|
||||
path: '/lots/:id?',
|
||||
name: 'lot',
|
||||
component: LotPage
|
||||
},
|
||||
{
|
||||
path: '/documents/:id/edit',
|
||||
name: 'edit-document',
|
||||
|
||||
@@ -40,6 +40,13 @@
|
||||
@apply text-sm text-gray-400 mt-1;
|
||||
}
|
||||
|
||||
/* --- Navigation laterale ---------------------------------------------- */
|
||||
|
||||
/* Entree de menu : la couleur (actif / inactif) est posee par App.vue. */
|
||||
.nav-link {
|
||||
@apply flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg transition-colors;
|
||||
}
|
||||
|
||||
/* --- Cartes ----------------------------------------------------------- */
|
||||
|
||||
.card {
|
||||
@@ -112,6 +119,15 @@
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
/* Champ de saisie dans une cellule de tableau. Bordure invisible au repos :
|
||||
une grille de 14 colonnes encadrees serait illisible, alors qu'un tableau
|
||||
de saisie doit d'abord se lire. */
|
||||
.input-cell {
|
||||
@apply bg-transparent border border-transparent rounded px-2 py-1 text-sm text-white
|
||||
placeholder-gray-600 hover:border-gray-700 focus:outline-none focus:border-blue-500
|
||||
focus:bg-gray-950 transition-colors [color-scheme:dark];
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
@apply text-xs text-gray-500 mt-1;
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
167
frontend/tests/colonnesReferentiel.test.js
Normal file
167
frontend/tests/colonnesReferentiel.test.js
Normal file
@@ -0,0 +1,167 @@
|
||||
// Les filtres de colonnes décident de ce que l'utilisateur voit : un filtre trop
|
||||
// zélé masque un lot sans rien dire, ce qui se remarque d'autant moins que le
|
||||
// tableau reste plausible. Ils sont donc testés cas par cas.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
COLONNES,
|
||||
ETAT_INUTILISES,
|
||||
ETAT_UTILISES,
|
||||
TYPE_EN_DESACCORD,
|
||||
VIDE,
|
||||
comparer,
|
||||
groupesFiltre,
|
||||
passeFiltre,
|
||||
valeursDistinctes,
|
||||
} from '../src/components/referentiel/colonnes.js'
|
||||
|
||||
const colonne = (cle) => COLONNES.find((c) => c.cle === cle)
|
||||
|
||||
function lot(numero, caracteristiques = null, extra = {}) {
|
||||
return {
|
||||
id: Number(numero),
|
||||
numero,
|
||||
immeuble_id: 1,
|
||||
immeuble_code: '33689020',
|
||||
immeuble_denomination: 'Servient',
|
||||
type_effectif: caracteristiques?.type ?? null,
|
||||
type_ecart: false,
|
||||
nb_revenus: 0,
|
||||
nb_depenses: 0,
|
||||
caracteristiques,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
// Un immeuble réduit : un local décrit, un appartement décrit, un lot vierge.
|
||||
const LOTS = [
|
||||
lot('01', {
|
||||
bat: 'Rue',
|
||||
etage: 'RC',
|
||||
type: 'Loc. Commercial',
|
||||
surface: 148,
|
||||
surface_impots: 146,
|
||||
delta_surface: -2,
|
||||
chauffage: 'Électrique',
|
||||
dpe_classe: 'C',
|
||||
dpe_echeance: '2031-02-08',
|
||||
numero_fiscal: '690123456789',
|
||||
}, { nb_revenus: 5 }),
|
||||
lot('02', {
|
||||
bat: 'Cour',
|
||||
etage: '1',
|
||||
type: 'Appartement T3',
|
||||
surface: 62,
|
||||
surface_impots: 62,
|
||||
delta_surface: 0,
|
||||
chauffage: 'Gaz',
|
||||
dpe_classe: 'F',
|
||||
dpe_echeance: '2020-01-01',
|
||||
}, { nb_revenus: 3, type_ecart: true }),
|
||||
lot('0003'),
|
||||
]
|
||||
|
||||
const filtrer = (cle, saisie) => LOTS.filter((l) => passeFiltre(l, colonne(cle), saisie)).map((l) => l.numero)
|
||||
|
||||
describe('filtres de colonne', () => {
|
||||
it('ne masque rien tant qu’aucune valeur n’est saisie', () => {
|
||||
expect(filtrer('chauffage', '')).toEqual(['01', '02', '0003'])
|
||||
})
|
||||
|
||||
it('filtre sur une valeur exacte de liste', () => {
|
||||
expect(filtrer('dpe_classe', 'F')).toEqual(['02'])
|
||||
})
|
||||
|
||||
it('isole les lots dont la colonne est vide', () => {
|
||||
expect(filtrer('chauffage', VIDE)).toEqual(['0003'])
|
||||
})
|
||||
|
||||
it('isole les types en désaccord avec le PDF', () => {
|
||||
expect(filtrer('type', TYPE_EN_DESACCORD)).toEqual(['02'])
|
||||
})
|
||||
|
||||
it('compare les nombres au lieu de comparer leur écriture', () => {
|
||||
// '100' contient '10', mais 62 n'est pas > 100 : une recherche textuelle
|
||||
// renverrait ici les deux lots décrits.
|
||||
expect(filtrer('surface', '> 100')).toEqual(['01'])
|
||||
expect(filtrer('surface', '<=62')).toEqual(['02'])
|
||||
})
|
||||
|
||||
it('sait isoler un écart de surface non nul', () => {
|
||||
expect(filtrer('delta_surface', '!=0')).toEqual(['01'])
|
||||
})
|
||||
|
||||
it('ne masque rien sur une saisie numérique incomplète', () => {
|
||||
expect(filtrer('surface', '>')).toEqual(['01', '02', '0003'])
|
||||
})
|
||||
|
||||
it('ignore casse et accents dans les filtres texte', () => {
|
||||
expect(filtrer('numero_fiscal', '6901')).toEqual(['01'])
|
||||
})
|
||||
|
||||
it('filtre par immeuble sur son nom d’usage', () => {
|
||||
const autre = { ...lot('01'), immeuble_id: 2, immeuble_code: 'M', immeuble_denomination: null }
|
||||
const parc = [...LOTS, autre]
|
||||
|
||||
const servient = parc.filter((l) => passeFiltre(l, colonne('immeuble'), 'Servient'))
|
||||
// Sans nom d'usage, l'immeuble reste filtrable par son code.
|
||||
const marietton = parc.filter((l) => passeFiltre(l, colonne('immeuble'), 'M'))
|
||||
|
||||
expect(servient).toHaveLength(3)
|
||||
expect(marietton).toEqual([autre])
|
||||
})
|
||||
|
||||
it('sépare les lots rattachés à un compte rendu des autres', () => {
|
||||
expect(filtrer('numero', ETAT_INUTILISES)).toEqual(['0003'])
|
||||
expect(filtrer('numero', ETAT_UTILISES)).toEqual(['01', '02'])
|
||||
})
|
||||
|
||||
it('filtre aussi sur un numéro de porte précis', () => {
|
||||
expect(filtrer('numero', '02')).toEqual(['02'])
|
||||
})
|
||||
|
||||
it('classe les DPE selon leur péremption', () => {
|
||||
expect(filtrer('dpe_echeance', 'perime')).toEqual(['02'])
|
||||
expect(filtrer('dpe_echeance', 'valide')).toEqual(['01'])
|
||||
expect(filtrer('dpe_echeance', 'absent')).toEqual(['0003'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('listes de choix', () => {
|
||||
it('ne propose que les valeurs présentes, le vide en dernier', () => {
|
||||
// Ordre alphabétique français : « É » se classe avec « E », donc avant « G ».
|
||||
expect(valeursDistinctes(LOTS, colonne('chauffage'))).toEqual(['Électrique', 'Gaz', VIDE])
|
||||
})
|
||||
|
||||
it('propose le désaccord à part des types, quand il y en a un', () => {
|
||||
const groupes = groupesFiltre(LOTS, colonne('type'))
|
||||
|
||||
expect(groupes[0].options).toEqual([TYPE_EN_DESACCORD])
|
||||
expect(groupes[1].options).toContain('Appartement T3')
|
||||
})
|
||||
|
||||
it('ne propose le contrôle des types que s’il a lieu d’être', () => {
|
||||
const sansDesaccord = LOTS.map((l) => ({ ...l, type_ecart: false }))
|
||||
|
||||
expect(groupesFiltre(sansDesaccord, colonne('type'))).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('propose les numéros de porte en plus du rattachement', () => {
|
||||
const [rattachement, portes] = groupesFiltre(LOTS, colonne('numero'))
|
||||
|
||||
expect(rattachement.options).toEqual([ETAT_UTILISES, ETAT_INUTILISES])
|
||||
expect(portes.options).toEqual(['01', '02', '0003'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('tri', () => {
|
||||
it('trie les nombres comme des nombres', () => {
|
||||
const tries = [...LOTS].sort(comparer(colonne('surface'), 'desc')).map((l) => l.numero)
|
||||
expect(tries).toEqual(['01', '02', '0003'])
|
||||
})
|
||||
|
||||
it('renvoie les cases vides en fin, dans les deux sens', () => {
|
||||
const asc = [...LOTS].sort(comparer(colonne('surface'), 'asc')).map((l) => l.numero)
|
||||
expect(asc).toEqual(['02', '01', '0003'])
|
||||
})
|
||||
})
|
||||
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.
|
||||
|
||||
#define AppName "Plesna Gérance"
|
||||
#define AppVersion "0.1.0"
|
||||
#define AppVersion "0.1.1"
|
||||
#define AppPublisher "Plesna"
|
||||
#define AppExeName "PlesnaGerance.exe"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "plesna-gerance"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
description = "Extracteur de comptes rendus de gérance Oralia/ICS"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
|
||||
182
scripts/release.py
Normal file
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.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.1.1"
|
||||
|
||||
from .extractor import extract_compte_rendu
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ from .routes import (
|
||||
documents_router,
|
||||
extraction_router,
|
||||
ia_router,
|
||||
lot_analyse_router,
|
||||
referentiel_router,
|
||||
revenus_router,
|
||||
tags_router,
|
||||
)
|
||||
@@ -52,6 +54,8 @@ app.include_router(tags_router)
|
||||
app.include_router(analytics_router)
|
||||
app.include_router(dashboard_router)
|
||||
app.include_router(revenus_router)
|
||||
app.include_router(referentiel_router)
|
||||
app.include_router(lot_analyse_router)
|
||||
if FEATURE_IA:
|
||||
app.include_router(ia_router)
|
||||
app.include_router(config_router)
|
||||
|
||||
@@ -6,6 +6,8 @@ from .dashboard import router as dashboard_router
|
||||
from .documents import router as documents_router
|
||||
from .extraction import router as extraction_router
|
||||
from .ia import router as ia_router
|
||||
from .lot_analyse import router as lot_analyse_router
|
||||
from .referentiel import router as referentiel_router
|
||||
from .revenus import router as revenus_router
|
||||
from .tags import router as tags_router
|
||||
|
||||
@@ -16,6 +18,8 @@ __all__ = [
|
||||
"analytics_router",
|
||||
"dashboard_router",
|
||||
"revenus_router",
|
||||
"referentiel_router",
|
||||
"lot_analyse_router",
|
||||
"ia_router",
|
||||
"config_router",
|
||||
]
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from ...database import get_session
|
||||
from ...database.models import Depense, Document, Immeuble, Lot, Tag
|
||||
from ...services.referentiel import TYPE_LOT_EFFECTIF, joindre_fiche
|
||||
from ..schemas.models import (
|
||||
CategorySummary,
|
||||
DepenseDetail,
|
||||
@@ -58,6 +59,7 @@ async def list_immeubles(
|
||||
ImmeubleResponse(
|
||||
id=row.Immeuble.id,
|
||||
code=row.Immeuble.code,
|
||||
denomination=row.Immeuble.denomination,
|
||||
adresse=row.Immeuble.adresse,
|
||||
ville=row.Immeuble.ville,
|
||||
code_postal=row.Immeuble.code_postal,
|
||||
@@ -77,11 +79,15 @@ async def list_lots(
|
||||
|
||||
- **immeuble_id**: ID de l'immeuble pour filtrer (optionnel)
|
||||
"""
|
||||
stmt = (
|
||||
select(Lot, Immeuble.code.label("immeuble_code"))
|
||||
.join(Immeuble, Lot.immeuble_id == Immeuble.id)
|
||||
.order_by(Immeuble.code, Lot.numero)
|
||||
)
|
||||
stmt = joindre_fiche(
|
||||
select(
|
||||
Lot.id,
|
||||
Lot.numero,
|
||||
Lot.immeuble_id,
|
||||
TYPE_LOT_EFFECTIF.label("type"),
|
||||
Immeuble.code.label("immeuble_code"),
|
||||
).join(Immeuble, Lot.immeuble_id == Immeuble.id)
|
||||
).order_by(Immeuble.code, Lot.numero)
|
||||
|
||||
if immeuble_id is not None:
|
||||
stmt = stmt.where(Lot.immeuble_id == immeuble_id)
|
||||
@@ -91,10 +97,10 @@ async def list_lots(
|
||||
|
||||
return [
|
||||
LotResponse(
|
||||
id=row.Lot.id,
|
||||
numero=row.Lot.numero,
|
||||
type=row.Lot.type,
|
||||
immeuble_id=row.Lot.immeuble_id,
|
||||
id=row.id,
|
||||
numero=row.numero,
|
||||
type=row.type,
|
||||
immeuble_id=row.immeuble_id,
|
||||
immeuble_code=row.immeuble_code,
|
||||
)
|
||||
for row in rows
|
||||
|
||||
363
src/plesna_gerance/api/routes/lot_analyse.py
Normal file
363
src/plesna_gerance/api/routes/lot_analyse.py
Normal file
@@ -0,0 +1,363 @@
|
||||
"""Vue d'un lot : ce que les comptes rendus disent de lui, et rien de plus.
|
||||
|
||||
Les autres pages agrègent le parc ; celle-ci descend à un lot et remet ses
|
||||
lignes bout à bout — loyers facturés, règlements, interventions — dans l'ordre
|
||||
où les comptes rendus les ont portées.
|
||||
|
||||
Deux limites sont assumées plutôt que contournées :
|
||||
|
||||
- **les dépenses d'un lot sont celles que le compte rendu lui impute**, pas une
|
||||
quote-part des charges d'immeuble. Aucune clé de répartition n'existe en base
|
||||
(ni tantièmes, ni surfaces complètes) : en inventer une donnerait des montants
|
||||
qu'aucun document ne justifie. Le solde d'un lot n'est donc pas un résultat
|
||||
net, et `depenses_immeuble_non_reparties` rappelle ce qui reste dehors ;
|
||||
- **les lignes sont rendues telles qu'extraites**, sans regroupement ni
|
||||
dédoublonnage. Un acompte et son solde restent deux lignes, parce que le
|
||||
compte rendu les porte ainsi.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...database import get_session
|
||||
from ...database.models import (
|
||||
Depense,
|
||||
Document,
|
||||
Immeuble,
|
||||
Locataire,
|
||||
Lot,
|
||||
Revenu,
|
||||
Tag,
|
||||
)
|
||||
from ...services.referentiel import type_effectif
|
||||
from ...services.revenus_query import (
|
||||
TYPE_LIGNE_REPORT,
|
||||
flux_par,
|
||||
restant_du_par,
|
||||
taux_de_recouvrement,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["lots"])
|
||||
|
||||
|
||||
class LotIdentite(BaseModel):
|
||||
"""Qui est ce lot : son rattachement, et ce que sa fiche en dit."""
|
||||
|
||||
id: int
|
||||
numero: str
|
||||
immeuble_id: int
|
||||
immeuble_code: str | None
|
||||
immeuble_denomination: str | None
|
||||
type_effectif: str | None
|
||||
|
||||
# Fiche saisie : `null` tant qu'elle ne l'est pas. La page montre le trou
|
||||
# plutôt que de le combler.
|
||||
surface: float | None = None
|
||||
etage: str | None = None
|
||||
bat: str | None = None
|
||||
chauffage: str | None = None
|
||||
dpe_classe: str | None = None
|
||||
|
||||
#: Noms portés par les comptes rendus. Les dates d'entrée et de sortie ne
|
||||
#: sont pas extraites : l'ordre n'a pas de sens ici.
|
||||
locataires: list[str] = []
|
||||
|
||||
|
||||
class LotChiffres(BaseModel):
|
||||
"""Totaux du lot sur tout son historique."""
|
||||
|
||||
# Recettes. `facture` exclut les reports de solde, `encaisse` les inclut :
|
||||
# un règlement de vieille dette est bien un encaissement de la période.
|
||||
facture: float = 0.0
|
||||
encaisse: float = 0.0
|
||||
loyers: float = 0.0
|
||||
provisions: float = 0.0
|
||||
#: Dette du lot au dernier compte rendu de son immeuble (photo, non cumulée).
|
||||
restant_du: float = 0.0
|
||||
taux_recouvrement: float = 100.0
|
||||
|
||||
# Dépenses imputées au lot par le compte rendu.
|
||||
depenses_debit: float = 0.0
|
||||
depenses_credit: float = 0.0
|
||||
depenses_deductible: float = 0.0
|
||||
depenses_locatif: float = 0.0
|
||||
nb_operations: int = 0
|
||||
|
||||
#: Encaissé moins décaissé sur le lot. Pas un résultat : les charges
|
||||
#: d'immeuble n'y sont pas (voir le module).
|
||||
solde: float = 0.0
|
||||
|
||||
#: Charges de l'immeuble non imputées à un lot, sur toute la période.
|
||||
#: Affiché comme contexte, jamais ventilé.
|
||||
depenses_immeuble_non_reparties: float = 0.0
|
||||
|
||||
|
||||
class LigneChronologie(BaseModel):
|
||||
"""Une ligne de compte rendu concernant le lot, recette ou dépense."""
|
||||
|
||||
date: date
|
||||
document_id: int
|
||||
nature: str # "recette" | "depense"
|
||||
libelle: str
|
||||
categorie: str | None = None
|
||||
|
||||
# Dépense
|
||||
fournisseur: str | None = None
|
||||
tag: str | None = None
|
||||
|
||||
# Recette
|
||||
type_ligne: str | None = None
|
||||
periode_debut: date | None = None
|
||||
periode_fin: date | None = None
|
||||
regle: float | None = None
|
||||
impaye: float | None = None
|
||||
|
||||
#: Montant de la ligne : total facturé pour une recette, débit net de crédit
|
||||
#: pour une dépense.
|
||||
montant: float = 0.0
|
||||
#: Report du solde antérieur : déjà compté par un compte rendu précédent, il
|
||||
#: s'affiche mais n'entre dans aucun cumul.
|
||||
est_report: bool = False
|
||||
|
||||
|
||||
class Intervenant(BaseModel):
|
||||
"""Une entreprise intervenue sur le lot."""
|
||||
|
||||
fournisseur: str
|
||||
nb_interventions: int
|
||||
#: Somme des lignes du fournisseur, crédit déduit — le total que le détail
|
||||
#: déplié doit retrouver ligne à ligne.
|
||||
montant: float
|
||||
derniere_date: date | None = None
|
||||
|
||||
|
||||
class LotAnalyseResponse(BaseModel):
|
||||
"""Fiche complète d'un lot."""
|
||||
|
||||
identite: LotIdentite
|
||||
chiffres: LotChiffres
|
||||
chronologie: list[LigneChronologie]
|
||||
intervenants: list[Intervenant]
|
||||
|
||||
|
||||
def _identite(session: Session, lot: Lot) -> LotIdentite:
|
||||
"""Identité du lot, fiche saisie comprise quand elle existe."""
|
||||
immeuble = session.get(Immeuble, lot.immeuble_id)
|
||||
fiche = lot.caracteristiques
|
||||
|
||||
noms = session.execute(
|
||||
select(Locataire.nom).where(Locataire.lot_id == lot.id).order_by(Locataire.nom)
|
||||
).scalars()
|
||||
|
||||
return LotIdentite(
|
||||
id=lot.id,
|
||||
numero=lot.numero,
|
||||
immeuble_id=lot.immeuble_id,
|
||||
immeuble_code=immeuble.code if immeuble else None,
|
||||
immeuble_denomination=immeuble.denomination if immeuble else None,
|
||||
type_effectif=type_effectif(lot),
|
||||
surface=fiche.surface if fiche else None,
|
||||
etage=fiche.etage if fiche else None,
|
||||
bat=fiche.bat if fiche else None,
|
||||
chauffage=fiche.chauffage if fiche else None,
|
||||
dpe_classe=fiche.dpe_classe if fiche else None,
|
||||
locataires=list(noms),
|
||||
)
|
||||
|
||||
|
||||
def _chiffres(session: Session, lot: Lot) -> LotChiffres:
|
||||
"""Totaux du lot, en réutilisant les règles flux/stock des revenus.
|
||||
|
||||
Passer par `flux_par` et `restant_du_par` plutôt que de resommer ici : ces
|
||||
fonctions portent la distinction entre ce qui se cumule et ce qui est une
|
||||
photo, et la rejouer à la main la ferait diverger de la page Recettes.
|
||||
"""
|
||||
flux = flux_par(Revenu.lot_id)
|
||||
dette = restant_du_par(Revenu.lot_id)
|
||||
|
||||
recettes = session.execute(
|
||||
select(
|
||||
flux.c.facture,
|
||||
flux.c.encaisse,
|
||||
flux.c.facture_regle,
|
||||
flux.c.loyers,
|
||||
flux.c.provisions,
|
||||
).where(flux.c.cle == lot.id)
|
||||
).first()
|
||||
|
||||
restant_du = session.execute(
|
||||
select(dette.c.restant_du).where(dette.c.cle == lot.id)
|
||||
).scalar()
|
||||
|
||||
depenses = session.execute(
|
||||
select(
|
||||
func.coalesce(func.sum(Depense.debit), 0.0),
|
||||
func.coalesce(func.sum(Depense.credit), 0.0),
|
||||
func.coalesce(func.sum(Depense.deductible), 0.0),
|
||||
func.coalesce(func.sum(Depense.locatif), 0.0),
|
||||
func.count(Depense.id),
|
||||
).where(Depense.lot_id == lot.id)
|
||||
).one()
|
||||
debit, credit, deductible, locatif, nb_operations = depenses
|
||||
|
||||
# Charges de l'immeuble laissées hors des lots, pour situer le solde.
|
||||
commun = session.execute(
|
||||
select(func.coalesce(func.sum(Depense.debit), 0.0)).where(
|
||||
Depense.immeuble_id == lot.immeuble_id, Depense.lot_id.is_(None)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
encaisse = (recettes.encaisse if recettes else 0.0) or 0.0
|
||||
|
||||
return LotChiffres(
|
||||
facture=(recettes.facture if recettes else 0.0) or 0.0,
|
||||
encaisse=encaisse,
|
||||
loyers=(recettes.loyers if recettes else 0.0) or 0.0,
|
||||
provisions=(recettes.provisions if recettes else 0.0) or 0.0,
|
||||
restant_du=restant_du or 0.0,
|
||||
taux_recouvrement=taux_de_recouvrement(
|
||||
recettes.facture if recettes else None,
|
||||
recettes.facture_regle if recettes else None,
|
||||
),
|
||||
depenses_debit=debit,
|
||||
depenses_credit=credit,
|
||||
depenses_deductible=deductible,
|
||||
depenses_locatif=locatif,
|
||||
nb_operations=nb_operations,
|
||||
solde=round(encaisse - debit + credit, 2),
|
||||
depenses_immeuble_non_reparties=commun,
|
||||
)
|
||||
|
||||
|
||||
def _libelle_recette(revenu: Revenu) -> str:
|
||||
"""Ce que la ligne de recette dit d'elle-même.
|
||||
|
||||
Le libellé d'une ligne « divers » porte le motif réel (régularisation,
|
||||
ordures ménagères…) : le préférer au type générique, qui n'apprendrait rien.
|
||||
"""
|
||||
return revenu.divers_libelle or revenu.type_ligne
|
||||
|
||||
|
||||
def _chronologie(session: Session, lot: Lot) -> list[LigneChronologie]:
|
||||
"""Recettes et dépenses du lot, remises dans l'ordre des comptes rendus."""
|
||||
lignes: list[LigneChronologie] = []
|
||||
|
||||
revenus = session.execute(
|
||||
select(Revenu, Document.date)
|
||||
.join(Document, Revenu.document_id == Document.id)
|
||||
.where(Revenu.lot_id == lot.id)
|
||||
).all()
|
||||
|
||||
for revenu, date_document in revenus:
|
||||
lignes.append(
|
||||
LigneChronologie(
|
||||
date=date_document,
|
||||
document_id=revenu.document_id,
|
||||
nature="recette",
|
||||
libelle=_libelle_recette(revenu),
|
||||
type_ligne=revenu.type_ligne,
|
||||
periode_debut=revenu.periode_debut,
|
||||
periode_fin=revenu.periode_fin,
|
||||
montant=revenu.total or 0.0,
|
||||
regle=revenu.regles or 0.0,
|
||||
impaye=revenu.impayes or 0.0,
|
||||
est_report=revenu.type_ligne == TYPE_LIGNE_REPORT,
|
||||
)
|
||||
)
|
||||
|
||||
depenses = session.execute(
|
||||
select(Depense, Document.date, Tag.nom)
|
||||
.join(Document, Depense.document_id == Document.id)
|
||||
.outerjoin(Tag, Tag.id == Depense.tag_id)
|
||||
.where(Depense.lot_id == lot.id)
|
||||
).all()
|
||||
|
||||
for depense, date_document, tag in depenses:
|
||||
lignes.append(
|
||||
LigneChronologie(
|
||||
date=date_document,
|
||||
document_id=depense.document_id,
|
||||
nature="depense",
|
||||
libelle=depense.description or depense.sous_categorie or "",
|
||||
categorie=depense.sous_categorie,
|
||||
fournisseur=depense.fournisseur,
|
||||
tag=tag,
|
||||
montant=round((depense.debit or 0.0) - (depense.credit or 0.0), 2),
|
||||
)
|
||||
)
|
||||
|
||||
# Tri en Python : les deux sources sont déjà chargées et un lot en porte
|
||||
# quelques dizaines de lignes. Les recettes d'abord à date égale, parce
|
||||
# qu'un compte rendu présente la situation locative avant les opérations.
|
||||
lignes.sort(key=lambda ligne: (ligne.date, ligne.nature != "recette"))
|
||||
return lignes
|
||||
|
||||
|
||||
def _intervenants(session: Session, lot: Lot) -> list[Intervenant]:
|
||||
"""Entreprises intervenues sur le lot, la plus engagée en tête.
|
||||
|
||||
Simple regroupement sur le fournisseur porté par chaque opération : rien
|
||||
n'est rapproché ni déduit au-delà de ce que la colonne contient.
|
||||
|
||||
Le montant est net du crédit, comme celui de chaque ligne de la
|
||||
chronologie : déplier une entreprise doit retrouver son total, pas un autre
|
||||
chiffre. Un avoir (« Remise état des lieux ») rend d'ailleurs ce montant
|
||||
négatif, ce qui est bien ce que le compte rendu porte.
|
||||
"""
|
||||
montant_net = func.coalesce(
|
||||
func.sum(
|
||||
func.coalesce(Depense.debit, 0.0) - func.coalesce(Depense.credit, 0.0)
|
||||
),
|
||||
0.0,
|
||||
)
|
||||
|
||||
rows = session.execute(
|
||||
select(
|
||||
Depense.fournisseur,
|
||||
func.count(Depense.id),
|
||||
montant_net,
|
||||
func.max(Document.date),
|
||||
)
|
||||
.join(Document, Depense.document_id == Document.id)
|
||||
.where(Depense.lot_id == lot.id, Depense.fournisseur.is_not(None))
|
||||
.group_by(Depense.fournisseur)
|
||||
.order_by(montant_net.desc())
|
||||
).all()
|
||||
|
||||
return [
|
||||
Intervenant(
|
||||
fournisseur=fournisseur,
|
||||
nb_interventions=nb,
|
||||
montant=montant,
|
||||
derniere_date=derniere_date,
|
||||
)
|
||||
for fournisseur, nb, montant, derniere_date in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/lots/{lot_id}/analyse", response_model=LotAnalyseResponse)
|
||||
async def analyser_lot(
|
||||
lot_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
) -> LotAnalyseResponse:
|
||||
"""Tout ce que les comptes rendus portent sur un lot.
|
||||
|
||||
- **lot_id**: ID du lot
|
||||
|
||||
Sans borne de période : l'historique est court et le montrer entier évite
|
||||
qu'un filtre par défaut cache des opérations sans le dire.
|
||||
"""
|
||||
lot = session.get(Lot, lot_id)
|
||||
if lot is None:
|
||||
raise HTTPException(status_code=404, detail="Lot introuvable.")
|
||||
|
||||
return LotAnalyseResponse(
|
||||
identite=_identite(session, lot),
|
||||
chiffres=_chiffres(session, lot),
|
||||
chronologie=_chronologie(session, lot),
|
||||
intervenants=_intervenants(session, lot),
|
||||
)
|
||||
248
src/plesna_gerance/api/routes/referentiel.py
Normal file
248
src/plesna_gerance/api/routes/referentiel.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""Référentiel des logements — caractéristiques saisies à la main.
|
||||
|
||||
Ces données ne viennent pas des PDF : elles décrivent le bien (surface, étage,
|
||||
DPE, chauffage) et donnent au référentiel une source de vérité indépendante de
|
||||
l'extraction.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...database import get_session
|
||||
from ...database.models import Depense, Immeuble, Lot, LotCaracteristiques, Revenu
|
||||
from ...services.referentiel import type_effectif
|
||||
from ...utils.logements import delta_surface, dpe_echeance, type_en_ecart
|
||||
from ..schemas.models import (
|
||||
CaracteristiquesBody,
|
||||
CaracteristiquesResponse,
|
||||
ImmeubleBody,
|
||||
ImmeubleResponse,
|
||||
LotReferentielResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["referentiel"])
|
||||
|
||||
#: Champs de la fiche, dans l'ordre de saisie. Sert à recopier le corps de la
|
||||
#: requête vers le modèle sans énumérer les champs à chaque fois.
|
||||
CHAMPS_FICHE = tuple(CaracteristiquesBody.model_fields)
|
||||
|
||||
|
||||
def _caracteristiques_response(
|
||||
fiche: LotCaracteristiques | None,
|
||||
) -> CaracteristiquesResponse | None:
|
||||
"""Fiche augmentée de ses valeurs dérivées, ou None si elle n'existe pas."""
|
||||
if fiche is None:
|
||||
return None
|
||||
|
||||
return CaracteristiquesResponse(
|
||||
**{champ: getattr(fiche, champ) for champ in CHAMPS_FICHE},
|
||||
dpe_echeance=dpe_echeance(fiche.dpe_date_realisation),
|
||||
delta_surface=delta_surface(fiche.surface, fiche.surface_impots),
|
||||
updated_at=fiche.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _lot_response(
|
||||
lot: Lot,
|
||||
immeuble_code: str | None,
|
||||
nb_revenus: int,
|
||||
nb_depenses: int,
|
||||
immeuble_denomination: str | None = None,
|
||||
) -> LotReferentielResponse:
|
||||
"""Assemble la ligne de référentiel d'un lot."""
|
||||
fiche = lot.caracteristiques
|
||||
|
||||
return LotReferentielResponse(
|
||||
id=lot.id,
|
||||
numero=lot.numero,
|
||||
immeuble_id=lot.immeuble_id,
|
||||
immeuble_code=immeuble_code,
|
||||
immeuble_denomination=immeuble_denomination,
|
||||
type_extrait=lot.type,
|
||||
type_effectif=type_effectif(lot),
|
||||
type_ecart=type_en_ecart(lot.type, fiche.type if fiche else None),
|
||||
caracteristiques=_caracteristiques_response(fiche),
|
||||
nb_revenus=nb_revenus,
|
||||
nb_depenses=nb_depenses,
|
||||
)
|
||||
|
||||
|
||||
def _requete_lignes():
|
||||
"""Requête des lots avec le nombre de revenus et de dépenses rattachés.
|
||||
|
||||
Partagée par la liste et l'enregistrement : une ligne renvoyée après
|
||||
écriture doit être comptée comme celle du tableau, sinon un lot bien occupé
|
||||
se met à passer pour inutilisé dès qu'on le décrit.
|
||||
|
||||
Sous-requêtes corrélées plutôt que des jointures : compter revenus et
|
||||
dépenses dans la même jointure multiplierait les lignes entre elles.
|
||||
"""
|
||||
nb_revenus = (
|
||||
select(func.count(Revenu.id))
|
||||
.where(Revenu.lot_id == Lot.id)
|
||||
.correlate(Lot)
|
||||
.scalar_subquery()
|
||||
)
|
||||
nb_depenses = (
|
||||
select(func.count(Depense.id))
|
||||
.where(Depense.lot_id == Lot.id)
|
||||
.correlate(Lot)
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
return select(Lot, nb_revenus.label("nb_revenus"), nb_depenses.label("nb_depenses"))
|
||||
|
||||
|
||||
@router.get("/lots/referentiel", response_model=list[LotReferentielResponse])
|
||||
async def list_lots_referentiel(
|
||||
immeuble_id: int | None = Query(None, description="Filtrer par immeuble"),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[LotReferentielResponse]:
|
||||
"""Liste les lots avec leur fiche de caractéristiques.
|
||||
|
||||
- **immeuble_id**: ID de l'immeuble pour restreindre la liste (optionnel)
|
||||
|
||||
Tout le parc par défaut : le tableau de saisie porte une colonne immeuble,
|
||||
et comparer deux immeubles au m² n'a de sens que s'ils s'affichent ensemble.
|
||||
|
||||
Les lots sans fiche sont renvoyés avec `caracteristiques` à `null` : le
|
||||
tableau doit montrer les lignes vides autant que les remplies.
|
||||
"""
|
||||
if immeuble_id is not None and session.get(Immeuble, immeuble_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Immeuble introuvable.")
|
||||
|
||||
stmt = (
|
||||
_requete_lignes()
|
||||
.add_columns(Immeuble.code, Immeuble.denomination)
|
||||
.join(Immeuble, Lot.immeuble_id == Immeuble.id)
|
||||
.order_by(Immeuble.code, Lot.numero)
|
||||
)
|
||||
if immeuble_id is not None:
|
||||
stmt = stmt.where(Lot.immeuble_id == immeuble_id)
|
||||
|
||||
return [
|
||||
_lot_response(
|
||||
row.Lot,
|
||||
row.code,
|
||||
immeuble_denomination=row.denomination,
|
||||
nb_revenus=row.nb_revenus or 0,
|
||||
nb_depenses=row.nb_depenses or 0,
|
||||
)
|
||||
for row in session.execute(stmt).all()
|
||||
]
|
||||
|
||||
|
||||
@router.put("/lots/{lot_id}/caracteristiques", response_model=LotReferentielResponse)
|
||||
async def upsert_caracteristiques(
|
||||
lot_id: int,
|
||||
body: CaracteristiquesBody,
|
||||
session: Session = Depends(get_session),
|
||||
) -> LotReferentielResponse:
|
||||
"""Enregistre la fiche d'un lot, en la créant si elle n'existe pas encore.
|
||||
|
||||
- **lot_id**: ID du lot
|
||||
|
||||
Le corps décrit la fiche complète : un champ omis ou vidé efface la valeur
|
||||
précédente, pour qu'une correction dans le tableau ne laisse pas de reste.
|
||||
"""
|
||||
lot = session.get(Lot, lot_id)
|
||||
if lot is None:
|
||||
raise HTTPException(status_code=404, detail="Lot introuvable.")
|
||||
|
||||
fiche = lot.caracteristiques
|
||||
if fiche is None:
|
||||
fiche = LotCaracteristiques(lot_id=lot.id)
|
||||
session.add(fiche)
|
||||
lot.caracteristiques = fiche
|
||||
|
||||
for champ in CHAMPS_FICHE:
|
||||
setattr(fiche, champ, getattr(body, champ))
|
||||
|
||||
session.commit()
|
||||
|
||||
row = session.execute(_requete_lignes().where(Lot.id == lot_id)).one()
|
||||
immeuble = session.get(Immeuble, lot.immeuble_id)
|
||||
return _lot_response(
|
||||
row.Lot,
|
||||
immeuble.code if immeuble else None,
|
||||
immeuble_denomination=immeuble.denomination if immeuble else None,
|
||||
nb_revenus=row.nb_revenus or 0,
|
||||
nb_depenses=row.nb_depenses or 0,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/lots/{lot_id}", status_code=204)
|
||||
async def supprimer_lot(
|
||||
lot_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
) -> None:
|
||||
"""Supprime un lot que rien ne rattache à un document.
|
||||
|
||||
- **lot_id**: ID du lot
|
||||
|
||||
Sert à nettoyer les lots laissés par un ancien format de numérotation, qui
|
||||
encombrent le tableau de saisie sans rien décrire. Un lot qui porte des
|
||||
revenus ou des dépenses est refusé : le supprimer emporterait des montants
|
||||
du compte rendu, et un compte faux est pire qu'une ligne en trop.
|
||||
"""
|
||||
lot = session.get(Lot, lot_id)
|
||||
if lot is None:
|
||||
raise HTTPException(status_code=404, detail="Lot introuvable.")
|
||||
|
||||
row = session.execute(_requete_lignes().where(Lot.id == lot_id)).one()
|
||||
if row.nb_revenus or row.nb_depenses:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
f"Le lot {lot.numero} porte {row.nb_revenus} revenu(s) et "
|
||||
f"{row.nb_depenses} dépense(s) : il ne peut pas être supprimé."
|
||||
),
|
||||
)
|
||||
|
||||
# Les locataires du lot partent avec lui (cascade). Un locataire qui aurait
|
||||
# encore des revenus les rattacherait au lot, deja refuse ci-dessus.
|
||||
session.delete(lot)
|
||||
session.commit()
|
||||
|
||||
|
||||
@router.put("/immeubles/{immeuble_id}", response_model=ImmeubleResponse)
|
||||
async def renommer_immeuble(
|
||||
immeuble_id: int,
|
||||
body: ImmeubleBody,
|
||||
session: Session = Depends(get_session),
|
||||
) -> ImmeubleResponse:
|
||||
"""Donne à l'immeuble son nom d'usage.
|
||||
|
||||
- **immeuble_id**: ID de l'immeuble
|
||||
|
||||
Le code de gestion ("33689020") vient des PDF et ne se remplace pas ; la
|
||||
dénomination ("Servient") s'affiche à sa place partout où l'immeuble est
|
||||
cité.
|
||||
"""
|
||||
immeuble = session.get(Immeuble, immeuble_id)
|
||||
if immeuble is None:
|
||||
raise HTTPException(status_code=404, detail="Immeuble introuvable.")
|
||||
|
||||
immeuble.denomination = body.denomination
|
||||
session.commit()
|
||||
|
||||
# Compteurs recalcules plutot que laisses a zero : la reponse remplace
|
||||
# l'immeuble dans les listes du client, qui le croirait vide de lots.
|
||||
nb_lots = session.execute(
|
||||
select(func.count(Lot.id)).where(Lot.immeuble_id == immeuble.id)
|
||||
).scalar_one()
|
||||
nb_depenses = session.execute(
|
||||
select(func.count(Depense.id)).where(Depense.immeuble_id == immeuble.id)
|
||||
).scalar_one()
|
||||
|
||||
return ImmeubleResponse(
|
||||
id=immeuble.id,
|
||||
code=immeuble.code,
|
||||
denomination=immeuble.denomination,
|
||||
adresse=immeuble.adresse,
|
||||
ville=immeuble.ville,
|
||||
code_postal=immeuble.code_postal,
|
||||
nb_lots=nb_lots,
|
||||
nb_depenses=nb_depenses,
|
||||
)
|
||||
@@ -15,6 +15,7 @@ from ...database.models import (
|
||||
Lot,
|
||||
Revenu,
|
||||
)
|
||||
from ...services.referentiel import TYPE_LOT_EFFECTIF, joindre_fiche
|
||||
from ...services.revenus_query import (
|
||||
est_flux,
|
||||
flux_par,
|
||||
@@ -390,10 +391,11 @@ async def get_revenus_by_lot(
|
||||
flux = flux_par(Revenu.lot_id)
|
||||
dette = restant_du_par(Revenu.lot_id)
|
||||
stmt = (
|
||||
joindre_fiche(
|
||||
select(
|
||||
Lot.id,
|
||||
Lot.numero,
|
||||
Lot.type,
|
||||
TYPE_LOT_EFFECTIF.label("type"),
|
||||
Immeuble.code,
|
||||
func.max(Locataire.nom).label("locataire_nom"),
|
||||
flux.c.facture,
|
||||
@@ -410,6 +412,7 @@ async def get_revenus_by_lot(
|
||||
)
|
||||
.outerjoin(flux, flux.c.cle == Lot.id)
|
||||
.outerjoin(dette, dette.c.cle == Lot.id)
|
||||
)
|
||||
.group_by(Lot.id)
|
||||
.order_by(desc("restant_du"), desc("facture"))
|
||||
.limit(limit)
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"""Pydantic schemas for API request/response models."""
|
||||
|
||||
from .models import (
|
||||
CaracteristiquesBody,
|
||||
CaracteristiquesResponse,
|
||||
DepenseDetail,
|
||||
DepensesSummary,
|
||||
DocumentSummary,
|
||||
ImmeubleBody,
|
||||
ImmeubleResponse,
|
||||
LotReferentielResponse,
|
||||
LotResponse,
|
||||
PredictTagsRequest,
|
||||
SaveRequest,
|
||||
@@ -18,6 +22,10 @@ __all__ = [
|
||||
"DocumentSummary",
|
||||
"DepenseDetail",
|
||||
"DepensesSummary",
|
||||
"ImmeubleBody",
|
||||
"ImmeubleResponse",
|
||||
"LotResponse",
|
||||
"CaracteristiquesBody",
|
||||
"CaracteristiquesResponse",
|
||||
"LotReferentielResponse",
|
||||
]
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Pydantic models for API requests and responses."""
|
||||
|
||||
from datetime import date
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from ...utils.logements import DPE_CLASSES
|
||||
|
||||
# ============================================================
|
||||
# Requests
|
||||
@@ -70,6 +72,7 @@ class ImmeubleResponse(BaseModel):
|
||||
|
||||
id: int
|
||||
code: str
|
||||
denomination: str | None = None
|
||||
adresse: str | None
|
||||
ville: str | None
|
||||
code_postal: str | None
|
||||
@@ -180,3 +183,98 @@ class DepensesSummary(BaseModel):
|
||||
by_tag: list[TagSummary]
|
||||
by_month: list[MonthlySummary]
|
||||
by_fournisseur: list[FournisseurSummary]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Référentiel des logements
|
||||
# ============================================================
|
||||
|
||||
|
||||
class ImmeubleBody(BaseModel):
|
||||
"""Ce qui se saisit sur un immeuble : son nom d'usage."""
|
||||
|
||||
denomination: str | None = None
|
||||
|
||||
@field_validator("denomination")
|
||||
@classmethod
|
||||
def _texte_vide_vaut_absent(cls, value: str | None) -> str | None:
|
||||
"""Effacer le nom doit rendre l'immeuble à son code, pas le nommer « »."""
|
||||
if value is None:
|
||||
return None
|
||||
return value.strip() or None
|
||||
|
||||
|
||||
class CaracteristiquesBody(BaseModel):
|
||||
"""Caractéristiques d'un logement telles que saisies.
|
||||
|
||||
Tous les champs sont optionnels : la fiche se remplit progressivement, et
|
||||
une fiche partielle vaut mieux qu'une fiche refusée.
|
||||
"""
|
||||
|
||||
bat: str | None = None
|
||||
etage: str | None = None
|
||||
type: str | None = None
|
||||
surface: float | None = Field(None, ge=0)
|
||||
surface_date_diag: date | None = None
|
||||
chauffage: str | None = None
|
||||
dpe_classe: str | None = None
|
||||
dpe_date_realisation: date | None = None
|
||||
numero_fiscal: str | None = None
|
||||
surface_impots: float | None = Field(None, ge=0)
|
||||
note_impots: str | None = None
|
||||
|
||||
@field_validator("dpe_classe")
|
||||
@classmethod
|
||||
def _classe_connue(cls, value: str | None) -> str | None:
|
||||
"""Refuse une classe hors A-G, faute de quoi les KPI DPE mentiraient."""
|
||||
if value is None or value == "":
|
||||
return None
|
||||
classe = value.strip().upper()
|
||||
if classe not in DPE_CLASSES:
|
||||
raise ValueError(f"Classe DPE inconnue : {value} (attendu A-G)")
|
||||
return classe
|
||||
|
||||
@field_validator(
|
||||
"bat", "etage", "type", "chauffage", "numero_fiscal", "note_impots"
|
||||
)
|
||||
@classmethod
|
||||
def _texte_vide_vaut_absent(cls, value: str | None) -> str | None:
|
||||
"""Un champ vide dans le tableau doit effacer la valeur, pas la figer."""
|
||||
if value is None:
|
||||
return None
|
||||
value = value.strip()
|
||||
return value or None
|
||||
|
||||
|
||||
class CaracteristiquesResponse(CaracteristiquesBody):
|
||||
"""Caractéristiques saisies, augmentées de leurs valeurs dérivées."""
|
||||
|
||||
#: Péremption du DPE, déduite de la date de réalisation (+10 ans).
|
||||
dpe_echeance: date | None = None
|
||||
#: Surface impôts moins surface mesurée ; None si une des deux manque.
|
||||
delta_surface: float | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class LotReferentielResponse(BaseModel):
|
||||
"""Un lot et sa fiche, tels qu'affichés dans le tableau du référentiel."""
|
||||
|
||||
id: int
|
||||
numero: str
|
||||
immeuble_id: int
|
||||
immeuble_code: str | None
|
||||
#: Nom d'usage de l'immeuble ; le tableau retombe sur le code s'il manque.
|
||||
immeuble_denomination: str | None = None
|
||||
|
||||
#: Type de lot vu par l'extraction PDF, conservé tel quel.
|
||||
type_extrait: str | None
|
||||
#: Type retenu : celui de la fiche s'il existe, sinon celui du PDF.
|
||||
type_effectif: str | None
|
||||
#: Vrai quand les deux sources se contredisent (comparaison normalisée).
|
||||
type_ecart: bool = False
|
||||
|
||||
caracteristiques: CaracteristiquesResponse | None = None
|
||||
|
||||
#: Rattachements existants : un lot qui en a n'est pas supprimable.
|
||||
nb_revenus: int = 0
|
||||
nb_depenses: int = 0
|
||||
|
||||
@@ -2,7 +2,17 @@
|
||||
|
||||
from . import storage
|
||||
from .connection import get_engine, get_session, get_session_factory, init_db
|
||||
from .models import Base, Depense, Document, Immeuble, Locataire, Lot, Revenu, Setting
|
||||
from .models import (
|
||||
Base,
|
||||
Depense,
|
||||
Document,
|
||||
Immeuble,
|
||||
Locataire,
|
||||
Lot,
|
||||
LotCaracteristiques,
|
||||
Revenu,
|
||||
Setting,
|
||||
)
|
||||
from .service import DatabaseService, DuplicateDocumentError
|
||||
|
||||
__all__ = [
|
||||
@@ -14,6 +24,7 @@ __all__ = [
|
||||
"Document",
|
||||
"Immeuble",
|
||||
"Lot",
|
||||
"LotCaracteristiques",
|
||||
"Locataire",
|
||||
"Revenu",
|
||||
"Depense",
|
||||
|
||||
@@ -96,14 +96,19 @@ def init_db(db_path: Path | None = None) -> Path:
|
||||
|
||||
|
||||
#: Colonnes ajoutees apres coup, par table : nom -> (definition SQL, valeur de
|
||||
#: rattrapage pour les lignes existantes). `create_all` ne modifie pas une table
|
||||
#: deja presente, et le projet n'utilise pas d'outil de migration : sans ce
|
||||
#: rattrapage, une base installee cesserait de fonctionner apres mise a jour.
|
||||
#: rattrapage pour les lignes existantes, ou None quand il n'y a rien de vrai a
|
||||
#: y mettre). `create_all` ne modifie pas une table deja presente, et le projet
|
||||
#: n'utilise pas d'outil de migration : sans ce rattrapage, une base installee
|
||||
#: cesserait de fonctionner apres mise a jour.
|
||||
_ADDED_COLUMNS = {
|
||||
"documents": {
|
||||
# Les documents deja en base ont ete extraits lors de leur import.
|
||||
"extracted_at": ("DATETIME", "created_at"),
|
||||
},
|
||||
"immeubles": {
|
||||
# Le nom d'usage se saisit : le deduire du code inventerait un nom.
|
||||
"denomination": ("VARCHAR(100)", None),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -119,7 +124,10 @@ def _apply_schema_updates(engine):
|
||||
for name, (definition, backfill) in columns.items():
|
||||
if name in existing:
|
||||
continue
|
||||
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {name} {definition}"))
|
||||
conn.execute(
|
||||
text(f"ALTER TABLE {table} ADD COLUMN {name} {definition}")
|
||||
)
|
||||
if backfill is not None:
|
||||
conn.execute(text(f"UPDATE {table} SET {name} = {backfill}"))
|
||||
|
||||
|
||||
|
||||
@@ -65,6 +65,9 @@ class Immeuble(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
code = Column(String(20), unique=True, nullable=False, index=True)
|
||||
#: Nom d'usage ("Servient"), saisi : les PDF ne donnent qu'un code de
|
||||
#: gestion, illisible partout où l'immeuble est cité.
|
||||
denomination = Column(String(100), nullable=True)
|
||||
adresse = Column(String(255), nullable=True)
|
||||
ville = Column(String(100), nullable=True)
|
||||
code_postal = Column(String(10), nullable=True)
|
||||
@@ -103,11 +106,58 @@ class Lot(Base):
|
||||
)
|
||||
revenus = relationship("Revenu", back_populates="lot")
|
||||
depenses = relationship("Depense", back_populates="lot")
|
||||
caracteristiques = relationship(
|
||||
"LotCaracteristiques",
|
||||
back_populates="lot",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Lot(numero={self.numero}, type={self.type})>"
|
||||
|
||||
|
||||
class LotCaracteristiques(Base):
|
||||
"""Caractéristiques d'un logement, saisies à la main.
|
||||
|
||||
Table séparée de `lots` à dessein : `lots` porte ce que l'extraction PDF
|
||||
sait d'un lot, celle-ci ce que le propriétaire en sait. Une ré-extraction ne
|
||||
peut donc structurellement pas écraser la saisie, et l'écart entre les deux
|
||||
sources (le type de lot) reste calculable au lieu d'être perdu.
|
||||
"""
|
||||
|
||||
__tablename__ = "lot_caracteristiques"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
lot_id = Column(Integer, ForeignKey("lots.id"), nullable=False, unique=True)
|
||||
|
||||
# Description physique
|
||||
bat = Column(String(50), nullable=True) # "Rue", "Cour"
|
||||
etage = Column(String(20), nullable=True) # "RC", "1", "SS", "Combles"
|
||||
type = Column(String(100), nullable=True) # prioritaire sur Lot.type
|
||||
surface = Column(Float, nullable=True) # m², mesure Oralia
|
||||
surface_date_diag = Column(Date, nullable=True)
|
||||
chauffage = Column(String(100), nullable=True)
|
||||
|
||||
# Réglementaire
|
||||
dpe_classe = Column(String(1), nullable=True) # A..G
|
||||
dpe_date_realisation = Column(Date, nullable=True) # échéance = +10 ans
|
||||
|
||||
# Rapprochement avec les impôts
|
||||
numero_fiscal = Column(String(50), nullable=True) # clé de recherche impots.gouv
|
||||
surface_impots = Column(Float, nullable=True) # m² déclarés
|
||||
note_impots = Column(Text, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||
|
||||
# Relations
|
||||
lot = relationship("Lot", back_populates="caracteristiques")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<LotCaracteristiques(lot_id={self.lot_id}, surface={self.surface})>"
|
||||
|
||||
|
||||
class Locataire(Base):
|
||||
"""Table des locataires avec historique."""
|
||||
|
||||
|
||||
34
src/plesna_gerance/services/referentiel.py
Normal file
34
src/plesna_gerance/services/referentiel.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Arbitrage entre ce que dit le PDF d'un lot et ce que sa fiche en dit.
|
||||
|
||||
Le type d'un lot est connu de deux sources : l'extraction PDF, qui le remplit
|
||||
parfois mal et souvent pas du tout, et la fiche saisie à la main, qui fait foi.
|
||||
La règle est donc « la fiche gagne, le PDF comble les trous » — et elle vit ici,
|
||||
en un seul endroit, sous ses deux formes : une expression SQL pour les
|
||||
agrégats, une fonction Python pour l'ORM. Les faire diverger reviendrait à
|
||||
afficher deux types différents pour un même lot selon la page consultée.
|
||||
|
||||
Le désaccord entre les deux sources n'est jamais résolu en silence : il reste
|
||||
visible via `type_en_ecart` (voir `utils.logements`).
|
||||
"""
|
||||
|
||||
from sqlalchemy import Select, func
|
||||
|
||||
from ..database.models import Lot, LotCaracteristiques
|
||||
|
||||
#: Type de lot retenu, en SQL. Requiert la jointure de `joindre_fiche`.
|
||||
TYPE_LOT_EFFECTIF = func.coalesce(LotCaracteristiques.type, Lot.type)
|
||||
|
||||
|
||||
def joindre_fiche(stmt: Select) -> Select:
|
||||
"""Ajoute à une requête sur `Lot` la jointure vers sa fiche.
|
||||
|
||||
La relation est 1↔1 : la jointure ne multiplie aucune ligne, elle peut donc
|
||||
s'ajouter à une requête agrégée sans fausser les totaux.
|
||||
"""
|
||||
return stmt.outerjoin(LotCaracteristiques, LotCaracteristiques.lot_id == Lot.id)
|
||||
|
||||
|
||||
def type_effectif(lot: Lot) -> str | None:
|
||||
"""Type de lot retenu, depuis un objet chargé par l'ORM."""
|
||||
fiche = lot.caracteristiques
|
||||
return (fiche.type if fiche else None) or lot.type
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
88
src/plesna_gerance/utils/logements.py
Normal file
88
src/plesna_gerance/utils/logements.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Valeurs dérivées des caractéristiques d'un logement.
|
||||
|
||||
Ni l'échéance du DPE ni l'écart de surface ne sont stockés : ce sont des
|
||||
conséquences de valeurs saisies, et une colonne dérivée finit toujours par
|
||||
mentir après une correction. Elles se calculent ici, en un seul endroit, pour
|
||||
que l'API et les futurs KPI donnent le même résultat.
|
||||
"""
|
||||
|
||||
import re
|
||||
from datetime import date
|
||||
|
||||
#: Durée de validité d'un DPE réalisé après la réforme de 2021 (10 ans).
|
||||
DPE_VALIDITE_ANNEES = 10
|
||||
|
||||
#: Classes possibles d'un DPE, de la plus performante à la moins performante.
|
||||
DPE_CLASSES = ("A", "B", "C", "D", "E", "F", "G")
|
||||
|
||||
|
||||
def dpe_echeance(date_realisation: date | None) -> date | None:
|
||||
"""Date de péremption d'un DPE réalisé à `date_realisation`.
|
||||
|
||||
Args:
|
||||
date_realisation: Date de réalisation du diagnostic, ou None
|
||||
|
||||
Returns:
|
||||
Date de fin de validité, ou None si la date de réalisation manque
|
||||
"""
|
||||
if date_realisation is None:
|
||||
return None
|
||||
|
||||
annee = date_realisation.year + DPE_VALIDITE_ANNEES
|
||||
try:
|
||||
return date_realisation.replace(year=annee)
|
||||
except ValueError:
|
||||
# 29 février d'une année bissextile vers une année qui ne l'est pas.
|
||||
return date(annee, 2, 28)
|
||||
|
||||
|
||||
def delta_surface(surface: float | None, surface_impots: float | None) -> float | None:
|
||||
"""Écart entre la surface déclarée aux impôts et la surface mesurée.
|
||||
|
||||
Signe positif : les impôts retiennent plus de surface que la mesure Oralia.
|
||||
|
||||
Args:
|
||||
surface: Surface mesurée (m²)
|
||||
surface_impots: Surface déclarée aux impôts (m²)
|
||||
|
||||
Returns:
|
||||
L'écart en m², ou None si une des deux surfaces manque
|
||||
"""
|
||||
if surface is None or surface_impots is None:
|
||||
return None
|
||||
|
||||
return round(surface_impots - surface, 2)
|
||||
|
||||
|
||||
def _type_comparable(valeur: str | None) -> str:
|
||||
"""Écriture normalisée d'un type de lot, pour comparaison seulement."""
|
||||
if not valeur:
|
||||
return ""
|
||||
return re.sub(r"[^a-z0-9]+", " ", valeur.lower()).strip()
|
||||
|
||||
|
||||
def type_en_ecart(type_extrait: str | None, type_saisi: str | None) -> bool:
|
||||
"""Le PDF et la fiche annoncent-ils deux types de lot différents ?
|
||||
|
||||
La comparaison ignore casse et ponctuation : le PDF écrit
|
||||
"Loc. Commercial" là où une saisie donne "Loc, Commercial" ou
|
||||
"loc commercial". Signaler ces trois-là comme un désaccord noierait le seul
|
||||
écart qui compte, celui où les deux sources ne parlent pas du même logement.
|
||||
|
||||
Un type absent d'un côté n'est pas un écart : c'est une information qui
|
||||
manque, pas une contradiction.
|
||||
|
||||
Args:
|
||||
type_extrait: Type de lot vu par l'extraction PDF
|
||||
type_saisi: Type de lot saisi dans la fiche
|
||||
|
||||
Returns:
|
||||
True si les deux valeurs sont renseignées et se contredisent
|
||||
"""
|
||||
extrait = _type_comparable(type_extrait)
|
||||
saisi = _type_comparable(type_saisi)
|
||||
|
||||
if not extrait or not saisi:
|
||||
return False
|
||||
|
||||
return extrait != saisi
|
||||
57
tests/test_logements.py
Normal file
57
tests/test_logements.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Tests des valeurs derivees des caracteristiques d'un logement.
|
||||
|
||||
Echeance de DPE et ecart de surface ne sont pas stockes : ils se recalculent a
|
||||
chaque lecture. Ces tests figent les regles de calcul, seul endroit ou une
|
||||
erreur passerait inapercue puisqu'aucune donnee saisie ne la contredirait.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from plesna_gerance.utils.logements import (
|
||||
delta_surface,
|
||||
dpe_echeance,
|
||||
type_en_ecart,
|
||||
)
|
||||
|
||||
|
||||
def test_un_dpe_vaut_dix_ans():
|
||||
assert dpe_echeance(date(2021, 2, 8)) == date(2031, 2, 8)
|
||||
|
||||
|
||||
def test_un_dpe_du_29_fevrier_expire_le_28():
|
||||
"""2020 est bissextile, 2030 non : la date doit reculer, pas exploser."""
|
||||
assert dpe_echeance(date(2020, 2, 29)) == date(2030, 2, 28)
|
||||
|
||||
|
||||
def test_sans_date_de_dpe_pas_d_echeance():
|
||||
assert dpe_echeance(None) is None
|
||||
|
||||
|
||||
def test_ecart_de_surface_positif_quand_les_impots_en_retiennent_plus():
|
||||
assert delta_surface(100.0, 119.0) == 19.0
|
||||
|
||||
|
||||
def test_ecart_de_surface_negatif_quand_la_mesure_est_plus_grande():
|
||||
assert delta_surface(148.0, 146.0) == -2.0
|
||||
|
||||
|
||||
def test_une_seule_surface_ne_donne_aucun_ecart():
|
||||
"""Un ecart de 0 affirmerait que les deux sources concordent."""
|
||||
assert delta_surface(148.0, None) is None
|
||||
assert delta_surface(None, 146.0) is None
|
||||
|
||||
|
||||
def test_meme_type_ecrit_autrement_n_est_pas_un_ecart():
|
||||
assert not type_en_ecart("Loc. Commercial", "Loc, Commercial")
|
||||
assert not type_en_ecart("Appartement T3", "appartement t3")
|
||||
|
||||
|
||||
def test_deux_types_differents_sont_un_ecart():
|
||||
assert type_en_ecart("Appartement T3", "Appartement T2")
|
||||
|
||||
|
||||
def test_un_type_absent_n_est_pas_un_ecart():
|
||||
"""Le PDF laisse souvent le type vide : ce n'est pas une contradiction."""
|
||||
assert not type_en_ecart(None, "Appartement T3")
|
||||
assert not type_en_ecart("Appartement T3", None)
|
||||
assert not type_en_ecart("", "")
|
||||
225
tests/test_lot_analyse.py
Normal file
225
tests/test_lot_analyse.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""Tests de la fiche d'un lot.
|
||||
|
||||
Cette page restitue un lot tel que les comptes rendus le portent. Les tests
|
||||
protègent donc ce qui la rendrait fausse ou trompeuse : cumuler un report de
|
||||
solde, ventiler des charges d'immeuble qu'aucun document n'attribue, ou
|
||||
regrouper des lignes que le compte rendu a émises séparément.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from plesna_gerance.database.models import Immeuble, Lot
|
||||
from plesna_gerance.database.service import DatabaseService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def donnees(db_session, sample_data):
|
||||
"""Deux comptes rendus sur un lot : un loyer réglé, puis un report impayé.
|
||||
|
||||
Le second document facture un loyer resté impayé et reporte le solde du
|
||||
premier — la configuration exacte où un cumul naïf compterait deux fois la
|
||||
même dette.
|
||||
"""
|
||||
service = DatabaseService(db_session)
|
||||
service.save_document(data=sample_data)
|
||||
|
||||
suivant = {
|
||||
**sample_data,
|
||||
"metadata": {
|
||||
**sample_data["metadata"],
|
||||
"document": {
|
||||
"reference": "REF002",
|
||||
"date": "2024-02-15",
|
||||
"type": "COMPTE RENDU DE GESTION",
|
||||
},
|
||||
},
|
||||
"situation_locataires": [
|
||||
{
|
||||
"lot": {"numero": "01", "type": "Appartement"},
|
||||
"locataire": {"nom": "DUPONT"},
|
||||
"lignes": [
|
||||
{
|
||||
"type": "solde_anterieur",
|
||||
"total": 300.0,
|
||||
"regles": 0.0,
|
||||
"impayes": 300.0,
|
||||
},
|
||||
{
|
||||
"type": "loyer",
|
||||
"periode": {"debut": "2024-02-01", "fin": "2024-02-29"},
|
||||
"loyers": 500.0,
|
||||
"total": 500.0,
|
||||
"regles": 200.0,
|
||||
"impayes": 300.0,
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
"recapitulatif_operations": [
|
||||
{
|
||||
"categorie": "DEPENSES_NON_RECUPERABLES",
|
||||
"sous_categorie": "Travaux divers",
|
||||
"fournisseur": "PLOMBERIE",
|
||||
"description": "S01 ACOMPTE 40% remplacement chaudière",
|
||||
# Le parser déduit ce numéro du préfixe de la description ; la
|
||||
# fixture le fournit tel qu'il arrive en base.
|
||||
"lot_numero": "01",
|
||||
"montants": {"debit": 400.0, "deductible": 400.0},
|
||||
},
|
||||
{
|
||||
"categorie": "DEPENSES_NON_RECUPERABLES",
|
||||
"sous_categorie": "Travaux divers",
|
||||
"fournisseur": "PLOMBERIE",
|
||||
"description": "S01 SOLDE remplacement chaudière",
|
||||
"lot_numero": "01",
|
||||
"montants": {"debit": 600.0, "deductible": 600.0},
|
||||
},
|
||||
{
|
||||
"categorie": "HONORAIRES_DE_GESTION",
|
||||
"sous_categorie": "Frais d'expert",
|
||||
"fournisseur": "EXPERTISE",
|
||||
"description": "S01 - Remise état des lieux sortie",
|
||||
"lot_numero": "01",
|
||||
# Un avoir : la ligne rend au propriétaire au lieu de lui coûter.
|
||||
"montants": {"debit": 0.0, "credit": 35.4},
|
||||
},
|
||||
],
|
||||
}
|
||||
service.save_document(data=suivant)
|
||||
|
||||
immeuble = db_session.query(Immeuble).filter(Immeuble.code == "IMM1").one()
|
||||
lot = db_session.query(Lot).filter(Lot.immeuble_id == immeuble.id).one()
|
||||
return immeuble, lot
|
||||
|
||||
|
||||
def test_lot_inconnu_donne_404(api_client, donnees):
|
||||
assert api_client.get("/api/lots/999999/analyse").status_code == 404
|
||||
|
||||
|
||||
def test_identite_montre_les_trous_de_la_fiche(api_client, donnees):
|
||||
"""Une caractéristique non saisie reste nulle : la page doit le montrer."""
|
||||
_, lot = donnees
|
||||
|
||||
identite = api_client.get(f"/api/lots/{lot.id}/analyse").json()["identite"]
|
||||
|
||||
assert identite["numero"] == "01"
|
||||
assert identite["immeuble_code"] == "IMM1"
|
||||
assert identite["type_effectif"] == "Appartement"
|
||||
assert identite["surface"] is None
|
||||
assert identite["dpe_classe"] is None
|
||||
assert identite["locataires"] == ["DUPONT"]
|
||||
|
||||
|
||||
def test_le_report_de_solde_ne_gonfle_pas_le_facture(api_client, donnees):
|
||||
"""Le facturé ne retient que les loyers, jamais la dette reportée.
|
||||
|
||||
Deux loyers de 500 € : cumuler en plus le report de 300 € afficherait 1300 €
|
||||
facturés pour un lot qui n'a jamais rien facturé de tel.
|
||||
"""
|
||||
_, lot = donnees
|
||||
|
||||
chiffres = api_client.get(f"/api/lots/{lot.id}/analyse").json()["chiffres"]
|
||||
|
||||
assert chiffres["facture"] == 1000.0
|
||||
assert chiffres["encaisse"] == 700.0
|
||||
#: Photo du dernier compte rendu (300 de report + 300 de loyer), pas un cumul.
|
||||
assert chiffres["restant_du"] == 600.0
|
||||
assert chiffres["taux_recouvrement"] == 70.0
|
||||
|
||||
|
||||
def test_les_charges_d_immeuble_restent_hors_du_lot(api_client, donnees):
|
||||
"""Le nettoyage de l'immeuble ne doit pas atterrir dans un lot.
|
||||
|
||||
Aucune donnée ne dit quelle part revient à quel lot : la ventiler
|
||||
inventerait des montants. Elle est exposée à part, comme contexte.
|
||||
"""
|
||||
_, lot = donnees
|
||||
|
||||
chiffres = api_client.get(f"/api/lots/{lot.id}/analyse").json()["chiffres"]
|
||||
|
||||
# 400 + 600 de travaux imputés au lot, sans le nettoyage de l'immeuble.
|
||||
assert chiffres["depenses_debit"] == 1000.0
|
||||
assert chiffres["depenses_credit"] == 35.4
|
||||
assert chiffres["nb_operations"] == 3
|
||||
# Le nettoyage du premier compte rendu, resté sans lot.
|
||||
assert chiffres["depenses_immeuble_non_reparties"] == 50.0
|
||||
# Encaissé (700) moins décaissé (1000), avoir rendu (35,40) : charges
|
||||
# communes exclues.
|
||||
assert chiffres["solde"] == -264.6
|
||||
|
||||
|
||||
def test_la_chronologie_garde_les_lignes_telles_qu_extraites(api_client, donnees):
|
||||
"""Acompte et solde restent deux lignes : le compte rendu les porte ainsi."""
|
||||
_, lot = donnees
|
||||
|
||||
chronologie = api_client.get(f"/api/lots/{lot.id}/analyse").json()["chronologie"]
|
||||
|
||||
travaux = [ligne for ligne in chronologie if ligne["fournisseur"] == "PLOMBERIE"]
|
||||
assert len(travaux) == 2
|
||||
assert {ligne["montant"] for ligne in travaux} == {400.0, 600.0}
|
||||
|
||||
|
||||
def test_la_chronologie_est_ordonnee_et_signale_les_reports(api_client, donnees):
|
||||
"""L'ordre des comptes rendus est l'ordre de lecture de la page."""
|
||||
_, lot = donnees
|
||||
|
||||
chronologie = api_client.get(f"/api/lots/{lot.id}/analyse").json()["chronologie"]
|
||||
|
||||
dates = [ligne["date"] for ligne in chronologie]
|
||||
assert dates == sorted(dates)
|
||||
assert dates[0] == "2024-01-15"
|
||||
|
||||
reports = [ligne for ligne in chronologie if ligne["est_report"]]
|
||||
assert len(reports) == 1
|
||||
assert reports[0]["montant"] == 300.0
|
||||
|
||||
|
||||
def test_les_intervenants_agregent_le_fournisseur_du_compte_rendu(api_client, donnees):
|
||||
"""Une entreprise, ses interventions et son montant — rien de déduit."""
|
||||
_, lot = donnees
|
||||
|
||||
intervenants = api_client.get(f"/api/lots/{lot.id}/analyse").json()["intervenants"]
|
||||
|
||||
par_nom = {ligne["fournisseur"]: ligne for ligne in intervenants}
|
||||
assert set(par_nom) == {"PLOMBERIE", "EXPERTISE"}
|
||||
assert par_nom["PLOMBERIE"]["nb_interventions"] == 2
|
||||
assert par_nom["PLOMBERIE"]["montant"] == 1000.0
|
||||
assert par_nom["PLOMBERIE"]["derniere_date"] == "2024-02-15"
|
||||
|
||||
|
||||
def test_un_avoir_rend_le_montant_de_l_intervenant_negatif(api_client, donnees):
|
||||
"""Le crédit est déduit : une remise ne doit pas s'afficher comme un coût."""
|
||||
_, lot = donnees
|
||||
|
||||
intervenants = api_client.get(f"/api/lots/{lot.id}/analyse").json()["intervenants"]
|
||||
|
||||
expertise = next(
|
||||
ligne for ligne in intervenants if ligne["fournisseur"] == "EXPERTISE"
|
||||
)
|
||||
assert expertise["montant"] == -35.4
|
||||
# La plus engagée en tête : un avoir se classe donc en dernier.
|
||||
assert intervenants[-1]["fournisseur"] == "EXPERTISE"
|
||||
|
||||
|
||||
def test_le_total_d_un_intervenant_est_celui_de_ses_lignes(api_client, donnees):
|
||||
"""Invariant du dépliage : le détail doit retrouver le total affiché.
|
||||
|
||||
Les deux chiffres viennent de calculs séparés (agrégat SQL d'un côté, lignes
|
||||
de la chronologie de l'autre) ; les laisser diverger ferait mentir la ligne
|
||||
qu'on vient d'ouvrir.
|
||||
"""
|
||||
_, lot = donnees
|
||||
|
||||
analyse = api_client.get(f"/api/lots/{lot.id}/analyse").json()
|
||||
|
||||
for intervenant in analyse["intervenants"]:
|
||||
lignes = [
|
||||
ligne
|
||||
for ligne in analyse["chronologie"]
|
||||
if ligne["fournisseur"] == intervenant["fournisseur"]
|
||||
]
|
||||
assert len(lignes) == intervenant["nb_interventions"]
|
||||
assert (
|
||||
round(sum(ligne["montant"] for ligne in lignes), 2)
|
||||
== intervenant["montant"]
|
||||
)
|
||||
360
tests/test_referentiel_api.py
Normal file
360
tests/test_referentiel_api.py
Normal file
@@ -0,0 +1,360 @@
|
||||
"""Tests du referentiel des logements.
|
||||
|
||||
Ces caracteristiques sont saisies a la main : elles n'ont aucune autre source
|
||||
que l'utilisateur, donc rien ne les reconstituerait si un enregistrement les
|
||||
perdait. Les tests portent sur ce qui menace cette saisie : l'ecraser depuis un
|
||||
PDF, la vider a moitie, ou la laisser contredire l'extraction en silence.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from plesna_gerance.database.models import (
|
||||
Depense,
|
||||
Immeuble,
|
||||
Lot,
|
||||
LotCaracteristiques,
|
||||
)
|
||||
from plesna_gerance.database.service import DatabaseService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def immeuble_et_lot(db_session, sample_data):
|
||||
"""Un immeuble et son lot 01, tels que l'extraction les cree."""
|
||||
DatabaseService(db_session).save_document(data=sample_data)
|
||||
|
||||
immeuble = db_session.query(Immeuble).filter(Immeuble.code == "IMM1").one()
|
||||
lot = db_session.query(Lot).filter(Lot.immeuble_id == immeuble.id).one()
|
||||
return immeuble, lot
|
||||
|
||||
|
||||
FICHE = {
|
||||
"bat": "Rue",
|
||||
"etage": "RC",
|
||||
"type": "Loc. Commercial",
|
||||
"surface": 148.0,
|
||||
"surface_date_diag": "2019-06-01",
|
||||
"chauffage": "Electrique",
|
||||
"dpe_classe": "C",
|
||||
"dpe_date_realisation": "2021-02-08",
|
||||
"numero_fiscal": "690123456789",
|
||||
"surface_impots": 146.0,
|
||||
"note_impots": "Surface relevee sur l'avis 2024",
|
||||
}
|
||||
|
||||
|
||||
def test_liste_les_lots_sans_fiche(api_client, immeuble_et_lot):
|
||||
"""Un lot jamais decrit doit apparaitre, sinon il n'est pas saisissable."""
|
||||
immeuble, lot = immeuble_et_lot
|
||||
|
||||
response = api_client.get(f"/api/lots/referentiel?immeuble_id={immeuble.id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
lignes = response.json()
|
||||
assert len(lignes) == 1
|
||||
assert lignes[0]["id"] == lot.id
|
||||
assert lignes[0]["numero"] == "01"
|
||||
assert lignes[0]["caracteristiques"] is None
|
||||
assert lignes[0]["type_extrait"] == "Appartement"
|
||||
assert lignes[0]["type_effectif"] == "Appartement"
|
||||
assert lignes[0]["type_ecart"] is False
|
||||
|
||||
|
||||
def test_liste_un_immeuble_inconnu_donne_404(api_client):
|
||||
assert api_client.get("/api/lots/referentiel?immeuble_id=99999").status_code == 404
|
||||
|
||||
|
||||
def test_liste_tout_le_parc_par_defaut(api_client, immeuble_et_lot, db_session):
|
||||
"""Le tableau porte une colonne immeuble : il les montre donc tous.
|
||||
|
||||
Restreindre a un immeuble par defaut obligerait a le choisir avant de voir
|
||||
quoi que ce soit, alors que le parc entier tient dans un ecran.
|
||||
"""
|
||||
autre = Immeuble(code="IMM2", denomination="Marietton")
|
||||
db_session.add(autre)
|
||||
db_session.flush()
|
||||
db_session.add(Lot(immeuble_id=autre.id, numero="01"))
|
||||
db_session.commit()
|
||||
|
||||
lignes = api_client.get("/api/lots/referentiel").json()
|
||||
|
||||
assert len(lignes) == 2
|
||||
assert {ligne["immeuble_code"] for ligne in lignes} == {"IMM1", "IMM2"}
|
||||
|
||||
|
||||
def test_expose_le_nom_d_usage_de_l_immeuble(api_client, immeuble_et_lot):
|
||||
"""Chaque ligne porte de quoi nommer son immeuble sans requete de plus."""
|
||||
immeuble, _ = immeuble_et_lot
|
||||
api_client.put(f"/api/immeubles/{immeuble.id}", json={"denomination": "Servient"})
|
||||
|
||||
ligne = api_client.get("/api/lots/referentiel").json()[0]
|
||||
|
||||
assert ligne["immeuble_denomination"] == "Servient"
|
||||
assert ligne["immeuble_code"] == "IMM1"
|
||||
|
||||
|
||||
def test_enregistre_puis_relit_une_fiche(api_client, immeuble_et_lot):
|
||||
immeuble, lot = immeuble_et_lot
|
||||
|
||||
response = api_client.put(f"/api/lots/{lot.id}/caracteristiques", json=FICHE)
|
||||
|
||||
assert response.status_code == 200
|
||||
fiche = response.json()["caracteristiques"]
|
||||
assert fiche["surface"] == 148.0
|
||||
assert fiche["etage"] == "RC"
|
||||
assert fiche["numero_fiscal"] == "690123456789"
|
||||
|
||||
relu = api_client.get(f"/api/lots/referentiel?immeuble_id={immeuble.id}").json()[0]
|
||||
assert relu["caracteristiques"]["surface"] == 148.0
|
||||
assert relu["caracteristiques"]["dpe_classe"] == "C"
|
||||
|
||||
|
||||
def test_expose_les_valeurs_derivees(api_client, immeuble_et_lot):
|
||||
"""L'echeance du DPE et l'ecart de surface arrivent calcules."""
|
||||
_, lot = immeuble_et_lot
|
||||
|
||||
fiche = api_client.put(f"/api/lots/{lot.id}/caracteristiques", json=FICHE).json()[
|
||||
"caracteristiques"
|
||||
]
|
||||
|
||||
assert fiche["dpe_echeance"] == "2031-02-08"
|
||||
assert fiche["delta_surface"] == -2.0
|
||||
|
||||
|
||||
def test_une_seconde_ecriture_met_a_jour_sans_dupliquer(api_client, immeuble_et_lot):
|
||||
immeuble, lot = immeuble_et_lot
|
||||
api_client.put(f"/api/lots/{lot.id}/caracteristiques", json=FICHE)
|
||||
|
||||
api_client.put(
|
||||
f"/api/lots/{lot.id}/caracteristiques", json={**FICHE, "surface": 150.0}
|
||||
)
|
||||
|
||||
lignes = api_client.get(f"/api/lots/referentiel?immeuble_id={immeuble.id}").json()
|
||||
assert len(lignes) == 1
|
||||
assert lignes[0]["caracteristiques"]["surface"] == 150.0
|
||||
|
||||
|
||||
def test_un_champ_vide_efface_la_valeur(api_client, immeuble_et_lot):
|
||||
"""Corriger une erreur de saisie doit pouvoir revenir a « inconnu »."""
|
||||
_, lot = immeuble_et_lot
|
||||
api_client.put(f"/api/lots/{lot.id}/caracteristiques", json=FICHE)
|
||||
|
||||
fiche = api_client.put(
|
||||
f"/api/lots/{lot.id}/caracteristiques",
|
||||
json={**FICHE, "etage": " ", "surface": None},
|
||||
).json()["caracteristiques"]
|
||||
|
||||
assert fiche["etage"] is None
|
||||
assert fiche["surface"] is None
|
||||
assert fiche["delta_surface"] is None
|
||||
|
||||
|
||||
def test_refuse_une_classe_dpe_inconnue(api_client, immeuble_et_lot):
|
||||
_, lot = immeuble_et_lot
|
||||
|
||||
response = api_client.put(
|
||||
f"/api/lots/{lot.id}/caracteristiques", json={**FICHE, "dpe_classe": "Z"}
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_refuse_une_surface_negative(api_client, immeuble_et_lot):
|
||||
_, lot = immeuble_et_lot
|
||||
|
||||
response = api_client.put(
|
||||
f"/api/lots/{lot.id}/caracteristiques", json={**FICHE, "surface": -10}
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_ecrire_sur_un_lot_inconnu_donne_404(api_client):
|
||||
assert (
|
||||
api_client.put("/api/lots/99999/caracteristiques", json=FICHE).status_code
|
||||
== 404
|
||||
)
|
||||
|
||||
|
||||
def test_signale_un_desaccord_de_type_sans_effacer_le_pdf(api_client, immeuble_et_lot):
|
||||
"""Les deux types restent lisibles : la fiche tranche, le PDF reste visible."""
|
||||
_, lot = immeuble_et_lot
|
||||
|
||||
ligne = api_client.put(
|
||||
f"/api/lots/{lot.id}/caracteristiques", json={**FICHE, "type": "Appartement T3"}
|
||||
).json()
|
||||
|
||||
assert ligne["type_extrait"] == "Appartement"
|
||||
assert ligne["type_effectif"] == "Appartement T3"
|
||||
assert ligne["type_ecart"] is True
|
||||
|
||||
|
||||
def test_le_type_saisi_prime_dans_la_liste_des_lots(api_client, immeuble_et_lot):
|
||||
"""La priorite du referentiel vaut partout, pas seulement sur sa page."""
|
||||
_, lot = immeuble_et_lot
|
||||
api_client.put(
|
||||
f"/api/lots/{lot.id}/caracteristiques", json={**FICHE, "type": "Appartement T3"}
|
||||
)
|
||||
|
||||
lots = api_client.get("/api/lots").json()
|
||||
|
||||
assert [ligne["type"] for ligne in lots] == ["Appartement T3"]
|
||||
|
||||
|
||||
def test_une_re_extraction_ne_touche_pas_la_fiche(
|
||||
api_client, immeuble_et_lot, db_session, sample_data
|
||||
):
|
||||
"""Le point critique : reimporter le PDF ne doit rien perdre de la saisie."""
|
||||
immeuble, lot = immeuble_et_lot
|
||||
api_client.put(f"/api/lots/{lot.id}/caracteristiques", json=FICHE)
|
||||
|
||||
DatabaseService(db_session).save_document(data=sample_data, overwrite=True)
|
||||
|
||||
lignes = api_client.get(f"/api/lots/referentiel?immeuble_id={immeuble.id}").json()
|
||||
fiche = next(ligne for ligne in lignes if ligne["id"] == lot.id)["caracteristiques"]
|
||||
assert fiche is not None, "la re-extraction a perdu la fiche du lot"
|
||||
assert fiche["surface"] == 148.0
|
||||
|
||||
|
||||
def test_compte_les_rattachements_du_lot(api_client, immeuble_et_lot):
|
||||
"""Un lot avec des revenus n'est pas un orphelin : la liste doit le dire."""
|
||||
immeuble, lot = immeuble_et_lot
|
||||
|
||||
ligne = api_client.get(f"/api/lots/referentiel?immeuble_id={immeuble.id}").json()[0]
|
||||
|
||||
assert ligne["nb_revenus"] == 1
|
||||
# La depense de la fixture porte sur l'immeuble (lot_id NULL). La compter
|
||||
# ici rendrait tout lot de l'immeuble faussement non supprimable.
|
||||
assert ligne["nb_depenses"] == 0
|
||||
|
||||
|
||||
def test_l_enregistrement_renvoie_les_memes_comptes_que_la_liste(
|
||||
api_client, immeuble_et_lot
|
||||
):
|
||||
"""Décrire un lot occupé ne doit pas le faire passer pour inutilisé.
|
||||
|
||||
La réponse du PUT remplace la ligne dans le tableau : si elle rapporte zéro
|
||||
revenu, le lot se pare d'un « inutilisé » que la liste dément au rechargement.
|
||||
"""
|
||||
_, lot = immeuble_et_lot
|
||||
|
||||
ligne = api_client.put(f"/api/lots/{lot.id}/caracteristiques", json=FICHE).json()
|
||||
|
||||
assert ligne["nb_revenus"] == 1
|
||||
|
||||
|
||||
def test_compte_les_depenses_propres_au_lot(api_client, immeuble_et_lot, db_session):
|
||||
"""Une depense rattachee au lot, elle, doit bien remonter sur sa ligne."""
|
||||
immeuble, lot = immeuble_et_lot
|
||||
depense = db_session.query(Depense).one()
|
||||
depense.lot_id = lot.id
|
||||
db_session.commit()
|
||||
|
||||
ligne = api_client.get(f"/api/lots/referentiel?immeuble_id={immeuble.id}").json()[0]
|
||||
|
||||
assert ligne["nb_depenses"] == 1
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Suppression des lots sans rattachement
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_supprime_un_lot_inutilise(api_client, immeuble_et_lot, db_session):
|
||||
"""Les lots d'un ancien format de numerotation doivent pouvoir disparaitre."""
|
||||
immeuble, _ = immeuble_et_lot
|
||||
orphelin = Lot(immeuble_id=immeuble.id, numero="0001", type="Appartement T1")
|
||||
db_session.add(orphelin)
|
||||
db_session.commit()
|
||||
|
||||
response = api_client.delete(f"/api/lots/{orphelin.id}")
|
||||
|
||||
assert response.status_code == 204
|
||||
numeros = [
|
||||
ligne["numero"]
|
||||
for ligne in api_client.get(
|
||||
f"/api/lots/referentiel?immeuble_id={immeuble.id}"
|
||||
).json()
|
||||
]
|
||||
assert numeros == ["01"]
|
||||
|
||||
|
||||
def test_supprimer_un_lot_emporte_sa_fiche(api_client, immeuble_et_lot, db_session):
|
||||
"""Sans cascade, la fiche resterait en base sans lot pour la porter."""
|
||||
immeuble, _ = immeuble_et_lot
|
||||
orphelin = Lot(immeuble_id=immeuble.id, numero="0001")
|
||||
db_session.add(orphelin)
|
||||
db_session.commit()
|
||||
api_client.put(f"/api/lots/{orphelin.id}/caracteristiques", json=FICHE)
|
||||
|
||||
api_client.delete(f"/api/lots/{orphelin.id}")
|
||||
|
||||
assert db_session.query(LotCaracteristiques).count() == 0
|
||||
|
||||
|
||||
def test_refuse_de_supprimer_un_lot_avec_des_revenus(api_client, immeuble_et_lot):
|
||||
"""Le refus protege des montants : les perdre fausserait les comptes."""
|
||||
_, lot = immeuble_et_lot
|
||||
|
||||
response = api_client.delete(f"/api/lots/{lot.id}")
|
||||
|
||||
assert response.status_code == 409
|
||||
assert "revenu" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_refuse_de_supprimer_un_lot_avec_des_depenses(
|
||||
api_client, immeuble_et_lot, db_session
|
||||
):
|
||||
"""Une depense seule suffit a retenir le lot, meme sans aucun revenu."""
|
||||
immeuble, _ = immeuble_et_lot
|
||||
lot_charge = Lot(immeuble_id=immeuble.id, numero="0002")
|
||||
db_session.add(lot_charge)
|
||||
db_session.commit()
|
||||
depense = db_session.query(Depense).one()
|
||||
depense.lot_id = lot_charge.id
|
||||
db_session.commit()
|
||||
|
||||
response = api_client.delete(f"/api/lots/{lot_charge.id}")
|
||||
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
def test_supprimer_un_lot_inconnu_donne_404(api_client):
|
||||
assert api_client.delete("/api/lots/99999").status_code == 404
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Nom d'usage de l'immeuble
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_nomme_un_immeuble(api_client, immeuble_et_lot):
|
||||
immeuble, _ = immeuble_et_lot
|
||||
|
||||
response = api_client.put(
|
||||
f"/api/immeubles/{immeuble.id}", json={"denomination": "Servient"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["denomination"] == "Servient"
|
||||
assert api_client.get("/api/immeubles").json()[0]["denomination"] == "Servient"
|
||||
# La reponse remplace l'immeuble dans les listes du client : des compteurs a
|
||||
# zero le feraient passer pour un immeuble sans lot.
|
||||
assert response.json()["nb_lots"] == 1
|
||||
|
||||
|
||||
def test_effacer_le_nom_rend_l_immeuble_a_son_code(api_client, immeuble_et_lot):
|
||||
immeuble, _ = immeuble_et_lot
|
||||
api_client.put(f"/api/immeubles/{immeuble.id}", json={"denomination": "Servient"})
|
||||
|
||||
response = api_client.put(
|
||||
f"/api/immeubles/{immeuble.id}", json={"denomination": " "}
|
||||
)
|
||||
|
||||
assert response.json()["denomination"] is None
|
||||
assert response.json()["code"] == "IMM1"
|
||||
|
||||
|
||||
def test_nommer_un_immeuble_inconnu_donne_404(api_client):
|
||||
response = api_client.put("/api/immeubles/99999", json={"denomination": "Servient"})
|
||||
|
||||
assert response.status_code == 404
|
||||
@@ -24,11 +24,7 @@ def test_ajoute_extracted_at_a_une_base_existante(tmp_path, monkeypatch):
|
||||
importe_le = datetime(2026, 3, 1, 10, 0, tzinfo=timezone.utc)
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("ALTER TABLE documents DROP COLUMN extracted_at"))
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO immeubles (code) VALUES ('IMM1');"
|
||||
)
|
||||
)
|
||||
conn.execute(text("INSERT INTO immeubles (code) VALUES ('IMM1');"))
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO documents (reference, date, immeuble_id, json_data,"
|
||||
@@ -55,6 +51,37 @@ def test_ajoute_extracted_at_a_une_base_existante(tmp_path, monkeypatch):
|
||||
connection.reset_connection()
|
||||
|
||||
|
||||
def test_ajoute_denomination_a_une_base_existante(tmp_path, monkeypatch):
|
||||
"""La colonne arrive vide : deduire un nom d'usage du code l'inventerait."""
|
||||
db_path = tmp_path / "ancienne.sqlite"
|
||||
monkeypatch.setenv("PLESNA_DB_PATH", str(db_path))
|
||||
monkeypatch.setenv("PLESNA_STORAGE_PATH", str(tmp_path / "documents"))
|
||||
|
||||
connection.reset_connection()
|
||||
connection.init_db(db_path)
|
||||
engine = connection.get_engine(db_path)
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("ALTER TABLE immeubles DROP COLUMN denomination"))
|
||||
conn.execute(text("INSERT INTO immeubles (code) VALUES ('33689020')"))
|
||||
connection.reset_connection()
|
||||
|
||||
connection.init_db(db_path)
|
||||
|
||||
with connection.get_engine(db_path).begin() as conn:
|
||||
colonnes = {
|
||||
row[1] for row in conn.execute(text("PRAGMA table_info(immeubles)"))
|
||||
}
|
||||
assert "denomination" in colonnes
|
||||
|
||||
denomination, code = conn.execute(
|
||||
text("SELECT denomination, code FROM immeubles")
|
||||
).one()
|
||||
assert denomination is None
|
||||
assert code == "33689020"
|
||||
|
||||
connection.reset_connection()
|
||||
|
||||
|
||||
def test_rattrapage_idempotent(tmp_path, monkeypatch):
|
||||
"""Relancer init_db sur une base a jour ne doit rien casser."""
|
||||
db_path = tmp_path / "a_jour.sqlite"
|
||||
@@ -67,7 +94,9 @@ def test_rattrapage_idempotent(tmp_path, monkeypatch):
|
||||
connection.init_db(db_path)
|
||||
|
||||
with connection.get_engine(db_path).begin() as conn:
|
||||
colonnes = [row[1] for row in conn.execute(text("PRAGMA table_info(documents)"))]
|
||||
colonnes = [
|
||||
row[1] for row in conn.execute(text("PRAGMA table_info(documents)"))
|
||||
]
|
||||
assert colonnes.count("extracted_at") == 1
|
||||
|
||||
connection.reset_connection()
|
||||
|
||||
Reference in New Issue
Block a user