Compare commits
12 Commits
ac63922896
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 }}
|
NAMESPACE: ${{ secrets.REGISTRY_NAMESPACE }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# Une image publiee est une image deployable : rien ne part au registre sans
|
||||||
|
# que la suite soit passee. Les tests golden se sautent d'eux-memes ici (le
|
||||||
|
# corpus PDF n'est pas versionne), le reste de la suite tourne.
|
||||||
|
test:
|
||||||
|
name: Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
|
||||||
|
- name: Install backend dependencies
|
||||||
|
run: uv sync
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: uv run ruff check .
|
||||||
|
|
||||||
|
- name: Backend tests
|
||||||
|
run: uv run pytest
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
|
||||||
|
- name: Frontend tests
|
||||||
|
working-directory: frontend
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm test
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Build App Image
|
name: Build App Image
|
||||||
|
needs: [test]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -78,14 +117,16 @@ jobs:
|
|||||||
name: Build Summary
|
name: Build Summary
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [build]
|
needs: [build]
|
||||||
if: always()
|
# Pas de `always()` : un resume qui annonce un succes apres un build rate
|
||||||
|
# est pire que pas de resume du tout.
|
||||||
steps:
|
steps:
|
||||||
- name: Build summary
|
- name: Build summary
|
||||||
run: |
|
run: |
|
||||||
echo "## 🐳 Docker Image Built Successfully"
|
echo "## 🐳 Docker Image Built Successfully"
|
||||||
echo ""
|
echo ""
|
||||||
echo "- Registry: ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/plesna-gerance"
|
echo "- Registry: ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/plesna-gerance"
|
||||||
echo "- Tags: latest, ${{ gitea.ref_name }}"
|
echo "- Ref: ${{ gitea.ref_name }}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "### 🚀 Deployment"
|
echo "### 🚀 Deployment"
|
||||||
echo "docker compose up -d"
|
echo "Epingler la version dans .env (PLESNA_VERSION), puis :"
|
||||||
|
echo "docker compose pull && docker compose up -d"
|
||||||
|
|||||||
14
Makefile
14
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: install dev_back dev_front dev docker docker-build
|
.PHONY: install dev_back dev_front dev test docker docker-build release
|
||||||
|
|
||||||
# Installe les dependances backend + frontend (a refaire par worktree)
|
# Installe les dependances backend + frontend (a refaire par worktree)
|
||||||
install:
|
install:
|
||||||
@@ -17,6 +17,12 @@ dev_front:
|
|||||||
dev:
|
dev:
|
||||||
$(MAKE) dev_back & $(MAKE) dev_front & wait
|
$(MAKE) dev_back & $(MAKE) dev_front & wait
|
||||||
|
|
||||||
|
# Ce que la CI verifie avant de publier une image (cf. .gitea/workflows)
|
||||||
|
test:
|
||||||
|
uv run ruff check .
|
||||||
|
uv run pytest
|
||||||
|
cd frontend && npm test
|
||||||
|
|
||||||
# Tire l'image publiee (registre Gitea) et lance via podman-compose
|
# Tire l'image publiee (registre Gitea) et lance via podman-compose
|
||||||
docker:
|
docker:
|
||||||
podman-compose pull
|
podman-compose pull
|
||||||
@@ -25,3 +31,9 @@ docker:
|
|||||||
# Construit l'image en local (test avant publication)
|
# Construit l'image en local (test avant publication)
|
||||||
docker-build:
|
docker-build:
|
||||||
podman build -f backend.Dockerfile -t plesna-gerance:local .
|
podman build -f backend.Dockerfile -t plesna-gerance:local .
|
||||||
|
|
||||||
|
# Pose une version : aligne les fichiers, commite, tague (sans pousser).
|
||||||
|
# Usage : make release VERSION=0.2.0
|
||||||
|
release:
|
||||||
|
@test -n "$(VERSION)" || { echo "Usage : make release VERSION=0.2.0"; exit 1; }
|
||||||
|
uv run python scripts/release.py $(VERSION)
|
||||||
|
|||||||
76
README.md
76
README.md
@@ -35,6 +35,80 @@ make dev
|
|||||||
|
|
||||||
Ouvrir **http://localhost:5173** — le proxy Vite redirige `/api` vers le backend.
|
Ouvrir **http://localhost:5173** — le proxy Vite redirige `/api` vers le backend.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test # ruff check + pytest + vitest, soit exactement ce que la CI verifie
|
||||||
|
```
|
||||||
|
|
||||||
|
Les tests de non-regression des parseurs comparent l'extraction de PDF reels a
|
||||||
|
des references figees. PDF (`data/`) et references (`tests/golden/`) contiennent
|
||||||
|
des donnees personnelles et ne sont pas versionnes : ces tests se **sautent**
|
||||||
|
d'eux-memes la ou le corpus est absent, CI comprise. Les lancer pour de vrai
|
||||||
|
suppose donc un corpus local (cf. `tests/test_parsers_golden.py`).
|
||||||
|
|
||||||
|
## Versions et cycle de vie
|
||||||
|
|
||||||
|
La production heberge des donnees reelles : elle suit des **versions**, pas la
|
||||||
|
pointe de `main`.
|
||||||
|
|
||||||
|
### Ce qu'est une version
|
||||||
|
|
||||||
|
Un tag git `vMAJEUR.MINEUR.CORRECTIF` (semver), pose sur `main`. Le meme numero
|
||||||
|
est ecrit dans quatre fichiers, que l'outillage tient d'accord entre eux :
|
||||||
|
`pyproject.toml`, `src/plesna_gerance/__init__.py`, `frontend/package.json` et
|
||||||
|
`packaging/installer.iss` (version affichee par l'installeur Windows).
|
||||||
|
|
||||||
|
Quand incrementer quoi :
|
||||||
|
|
||||||
|
| Segment | Quand | Exemple |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| CORRECTIF (`0.1.1`) | correction sans changement d'usage | un montant mal lu |
|
||||||
|
| MINEUR (`0.2.0`) | nouvelle fonctionnalite, donnees existantes intactes | une nouvelle page |
|
||||||
|
| MAJEUR (`1.0.0`) | rupture : migration de base ou changement d'usage a annoncer | refonte du referentiel |
|
||||||
|
|
||||||
|
### Publier une version
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test # ce que la CI verifiera de toute facon
|
||||||
|
make release VERSION=0.2.0 # aligne les 4 fichiers, commite, pose le tag
|
||||||
|
git push origin main v0.2.0 # <- c'est ce push qui publie
|
||||||
|
```
|
||||||
|
|
||||||
|
`make release` **ne pousse pas** : le tag reste local tant qu'on ne l'a pas
|
||||||
|
envoye, ce qui laisse le temps de relire le commit de version. Le script
|
||||||
|
(`scripts/release.py`) refuse d'avancer hors de `main`, sur un arbre sale, ou
|
||||||
|
si le tag existe deja — un tag publie ne se reecrit pas.
|
||||||
|
|
||||||
|
### Ce que declenche le push d'un tag
|
||||||
|
|
||||||
|
- **`.gitea/workflows/docker-publish.yml`** : lance d'abord les tests
|
||||||
|
(`ruff`, `pytest`, `vitest`), et seulement s'ils passent construit et publie
|
||||||
|
l'image sous **trois** tags — `0.2.0`, `0.2` et `0`. Epingler `0.2.0` fige le
|
||||||
|
deploiement a l'octet pres ; `0.2` laisse entrer les correctifs de la serie.
|
||||||
|
- **`.github/workflows/build-windows.yml`** : construit l'executable et
|
||||||
|
l'installeur Windows (necessite un runner `windows-latest`).
|
||||||
|
|
||||||
|
Un push sur `main` **sans tag** publie `latest` et `main` : utile pour essayer
|
||||||
|
la pointe, jamais pour la production.
|
||||||
|
|
||||||
|
### Deployer une version
|
||||||
|
|
||||||
|
La version deployee est epinglee dans `.env`, lu par `docker-compose.yml` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # une seule fois
|
||||||
|
# editer PLESNA_VERSION=0.2.0
|
||||||
|
make docker # pull + up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Revenir en arriere, c'est la meme manoeuvre : remettre le numero precedent dans
|
||||||
|
`.env` et relancer `make docker`. Les donnees vivent dans le volume nomme
|
||||||
|
`plesna-data`, independamment de l'image — un retour arriere d'image ne les
|
||||||
|
touche pas. En revanche une version qui a **migre le schema** de la base ne se
|
||||||
|
defait pas en changeant le numero : sauvegarder le volume avant une montee de
|
||||||
|
version majeure.
|
||||||
|
|
||||||
## Production (Docker)
|
## Production (Docker)
|
||||||
|
|
||||||
Un **conteneur unique** : l'image embarque le frontend Vue builde, servi par
|
Un **conteneur unique** : l'image embarque le frontend Vue builde, servi par
|
||||||
@@ -42,7 +116,7 @@ le backend FastAPI (API + interface web sur le meme port). Pas de nginx.
|
|||||||
|
|
||||||
L'image est **publiee par la CI Gitea** (`.gitea/workflows/docker-publish.yml`)
|
L'image est **publiee par la CI Gitea** (`.gitea/workflows/docker-publish.yml`)
|
||||||
sur `git.opytex.org/lafrite/plesna-gerance`. Le `docker-compose.yml` la **tire**
|
sur `git.opytex.org/lafrite/plesna-gerance`. Le `docker-compose.yml` la **tire**
|
||||||
directement (pas de build local) :
|
directement (pas de build local), a la version epinglee dans `.env` :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make docker # podman-compose pull && podman-compose up -d
|
make docker # podman-compose pull && podman-compose up -d
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
# Image publiée par la CI Gitea (pas de build local).
|
# Image publiée par la CI Gitea (pas de build local). La version déployée
|
||||||
image: git.opytex.org/lafrite/plesna-gerance:latest
|
# est épinglée dans .env (voir .env.example) : en production on suit un
|
||||||
|
# numéro de version, pas la pointe de main. Sans .env, on retombe sur
|
||||||
|
# `latest`, qui suit main et n'a donc pas de garantie de stabilité.
|
||||||
|
image: git.opytex.org/lafrite/plesna-gerance:${PLESNA_VERSION:-latest}
|
||||||
ports:
|
ports:
|
||||||
# hôte:conteneur — l'app (API + interface web) écoute sur 8000
|
# hôte:conteneur — l'app (API + interface web) écoute sur 8000
|
||||||
- "8080:8000"
|
- "8080:8000"
|
||||||
|
|||||||
@@ -1,39 +1,69 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-screen flex flex-col bg-gray-950">
|
<div class="h-screen flex bg-gray-950">
|
||||||
<!-- Header compact -->
|
<!-- Navigation laterale -->
|
||||||
<header class="flex-shrink-0 bg-gray-900 border-b border-gray-700 px-6 py-2">
|
<aside
|
||||||
<div class="flex items-center justify-between gap-6">
|
class="flex-shrink-0 bg-gray-900 border-r border-gray-700 flex flex-col transition-all duration-200"
|
||||||
<router-link to="/" class="text-lg font-semibold text-white hover:text-blue-400 transition-colors whitespace-nowrap">
|
:class="replie ? 'w-16' : 'w-52'"
|
||||||
Plesna Gerance
|
>
|
||||||
<span class="text-gray-400 font-normal text-sm ml-2">Extracteur de comptes rendus</span>
|
<router-link
|
||||||
</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>
|
||||||
|
|
||||||
<!-- Navigation -->
|
<nav class="flex-1 overflow-y-auto px-2 py-3 space-y-4">
|
||||||
<nav class="flex items-center gap-1">
|
<NavLien v-bind="accueil" :replie="replie" />
|
||||||
<router-link
|
|
||||||
v-for="link in navLinks"
|
<div v-for="groupe in groupes" :key="groupe.titre">
|
||||||
:key="link.to"
|
<!-- Replie, le titre de groupe n'a plus la place de s'ecrire : un
|
||||||
:to="link.to"
|
filet garde la separation entre saisie et analyse. -->
|
||||||
class="px-3 py-1.5 text-sm rounded-lg transition-colors"
|
<div
|
||||||
:class="$route.path === link.to ? 'bg-gray-800 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-800'"
|
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"
|
||||||
>
|
>
|
||||||
{{ link.label }}
|
{{ groupe.titre }}
|
||||||
</router-link>
|
</div>
|
||||||
|
<NavLien v-for="lien in groupe.liens" :key="lien.to" v-bind="lien" :replie="replie" />
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<!-- Import button with file input -->
|
<!-- Config n'est pas une destination de travail : elle vit a l'ecart, en bas -->
|
||||||
<label class="btn btn-primary ml-2 cursor-pointer">
|
<div class="px-2 py-3 border-t border-gray-800 space-y-1">
|
||||||
Importer un PDF
|
<NavLien v-bind="config" :replie="replie" />
|
||||||
<input
|
|
||||||
ref="fileInput"
|
<button
|
||||||
type="file"
|
type="button"
|
||||||
accept="application/pdf,.pdf"
|
class="nav-link w-full text-gray-500 hover:text-white hover:bg-gray-800"
|
||||||
class="hidden"
|
:class="replie ? 'justify-center px-0' : ''"
|
||||||
@change="onFileSelect"
|
: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'"
|
||||||
/>
|
/>
|
||||||
</label>
|
</svg>
|
||||||
</nav>
|
<span v-if="!replie" class="truncate">Replier</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</aside>
|
||||||
|
|
||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<main class="flex-1 flex overflow-hidden">
|
<main class="flex-1 flex overflow-hidden">
|
||||||
@@ -43,32 +73,88 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import NavLien from './components/NavLien.vue'
|
||||||
import { pendingFile } from './store'
|
|
||||||
import { FEATURE_IA } from './features'
|
import { FEATURE_IA } from './features'
|
||||||
|
|
||||||
const router = useRouter()
|
// Le repli survit au rechargement : c'est un reglage d'espace de travail, pas
|
||||||
const fileInput = ref(null)
|
// 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
|
// URL gardent leurs noms d'origine (/revenus, /analytics), qui circulent dans
|
||||||
// des liens deja partages.
|
// des liens deja partages.
|
||||||
const navLinks = [
|
//
|
||||||
{ to: '/', label: 'Accueil' },
|
// Les fleches de Recettes et Depenses sont celles des actions rapides de
|
||||||
{ to: '/documents', label: 'Documents' },
|
// l'accueil : replie, le menu ne montre plus que ces icones, elles doivent
|
||||||
{ to: '/revenus', label: 'Recettes' },
|
// designer la meme chose d'un ecran a l'autre.
|
||||||
{ to: '/analytics', label: 'Dépenses' },
|
const groupes = [
|
||||||
...(FEATURE_IA ? [{ to: '/ia', label: 'IA' }] : []),
|
{
|
||||||
{ to: '/config', label: 'Config' }
|
titre: 'Saisie',
|
||||||
]
|
liens: [
|
||||||
|
{
|
||||||
function onFileSelect(e) {
|
to: '/documents',
|
||||||
const file = e.target.files[0]
|
label: 'Documents',
|
||||||
if (file && file.name.toLowerCase().endsWith('.pdf')) {
|
paths: [
|
||||||
pendingFile.value = file
|
'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'
|
||||||
router.push('/extract')
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: [])
|
||||||
|
]
|
||||||
}
|
}
|
||||||
e.target.value = '' // Reset input
|
]
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -4,15 +4,20 @@
|
|||||||
:class="{ 'w-full': fullWidth }"
|
:class="{ 'w-full': fullWidth }"
|
||||||
>
|
>
|
||||||
<!-- Mode lecture -->
|
<!-- Mode lecture -->
|
||||||
|
<!--
|
||||||
|
Le pointille reste visible au repos : sur une page ou cohabitent des
|
||||||
|
valeurs extraites et des valeurs deduites, son absence est ce qui signale
|
||||||
|
qu'un montant n'est pas modifiable.
|
||||||
|
-->
|
||||||
<span
|
<span
|
||||||
v-if="!isEditing"
|
v-if="!isEditing"
|
||||||
@click="startEditing"
|
@click="startEditing"
|
||||||
class="cursor-pointer border-b border-dashed border-transparent hover:border-gray-500 hover:bg-gray-700/60 px-1 py-0.5 rounded transition-colors min-w-[2rem]"
|
class="cursor-pointer border-b border-dashed border-gray-600 hover:border-gray-400 hover:bg-gray-700/60 px-1 py-0.5 rounded-t transition-colors min-w-[2rem]"
|
||||||
:class="[
|
:class="[
|
||||||
displayClass,
|
displayClass,
|
||||||
{ 'text-gray-500 italic': isEmpty }
|
{ 'text-gray-500 italic': isEmpty }
|
||||||
]"
|
]"
|
||||||
:title="'Cliquer pour modifier'"
|
:title="titreSurvol"
|
||||||
>
|
>
|
||||||
{{ displayValue }}
|
{{ displayValue }}
|
||||||
</span>
|
</span>
|
||||||
@@ -74,6 +79,15 @@ const props = defineProps({
|
|||||||
inputClass: {
|
inputClass: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: ''
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* D'où vient la valeur, pour l'infobulle. Les trois écrans qui emploient ce
|
||||||
|
* champ éditent aujourd'hui de l'extraction, d'où ce défaut ; une saisie
|
||||||
|
* manuelle (référentiel, formulaire) passerait son propre libellé.
|
||||||
|
*/
|
||||||
|
origine: {
|
||||||
|
type: String,
|
||||||
|
default: 'extraite du compte rendu'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -101,6 +115,14 @@ const displayValue = computed(() => {
|
|||||||
return props.modelValue
|
return props.modelValue
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Le survol dit d'où vient la valeur, pour qu'on ne la confonde jamais avec une
|
||||||
|
// valeur déduite affichée à côté.
|
||||||
|
const titreSurvol = computed(() =>
|
||||||
|
isEmpty.value
|
||||||
|
? `Aucune valeur ${props.origine} — cliquer pour la saisir`
|
||||||
|
: `Valeur ${props.origine} — cliquer pour la modifier`
|
||||||
|
)
|
||||||
|
|
||||||
const inputType = computed(() => {
|
const inputType = computed(() => {
|
||||||
if (props.type === 'currency' || props.type === 'number') return 'number'
|
if (props.type === 'currency' || props.type === 'number') return 'number'
|
||||||
if (props.type === 'date') return 'date'
|
if (props.type === 'date') return 'date'
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ import DataCard from './DataCard.vue'
|
|||||||
import DataRow from './DataRow.vue'
|
import DataRow from './DataRow.vue'
|
||||||
import LocataireCard from './LocataireCard.vue'
|
import LocataireCard from './LocataireCard.vue'
|
||||||
import OperationCard from './OperationCard.vue'
|
import OperationCard from './OperationCard.vue'
|
||||||
|
import { setNestedValue } from '../utils/chemin'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
data: {
|
data: {
|
||||||
@@ -348,19 +349,6 @@ function cloneData() {
|
|||||||
return JSON.parse(JSON.stringify(props.data))
|
return JSON.parse(JSON.stringify(props.data))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper pour setter une valeur nested
|
|
||||||
function setNestedValue(obj, path, value) {
|
|
||||||
const parts = path.split('.')
|
|
||||||
let current = obj
|
|
||||||
for (let i = 0; i < parts.length - 1; i++) {
|
|
||||||
if (!current[parts[i]]) {
|
|
||||||
current[parts[i]] = {}
|
|
||||||
}
|
|
||||||
current = current[parts[i]]
|
|
||||||
}
|
|
||||||
current[parts[parts.length - 1]] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mettre a jour les metadonnees
|
// Mettre a jour les metadonnees
|
||||||
function updateMetadata(path, value) {
|
function updateMetadata(path, value) {
|
||||||
const updated = cloneData()
|
const updated = cloneData()
|
||||||
|
|||||||
@@ -43,18 +43,31 @@
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- L'extraction ne se recoupe pas : a verifier en priorite -->
|
||||||
|
<span
|
||||||
|
v-if="aDesEcarts"
|
||||||
|
class="badge badge-warning text-[10px] uppercase tracking-wide"
|
||||||
|
:title="resumeEcarts"
|
||||||
|
>
|
||||||
|
A verifier
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="changed"
|
v-if="changed"
|
||||||
class="badge badge-warning text-[10px] uppercase tracking-wide"
|
class="badge badge-warning text-[10px] uppercase tracking-wide"
|
||||||
>
|
>
|
||||||
Modifié
|
Modifié
|
||||||
</span>
|
</span>
|
||||||
<div class="text-right">
|
<!-- Rappel de la ligne « Totaux » : elle se modifie dans le tableau, pas ici -->
|
||||||
|
<div
|
||||||
|
class="text-right"
|
||||||
|
title="Valeur extraite, reportée de la ligne Totaux — déplier le lot pour la modifier"
|
||||||
|
>
|
||||||
<div class="text-sm font-semibold" :class="totalClass">
|
<div class="text-sm font-semibold" :class="totalClass">
|
||||||
{{ formatCurrency(locataire.totaux?.total) }}
|
{{ formatMontantPrecis(locataire.totaux?.total) }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="locataire.totaux?.impayes > 0" class="text-xs text-red-400">
|
<div v-if="locataire.totaux?.impayes" class="text-xs" :class="locataire.totaux.impayes > 0 ? 'text-red-400' : 'text-blue-400'">
|
||||||
Impayes: {{ formatCurrency(locataire.totaux?.impayes) }}
|
{{ locataire.totaux.impayes > 0 ? 'Impayes' : 'Trop-percu' }}:
|
||||||
|
{{ formatMontantPrecis(Math.abs(locataire.totaux.impayes)) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Bouton supprimer -->
|
<!-- Bouton supprimer -->
|
||||||
@@ -69,156 +82,218 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<div v-show="expanded" class="border-t border-gray-700 bg-gray-900/60 p-3">
|
|
||||||
<!-- Totaux editables -->
|
|
||||||
<div class="grid grid-cols-4 gap-2 text-xs mb-3">
|
|
||||||
<div class="text-center p-2 bg-gray-800 border border-gray-700 rounded">
|
|
||||||
<div class="text-gray-500 mb-1">Loyers</div>
|
|
||||||
<EditableField
|
|
||||||
:modelValue="locataire.totaux?.loyers"
|
|
||||||
@update:modelValue="updateField('totaux.loyers', $event)"
|
|
||||||
type="currency"
|
|
||||||
displayClass="font-semibold text-gray-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="text-center p-2 bg-gray-800 border border-gray-700 rounded">
|
|
||||||
<div class="text-gray-500 mb-1">Taxes</div>
|
|
||||||
<EditableField
|
|
||||||
:modelValue="locataire.totaux?.taxes"
|
|
||||||
@update:modelValue="updateField('totaux.taxes', $event)"
|
|
||||||
type="currency"
|
|
||||||
displayClass="font-semibold text-gray-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="text-center p-2 bg-gray-800 border border-gray-700 rounded">
|
|
||||||
<div class="text-gray-500 mb-1">Provisions</div>
|
|
||||||
<EditableField
|
|
||||||
:modelValue="locataire.totaux?.provisions"
|
|
||||||
@update:modelValue="updateField('totaux.provisions', $event)"
|
|
||||||
type="currency"
|
|
||||||
displayClass="font-semibold text-gray-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="text-center p-2 bg-gray-800 border border-gray-700 rounded">
|
|
||||||
<div class="text-gray-500 mb-1">Regles</div>
|
|
||||||
<EditableField
|
|
||||||
:modelValue="locataire.totaux?.regles"
|
|
||||||
@update:modelValue="updateField('totaux.regles', $event)"
|
|
||||||
type="currency"
|
|
||||||
displayClass="font-semibold text-green-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Lignes detail -->
|
<!-- Content : le tableau du compte rendu, colonne pour colonne.
|
||||||
<div v-if="locataire.lignes?.length" class="space-y-1">
|
Monte a l'ouverture (`v-if`) : un document compte une vingtaine de lots,
|
||||||
<div class="flex items-center justify-between mb-1">
|
et garder replies une vingtaine de tableaux d'une centaine de champs
|
||||||
<span class="text-xs text-gray-500 font-medium">Detail des lignes</span>
|
editables les ferait re-rendre a chaque frappe sans qu'on les voie. -->
|
||||||
<button
|
<div v-if="expanded" class="border-t border-gray-700 bg-gray-900/60 p-3">
|
||||||
@click="addLigne"
|
<div class="flex items-center justify-between mb-1">
|
||||||
class="flex items-center gap-1 px-2 py-1 text-xs text-blue-400 hover:bg-blue-500/10 rounded transition-colors"
|
<span class="text-xs text-gray-500 font-medium">Detail des lignes</span>
|
||||||
>
|
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
|
||||||
</svg>
|
|
||||||
Ajouter
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-for="(ligne, idx) in locataire.lignes"
|
|
||||||
:key="idx"
|
|
||||||
class="text-xs bg-gray-800 border border-gray-700 rounded p-2"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-2">
|
|
||||||
<div class="flex items-center gap-2 flex-1">
|
|
||||||
<!-- Type de ligne -->
|
|
||||||
<select
|
|
||||||
:value="ligne.type"
|
|
||||||
@change="updateLigneField(idx, 'type', $event.target.value)"
|
|
||||||
class="px-1.5 py-0.5 rounded text-xs border border-gray-700 bg-gray-950 text-gray-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
<option value="loyer">loyer</option>
|
|
||||||
<option value="solde_anterieur">solde_anterieur</option>
|
|
||||||
<option value="rappel_loyer">rappel_loyer</option>
|
|
||||||
<option value="divers">divers</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<!-- Periode -->
|
|
||||||
<div class="flex items-center gap-1 text-gray-500">
|
|
||||||
<EditableField
|
|
||||||
:modelValue="ligne.periode?.debut"
|
|
||||||
@update:modelValue="updateLigneField(idx, 'periode.debut', $event)"
|
|
||||||
type="date"
|
|
||||||
placeholder="debut"
|
|
||||||
displayClass="text-xs"
|
|
||||||
/>
|
|
||||||
<span>-</span>
|
|
||||||
<EditableField
|
|
||||||
:modelValue="ligne.periode?.fin"
|
|
||||||
@update:modelValue="updateLigneField(idx, 'periode.fin', $event)"
|
|
||||||
type="date"
|
|
||||||
placeholder="fin"
|
|
||||||
displayClass="text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Libelle divers -->
|
|
||||||
<EditableField
|
|
||||||
v-if="ligne.type === 'divers'"
|
|
||||||
:modelValue="ligne.divers?.libelle"
|
|
||||||
@update:modelValue="updateLigneField(idx, 'divers.libelle', $event)"
|
|
||||||
type="text"
|
|
||||||
placeholder="libellé"
|
|
||||||
displayClass="text-xs text-gray-400 italic truncate"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<!-- Montant : divers -> montant divers ; sinon total (ou loyers) -->
|
|
||||||
<EditableField
|
|
||||||
v-if="ligne.type === 'divers'"
|
|
||||||
:modelValue="ligne.divers?.montant"
|
|
||||||
@update:modelValue="updateLigneField(idx, 'divers.montant', $event)"
|
|
||||||
type="currency"
|
|
||||||
displayClass="font-medium text-gray-200"
|
|
||||||
/>
|
|
||||||
<EditableField
|
|
||||||
v-else
|
|
||||||
:modelValue="ligne.total || ligne.loyers"
|
|
||||||
@update:modelValue="updateLigneField(idx, 'total', $event)"
|
|
||||||
type="currency"
|
|
||||||
displayClass="font-medium text-gray-200"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Bouton supprimer ligne -->
|
|
||||||
<button
|
|
||||||
@click="removeLigne(idx)"
|
|
||||||
class="p-1 text-gray-500 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
|
|
||||||
title="Supprimer cette ligne"
|
|
||||||
>
|
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bouton ajouter ligne si aucune -->
|
|
||||||
<div v-else class="text-center py-2">
|
|
||||||
<button
|
<button
|
||||||
@click="addLigne"
|
@click="addLigne"
|
||||||
class="flex items-center gap-1 px-3 py-1.5 text-xs text-blue-400 hover:bg-blue-500/10 rounded transition-colors mx-auto"
|
class="flex items-center gap-1 px-2 py-1 text-xs text-blue-400 hover:bg-blue-500/10 rounded transition-colors"
|
||||||
>
|
>
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
</svg>
|
</svg>
|
||||||
Ajouter une ligne
|
Ajouter
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-xs border-separate border-spacing-0">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-[10px] uppercase tracking-wide text-gray-500">
|
||||||
|
<th class="text-left font-medium pb-1 pr-2">Type</th>
|
||||||
|
<!-- Le compte rendu loge dans cette colonne aussi bien une periode
|
||||||
|
qu'un libelle de report : l'en-tete le dit. -->
|
||||||
|
<th class="text-left font-medium pb-1 pr-2">Periode / Libelle</th>
|
||||||
|
<th
|
||||||
|
v-for="colonne in COLONNES_TABLEAU"
|
||||||
|
:key="colonne.champ"
|
||||||
|
class="font-medium pb-1 px-2"
|
||||||
|
:class="alignement(colonne)"
|
||||||
|
>
|
||||||
|
{{ colonne.libelle }}
|
||||||
|
</th>
|
||||||
|
<th class="pb-1"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="(ligne, idx) in locataire.lignes"
|
||||||
|
:key="idx"
|
||||||
|
class="bg-gray-800/60 hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
<td class="py-1 pr-2 border-t border-gray-700/60">
|
||||||
|
<select
|
||||||
|
:value="ligne.type"
|
||||||
|
@change="updateLigneField(idx, 'type', $event.target.value)"
|
||||||
|
class="px-1.5 py-0.5 rounded text-xs border border-gray-700 bg-gray-950 text-gray-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="loyer">loyer</option>
|
||||||
|
<option value="solde_anterieur">solde_anterieur</option>
|
||||||
|
<option value="rappel_loyer">rappel_loyer</option>
|
||||||
|
<option value="divers">divers</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Un report de solde porte son montant dans cette colonne sur le
|
||||||
|
compte rendu, pas dans « Loyers » : le tableau fait de meme,
|
||||||
|
sans quoi la colonne Loyers semblerait ne pas sommer. Le libelle
|
||||||
|
du compte rendu n'est pas repris, la colonne « Type » le donne. -->
|
||||||
|
<td class="py-1 pr-2 border-t border-gray-700/60">
|
||||||
|
<div v-if="ligne.type === 'solde_anterieur'" class="flex items-center gap-1 whitespace-nowrap">
|
||||||
|
<EditableField
|
||||||
|
:modelValue="ligne.loyers"
|
||||||
|
@update:modelValue="updateLigneField(idx, 'loyers', $event)"
|
||||||
|
type="currency"
|
||||||
|
displayClass="text-xs text-gray-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex items-center gap-1 text-gray-500 whitespace-nowrap">
|
||||||
|
<EditableField
|
||||||
|
:modelValue="ligne.periode?.debut"
|
||||||
|
@update:modelValue="updateLigneField(idx, 'periode.debut', $event)"
|
||||||
|
type="date"
|
||||||
|
placeholder="debut"
|
||||||
|
displayClass="text-xs"
|
||||||
|
/>
|
||||||
|
<span>-</span>
|
||||||
|
<EditableField
|
||||||
|
:modelValue="ligne.periode?.fin"
|
||||||
|
@update:modelValue="updateLigneField(idx, 'periode.fin', $event)"
|
||||||
|
type="date"
|
||||||
|
placeholder="fin"
|
||||||
|
displayClass="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td
|
||||||
|
v-for="colonne in COLONNES_TABLEAU"
|
||||||
|
:key="colonne.champ"
|
||||||
|
class="py-1 px-2 border-t border-gray-700/60"
|
||||||
|
>
|
||||||
|
<!-- Divers : libelle puis montant, comme sur le compte rendu.
|
||||||
|
Le libelle reste vide tant qu'il n'y en a pas, mais cliquable. -->
|
||||||
|
<div v-if="colonne.champ === 'divers'" class="flex items-center justify-between gap-2">
|
||||||
|
<EditableField
|
||||||
|
:modelValue="ligne.divers?.libelle"
|
||||||
|
@update:modelValue="updateLigneField(idx, 'divers.libelle', $event)"
|
||||||
|
type="text"
|
||||||
|
placeholder=""
|
||||||
|
displayClass="text-xs text-gray-400 italic"
|
||||||
|
/>
|
||||||
|
<EditableField
|
||||||
|
:modelValue="ligne.divers?.montant"
|
||||||
|
@update:modelValue="updateLigneField(idx, 'divers.montant', $event)"
|
||||||
|
type="currency"
|
||||||
|
displayClass="text-xs text-gray-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- Le montant d'un report est rendu dans la colonne « Periode » -->
|
||||||
|
<div
|
||||||
|
v-else-if="colonne.champ !== 'loyers' || ligne.type !== 'solde_anterieur'"
|
||||||
|
class="flex justify-end"
|
||||||
|
>
|
||||||
|
<EditableField
|
||||||
|
:modelValue="ligne[colonne.champ]"
|
||||||
|
@update:modelValue="updateLigneField(idx, colonne.champ, $event)"
|
||||||
|
type="currency"
|
||||||
|
:displayClass="`text-xs ${classeMontant(colonne.champ, ligne[colonne.champ])}`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="py-1 border-t border-gray-700/60">
|
||||||
|
<button
|
||||||
|
@click="removeLigne(idx)"
|
||||||
|
class="p-1 text-gray-500 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
|
||||||
|
title="Supprimer cette ligne"
|
||||||
|
>
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr v-if="!locataire.lignes?.length">
|
||||||
|
<td colspan="10" class="py-3 text-center text-gray-500 border-t border-gray-700/60">
|
||||||
|
Aucune ligne extraite pour ce lot
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
|
||||||
|
<!-- Ligne « Totaux » du compte rendu : extraite elle aussi, donc editable -->
|
||||||
|
<tfoot>
|
||||||
|
<tr class="bg-gray-800">
|
||||||
|
<td class="py-1 pr-2 border-t-2 border-gray-600 text-gray-300 font-medium">Totaux</td>
|
||||||
|
|
||||||
|
<!-- Le solde anterieur occupe la colonne « Periode », comme sur le
|
||||||
|
compte rendu, mais se recoupe comme les autres colonnes. -->
|
||||||
|
<td
|
||||||
|
class="py-1 pr-2 border-t-2 border-gray-600"
|
||||||
|
:class="classeCellule('solde_anterieur')"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-1 whitespace-nowrap">
|
||||||
|
<span class="text-[10px] uppercase tracking-wide text-gray-500">Solde ant.</span>
|
||||||
|
<EditableField
|
||||||
|
:modelValue="locataire.totaux?.solde_anterieur"
|
||||||
|
@update:modelValue="updateField('totaux.solde_anterieur', $event)"
|
||||||
|
type="currency"
|
||||||
|
displayClass="text-xs text-gray-200"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-if="ecarts.solde_anterieur"
|
||||||
|
class="text-[10px] text-amber-500/90 italic cursor-help whitespace-nowrap"
|
||||||
|
:title="detailEcart('solde_anterieur')"
|
||||||
|
>
|
||||||
|
calculé : {{ formatMontantPrecis(ecarts.solde_anterieur.calcule) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Une colonne qui ne somme pas est signalee sur toute la cellule :
|
||||||
|
c'est le premier endroit ou l'oeil verifie le compte rendu. -->
|
||||||
|
<td
|
||||||
|
v-for="colonne in COLONNES_TABLEAU"
|
||||||
|
:key="colonne.champ"
|
||||||
|
class="py-1 px-2 border-t-2 border-gray-600"
|
||||||
|
:class="classeCellule(colonne.champ)"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-end">
|
||||||
|
<EditableField
|
||||||
|
:modelValue="locataire.totaux?.[colonne.champ]"
|
||||||
|
@update:modelValue="updateField(`totaux.${colonne.champ}`, $event)"
|
||||||
|
type="currency"
|
||||||
|
:displayClass="`text-xs font-semibold ${classeMontant(colonne.champ, locataire.totaux?.[colonne.champ])}`"
|
||||||
|
/>
|
||||||
|
<!-- Somme de la colonne. Ni pointille ni contraste : non modifiable. -->
|
||||||
|
<span
|
||||||
|
v-if="ecarts[colonne.champ]"
|
||||||
|
class="text-[10px] text-amber-500/90 italic mt-0.5 cursor-help px-1 whitespace-nowrap"
|
||||||
|
:title="detailEcart(colonne.champ)"
|
||||||
|
>
|
||||||
|
calculé : {{ formatMontantPrecis(ecarts[colonne.champ].calcule) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="border-t-2 border-gray-600"></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="aDesEcarts" class="mt-2 text-[10px] text-gray-500 italic">
|
||||||
|
Colonne(s) en surbrillance : le montant du compte rendu ne correspond pas a ce que
|
||||||
|
totalisent les lignes ci-dessus (« calculé : … »), signe qu'une valeur n'a pas ete
|
||||||
|
extraite. Seuls les champs soulignes sont modifiables, et eux seuls partent en base.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -226,6 +301,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, nextTick } from 'vue'
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
import EditableField from './EditableField.vue'
|
import EditableField from './EditableField.vue'
|
||||||
|
import { setNestedValue } from '../utils/chemin'
|
||||||
|
import { formatMontantPrecis } from '../utils/format'
|
||||||
|
import {
|
||||||
|
COLONNES_CRG,
|
||||||
|
COLONNES_TABLEAU,
|
||||||
|
ecartsAvecLignes,
|
||||||
|
totauxCalcules,
|
||||||
|
} from '../utils/totauxLocataire'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
locataire: {
|
locataire: {
|
||||||
@@ -248,6 +331,10 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['update:locataire', 'remove'])
|
const emit = defineEmits(['update:locataire', 'remove'])
|
||||||
|
|
||||||
|
const LIBELLES = Object.fromEntries(
|
||||||
|
COLONNES_CRG.map(({ champ, libelle }) => [champ, libelle])
|
||||||
|
)
|
||||||
|
|
||||||
const expanded = ref(false)
|
const expanded = ref(false)
|
||||||
const rootEl = ref(null)
|
const rootEl = ref(null)
|
||||||
const isHighlighted = ref(false)
|
const isHighlighted = ref(false)
|
||||||
@@ -265,6 +352,32 @@ watch(() => props.highlighted, (val) => {
|
|||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Tout ce qui est affiche vient de l'extraction. Les lignes sont neanmoins
|
||||||
|
// reagregees pour un seul usage : reperer les lots ou l'extraction se contredit.
|
||||||
|
// Ces valeurs ne sont ni enregistrees, ni reportees dans les champs.
|
||||||
|
const calcules = computed(() => totauxCalcules(props.locataire.lignes))
|
||||||
|
|
||||||
|
const ecarts = computed(() => ecartsAvecLignes(props.locataire.totaux, calcules.value))
|
||||||
|
|
||||||
|
const aDesEcarts = computed(() => Object.keys(ecarts.value).length > 0)
|
||||||
|
|
||||||
|
const resumeEcarts = computed(() =>
|
||||||
|
Object.keys(ecarts.value)
|
||||||
|
.map((champ) => detailEcart(champ))
|
||||||
|
.join('\n\n')
|
||||||
|
)
|
||||||
|
|
||||||
|
function detailEcart(champ) {
|
||||||
|
const nb = props.locataire.lignes?.length || 0
|
||||||
|
const { extrait, calcule, manquant } = ecarts.value[champ]
|
||||||
|
return (
|
||||||
|
`${LIBELLES[champ]}\n` +
|
||||||
|
`Extrait du compte rendu : ${formatMontantPrecis(extrait)}\n` +
|
||||||
|
`Calculé sur les ${nb} ligne(s) : ${formatMontantPrecis(calcule)}\n` +
|
||||||
|
`Écart de ${formatMontantPrecis(manquant)} : aucune ligne ne porte ce montant.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const totalClass = computed(() => {
|
const totalClass = computed(() => {
|
||||||
const total = props.locataire.totaux?.total || 0
|
const total = props.locataire.totaux?.total || 0
|
||||||
if (total > 0) return 'text-green-400'
|
if (total > 0) return 'text-green-400'
|
||||||
@@ -272,9 +385,20 @@ const totalClass = computed(() => {
|
|||||||
return 'text-gray-400'
|
return 'text-gray-400'
|
||||||
})
|
})
|
||||||
|
|
||||||
function formatCurrency(value) {
|
// Les regles apparaissent en vert sur le compte rendu, les impayes en rouge.
|
||||||
if (value === null || value === undefined) return '-'
|
function classeMontant(champ, valeur) {
|
||||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value)
|
if (champ === 'regles') return 'text-green-400'
|
||||||
|
if (champ === 'impayes') return valeur ? 'text-red-400' : 'text-gray-200'
|
||||||
|
return 'text-gray-200'
|
||||||
|
}
|
||||||
|
|
||||||
|
function classeCellule(champ) {
|
||||||
|
return ecarts.value[champ] ? 'bg-amber-500/10 ring-1 ring-inset ring-amber-500/40' : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le compte rendu aligne ses montants a droite et le libelle « Divers » a gauche.
|
||||||
|
function alignement(colonne) {
|
||||||
|
return colonne.champ === 'divers' ? 'text-left' : 'text-right'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mettre a jour un champ nested (ex: "lot.numero", "totaux.loyers")
|
// Mettre a jour un champ nested (ex: "lot.numero", "totaux.loyers")
|
||||||
@@ -284,30 +408,14 @@ function updateField(path, value) {
|
|||||||
emit('update:locataire', updated)
|
emit('update:locataire', updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper pour setter une valeur nested
|
// Mettre a jour un champ d'une ligne. Aucun autre champ n'est touche : une
|
||||||
function setNestedValue(obj, path, value) {
|
// correction de l'utilisateur ne doit rien declencher qu'il n'ait pas demande.
|
||||||
const parts = path.split('.')
|
|
||||||
let current = obj
|
|
||||||
for (let i = 0; i < parts.length - 1; i++) {
|
|
||||||
if (!current[parts[i]]) {
|
|
||||||
current[parts[i]] = {}
|
|
||||||
}
|
|
||||||
current = current[parts[i]]
|
|
||||||
}
|
|
||||||
current[parts[parts.length - 1]] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mettre a jour un champ d'une ligne
|
|
||||||
function updateLigneField(ligneIndex, path, value) {
|
function updateLigneField(ligneIndex, path, value) {
|
||||||
const updated = JSON.parse(JSON.stringify(props.locataire))
|
const updated = JSON.parse(JSON.stringify(props.locataire))
|
||||||
if (!updated.lignes) updated.lignes = []
|
if (!updated.lignes) updated.lignes = []
|
||||||
|
|
||||||
if (path.includes('.')) {
|
setNestedValue(updated.lignes[ligneIndex], path, value)
|
||||||
setNestedValue(updated.lignes[ligneIndex], path, value)
|
|
||||||
} else {
|
|
||||||
updated.lignes[ligneIndex][path] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
emit('update:locataire', updated)
|
emit('update:locataire', updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,7 +423,7 @@ function updateLigneField(ligneIndex, path, value) {
|
|||||||
function addLigne() {
|
function addLigne() {
|
||||||
const updated = JSON.parse(JSON.stringify(props.locataire))
|
const updated = JSON.parse(JSON.stringify(props.locataire))
|
||||||
if (!updated.lignes) updated.lignes = []
|
if (!updated.lignes) updated.lignes = []
|
||||||
|
|
||||||
updated.lignes.push({
|
updated.lignes.push({
|
||||||
type: 'loyer',
|
type: 'loyer',
|
||||||
periode: { debut: null, fin: null },
|
periode: { debut: null, fin: null },
|
||||||
@@ -327,7 +435,7 @@ function addLigne() {
|
|||||||
regles: 0,
|
regles: 0,
|
||||||
impayes: 0
|
impayes: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
emit('update:locataire', updated)
|
emit('update:locataire', updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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>
|
<script setup>
|
||||||
import { ref, computed, watch, nextTick } from 'vue'
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
import EditableField from './EditableField.vue'
|
import EditableField from './EditableField.vue'
|
||||||
|
import { setNestedValue } from '../utils/chemin'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
categorie: {
|
categorie: {
|
||||||
@@ -257,19 +258,6 @@ function formatCurrency(value) {
|
|||||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value)
|
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper pour setter une valeur nested
|
|
||||||
function setNestedValue(obj, path, value) {
|
|
||||||
const parts = path.split('.')
|
|
||||||
let current = obj
|
|
||||||
for (let i = 0; i < parts.length - 1; i++) {
|
|
||||||
if (!current[parts[i]]) {
|
|
||||||
current[parts[i]] = {}
|
|
||||||
}
|
|
||||||
current = current[parts[i]]
|
|
||||||
}
|
|
||||||
current[parts[parts.length - 1]] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mettre a jour un champ d'une operation
|
// Mettre a jour un champ d'une operation
|
||||||
function updateOperationField(opIndex, path, value) {
|
function updateOperationField(opIndex, path, value) {
|
||||||
const updatedOperations = JSON.parse(JSON.stringify(props.operations))
|
const updatedOperations = JSON.parse(JSON.stringify(props.operations))
|
||||||
|
|||||||
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 AnalyticsPage from './pages/AnalyticsPage.vue'
|
||||||
import RevenusPage from './pages/RevenusPage.vue'
|
import RevenusPage from './pages/RevenusPage.vue'
|
||||||
import DocumentsPage from './pages/DocumentsPage.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 EditDocumentPage from './pages/EditDocumentPage.vue'
|
||||||
import ReExtractionPage from './pages/ReExtractionPage.vue'
|
import ReExtractionPage from './pages/ReExtractionPage.vue'
|
||||||
import ConfigPage from './pages/ConfigPage.vue'
|
import ConfigPage from './pages/ConfigPage.vue'
|
||||||
@@ -55,6 +57,18 @@ const routes = [
|
|||||||
name: 'documents',
|
name: 'documents',
|
||||||
component: DocumentsPage
|
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',
|
path: '/documents/:id/edit',
|
||||||
name: 'edit-document',
|
name: 'edit-document',
|
||||||
|
|||||||
@@ -40,6 +40,13 @@
|
|||||||
@apply text-sm text-gray-400 mt-1;
|
@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 ----------------------------------------------------------- */
|
/* --- Cartes ----------------------------------------------------------- */
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
@@ -112,6 +119,15 @@
|
|||||||
@apply cursor-pointer;
|
@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 {
|
.form-hint {
|
||||||
@apply text-xs text-gray-500 mt-1;
|
@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({})
|
||||||
|
})
|
||||||
|
})
|
||||||
155
scripts/release.py
Normal file
155
scripts/release.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
"""Pose une version : aligne les fichiers, commite, tague.
|
||||||
|
|
||||||
|
Le numéro de version est écrit à quatre endroits qui doivent rester d'accord
|
||||||
|
(paquet Python, module, frontend, installeur Windows). Les tenir à jour à la
|
||||||
|
main, c'est publier tôt ou tard un tag `v0.3.0` sur un code qui se déclare
|
||||||
|
`0.1.0`. Ce script fait la mise à jour d'un bloc et refuse d'avancer au moindre
|
||||||
|
doute plutôt que de produire une version à moitié cohérente.
|
||||||
|
|
||||||
|
uv run python scripts/release.py 0.2.0
|
||||||
|
|
||||||
|
Il s'arrête avant le `git push` : le tag reste local tant qu'il n'est pas
|
||||||
|
poussé, et c'est le push qui déclenche la CI (images Docker + build Windows).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
RACINE = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# Un fichier, le motif qui y porte la version, et le remplacement. Le motif doit
|
||||||
|
# capturer la version dans le groupe 1 pour que l'on puisse relire l'ancienne
|
||||||
|
# valeur, et matcher **exactement une fois** (vérifié plus bas).
|
||||||
|
PORTEURS_DE_VERSION = [
|
||||||
|
("pyproject.toml", re.compile(r'^version = "([^"]+)"$', re.M), 'version = "{v}"'),
|
||||||
|
(
|
||||||
|
"src/plesna_gerance/__init__.py",
|
||||||
|
re.compile(r'^__version__ = "([^"]+)"$', re.M),
|
||||||
|
'__version__ = "{v}"',
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"frontend/package.json",
|
||||||
|
re.compile(r'^ "version": "([^"]+)",$', re.M),
|
||||||
|
' "version": "{v}",',
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"packaging/installer.iss",
|
||||||
|
re.compile(r'^#define AppVersion "([^"]+)"$', re.M),
|
||||||
|
'#define AppVersion "{v}"',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
SEMVER = re.compile(r"^\d+\.\d+\.\d+$")
|
||||||
|
|
||||||
|
|
||||||
|
class Refus(Exception):
|
||||||
|
"""Condition non remplie : on s'arrête sans rien modifier."""
|
||||||
|
|
||||||
|
|
||||||
|
def git(*args: str) -> str:
|
||||||
|
"""Lance une commande git dans le dépôt et renvoie sa sortie."""
|
||||||
|
resultat = subprocess.run(
|
||||||
|
["git", "-C", str(RACINE), *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if resultat.returncode != 0:
|
||||||
|
raise Refus(f"git {' '.join(args)} a échoué :\n{resultat.stderr.strip()}")
|
||||||
|
return resultat.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def verifier_le_depot(version: str, autoriser_hors_main: bool) -> None:
|
||||||
|
"""Refuse de poser une version depuis un état de dépôt douteux."""
|
||||||
|
branche = git("rev-parse", "--abbrev-ref", "HEAD")
|
||||||
|
if branche != "main" and not autoriser_hors_main:
|
||||||
|
raise Refus(
|
||||||
|
f"branche courante « {branche} » : une version se pose sur main.\n"
|
||||||
|
"Fusionner d'abord, ou forcer avec --autoriser-hors-main."
|
||||||
|
)
|
||||||
|
|
||||||
|
if git("status", "--porcelain"):
|
||||||
|
raise Refus(
|
||||||
|
"l'arbre de travail n'est pas propre : commiter ou remiser d'abord.\n"
|
||||||
|
"Une version doit correspondre à un état de code identifiable."
|
||||||
|
)
|
||||||
|
|
||||||
|
if git("tag", "--list", f"v{version}"):
|
||||||
|
raise Refus(
|
||||||
|
f"le tag v{version} existe déjà. Choisir un numéro supérieur "
|
||||||
|
"(un tag publié ne se réécrit pas)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def appliquer_la_version(version: str) -> list[str]:
|
||||||
|
"""Écrit la version dans les fichiers porteurs. Renvoie ceux qui ont changé."""
|
||||||
|
modifies = []
|
||||||
|
for chemin_relatif, motif, remplacement in PORTEURS_DE_VERSION:
|
||||||
|
chemin = RACINE / chemin_relatif
|
||||||
|
contenu = chemin.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
occurrences = motif.findall(contenu)
|
||||||
|
if len(occurrences) != 1:
|
||||||
|
raise Refus(
|
||||||
|
f"{chemin_relatif} : {len(occurrences)} ligne(s) de version trouvée(s), "
|
||||||
|
"une seule attendue. Le fichier a changé de forme : "
|
||||||
|
"corriger le motif dans scripts/release.py."
|
||||||
|
)
|
||||||
|
|
||||||
|
if occurrences[0] == version:
|
||||||
|
continue
|
||||||
|
|
||||||
|
chemin.write_text(
|
||||||
|
motif.sub(remplacement.format(v=version), contenu), encoding="utf-8"
|
||||||
|
)
|
||||||
|
modifies.append(chemin_relatif)
|
||||||
|
|
||||||
|
return modifies
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parseur = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parseur.add_argument("version", help="numéro de version, sans le v (ex. 0.2.0)")
|
||||||
|
parseur.add_argument(
|
||||||
|
"--autoriser-hors-main",
|
||||||
|
action="store_true",
|
||||||
|
help="pose la version depuis une autre branche (rattrapage)",
|
||||||
|
)
|
||||||
|
arguments = parseur.parse_args()
|
||||||
|
|
||||||
|
version = arguments.version.lstrip("v")
|
||||||
|
if not SEMVER.match(version):
|
||||||
|
print(
|
||||||
|
f"Version « {arguments.version} » invalide : attendu MAJEUR.MINEUR.CORRECTIF "
|
||||||
|
"(ex. 0.2.0).",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
verifier_le_depot(version, arguments.autoriser_hors_main)
|
||||||
|
modifies = appliquer_la_version(version)
|
||||||
|
|
||||||
|
if modifies:
|
||||||
|
git("add", *modifies)
|
||||||
|
git("commit", "-m", f"chore: passe en version {version}")
|
||||||
|
print(f"Version écrite dans : {', '.join(modifies)}")
|
||||||
|
else:
|
||||||
|
print(f"Les fichiers déclarent déjà {version} : aucun commit de version.")
|
||||||
|
|
||||||
|
git("tag", "-a", f"v{version}", "-m", f"Version {version}")
|
||||||
|
except Refus as refus:
|
||||||
|
print(f"Publication interrompue : {refus}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"Tag v{version} posé sur {git('rev-parse', '--short', 'HEAD')}.")
|
||||||
|
print("\nRien n'est publié tant que le tag n'est pas poussé :")
|
||||||
|
print(f" git push origin main v{version}")
|
||||||
|
print("\nLe push déclenche la CI : images Docker taguées puis build Windows.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -18,6 +18,8 @@ from .routes import (
|
|||||||
documents_router,
|
documents_router,
|
||||||
extraction_router,
|
extraction_router,
|
||||||
ia_router,
|
ia_router,
|
||||||
|
lot_analyse_router,
|
||||||
|
referentiel_router,
|
||||||
revenus_router,
|
revenus_router,
|
||||||
tags_router,
|
tags_router,
|
||||||
)
|
)
|
||||||
@@ -52,6 +54,8 @@ app.include_router(tags_router)
|
|||||||
app.include_router(analytics_router)
|
app.include_router(analytics_router)
|
||||||
app.include_router(dashboard_router)
|
app.include_router(dashboard_router)
|
||||||
app.include_router(revenus_router)
|
app.include_router(revenus_router)
|
||||||
|
app.include_router(referentiel_router)
|
||||||
|
app.include_router(lot_analyse_router)
|
||||||
if FEATURE_IA:
|
if FEATURE_IA:
|
||||||
app.include_router(ia_router)
|
app.include_router(ia_router)
|
||||||
app.include_router(config_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 .documents import router as documents_router
|
||||||
from .extraction import router as extraction_router
|
from .extraction import router as extraction_router
|
||||||
from .ia import router as ia_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 .revenus import router as revenus_router
|
||||||
from .tags import router as tags_router
|
from .tags import router as tags_router
|
||||||
|
|
||||||
@@ -16,6 +18,8 @@ __all__ = [
|
|||||||
"analytics_router",
|
"analytics_router",
|
||||||
"dashboard_router",
|
"dashboard_router",
|
||||||
"revenus_router",
|
"revenus_router",
|
||||||
|
"referentiel_router",
|
||||||
|
"lot_analyse_router",
|
||||||
"ia_router",
|
"ia_router",
|
||||||
"config_router",
|
"config_router",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from ...database import get_session
|
from ...database import get_session
|
||||||
from ...database.models import Depense, Document, Immeuble, Lot, Tag
|
from ...database.models import Depense, Document, Immeuble, Lot, Tag
|
||||||
|
from ...services.referentiel import TYPE_LOT_EFFECTIF, joindre_fiche
|
||||||
from ..schemas.models import (
|
from ..schemas.models import (
|
||||||
CategorySummary,
|
CategorySummary,
|
||||||
DepenseDetail,
|
DepenseDetail,
|
||||||
@@ -58,6 +59,7 @@ async def list_immeubles(
|
|||||||
ImmeubleResponse(
|
ImmeubleResponse(
|
||||||
id=row.Immeuble.id,
|
id=row.Immeuble.id,
|
||||||
code=row.Immeuble.code,
|
code=row.Immeuble.code,
|
||||||
|
denomination=row.Immeuble.denomination,
|
||||||
adresse=row.Immeuble.adresse,
|
adresse=row.Immeuble.adresse,
|
||||||
ville=row.Immeuble.ville,
|
ville=row.Immeuble.ville,
|
||||||
code_postal=row.Immeuble.code_postal,
|
code_postal=row.Immeuble.code_postal,
|
||||||
@@ -77,11 +79,15 @@ async def list_lots(
|
|||||||
|
|
||||||
- **immeuble_id**: ID de l'immeuble pour filtrer (optionnel)
|
- **immeuble_id**: ID de l'immeuble pour filtrer (optionnel)
|
||||||
"""
|
"""
|
||||||
stmt = (
|
stmt = joindre_fiche(
|
||||||
select(Lot, Immeuble.code.label("immeuble_code"))
|
select(
|
||||||
.join(Immeuble, Lot.immeuble_id == Immeuble.id)
|
Lot.id,
|
||||||
.order_by(Immeuble.code, Lot.numero)
|
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:
|
if immeuble_id is not None:
|
||||||
stmt = stmt.where(Lot.immeuble_id == immeuble_id)
|
stmt = stmt.where(Lot.immeuble_id == immeuble_id)
|
||||||
@@ -91,10 +97,10 @@ async def list_lots(
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
LotResponse(
|
LotResponse(
|
||||||
id=row.Lot.id,
|
id=row.id,
|
||||||
numero=row.Lot.numero,
|
numero=row.numero,
|
||||||
type=row.Lot.type,
|
type=row.type,
|
||||||
immeuble_id=row.Lot.immeuble_id,
|
immeuble_id=row.immeuble_id,
|
||||||
immeuble_code=row.immeuble_code,
|
immeuble_code=row.immeuble_code,
|
||||||
)
|
)
|
||||||
for row in rows
|
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,
|
Lot,
|
||||||
Revenu,
|
Revenu,
|
||||||
)
|
)
|
||||||
|
from ...services.referentiel import TYPE_LOT_EFFECTIF, joindre_fiche
|
||||||
from ...services.revenus_query import (
|
from ...services.revenus_query import (
|
||||||
est_flux,
|
est_flux,
|
||||||
flux_par,
|
flux_par,
|
||||||
@@ -390,26 +391,28 @@ async def get_revenus_by_lot(
|
|||||||
flux = flux_par(Revenu.lot_id)
|
flux = flux_par(Revenu.lot_id)
|
||||||
dette = restant_du_par(Revenu.lot_id)
|
dette = restant_du_par(Revenu.lot_id)
|
||||||
stmt = (
|
stmt = (
|
||||||
select(
|
joindre_fiche(
|
||||||
Lot.id,
|
select(
|
||||||
Lot.numero,
|
Lot.id,
|
||||||
Lot.type,
|
Lot.numero,
|
||||||
Immeuble.code,
|
TYPE_LOT_EFFECTIF.label("type"),
|
||||||
func.max(Locataire.nom).label("locataire_nom"),
|
Immeuble.code,
|
||||||
flux.c.facture,
|
func.max(Locataire.nom).label("locataire_nom"),
|
||||||
flux.c.encaisse,
|
flux.c.facture,
|
||||||
dette.c.restant_du,
|
flux.c.encaisse,
|
||||||
func.max(Document.date).label("derniere_date"),
|
dette.c.restant_du,
|
||||||
|
func.max(Document.date).label("derniere_date"),
|
||||||
|
)
|
||||||
|
.join(Immeuble, Lot.immeuble_id == Immeuble.id)
|
||||||
|
.join(Revenu, Revenu.lot_id == Lot.id)
|
||||||
|
.join(Document, Revenu.document_id == Document.id)
|
||||||
|
.outerjoin(
|
||||||
|
Locataire,
|
||||||
|
and_(Locataire.lot_id == Lot.id, Locataire.date_fin.is_(None)),
|
||||||
|
)
|
||||||
|
.outerjoin(flux, flux.c.cle == Lot.id)
|
||||||
|
.outerjoin(dette, dette.c.cle == Lot.id)
|
||||||
)
|
)
|
||||||
.join(Immeuble, Lot.immeuble_id == Immeuble.id)
|
|
||||||
.join(Revenu, Revenu.lot_id == Lot.id)
|
|
||||||
.join(Document, Revenu.document_id == Document.id)
|
|
||||||
.outerjoin(
|
|
||||||
Locataire,
|
|
||||||
and_(Locataire.lot_id == Lot.id, Locataire.date_fin.is_(None)),
|
|
||||||
)
|
|
||||||
.outerjoin(flux, flux.c.cle == Lot.id)
|
|
||||||
.outerjoin(dette, dette.c.cle == Lot.id)
|
|
||||||
.group_by(Lot.id)
|
.group_by(Lot.id)
|
||||||
.order_by(desc("restant_du"), desc("facture"))
|
.order_by(desc("restant_du"), desc("facture"))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
"""Pydantic schemas for API request/response models."""
|
"""Pydantic schemas for API request/response models."""
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
|
CaracteristiquesBody,
|
||||||
|
CaracteristiquesResponse,
|
||||||
DepenseDetail,
|
DepenseDetail,
|
||||||
DepensesSummary,
|
DepensesSummary,
|
||||||
DocumentSummary,
|
DocumentSummary,
|
||||||
|
ImmeubleBody,
|
||||||
ImmeubleResponse,
|
ImmeubleResponse,
|
||||||
|
LotReferentielResponse,
|
||||||
LotResponse,
|
LotResponse,
|
||||||
PredictTagsRequest,
|
PredictTagsRequest,
|
||||||
SaveRequest,
|
SaveRequest,
|
||||||
@@ -18,6 +22,10 @@ __all__ = [
|
|||||||
"DocumentSummary",
|
"DocumentSummary",
|
||||||
"DepenseDetail",
|
"DepenseDetail",
|
||||||
"DepensesSummary",
|
"DepensesSummary",
|
||||||
|
"ImmeubleBody",
|
||||||
"ImmeubleResponse",
|
"ImmeubleResponse",
|
||||||
"LotResponse",
|
"LotResponse",
|
||||||
|
"CaracteristiquesBody",
|
||||||
|
"CaracteristiquesResponse",
|
||||||
|
"LotReferentielResponse",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"""Pydantic models for API requests and responses."""
|
"""Pydantic models for API requests and responses."""
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
from ...utils.logements import DPE_CLASSES
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Requests
|
# Requests
|
||||||
@@ -70,6 +72,7 @@ class ImmeubleResponse(BaseModel):
|
|||||||
|
|
||||||
id: int
|
id: int
|
||||||
code: str
|
code: str
|
||||||
|
denomination: str | None = None
|
||||||
adresse: str | None
|
adresse: str | None
|
||||||
ville: str | None
|
ville: str | None
|
||||||
code_postal: str | None
|
code_postal: str | None
|
||||||
@@ -180,3 +183,98 @@ class DepensesSummary(BaseModel):
|
|||||||
by_tag: list[TagSummary]
|
by_tag: list[TagSummary]
|
||||||
by_month: list[MonthlySummary]
|
by_month: list[MonthlySummary]
|
||||||
by_fournisseur: list[FournisseurSummary]
|
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 . import storage
|
||||||
from .connection import get_engine, get_session, get_session_factory, init_db
|
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
|
from .service import DatabaseService, DuplicateDocumentError
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -14,6 +24,7 @@ __all__ = [
|
|||||||
"Document",
|
"Document",
|
||||||
"Immeuble",
|
"Immeuble",
|
||||||
"Lot",
|
"Lot",
|
||||||
|
"LotCaracteristiques",
|
||||||
"Locataire",
|
"Locataire",
|
||||||
"Revenu",
|
"Revenu",
|
||||||
"Depense",
|
"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
|
#: Colonnes ajoutees apres coup, par table : nom -> (definition SQL, valeur de
|
||||||
#: rattrapage pour les lignes existantes). `create_all` ne modifie pas une table
|
#: rattrapage pour les lignes existantes, ou None quand il n'y a rien de vrai a
|
||||||
#: deja presente, et le projet n'utilise pas d'outil de migration : sans ce
|
#: y mettre). `create_all` ne modifie pas une table deja presente, et le projet
|
||||||
#: rattrapage, une base installee cesserait de fonctionner apres mise a jour.
|
#: n'utilise pas d'outil de migration : sans ce rattrapage, une base installee
|
||||||
|
#: cesserait de fonctionner apres mise a jour.
|
||||||
_ADDED_COLUMNS = {
|
_ADDED_COLUMNS = {
|
||||||
"documents": {
|
"documents": {
|
||||||
# Les documents deja en base ont ete extraits lors de leur import.
|
# Les documents deja en base ont ete extraits lors de leur import.
|
||||||
"extracted_at": ("DATETIME", "created_at"),
|
"extracted_at": ("DATETIME", "created_at"),
|
||||||
},
|
},
|
||||||
|
"immeubles": {
|
||||||
|
# Le nom d'usage se saisit : le deduire du code inventerait un nom.
|
||||||
|
"denomination": ("VARCHAR(100)", None),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -119,8 +124,11 @@ def _apply_schema_updates(engine):
|
|||||||
for name, (definition, backfill) in columns.items():
|
for name, (definition, backfill) in columns.items():
|
||||||
if name in existing:
|
if name in existing:
|
||||||
continue
|
continue
|
||||||
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {name} {definition}"))
|
conn.execute(
|
||||||
conn.execute(text(f"UPDATE {table} SET {name} = {backfill}"))
|
text(f"ALTER TABLE {table} ADD COLUMN {name} {definition}")
|
||||||
|
)
|
||||||
|
if backfill is not None:
|
||||||
|
conn.execute(text(f"UPDATE {table} SET {name} = {backfill}"))
|
||||||
|
|
||||||
|
|
||||||
def _seed_tags_if_empty(engine):
|
def _seed_tags_if_empty(engine):
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ class Immeuble(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
code = Column(String(20), unique=True, nullable=False, index=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)
|
adresse = Column(String(255), nullable=True)
|
||||||
ville = Column(String(100), nullable=True)
|
ville = Column(String(100), nullable=True)
|
||||||
code_postal = Column(String(10), nullable=True)
|
code_postal = Column(String(10), nullable=True)
|
||||||
@@ -103,11 +106,58 @@ class Lot(Base):
|
|||||||
)
|
)
|
||||||
revenus = relationship("Revenu", back_populates="lot")
|
revenus = relationship("Revenu", back_populates="lot")
|
||||||
depenses = relationship("Depense", 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:
|
def __repr__(self) -> str:
|
||||||
return f"<Lot(numero={self.numero}, type={self.type})>"
|
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):
|
class Locataire(Base):
|
||||||
"""Table des locataires avec historique."""
|
"""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
|
from ..database.models import Document, Revenu
|
||||||
|
|
||||||
#: Type des lignes qui reportent le solde du compte rendu précédent.
|
#: Type des lignes qui reportent le solde du compte rendu précédent.
|
||||||
|
#:
|
||||||
|
#: Repris tel quel côté frontend (``utils/totauxLocataire.js``), où il sépare le
|
||||||
|
#: solde antérieur des loyers de la période. Les deux décrivent la sortie du
|
||||||
|
#: même parser et doivent bouger ensemble.
|
||||||
TYPE_LIGNE_REPORT = "solde_anterieur"
|
TYPE_LIGNE_REPORT = "solde_anterieur"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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)
|
importe_le = datetime(2026, 3, 1, 10, 0, tzinfo=timezone.utc)
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
conn.execute(text("ALTER TABLE documents DROP COLUMN extracted_at"))
|
conn.execute(text("ALTER TABLE documents DROP COLUMN extracted_at"))
|
||||||
conn.execute(
|
conn.execute(text("INSERT INTO immeubles (code) VALUES ('IMM1');"))
|
||||||
text(
|
|
||||||
"INSERT INTO immeubles (code) VALUES ('IMM1');"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
text(
|
text(
|
||||||
"INSERT INTO documents (reference, date, immeuble_id, json_data,"
|
"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()
|
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):
|
def test_rattrapage_idempotent(tmp_path, monkeypatch):
|
||||||
"""Relancer init_db sur une base a jour ne doit rien casser."""
|
"""Relancer init_db sur une base a jour ne doit rien casser."""
|
||||||
db_path = tmp_path / "a_jour.sqlite"
|
db_path = tmp_path / "a_jour.sqlite"
|
||||||
@@ -67,7 +94,9 @@ def test_rattrapage_idempotent(tmp_path, monkeypatch):
|
|||||||
connection.init_db(db_path)
|
connection.init_db(db_path)
|
||||||
|
|
||||||
with connection.get_engine(db_path).begin() as conn:
|
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
|
assert colonnes.count("extracted_at") == 1
|
||||||
|
|
||||||
connection.reset_connection()
|
connection.reset_connection()
|
||||||
|
|||||||
Reference in New Issue
Block a user