Compare commits
25 Commits
37bddae6ca
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 233f000b2a | |||
| 24db55c46c | |||
| 552fc453dd | |||
| e2e2f5bc0e | |||
| b15f5c95b6 | |||
| faab7173bd | |||
| 78b9bbf129 | |||
| a5a1db9968 | |||
| e81cd5ffa4 | |||
| 7c19676400 | |||
| 24420027a5 | |||
| 005675728d | |||
| 2ee8e101b8 | |||
| 288b67c26f | |||
| b1ff0812c1 | |||
| fa54d523a5 | |||
| 4fe2bf75c0 | |||
| c42e190aa7 | |||
| 62d6eb169c | |||
| 5fc6f66955 | |||
| 1d138b8ca9 | |||
| 8c959f5d5f | |||
| 6a638df1ab | |||
| 7b033a0184 | |||
| 8d869a3dde |
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
||||
# Configuration du déploiement — copier en .env (non versionné) :
|
||||
# cp .env.example .env
|
||||
|
||||
# Version de l'image déployée, telle que publiée par la CI à partir d'un tag
|
||||
# git. Un tag v0.2.0 publie les images 0.2.0, 0.2 et 0 : épingler 0.2.0 fige
|
||||
# le déploiement à l'octet près, 0.2 laisse entrer les correctifs de la série.
|
||||
#
|
||||
# Monter de version ou revenir en arrière = changer ce numéro puis `make docker`.
|
||||
PLESNA_VERSION=0.1.0
|
||||
@@ -13,8 +13,47 @@ env:
|
||||
NAMESPACE: ${{ secrets.REGISTRY_NAMESPACE }}
|
||||
|
||||
jobs:
|
||||
# Une image publiee est une image deployable : rien ne part au registre sans
|
||||
# que la suite soit passee. Les tests golden se sautent d'eux-memes ici (le
|
||||
# corpus PDF n'est pas versionne), le reste de la suite tourne.
|
||||
test:
|
||||
name: Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
|
||||
- name: Install backend dependencies
|
||||
run: uv sync
|
||||
|
||||
- name: Lint
|
||||
run: uv run ruff check .
|
||||
|
||||
- name: Backend tests
|
||||
run: uv run pytest
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Frontend tests
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm ci
|
||||
npm test
|
||||
|
||||
build:
|
||||
name: Build App Image
|
||||
needs: [test]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -78,14 +117,16 @@ jobs:
|
||||
name: Build Summary
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
if: always()
|
||||
# Pas de `always()` : un resume qui annonce un succes apres un build rate
|
||||
# est pire que pas de resume du tout.
|
||||
steps:
|
||||
- name: Build summary
|
||||
run: |
|
||||
echo "## 🐳 Docker Image Built Successfully"
|
||||
echo ""
|
||||
echo "- Registry: ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/plesna-gerance"
|
||||
echo "- Tags: latest, ${{ gitea.ref_name }}"
|
||||
echo "- Ref: ${{ gitea.ref_name }}"
|
||||
echo ""
|
||||
echo "### 🚀 Deployment"
|
||||
echo "docker compose up -d"
|
||||
echo "Epingler la version dans .env (PLESNA_VERSION), puis :"
|
||||
echo "docker compose pull && docker compose up -d"
|
||||
|
||||
14
Makefile
14
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: install dev_back dev_front dev docker docker-build
|
||||
.PHONY: install dev_back dev_front dev test docker docker-build release
|
||||
|
||||
# Installe les dependances backend + frontend (a refaire par worktree)
|
||||
install:
|
||||
@@ -17,6 +17,12 @@ dev_front:
|
||||
dev:
|
||||
$(MAKE) dev_back & $(MAKE) dev_front & wait
|
||||
|
||||
# Ce que la CI verifie avant de publier une image (cf. .gitea/workflows)
|
||||
test:
|
||||
uv run ruff check .
|
||||
uv run pytest
|
||||
cd frontend && npm test
|
||||
|
||||
# Tire l'image publiee (registre Gitea) et lance via podman-compose
|
||||
docker:
|
||||
podman-compose pull
|
||||
@@ -25,3 +31,9 @@ docker:
|
||||
# Construit l'image en local (test avant publication)
|
||||
docker-build:
|
||||
podman build -f backend.Dockerfile -t plesna-gerance:local .
|
||||
|
||||
# Pose une version : aligne les fichiers, commite, tague (sans pousser).
|
||||
# Usage : make release VERSION=0.2.0
|
||||
release:
|
||||
@test -n "$(VERSION)" || { echo "Usage : make release VERSION=0.2.0"; exit 1; }
|
||||
uv run python scripts/release.py $(VERSION)
|
||||
|
||||
78
README.md
78
README.md
@@ -35,6 +35,82 @@ make dev
|
||||
|
||||
Ouvrir **http://localhost:5173** — le proxy Vite redirige `/api` vers le backend.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
make test # ruff check + pytest + vitest, soit exactement ce que la CI verifie
|
||||
```
|
||||
|
||||
Les tests de non-regression des parseurs comparent l'extraction de PDF reels a
|
||||
des references figees. PDF (`data/`) et references (`tests/golden/`) contiennent
|
||||
des donnees personnelles et ne sont pas versionnes : ces tests se **sautent**
|
||||
d'eux-memes la ou le corpus est absent, CI comprise. Les lancer pour de vrai
|
||||
suppose donc un corpus local (cf. `tests/test_parsers_golden.py`).
|
||||
|
||||
## Versions et cycle de vie
|
||||
|
||||
La production heberge des donnees reelles : elle suit des **versions**, pas la
|
||||
pointe de `main`.
|
||||
|
||||
### Ce qu'est une version
|
||||
|
||||
Un tag git `vMAJEUR.MINEUR.CORRECTIF` (semver), pose sur `main`. Le meme numero
|
||||
est ecrit dans cinq fichiers, que l'outillage tient d'accord entre eux :
|
||||
`pyproject.toml`, `src/plesna_gerance/__init__.py`, `frontend/package.json`,
|
||||
`frontend/package-lock.json` (qui porte lui aussi la version du paquet racine,
|
||||
sans quoi `npm ci` peut refuser de tourner en CI) et `packaging/installer.iss`
|
||||
(version affichee par l'installeur Windows).
|
||||
|
||||
Quand incrementer quoi :
|
||||
|
||||
| Segment | Quand | Exemple |
|
||||
| --- | --- | --- |
|
||||
| CORRECTIF (`0.1.1`) | correction sans changement d'usage | un montant mal lu |
|
||||
| MINEUR (`0.2.0`) | nouvelle fonctionnalite, donnees existantes intactes | une nouvelle page |
|
||||
| MAJEUR (`1.0.0`) | rupture : migration de base ou changement d'usage a annoncer | refonte du referentiel |
|
||||
|
||||
### Publier une version
|
||||
|
||||
```bash
|
||||
make test # ce que la CI verifiera de toute facon
|
||||
make release VERSION=0.2.0 # aligne les 4 fichiers, commite, pose le tag
|
||||
git push origin main v0.2.0 # <- c'est ce push qui publie
|
||||
```
|
||||
|
||||
`make release` **ne pousse pas** : le tag reste local tant qu'on ne l'a pas
|
||||
envoye, ce qui laisse le temps de relire le commit de version. Le script
|
||||
(`scripts/release.py`) refuse d'avancer hors de `main`, sur un arbre sale, ou
|
||||
si le tag existe deja — un tag publie ne se reecrit pas.
|
||||
|
||||
### Ce que declenche le push d'un tag
|
||||
|
||||
- **`.gitea/workflows/docker-publish.yml`** : lance d'abord les tests
|
||||
(`ruff`, `pytest`, `vitest`), et seulement s'ils passent construit et publie
|
||||
l'image sous **trois** tags — `0.2.0`, `0.2` et `0`. Epingler `0.2.0` fige le
|
||||
deploiement a l'octet pres ; `0.2` laisse entrer les correctifs de la serie.
|
||||
- **`.github/workflows/build-windows.yml`** : construit l'executable et
|
||||
l'installeur Windows (necessite un runner `windows-latest`).
|
||||
|
||||
Un push sur `main` **sans tag** publie `latest` et `main` : utile pour essayer
|
||||
la pointe, jamais pour la production.
|
||||
|
||||
### Deployer une version
|
||||
|
||||
La version deployee est epinglee dans `.env`, lu par `docker-compose.yml` :
|
||||
|
||||
```bash
|
||||
cp .env.example .env # une seule fois
|
||||
# editer PLESNA_VERSION=0.2.0
|
||||
make docker # pull + up -d
|
||||
```
|
||||
|
||||
Revenir en arriere, c'est la meme manoeuvre : remettre le numero precedent dans
|
||||
`.env` et relancer `make docker`. Les donnees vivent dans le volume nomme
|
||||
`plesna-data`, independamment de l'image — un retour arriere d'image ne les
|
||||
touche pas. En revanche une version qui a **migre le schema** de la base ne se
|
||||
defait pas en changeant le numero : sauvegarder le volume avant une montee de
|
||||
version majeure.
|
||||
|
||||
## Production (Docker)
|
||||
|
||||
Un **conteneur unique** : l'image embarque le frontend Vue builde, servi par
|
||||
@@ -42,7 +118,7 @@ le backend FastAPI (API + interface web sur le meme port). Pas de nginx.
|
||||
|
||||
L'image est **publiee par la CI Gitea** (`.gitea/workflows/docker-publish.yml`)
|
||||
sur `git.opytex.org/lafrite/plesna-gerance`. Le `docker-compose.yml` la **tire**
|
||||
directement (pas de build local) :
|
||||
directement (pas de build local), a la version epinglee dans `.env` :
|
||||
|
||||
```bash
|
||||
make docker # podman-compose pull && podman-compose up -d
|
||||
|
||||
@@ -7,8 +7,11 @@
|
||||
FROM node:22-alpine AS frontend
|
||||
|
||||
WORKDIR /frontend
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
RUN npm install
|
||||
# `npm ci` (et non `npm install`) : l'image doit installer exactement l'arbre
|
||||
# du lock, sinon deux builds du même tag peuvent embarquer des dépendances
|
||||
# différentes. Le lock n'est donc plus optionnel.
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
services:
|
||||
app:
|
||||
# Image publiée par la CI Gitea (pas de build local).
|
||||
image: git.opytex.org/lafrite/plesna-gerance:latest
|
||||
# Image publiée par la CI Gitea (pas de build local). La version déployée
|
||||
# est épinglée dans .env (voir .env.example) : en production on suit un
|
||||
# numéro de version, pas la pointe de main. Sans .env, on retombe sur
|
||||
# `latest`, qui suit main et n'a donc pas de garantie de stabilité.
|
||||
image: git.opytex.org/lafrite/plesna-gerance:${PLESNA_VERSION:-latest}
|
||||
ports:
|
||||
# hôte:conteneur — l'app (API + interface web) écoute sur 8000
|
||||
- "8080:8000"
|
||||
|
||||
1417
frontend/package-lock.json
generated
1417
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "plesna-gerance-frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -11,17 +11,16 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.4.1",
|
||||
"pdfjs-dist": "^6.0.227",
|
||||
"vue": "^3.4.21",
|
||||
"vue-chartjs": "^5.3.0",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"vite": "^5.1.6",
|
||||
"vite": "^7.3.6",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,69 @@
|
||||
<template>
|
||||
<div class="h-screen flex flex-col bg-gray-950">
|
||||
<!-- Header compact -->
|
||||
<header class="flex-shrink-0 bg-gray-900 border-b border-gray-700 px-6 py-2">
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<router-link to="/" class="text-lg font-semibold text-white hover:text-blue-400 transition-colors whitespace-nowrap">
|
||||
Plesna Gerance
|
||||
<span class="text-gray-400 font-normal text-sm ml-2">Extracteur de comptes rendus</span>
|
||||
</router-link>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex items-center gap-1">
|
||||
<router-link
|
||||
v-for="link in navLinks"
|
||||
:key="link.to"
|
||||
:to="link.to"
|
||||
class="px-3 py-1.5 text-sm rounded-lg transition-colors"
|
||||
:class="estActif(link.to) ? 'bg-gray-800 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-800'"
|
||||
<div class="h-screen flex bg-gray-950">
|
||||
<!-- Navigation laterale -->
|
||||
<aside
|
||||
class="flex-shrink-0 bg-gray-900 border-r border-gray-700 flex flex-col transition-all duration-200"
|
||||
:class="replie ? 'w-16' : 'w-52'"
|
||||
>
|
||||
{{ link.label }}
|
||||
<router-link
|
||||
to="/"
|
||||
class="block border-b border-gray-800 hover:bg-gray-800 transition-colors"
|
||||
:class="replie ? 'px-2 py-3 text-center' : 'px-4 py-3'"
|
||||
:title="replie ? 'Plesna Gerance' : null"
|
||||
>
|
||||
<template v-if="replie">
|
||||
<div class="text-base font-semibold text-white">PG</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="text-base font-semibold text-white leading-tight">Plesna Gerance</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5">Extracteur de comptes rendus</div>
|
||||
</template>
|
||||
</router-link>
|
||||
|
||||
<!-- Import button with file input -->
|
||||
<label class="btn btn-primary ml-2 cursor-pointer">
|
||||
Importer un PDF
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
class="hidden"
|
||||
@change="onFileSelect"
|
||||
/>
|
||||
</label>
|
||||
</nav>
|
||||
<nav class="flex-1 overflow-y-auto px-2 py-3 space-y-4">
|
||||
<NavLien v-bind="accueil" :replie="replie" />
|
||||
|
||||
<div v-for="groupe in groupes" :key="groupe.titre">
|
||||
<!-- Replie, le titre de groupe n'a plus la place de s'ecrire : un
|
||||
filet garde la separation entre saisie et analyse. -->
|
||||
<div
|
||||
v-if="replie"
|
||||
class="mx-3 mb-2 border-t border-gray-800"
|
||||
:aria-label="groupe.titre"
|
||||
></div>
|
||||
<div
|
||||
v-else
|
||||
class="px-3 pb-1 text-[11px] font-semibold uppercase tracking-wider text-gray-600"
|
||||
>
|
||||
{{ groupe.titre }}
|
||||
</div>
|
||||
</header>
|
||||
<NavLien v-for="lien in groupe.liens" :key="lien.to" v-bind="lien" :replie="replie" />
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Config n'est pas une destination de travail : elle vit a l'ecart, en bas -->
|
||||
<div class="px-2 py-3 border-t border-gray-800 space-y-1">
|
||||
<NavLien v-bind="config" :replie="replie" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="nav-link w-full text-gray-500 hover:text-white hover:bg-gray-800"
|
||||
:class="replie ? 'justify-center px-0' : ''"
|
||||
:title="replie ? 'Déplier le menu' : 'Replier le menu'"
|
||||
@click="replie = !replie"
|
||||
>
|
||||
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
:d="replie ? 'M13 5l7 7-7 7M5 5l7 7-7 7' : 'M11 19l-7-7 7-7m8 14l-7-7 7-7'"
|
||||
/>
|
||||
</svg>
|
||||
<span v-if="!replie" class="truncate">Replier</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="flex-1 flex overflow-hidden">
|
||||
@@ -43,42 +73,88 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { pendingFile } from './store'
|
||||
import { ref, watch } from 'vue'
|
||||
import NavLien from './components/NavLien.vue'
|
||||
import { FEATURE_IA } from './features'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const fileInput = ref(null)
|
||||
// Le repli survit au rechargement : c'est un reglage d'espace de travail, pas
|
||||
// un etat de page — le retrouver deplie a chaque F5 serait une corvee.
|
||||
const replie = ref(localStorage.getItem('nav-repliee') === '1')
|
||||
watch(replie, (valeur) => localStorage.setItem('nav-repliee', valeur ? '1' : '0'))
|
||||
|
||||
// Une fiche de lot (/lots/26) doit garder son onglet allumé : comparer les
|
||||
// chemins à l'identique éteindrait la navigation dès qu'une page a des
|
||||
// sous-routes.
|
||||
function estActif(chemin) {
|
||||
return route.path === chemin || route.path.startsWith(`${chemin}/`)
|
||||
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'
|
||||
]
|
||||
}
|
||||
|
||||
// Les libelles suivent le vocabulaire de l'accueil : recettes et depenses. Les
|
||||
const config = {
|
||||
to: '/config',
|
||||
label: 'Config',
|
||||
paths: [
|
||||
'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z',
|
||||
'M15 12a3 3 0 11-6 0 3 3 0 016 0z'
|
||||
]
|
||||
}
|
||||
|
||||
// Deux natures de travail, deux groupes : on alimente la base (Saisie) ou on
|
||||
// lit ce qu'elle contient (Analyse). « Fiches logements » et « Par lot »
|
||||
// portent des noms distincts parce que les deux pages parlent du même objet :
|
||||
// l'une décrit le logement, l'autre analyse ce qui s'y est passé.
|
||||
//
|
||||
// Les libellés suivent le vocabulaire de l'accueil : recettes et depenses. Les
|
||||
// URL gardent leurs noms d'origine (/revenus, /analytics), qui circulent dans
|
||||
// des liens deja partages.
|
||||
const navLinks = [
|
||||
{ to: '/', label: 'Accueil' },
|
||||
{ to: '/documents', label: 'Documents' },
|
||||
{ to: '/logements', label: 'Logements' },
|
||||
{ to: '/lots', label: 'Lots' },
|
||||
{ to: '/revenus', label: 'Recettes' },
|
||||
{ to: '/analytics', label: 'Dépenses' },
|
||||
...(FEATURE_IA ? [{ to: '/ia', label: 'IA' }] : []),
|
||||
{ to: '/config', label: 'Config' }
|
||||
]
|
||||
|
||||
function onFileSelect(e) {
|
||||
const file = e.target.files[0]
|
||||
if (file && file.name.toLowerCase().endsWith('.pdf')) {
|
||||
pendingFile.value = file
|
||||
router.push('/extract')
|
||||
//
|
||||
// Les fleches de Recettes et Depenses sont celles des actions rapides de
|
||||
// l'accueil : replie, le menu ne montre plus que ces icones, elles doivent
|
||||
// designer la meme chose d'un ecran a l'autre.
|
||||
const groupes = [
|
||||
{
|
||||
titre: 'Saisie',
|
||||
liens: [
|
||||
{
|
||||
to: '/documents',
|
||||
label: 'Documents',
|
||||
paths: [
|
||||
'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'
|
||||
]
|
||||
},
|
||||
{
|
||||
to: '/logements',
|
||||
label: 'Fiches logements',
|
||||
paths: [
|
||||
'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4'
|
||||
]
|
||||
}
|
||||
e.target.value = '' // Reset input
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
titre: 'Analyse',
|
||||
liens: [
|
||||
{ to: '/revenus', label: 'Recettes', paths: ['M12 19V5m0 0l-6 6m6-6l6 6'] },
|
||||
{ to: '/analytics', label: 'Dépenses', paths: ['M12 5v14m0 0l6-6m-6 6l-6-6'] },
|
||||
{
|
||||
to: '/lots',
|
||||
label: 'Par lot',
|
||||
paths: [
|
||||
'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z'
|
||||
]
|
||||
},
|
||||
...(FEATURE_IA
|
||||
? [
|
||||
{
|
||||
to: '/ia',
|
||||
label: 'IA',
|
||||
paths: [
|
||||
'M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z'
|
||||
]
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -4,15 +4,20 @@
|
||||
:class="{ 'w-full': fullWidth }"
|
||||
>
|
||||
<!-- Mode lecture -->
|
||||
<!--
|
||||
Le pointille reste visible au repos : sur une page ou cohabitent des
|
||||
valeurs extraites et des valeurs deduites, son absence est ce qui signale
|
||||
qu'un montant n'est pas modifiable.
|
||||
-->
|
||||
<span
|
||||
v-if="!isEditing"
|
||||
@click="startEditing"
|
||||
class="cursor-pointer border-b border-dashed border-transparent hover:border-gray-500 hover:bg-gray-700/60 px-1 py-0.5 rounded transition-colors min-w-[2rem]"
|
||||
class="cursor-pointer border-b border-dashed border-gray-600 hover:border-gray-400 hover:bg-gray-700/60 px-1 py-0.5 rounded-t transition-colors min-w-[2rem]"
|
||||
:class="[
|
||||
displayClass,
|
||||
{ 'text-gray-500 italic': isEmpty }
|
||||
]"
|
||||
:title="'Cliquer pour modifier'"
|
||||
:title="titreSurvol"
|
||||
>
|
||||
{{ displayValue }}
|
||||
</span>
|
||||
@@ -74,6 +79,15 @@ const props = defineProps({
|
||||
inputClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/**
|
||||
* D'où vient la valeur, pour l'infobulle. Les trois écrans qui emploient ce
|
||||
* champ éditent aujourd'hui de l'extraction, d'où ce défaut ; une saisie
|
||||
* manuelle (référentiel, formulaire) passerait son propre libellé.
|
||||
*/
|
||||
origine: {
|
||||
type: String,
|
||||
default: 'extraite du compte rendu'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -101,6 +115,14 @@ const displayValue = computed(() => {
|
||||
return props.modelValue
|
||||
})
|
||||
|
||||
// Le survol dit d'où vient la valeur, pour qu'on ne la confonde jamais avec une
|
||||
// valeur déduite affichée à côté.
|
||||
const titreSurvol = computed(() =>
|
||||
isEmpty.value
|
||||
? `Aucune valeur ${props.origine} — cliquer pour la saisir`
|
||||
: `Valeur ${props.origine} — cliquer pour la modifier`
|
||||
)
|
||||
|
||||
const inputType = computed(() => {
|
||||
if (props.type === 'currency' || props.type === 'number') return 'number'
|
||||
if (props.type === 'date') return 'date'
|
||||
|
||||
@@ -177,6 +177,7 @@ import DataCard from './DataCard.vue'
|
||||
import DataRow from './DataRow.vue'
|
||||
import LocataireCard from './LocataireCard.vue'
|
||||
import OperationCard from './OperationCard.vue'
|
||||
import { setNestedValue } from '../utils/chemin'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
@@ -248,7 +249,7 @@ const copyLabel = ref('Copier')
|
||||
const expandedSections = reactive({
|
||||
metadata: true,
|
||||
locataires: true,
|
||||
operations: false
|
||||
operations: true
|
||||
})
|
||||
|
||||
// Highlight state
|
||||
@@ -348,19 +349,6 @@ function cloneData() {
|
||||
return JSON.parse(JSON.stringify(props.data))
|
||||
}
|
||||
|
||||
// Helper pour setter une valeur nested
|
||||
function setNestedValue(obj, path, value) {
|
||||
const parts = path.split('.')
|
||||
let current = obj
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (!current[parts[i]]) {
|
||||
current[parts[i]] = {}
|
||||
}
|
||||
current = current[parts[i]]
|
||||
}
|
||||
current[parts[parts.length - 1]] = value
|
||||
}
|
||||
|
||||
// Mettre a jour les metadonnees
|
||||
function updateMetadata(path, value) {
|
||||
const updated = cloneData()
|
||||
|
||||
@@ -43,18 +43,31 @@
|
||||
/>
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- L'extraction ne se recoupe pas : a verifier en priorite -->
|
||||
<span
|
||||
v-if="aDesEcarts"
|
||||
class="badge badge-warning text-[10px] uppercase tracking-wide"
|
||||
:title="resumeEcarts"
|
||||
>
|
||||
A verifier
|
||||
</span>
|
||||
<span
|
||||
v-if="changed"
|
||||
class="badge badge-warning text-[10px] uppercase tracking-wide"
|
||||
>
|
||||
Modifié
|
||||
</span>
|
||||
<div class="text-right">
|
||||
<!-- Rappel de la ligne « Totaux » : elle se modifie dans le tableau, pas ici -->
|
||||
<div
|
||||
class="text-right"
|
||||
title="Valeur extraite, reportée de la ligne Totaux — déplier le lot pour la modifier"
|
||||
>
|
||||
<div class="text-sm font-semibold" :class="totalClass">
|
||||
{{ formatCurrency(locataire.totaux?.total) }}
|
||||
{{ formatMontantPrecis(locataire.totaux?.total) }}
|
||||
</div>
|
||||
<div v-if="locataire.totaux?.impayes > 0" class="text-xs text-red-400">
|
||||
Impayes: {{ formatCurrency(locataire.totaux?.impayes) }}
|
||||
<div v-if="locataire.totaux?.impayes" class="text-xs" :class="locataire.totaux.impayes > 0 ? 'text-red-400' : 'text-blue-400'">
|
||||
{{ locataire.totaux.impayes > 0 ? 'Impayes' : 'Trop-percu' }}:
|
||||
{{ formatMontantPrecis(Math.abs(locataire.totaux.impayes)) }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Bouton supprimer -->
|
||||
@@ -70,50 +83,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div v-show="expanded" class="border-t border-gray-700 bg-gray-900/60 p-3">
|
||||
<!-- Totaux editables -->
|
||||
<div class="grid grid-cols-4 gap-2 text-xs mb-3">
|
||||
<div class="text-center p-2 bg-gray-800 border border-gray-700 rounded">
|
||||
<div class="text-gray-500 mb-1">Loyers</div>
|
||||
<EditableField
|
||||
:modelValue="locataire.totaux?.loyers"
|
||||
@update:modelValue="updateField('totaux.loyers', $event)"
|
||||
type="currency"
|
||||
displayClass="font-semibold text-gray-200"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-center p-2 bg-gray-800 border border-gray-700 rounded">
|
||||
<div class="text-gray-500 mb-1">Taxes</div>
|
||||
<EditableField
|
||||
:modelValue="locataire.totaux?.taxes"
|
||||
@update:modelValue="updateField('totaux.taxes', $event)"
|
||||
type="currency"
|
||||
displayClass="font-semibold text-gray-200"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-center p-2 bg-gray-800 border border-gray-700 rounded">
|
||||
<div class="text-gray-500 mb-1">Provisions</div>
|
||||
<EditableField
|
||||
:modelValue="locataire.totaux?.provisions"
|
||||
@update:modelValue="updateField('totaux.provisions', $event)"
|
||||
type="currency"
|
||||
displayClass="font-semibold text-gray-200"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-center p-2 bg-gray-800 border border-gray-700 rounded">
|
||||
<div class="text-gray-500 mb-1">Regles</div>
|
||||
<EditableField
|
||||
:modelValue="locataire.totaux?.regles"
|
||||
@update:modelValue="updateField('totaux.regles', $event)"
|
||||
type="currency"
|
||||
displayClass="font-semibold text-green-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lignes detail -->
|
||||
<div v-if="locataire.lignes?.length" class="space-y-1">
|
||||
<!-- Content : le tableau du compte rendu, colonne pour colonne.
|
||||
Monte a l'ouverture (`v-if`) : un document compte une vingtaine de lots,
|
||||
et garder replies une vingtaine de tableaux d'une centaine de champs
|
||||
editables les ferait re-rendre a chaque frappe sans qu'on les voie. -->
|
||||
<div v-if="expanded" class="border-t border-gray-700 bg-gray-900/60 p-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-gray-500 font-medium">Detail des lignes</span>
|
||||
<button
|
||||
@@ -126,14 +100,34 @@
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-xs border-separate border-spacing-0">
|
||||
<thead>
|
||||
<tr class="text-[10px] uppercase tracking-wide text-gray-500">
|
||||
<th class="text-left font-medium pb-1 pr-2">Type</th>
|
||||
<!-- Le compte rendu loge dans cette colonne aussi bien une periode
|
||||
qu'un libelle de report : l'en-tete le dit. -->
|
||||
<th class="text-left font-medium pb-1 pr-2">Periode / Libelle</th>
|
||||
<th
|
||||
v-for="colonne in COLONNES_TABLEAU"
|
||||
:key="colonne.champ"
|
||||
class="font-medium pb-1 px-2"
|
||||
:class="alignement(colonne)"
|
||||
>
|
||||
{{ colonne.libelle }}
|
||||
</th>
|
||||
<th class="pb-1"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(ligne, idx) in locataire.lignes"
|
||||
:key="idx"
|
||||
class="text-xs bg-gray-800 border border-gray-700 rounded p-2"
|
||||
class="bg-gray-800/60 hover:bg-gray-800"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<!-- Type de ligne -->
|
||||
<td class="py-1 pr-2 border-t border-gray-700/60">
|
||||
<select
|
||||
:value="ligne.type"
|
||||
@change="updateLigneField(idx, 'type', $event.target.value)"
|
||||
@@ -144,9 +138,22 @@
|
||||
<option value="rappel_loyer">rappel_loyer</option>
|
||||
<option value="divers">divers</option>
|
||||
</select>
|
||||
</td>
|
||||
|
||||
<!-- Periode -->
|
||||
<div class="flex items-center gap-1 text-gray-500">
|
||||
<!-- Un report de solde porte son montant dans cette colonne sur le
|
||||
compte rendu, pas dans « Loyers » : le tableau fait de meme,
|
||||
sans quoi la colonne Loyers semblerait ne pas sommer. Le libelle
|
||||
du compte rendu n'est pas repris, la colonne « Type » le donne. -->
|
||||
<td class="py-1 pr-2 border-t border-gray-700/60">
|
||||
<div v-if="ligne.type === 'solde_anterieur'" class="flex items-center gap-1 whitespace-nowrap">
|
||||
<EditableField
|
||||
:modelValue="ligne.loyers"
|
||||
@update:modelValue="updateLigneField(idx, 'loyers', $event)"
|
||||
type="currency"
|
||||
displayClass="text-xs text-gray-200"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-1 text-gray-500 whitespace-nowrap">
|
||||
<EditableField
|
||||
:modelValue="ligne.periode?.debut"
|
||||
@update:modelValue="updateLigneField(idx, 'periode.debut', $event)"
|
||||
@@ -163,36 +170,45 @@
|
||||
displayClass="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Libelle divers -->
|
||||
<td
|
||||
v-for="colonne in COLONNES_TABLEAU"
|
||||
:key="colonne.champ"
|
||||
class="py-1 px-2 border-t border-gray-700/60"
|
||||
>
|
||||
<!-- Divers : libelle puis montant, comme sur le compte rendu.
|
||||
Le libelle reste vide tant qu'il n'y en a pas, mais cliquable. -->
|
||||
<div v-if="colonne.champ === 'divers'" class="flex items-center justify-between gap-2">
|
||||
<EditableField
|
||||
v-if="ligne.type === 'divers'"
|
||||
:modelValue="ligne.divers?.libelle"
|
||||
@update:modelValue="updateLigneField(idx, 'divers.libelle', $event)"
|
||||
type="text"
|
||||
placeholder="libellé"
|
||||
displayClass="text-xs text-gray-400 italic truncate"
|
||||
placeholder=""
|
||||
displayClass="text-xs text-gray-400 italic"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Montant : divers -> montant divers ; sinon total (ou loyers) -->
|
||||
<EditableField
|
||||
v-if="ligne.type === 'divers'"
|
||||
:modelValue="ligne.divers?.montant"
|
||||
@update:modelValue="updateLigneField(idx, 'divers.montant', $event)"
|
||||
type="currency"
|
||||
displayClass="font-medium text-gray-200"
|
||||
displayClass="text-xs text-gray-200"
|
||||
/>
|
||||
</div>
|
||||
<!-- Le montant d'un report est rendu dans la colonne « Periode » -->
|
||||
<div
|
||||
v-else-if="colonne.champ !== 'loyers' || ligne.type !== 'solde_anterieur'"
|
||||
class="flex justify-end"
|
||||
>
|
||||
<EditableField
|
||||
v-else
|
||||
:modelValue="ligne.total || ligne.loyers"
|
||||
@update:modelValue="updateLigneField(idx, 'total', $event)"
|
||||
:modelValue="ligne[colonne.champ]"
|
||||
@update:modelValue="updateLigneField(idx, colonne.champ, $event)"
|
||||
type="currency"
|
||||
displayClass="font-medium text-gray-200"
|
||||
:displayClass="`text-xs ${classeMontant(colonne.champ, ligne[colonne.champ])}`"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Bouton supprimer ligne -->
|
||||
<td class="py-1 border-t border-gray-700/60">
|
||||
<button
|
||||
@click="removeLigne(idx)"
|
||||
class="p-1 text-gray-500 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
|
||||
@@ -202,23 +218,82 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr v-if="!locataire.lignes?.length">
|
||||
<td colspan="10" class="py-3 text-center text-gray-500 border-t border-gray-700/60">
|
||||
Aucune ligne extraite pour ce lot
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
<!-- Ligne « Totaux » du compte rendu : extraite elle aussi, donc editable -->
|
||||
<tfoot>
|
||||
<tr class="bg-gray-800">
|
||||
<td class="py-1 pr-2 border-t-2 border-gray-600 text-gray-300 font-medium">Totaux</td>
|
||||
|
||||
<!-- Le solde anterieur occupe la colonne « Periode », comme sur le
|
||||
compte rendu, mais se recoupe comme les autres colonnes. -->
|
||||
<td
|
||||
class="py-1 pr-2 border-t-2 border-gray-600"
|
||||
:class="classeCellule('solde_anterieur')"
|
||||
>
|
||||
<div class="flex items-center gap-1 whitespace-nowrap">
|
||||
<span class="text-[10px] uppercase tracking-wide text-gray-500">Solde ant.</span>
|
||||
<EditableField
|
||||
:modelValue="locataire.totaux?.solde_anterieur"
|
||||
@update:modelValue="updateField('totaux.solde_anterieur', $event)"
|
||||
type="currency"
|
||||
displayClass="text-xs text-gray-200"
|
||||
/>
|
||||
<span
|
||||
v-if="ecarts.solde_anterieur"
|
||||
class="text-[10px] text-amber-500/90 italic cursor-help whitespace-nowrap"
|
||||
:title="detailEcart('solde_anterieur')"
|
||||
>
|
||||
calculé : {{ formatMontantPrecis(ecarts.solde_anterieur.calcule) }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Une colonne qui ne somme pas est signalee sur toute la cellule :
|
||||
c'est le premier endroit ou l'oeil verifie le compte rendu. -->
|
||||
<td
|
||||
v-for="colonne in COLONNES_TABLEAU"
|
||||
:key="colonne.champ"
|
||||
class="py-1 px-2 border-t-2 border-gray-600"
|
||||
:class="classeCellule(colonne.champ)"
|
||||
>
|
||||
<div class="flex flex-col items-end">
|
||||
<EditableField
|
||||
:modelValue="locataire.totaux?.[colonne.champ]"
|
||||
@update:modelValue="updateField(`totaux.${colonne.champ}`, $event)"
|
||||
type="currency"
|
||||
:displayClass="`text-xs font-semibold ${classeMontant(colonne.champ, locataire.totaux?.[colonne.champ])}`"
|
||||
/>
|
||||
<!-- Somme de la colonne. Ni pointille ni contraste : non modifiable. -->
|
||||
<span
|
||||
v-if="ecarts[colonne.champ]"
|
||||
class="text-[10px] text-amber-500/90 italic mt-0.5 cursor-help px-1 whitespace-nowrap"
|
||||
:title="detailEcart(colonne.champ)"
|
||||
>
|
||||
calculé : {{ formatMontantPrecis(ecarts[colonne.champ].calcule) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="border-t-2 border-gray-600"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Bouton ajouter ligne si aucune -->
|
||||
<div v-else class="text-center py-2">
|
||||
<button
|
||||
@click="addLigne"
|
||||
class="flex items-center gap-1 px-3 py-1.5 text-xs text-blue-400 hover:bg-blue-500/10 rounded transition-colors mx-auto"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Ajouter une ligne
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="aDesEcarts" class="mt-2 text-[10px] text-gray-500 italic">
|
||||
Colonne(s) en surbrillance : le montant du compte rendu ne correspond pas a ce que
|
||||
totalisent les lignes ci-dessus (« calculé : … »), signe qu'une valeur n'a pas ete
|
||||
extraite. Seuls les champs soulignes sont modifiables, et eux seuls partent en base.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -226,6 +301,14 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import EditableField from './EditableField.vue'
|
||||
import { setNestedValue } from '../utils/chemin'
|
||||
import { formatMontantPrecis } from '../utils/format'
|
||||
import {
|
||||
COLONNES_CRG,
|
||||
COLONNES_TABLEAU,
|
||||
ecartsAvecLignes,
|
||||
totauxCalcules,
|
||||
} from '../utils/totauxLocataire'
|
||||
|
||||
const props = defineProps({
|
||||
locataire: {
|
||||
@@ -248,6 +331,10 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['update:locataire', 'remove'])
|
||||
|
||||
const LIBELLES = Object.fromEntries(
|
||||
COLONNES_CRG.map(({ champ, libelle }) => [champ, libelle])
|
||||
)
|
||||
|
||||
const expanded = ref(false)
|
||||
const rootEl = ref(null)
|
||||
const isHighlighted = ref(false)
|
||||
@@ -265,6 +352,32 @@ watch(() => props.highlighted, (val) => {
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Tout ce qui est affiche vient de l'extraction. Les lignes sont neanmoins
|
||||
// reagregees pour un seul usage : reperer les lots ou l'extraction se contredit.
|
||||
// Ces valeurs ne sont ni enregistrees, ni reportees dans les champs.
|
||||
const calcules = computed(() => totauxCalcules(props.locataire.lignes))
|
||||
|
||||
const ecarts = computed(() => ecartsAvecLignes(props.locataire.totaux, calcules.value))
|
||||
|
||||
const aDesEcarts = computed(() => Object.keys(ecarts.value).length > 0)
|
||||
|
||||
const resumeEcarts = computed(() =>
|
||||
Object.keys(ecarts.value)
|
||||
.map((champ) => detailEcart(champ))
|
||||
.join('\n\n')
|
||||
)
|
||||
|
||||
function detailEcart(champ) {
|
||||
const nb = props.locataire.lignes?.length || 0
|
||||
const { extrait, calcule, manquant } = ecarts.value[champ]
|
||||
return (
|
||||
`${LIBELLES[champ]}\n` +
|
||||
`Extrait du compte rendu : ${formatMontantPrecis(extrait)}\n` +
|
||||
`Calculé sur les ${nb} ligne(s) : ${formatMontantPrecis(calcule)}\n` +
|
||||
`Écart de ${formatMontantPrecis(manquant)} : aucune ligne ne porte ce montant.`
|
||||
)
|
||||
}
|
||||
|
||||
const totalClass = computed(() => {
|
||||
const total = props.locataire.totaux?.total || 0
|
||||
if (total > 0) return 'text-green-400'
|
||||
@@ -272,9 +385,20 @@ const totalClass = computed(() => {
|
||||
return 'text-gray-400'
|
||||
})
|
||||
|
||||
function formatCurrency(value) {
|
||||
if (value === null || value === undefined) return '-'
|
||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value)
|
||||
// Les regles apparaissent en vert sur le compte rendu, les impayes en rouge.
|
||||
function classeMontant(champ, valeur) {
|
||||
if (champ === 'regles') return 'text-green-400'
|
||||
if (champ === 'impayes') return valeur ? 'text-red-400' : 'text-gray-200'
|
||||
return 'text-gray-200'
|
||||
}
|
||||
|
||||
function classeCellule(champ) {
|
||||
return ecarts.value[champ] ? 'bg-amber-500/10 ring-1 ring-inset ring-amber-500/40' : ''
|
||||
}
|
||||
|
||||
// Le compte rendu aligne ses montants a droite et le libelle « Divers » a gauche.
|
||||
function alignement(colonne) {
|
||||
return colonne.champ === 'divers' ? 'text-left' : 'text-right'
|
||||
}
|
||||
|
||||
// Mettre a jour un champ nested (ex: "lot.numero", "totaux.loyers")
|
||||
@@ -284,29 +408,13 @@ function updateField(path, value) {
|
||||
emit('update:locataire', updated)
|
||||
}
|
||||
|
||||
// Helper pour setter une valeur nested
|
||||
function setNestedValue(obj, path, value) {
|
||||
const parts = path.split('.')
|
||||
let current = obj
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (!current[parts[i]]) {
|
||||
current[parts[i]] = {}
|
||||
}
|
||||
current = current[parts[i]]
|
||||
}
|
||||
current[parts[parts.length - 1]] = value
|
||||
}
|
||||
|
||||
// Mettre a jour un champ d'une ligne
|
||||
// Mettre a jour un champ d'une ligne. Aucun autre champ n'est touche : une
|
||||
// correction de l'utilisateur ne doit rien declencher qu'il n'ait pas demande.
|
||||
function updateLigneField(ligneIndex, path, value) {
|
||||
const updated = JSON.parse(JSON.stringify(props.locataire))
|
||||
if (!updated.lignes) updated.lignes = []
|
||||
|
||||
if (path.includes('.')) {
|
||||
setNestedValue(updated.lignes[ligneIndex], path, value)
|
||||
} else {
|
||||
updated.lignes[ligneIndex][path] = value
|
||||
}
|
||||
|
||||
emit('update:locataire', updated)
|
||||
}
|
||||
|
||||
45
frontend/src/components/NavLien.vue
Normal file
45
frontend/src/components/NavLien.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<router-link
|
||||
:to="to"
|
||||
class="nav-link"
|
||||
:class="[
|
||||
actif ? 'bg-gray-800 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-800',
|
||||
replie ? 'justify-center px-0' : ''
|
||||
]"
|
||||
:title="replie ? label : null"
|
||||
:aria-label="label"
|
||||
>
|
||||
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
v-for="d in paths"
|
||||
:key="d"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
:d="d"
|
||||
/>
|
||||
</svg>
|
||||
<span v-if="!replie" class="truncate">{{ label }}</span>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const props = defineProps({
|
||||
to: { type: String, required: true },
|
||||
label: { type: String, required: true },
|
||||
paths: { type: Array, required: true },
|
||||
replie: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// Une fiche de lot (/lots/26) doit garder son entrée allumée : comparer les
|
||||
// chemins à l'identique éteindrait la navigation dès qu'une page a des
|
||||
// sous-routes.
|
||||
const actif = computed(
|
||||
() => route.path === props.to || route.path.startsWith(`${props.to}/`)
|
||||
)
|
||||
</script>
|
||||
@@ -194,6 +194,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import EditableField from './EditableField.vue'
|
||||
import { setNestedValue } from '../utils/chemin'
|
||||
|
||||
const props = defineProps({
|
||||
categorie: {
|
||||
@@ -216,7 +217,9 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['update:operations'])
|
||||
|
||||
const expanded = ref(false)
|
||||
// Déplié par défaut : les dépenses sont l'information qu'on vient consulter et
|
||||
// corriger, les replier une par une coûtait un clic avant toute lecture.
|
||||
const expanded = ref(true)
|
||||
const opRefs = ref({})
|
||||
const highlightedIdx = ref(-1)
|
||||
|
||||
@@ -257,19 +260,6 @@ function formatCurrency(value) {
|
||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value)
|
||||
}
|
||||
|
||||
// Helper pour setter une valeur nested
|
||||
function setNestedValue(obj, path, value) {
|
||||
const parts = path.split('.')
|
||||
let current = obj
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (!current[parts[i]]) {
|
||||
current[parts[i]] = {}
|
||||
}
|
||||
current = current[parts[i]]
|
||||
}
|
||||
current[parts[parts.length - 1]] = value
|
||||
}
|
||||
|
||||
// Mettre a jour un champ d'une operation
|
||||
function updateOperationField(opIndex, path, value) {
|
||||
const updatedOperations = JSON.parse(JSON.stringify(props.operations))
|
||||
|
||||
@@ -1,53 +1,51 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full bg-gray-950 overflow-hidden relative">
|
||||
<!-- Header -->
|
||||
<div class="flex-shrink-0 flex items-center px-3 py-2 bg-gray-900 border-b border-gray-700 text-white">
|
||||
<span class="text-sm font-medium truncate">{{ fileName }}</span>
|
||||
<div class="flex flex-col h-full bg-gray-950 overflow-hidden">
|
||||
<!-- Bandeau -->
|
||||
<div class="flex-shrink-0 flex items-center gap-2 px-3 py-2 bg-gray-900 border-b border-gray-700 text-white">
|
||||
<span class="text-sm font-medium truncate flex-1">{{ fileName }}</span>
|
||||
<!-- Porte de sortie : si le moteur n'a pas de lecteur PDF intégré, le
|
||||
volet reste vide et ce lien reste le seul moyen d'ouvrir le
|
||||
document. Il sert aussi à le lire en grand sans quitter la page. -->
|
||||
<a
|
||||
v-if="pdfUrl"
|
||||
:href="pdfUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="flex-shrink-0 p-1 rounded text-gray-400 hover:text-white hover:bg-gray-700 transition-colors"
|
||||
title="Ouvrir le PDF dans un nouvel onglet"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- États -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex-1 flex items-center justify-center text-gray-500 text-sm"
|
||||
>
|
||||
Chargement du PDF…
|
||||
</div>
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="flex-1 flex items-center justify-center px-4 text-center text-red-400 text-sm"
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
<!-- Rendu PDF.js : une <canvas> par page, empilées et scrollables.
|
||||
Ne dépend pas du lecteur PDF natif du moteur (Qt WebEngine,
|
||||
WebView2, navigateur) -> rendu identique partout. -->
|
||||
<div
|
||||
v-else-if="hasSource"
|
||||
ref="container"
|
||||
class="flex-1 overflow-auto custom-scrollbar p-2 bg-gray-950"
|
||||
<div class="flex-1 relative overflow-hidden">
|
||||
<!-- Lecteur PDF natif du moteur : il apporte le zoom, la recherche, la
|
||||
sélection de texte et l'impression sans code de notre côté, et
|
||||
redessine à chaque niveau de zoom (donc net partout). Un rendu
|
||||
maison sur <canvas> était figé à la largeur du volet, soit 0,83x
|
||||
sur une page A4 dans une fenêtre de 1280 px : les tableaux du CRG
|
||||
en 7 pt tombaient à 6 px de haut, illisibles. -->
|
||||
<iframe
|
||||
v-if="pdfUrl"
|
||||
:key="pdfUrl"
|
||||
:src="urlLecteur"
|
||||
type="application/pdf"
|
||||
:title="fileName"
|
||||
class="absolute inset-0 w-full h-full border-0"
|
||||
/>
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-else
|
||||
class="flex-1 flex items-center justify-center text-gray-500 text-sm"
|
||||
>
|
||||
|
||||
<!-- Aucune source -->
|
||||
<div v-else class="h-full flex items-center justify-center text-gray-500 text-sm">
|
||||
Aucun PDF sélectionné
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onUnmounted, nextTick } from 'vue'
|
||||
// Build « legacy » (et non le build par défaut) : il embarque les polyfills
|
||||
// nécessaires aux moteurs Chromium embarqués (Qt WebEngine, WebView2), plus
|
||||
// anciens que la dernière version de Chrome. Le build moderne utilise
|
||||
// `Map.prototype.getOrInsertComputed` (proposition JS récente) sans polyfill,
|
||||
// ce qui casse le rendu dans la fenêtre bureau. Voir docs pdf.js « legacy ».
|
||||
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs'
|
||||
import PdfWorker from 'pdfjs-dist/legacy/build/pdf.worker.min.mjs?url'
|
||||
|
||||
// Worker bundlé localement par Vite (pas de CDN) -> fonctionne hors-ligne.
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = PdfWorker
|
||||
import { computed, ref, watch, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
file: { type: File, default: null },
|
||||
@@ -55,92 +53,44 @@ const props = defineProps({
|
||||
fileName: { type: String, default: 'document.pdf' }
|
||||
})
|
||||
|
||||
const container = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const hasSource = ref(false)
|
||||
const pdfUrl = ref(null)
|
||||
let objectUrl = null
|
||||
|
||||
let loadingTask = null
|
||||
// Jeton de génération : invalide les rendus concurrents (changement de source).
|
||||
let renderToken = 0
|
||||
/** Ouverture à l'échelle 1:1. Le zoom par défaut du lecteur intégré tombe vers
|
||||
35-40 % dans un volet étroit, ce qui réduit les tableaux du CRG (7 pt) à
|
||||
3 px de haut. « Ajuster à la largeur » ne sauve rien ici — dans un volet de
|
||||
430 px cela ne donne que 54 %, soit 5 px — alors qu'à 100 % le 7 pt fait
|
||||
9,3 px et redevient lisible, quitte à défiler tant que le volet est étroit.
|
||||
Les mots-clés `page-width`, `view=FitH` et `pagemode=none` sont ignorés par
|
||||
le lecteur de Chrome ; une valeur numérique, elle, est bien honorée
|
||||
(vérifié à la main). Le reste se règle dans le lecteur lui-même : boutons
|
||||
− / + pour le zoom, ☰ pour replier la barre latérale de vignettes. Ces deux
|
||||
choix sont mémorisés par le moteur, d'où le profil persistant imposé à la
|
||||
fenêtre bureau (voir `desktop.py`). */
|
||||
const urlLecteur = computed(() => (pdfUrl.value ? `${pdfUrl.value}#zoom=100` : null))
|
||||
|
||||
async function resolveSource() {
|
||||
if (props.file) return { data: await props.file.arrayBuffer() }
|
||||
if (props.url) return { url: props.url }
|
||||
return null
|
||||
}
|
||||
|
||||
async function destroyTask() {
|
||||
if (loadingTask) {
|
||||
try {
|
||||
await loadingTask.destroy()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
loadingTask = null
|
||||
function libererObjectUrl() {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
objectUrl = null
|
||||
}
|
||||
}
|
||||
|
||||
async function render() {
|
||||
const token = ++renderToken
|
||||
error.value = null
|
||||
await destroyTask()
|
||||
|
||||
const source = await resolveSource()
|
||||
hasSource.value = source !== null
|
||||
if (!source) {
|
||||
loading.value = false
|
||||
return
|
||||
watch(
|
||||
[() => props.file, () => props.url],
|
||||
([fichier, url]) => {
|
||||
libererObjectUrl()
|
||||
if (url) {
|
||||
pdfUrl.value = url
|
||||
} else if (fichier) {
|
||||
objectUrl = URL.createObjectURL(fichier)
|
||||
pdfUrl.value = objectUrl
|
||||
} else {
|
||||
pdfUrl.value = null
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
loadingTask = pdfjsLib.getDocument(source)
|
||||
const doc = await loadingTask.promise
|
||||
if (token !== renderToken) return
|
||||
|
||||
// Le conteneur n'est monté qu'une fois loading=false.
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
const el = container.value
|
||||
if (!el || token !== renderToken) return
|
||||
el.innerHTML = ''
|
||||
|
||||
const targetWidth = Math.max(el.clientWidth - 16, 0)
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
|
||||
for (let n = 1; n <= doc.numPages; n++) {
|
||||
if (token !== renderToken) return
|
||||
const page = await doc.getPage(n)
|
||||
const base = page.getViewport({ scale: 1 })
|
||||
const scale = targetWidth > 0 ? targetWidth / base.width : 1
|
||||
const viewport = page.getViewport({ scale })
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = Math.floor(viewport.width * dpr)
|
||||
canvas.height = Math.floor(viewport.height * dpr)
|
||||
canvas.style.width = '100%'
|
||||
canvas.className = 'block mx-auto mb-2 bg-white shadow'
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.scale(dpr, dpr)
|
||||
await page.render({ canvasContext: ctx, viewport }).promise
|
||||
if (token !== renderToken) return
|
||||
el.appendChild(canvas)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[PdfPreview] render error:', e?.message, e)
|
||||
if (token === renderToken) {
|
||||
error.value = "Impossible d'afficher le PDF."
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch([() => props.file, () => props.url], render, { immediate: true })
|
||||
|
||||
onUnmounted(() => {
|
||||
renderToken++
|
||||
destroyTask()
|
||||
})
|
||||
onUnmounted(libererObjectUrl)
|
||||
</script>
|
||||
|
||||
197
frontend/src/components/SelectionMultiple.vue
Normal file
197
frontend/src/components/SelectionMultiple.vue
Normal file
@@ -0,0 +1,197 @@
|
||||
<template>
|
||||
<div class="relative" ref="conteneur">
|
||||
<!-- Le declencheur reprend l'allure d'un select : dans une grille de
|
||||
filtres, un controle qui ne ressemble pas aux autres se cherche. -->
|
||||
<button
|
||||
type="button"
|
||||
class="input flex items-center justify-between gap-2 text-left"
|
||||
@click="basculerPanneau"
|
||||
>
|
||||
<span class="truncate" :class="selection.length ? 'text-white' : 'text-gray-500'">
|
||||
{{ resume }}
|
||||
</span>
|
||||
<svg
|
||||
class="w-4 h-4 shrink-0 text-gray-500 transition-transform"
|
||||
:class="{ 'rotate-180': ouvert }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="ouvert"
|
||||
class="absolute z-50 w-full min-w-[16rem] mt-1 bg-gray-900 border border-gray-700 rounded-lg shadow-lg"
|
||||
>
|
||||
<div class="p-2 border-b border-gray-800">
|
||||
<input
|
||||
ref="champRecherche"
|
||||
v-model="recherche"
|
||||
type="text"
|
||||
:placeholder="placeholderRecherche"
|
||||
class="input"
|
||||
@keydown.escape="fermerPanneau"
|
||||
@keydown.enter.prevent="cocherLePremier"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selection.length"
|
||||
class="px-3 py-1.5 flex items-center justify-between text-xs border-b border-gray-800"
|
||||
>
|
||||
<span class="text-gray-400">{{ selection.length }} {{ selection.length > 1 ? 'selectionnes' : 'selectionne' }}</span>
|
||||
<button type="button" class="text-blue-400 hover:text-blue-300" @click="toutDecocher">
|
||||
Tout decocher
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-60 overflow-y-auto custom-scrollbar">
|
||||
<label
|
||||
v-for="option in optionsFiltrees"
|
||||
:key="option.valeur"
|
||||
class="flex items-center gap-2 px-3 py-2 cursor-pointer text-sm hover:bg-gray-800"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="shrink-0 accent-blue-500"
|
||||
:checked="estSelectionnee(option.valeur)"
|
||||
@change="basculerOption(option.valeur)"
|
||||
/>
|
||||
<span class="flex-1 truncate text-gray-300" v-html="surligner(option.libelle)"></span>
|
||||
<span v-if="option.complement" class="shrink-0 text-xs text-gray-500">
|
||||
{{ option.complement }}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div v-if="!optionsFiltrees.length" class="px-3 py-3 text-sm text-gray-500">
|
||||
Aucun resultat pour « {{ recherche }} »
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
/** Valeurs cochees. Un tableau vide vaut « aucun filtre », pas « rien ». */
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
/** [{ valeur, libelle, complement? }] */
|
||||
options: { type: Array, default: () => [] },
|
||||
libelleVide: { type: String, default: 'Tous' },
|
||||
placeholderRecherche: { type: String, default: 'Rechercher...' },
|
||||
/** Nom au pluriel employe dans le resume : « 3 fournisseurs ». */
|
||||
nomPluriel: { type: String, default: 'elements' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const conteneur = ref(null)
|
||||
const champRecherche = ref(null)
|
||||
const ouvert = ref(false)
|
||||
const recherche = ref('')
|
||||
|
||||
const selection = computed(() => props.modelValue ?? [])
|
||||
|
||||
/** Une seule valeur cochee s'affiche en clair : le compte seul cacherait
|
||||
laquelle, alors que c'est justement le cas ou on veut la lire. */
|
||||
const resume = computed(() => {
|
||||
if (!selection.value.length) return props.libelleVide
|
||||
if (selection.value.length === 1) {
|
||||
const option = props.options.find((o) => o.valeur === selection.value[0])
|
||||
return option?.libelle ?? String(selection.value[0])
|
||||
}
|
||||
return `${selection.value.length} ${props.nomPluriel}`
|
||||
})
|
||||
|
||||
const optionsFiltrees = computed(() => {
|
||||
const requete = recherche.value.trim().toLowerCase()
|
||||
if (!requete) return props.options
|
||||
return props.options.filter((o) => o.libelle.toLowerCase().includes(requete))
|
||||
})
|
||||
|
||||
function estSelectionnee(valeur) {
|
||||
return selection.value.includes(valeur)
|
||||
}
|
||||
|
||||
function basculerOption(valeur) {
|
||||
const suivante = estSelectionnee(valeur)
|
||||
? selection.value.filter((v) => v !== valeur)
|
||||
: [...selection.value, valeur]
|
||||
emit('update:modelValue', suivante)
|
||||
}
|
||||
|
||||
/** Entree coche la seule option restante : filtrer puis valider est le geste
|
||||
naturel quand on sait deja quel fournisseur on cherche. */
|
||||
function cocherLePremier() {
|
||||
if (optionsFiltrees.value.length) {
|
||||
basculerOption(optionsFiltrees.value[0].valeur)
|
||||
recherche.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function toutDecocher() {
|
||||
emit('update:modelValue', [])
|
||||
}
|
||||
|
||||
function basculerPanneau() {
|
||||
ouvert.value ? fermerPanneau() : ouvrirPanneau()
|
||||
}
|
||||
|
||||
function ouvrirPanneau() {
|
||||
ouvert.value = true
|
||||
recherche.value = ''
|
||||
nextTick(() => champRecherche.value?.focus())
|
||||
}
|
||||
|
||||
function fermerPanneau() {
|
||||
ouvert.value = false
|
||||
}
|
||||
|
||||
function surClicExterieur(evenement) {
|
||||
if (ouvert.value && conteneur.value && !conteneur.value.contains(evenement.target)) {
|
||||
fermerPanneau()
|
||||
}
|
||||
}
|
||||
|
||||
/** Les valeurs cochees qui ne figurent plus dans les options seraient
|
||||
invisibles tout en filtrant : on les laisse tomber avec les options. */
|
||||
watch(
|
||||
() => props.options,
|
||||
(options) => {
|
||||
if (!options.length || !selection.value.length) return
|
||||
const connues = new Set(options.map((o) => o.valeur))
|
||||
const retenues = selection.value.filter((v) => connues.has(v))
|
||||
if (retenues.length !== selection.value.length) {
|
||||
emit('update:modelValue', retenues)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => document.addEventListener('mousedown', surClicExterieur))
|
||||
onBeforeUnmount(() => document.removeEventListener('mousedown', surClicExterieur))
|
||||
|
||||
function echapper(texte) {
|
||||
return texte.replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
})[c])
|
||||
}
|
||||
|
||||
function surligner(texte) {
|
||||
const echappe = echapper(texte)
|
||||
const requete = recherche.value.trim()
|
||||
if (!requete) return echappe
|
||||
const motif = echapper(requete).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
return echappe.replace(
|
||||
new RegExp(`(${motif})`, 'gi'),
|
||||
'<mark class="bg-amber-500/30 text-amber-200 rounded px-0.5">$1</mark>'
|
||||
)
|
||||
}
|
||||
</script>
|
||||
117
frontend/src/components/VoletsAjustables.vue
Normal file
117
frontend/src/components/VoletsAjustables.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div ref="conteneur" class="flex-1 flex overflow-hidden">
|
||||
<div
|
||||
class="flex flex-col overflow-hidden"
|
||||
:class="classeGauche"
|
||||
:style="{ width: `${largeurGauche}%` }"
|
||||
>
|
||||
<slot name="gauche" />
|
||||
</div>
|
||||
|
||||
<!-- Bande de saisie de 8 px pour un trait de 1 px : viser un filet d'un
|
||||
pixel à la souris est pénible. Les marges négatives annulent sa
|
||||
largeur dans le calcul du flux, pour que les deux volets se partagent
|
||||
bien 100 % ; la bande déborde donc de 4 px sur chacun, d'où le z-10. -->
|
||||
<div
|
||||
class="group flex-shrink-0 w-2 -mx-1 z-10 flex justify-center cursor-col-resize"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
title="Glisser pour redimensionner — double-clic pour réinitialiser"
|
||||
@mousedown.prevent="demarrerGlissement"
|
||||
@dblclick="reinitialiser"
|
||||
>
|
||||
<div
|
||||
class="h-full w-px bg-gray-700 group-hover:bg-blue-500 transition-colors"
|
||||
:class="{ '!bg-blue-500': glissement }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col overflow-hidden"
|
||||
:class="classeDroite"
|
||||
:style="{ width: `${100 - largeurGauche}%` }"
|
||||
>
|
||||
<slot name="droite" />
|
||||
</div>
|
||||
|
||||
<!-- Voile actif seulement pendant le glissement. Sans lui, l'<iframe> du
|
||||
PDF avale les évènements souris dès que le curseur la survole et le
|
||||
redimensionnement se fige en cours de route. -->
|
||||
<div v-if="glissement" class="fixed inset-0 z-50 cursor-col-resize" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
/** Clé localStorage : la largeur choisie est propre à chaque écran. */
|
||||
cleStockage: { type: String, required: true },
|
||||
/** Largeur du volet gauche en %, au premier affichage et au double-clic. */
|
||||
largeurDefaut: { type: Number, default: 40 },
|
||||
largeurMin: { type: Number, default: 20 },
|
||||
largeurMax: { type: Number, default: 80 },
|
||||
/** Classes posées sur les conteneurs de volet (fond, bordure...). */
|
||||
classeGauche: { type: String, default: '' },
|
||||
classeDroite: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const conteneur = ref(null)
|
||||
const glissement = ref(false)
|
||||
|
||||
/** Le stockage peut être indisponible (navigation privée, réglages) ou
|
||||
contenir une valeur hors bornes si les props ont changé depuis. */
|
||||
function largeurMemorisee() {
|
||||
try {
|
||||
const valeur = Number(localStorage.getItem(props.cleStockage))
|
||||
if (Number.isFinite(valeur) && valeur >= props.largeurMin && valeur <= props.largeurMax) {
|
||||
return valeur
|
||||
}
|
||||
} catch {
|
||||
/* stockage indisponible */
|
||||
}
|
||||
return props.largeurDefaut
|
||||
}
|
||||
|
||||
const largeurGauche = ref(largeurMemorisee())
|
||||
|
||||
function memoriser(largeur) {
|
||||
try {
|
||||
localStorage.setItem(props.cleStockage, String(Math.round(largeur)))
|
||||
} catch {
|
||||
/* stockage indisponible */
|
||||
}
|
||||
}
|
||||
|
||||
function surDeplacement(evenement) {
|
||||
const rect = conteneur.value?.getBoundingClientRect()
|
||||
if (!rect?.width) return
|
||||
const pourcentage = ((evenement.clientX - rect.left) / rect.width) * 100
|
||||
largeurGauche.value = Math.min(props.largeurMax, Math.max(props.largeurMin, pourcentage))
|
||||
}
|
||||
|
||||
function arreterGlissement() {
|
||||
if (!glissement.value) return
|
||||
glissement.value = false
|
||||
document.removeEventListener('mousemove', surDeplacement)
|
||||
document.removeEventListener('mouseup', arreterGlissement)
|
||||
document.body.style.userSelect = ''
|
||||
memoriser(largeurGauche.value)
|
||||
}
|
||||
|
||||
function demarrerGlissement() {
|
||||
glissement.value = true
|
||||
document.addEventListener('mousemove', surDeplacement)
|
||||
document.addEventListener('mouseup', arreterGlissement)
|
||||
// Sans quoi le glissement sélectionne le texte des deux volets.
|
||||
document.body.style.userSelect = 'none'
|
||||
}
|
||||
|
||||
function reinitialiser() {
|
||||
largeurGauche.value = props.largeurDefaut
|
||||
memoriser(props.largeurDefaut)
|
||||
}
|
||||
|
||||
// Un démontage en plein glissement laisserait les écouteurs sur `document`.
|
||||
onUnmounted(arreterGlissement)
|
||||
</script>
|
||||
@@ -84,7 +84,7 @@
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<router-link
|
||||
:to="{ path: '/documents/' + dep.document_id + '/edit', query: { highlight: 'operation', fournisseur: dep.fournisseur, description: dep.description } }"
|
||||
:to="lienEdition(dep)"
|
||||
class="text-gray-500 hover:text-blue-400 transition-colors"
|
||||
title="Editer le document source"
|
||||
>
|
||||
@@ -142,6 +142,9 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const props = defineProps({
|
||||
depenses: {
|
||||
@@ -169,6 +172,20 @@ const paginatedDepenses = computed(() =>
|
||||
props.depenses.slice(startIndex.value, endIndex.value)
|
||||
)
|
||||
|
||||
/** L'editeur recoit la vue d'ou l'on part, filtres compris : une fois
|
||||
l'enregistrement termine, il y ramene au lieu de la liste des documents. */
|
||||
function lienEdition(depense) {
|
||||
return {
|
||||
path: `/documents/${depense.document_id}/edit`,
|
||||
query: {
|
||||
highlight: 'operation',
|
||||
fournisseur: depense.fournisseur,
|
||||
description: depense.description,
|
||||
retour: route.fullPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
const d = new Date(dateStr)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<div class="card card-body space-y-4">
|
||||
<!-- overflow-visible : `.card` rogne son contenu, ce qui decapitait le menu
|
||||
deroulant des fournisseurs. Rien d'autre ne deborde de cette carte. -->
|
||||
<div class="card card-body space-y-4 overflow-visible">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="card-title">Filtres</h3>
|
||||
<button
|
||||
@@ -67,6 +69,9 @@
|
||||
class="input"
|
||||
>
|
||||
<option :value="null">Tous</option>
|
||||
<!-- « Sans tag » est un choix de filtre, pas l'absence de filtre :
|
||||
c'est ainsi qu'on retrouve les depenses restant a tagger. -->
|
||||
<option :value="SANS_TAG">Sans tag</option>
|
||||
<option v-for="tag in tags" :key="tag.id" :value="tag.id">
|
||||
{{ tag.nom }}
|
||||
</option>
|
||||
@@ -95,15 +100,16 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Fournisseur (recherche) -->
|
||||
<!-- Fournisseurs (selection multiple, filtrable au clavier) -->
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Fournisseur</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="filters.fournisseur"
|
||||
@input="emitFilters"
|
||||
placeholder="Rechercher..."
|
||||
class="input"
|
||||
<label class="block text-xs text-gray-400 mb-1">Fournisseurs</label>
|
||||
<SelectionMultiple
|
||||
:model-value="filters.fournisseurs"
|
||||
:options="optionsFournisseurs"
|
||||
libelle-vide="Tous"
|
||||
nom-pluriel="fournisseurs"
|
||||
placeholder-recherche="Filtrer la liste..."
|
||||
@update:model-value="onFournisseursChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,14 +118,24 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import SelectionMultiple from '../SelectionMultiple.vue'
|
||||
|
||||
const props = defineProps({
|
||||
/** Etat de depart, tel que la page l'a lu dans l'URL. */
|
||||
filtresInitiaux: { type: Object, default: null }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['filter-change'])
|
||||
|
||||
/** Valeur de tag_id demandant les depenses sans tag (cf. SANS_TAG cote API). */
|
||||
const SANS_TAG = 0
|
||||
|
||||
// Reference data
|
||||
const immeubles = ref([])
|
||||
const lots = ref([])
|
||||
const categories = ref([])
|
||||
const tags = ref([])
|
||||
const fournisseurs = ref([])
|
||||
|
||||
// Local filters state
|
||||
const filters = reactive({
|
||||
@@ -127,11 +143,25 @@ const filters = reactive({
|
||||
lot_id: null,
|
||||
categorie: null,
|
||||
tag_id: null,
|
||||
fournisseur: null,
|
||||
fournisseurs: [],
|
||||
date_debut: null,
|
||||
date_fin: null
|
||||
date_fin: null,
|
||||
...(props.filtresInitiaux ?? {})
|
||||
})
|
||||
|
||||
/** Ordre alphabetique - l'API les trie par montant, ce qui se lit bien dans un
|
||||
classement mais rend introuvable un fournisseur qu'on cherche a l'oeil. Le
|
||||
nombre de depenses reste affiche a droite pour situer le poids. */
|
||||
const optionsFournisseurs = computed(() =>
|
||||
[...fournisseurs.value]
|
||||
.sort((a, b) => a.nom.localeCompare(b.nom, 'fr'))
|
||||
.map((f) => ({
|
||||
valeur: f.nom,
|
||||
libelle: f.nom,
|
||||
complement: `${f.nb_depenses}`
|
||||
}))
|
||||
)
|
||||
|
||||
const filteredLots = computed(() => {
|
||||
if (!filters.immeuble_id) return []
|
||||
return lots.value.filter(l => l.immeuble_id === filters.immeuble_id)
|
||||
@@ -144,7 +174,7 @@ const hasActiveFilters = computed(() => {
|
||||
filters.tag_id !== null ||
|
||||
filters.date_debut !== null ||
|
||||
filters.date_fin !== null ||
|
||||
(filters.fournisseur && filters.fournisseur.length > 0)
|
||||
filters.fournisseurs.length > 0
|
||||
})
|
||||
|
||||
function onImmeubleChange() {
|
||||
@@ -153,6 +183,11 @@ function onImmeubleChange() {
|
||||
emitFilters()
|
||||
}
|
||||
|
||||
function onFournisseursChange(valeurs) {
|
||||
filters.fournisseurs = valeurs
|
||||
emitFilters()
|
||||
}
|
||||
|
||||
function emitFilters() {
|
||||
console.log('Emitting filters:', { ...filters })
|
||||
emit('filter-change', { ...filters })
|
||||
@@ -163,7 +198,7 @@ function resetFilters() {
|
||||
filters.lot_id = null
|
||||
filters.categorie = null
|
||||
filters.tag_id = null
|
||||
filters.fournisseur = null
|
||||
filters.fournisseurs = []
|
||||
filters.date_debut = null
|
||||
filters.date_fin = null
|
||||
emitFilters()
|
||||
@@ -179,23 +214,26 @@ function formatCategorie(cat) {
|
||||
|
||||
async function loadReferenceData() {
|
||||
try {
|
||||
const [immeublesRes, lotsRes, categoriesRes, tagsRes] = await Promise.all([
|
||||
const [immeublesRes, lotsRes, categoriesRes, tagsRes, fournisseursRes] = await Promise.all([
|
||||
fetch('/api/immeubles'),
|
||||
fetch('/api/lots'),
|
||||
fetch('/api/analytics/categories'),
|
||||
fetch('/api/tags')
|
||||
fetch('/api/tags'),
|
||||
fetch('/api/fournisseurs')
|
||||
])
|
||||
|
||||
if (immeublesRes.ok) immeubles.value = await immeublesRes.json()
|
||||
if (lotsRes.ok) lots.value = await lotsRes.json()
|
||||
if (categoriesRes.ok) categories.value = await categoriesRes.json()
|
||||
if (tagsRes.ok) tags.value = await tagsRes.json()
|
||||
if (fournisseursRes.ok) fournisseurs.value = await fournisseursRes.json()
|
||||
|
||||
console.log('Reference data loaded:', {
|
||||
immeubles: immeubles.value.length,
|
||||
lots: lots.value.length,
|
||||
categories: categories.value.length,
|
||||
tags: tags.value.length
|
||||
tags: tags.value.length,
|
||||
fournisseurs: fournisseurs.value.length
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to load reference data:', err)
|
||||
|
||||
93
frontend/src/components/analytics/TagChart.vue
Normal file
93
frontend/src/components/analytics/TagChart.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div class="card card-body">
|
||||
<h3 class="card-title block mb-4">Repartition par tag</h3>
|
||||
<div class="h-64">
|
||||
<Doughnut
|
||||
v-if="chartData.labels.length > 0"
|
||||
:data="chartData"
|
||||
:options="chartOptions"
|
||||
/>
|
||||
<div v-else class="h-full flex items-center justify-center text-gray-500 text-sm">
|
||||
Aucune donnee
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { Doughnut } from 'vue-chartjs'
|
||||
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'
|
||||
|
||||
ChartJS.register(ArcElement, Tooltip, Legend)
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const colors = [
|
||||
'#3B82F6', // blue
|
||||
'#EF4444', // red
|
||||
'#10B981', // green
|
||||
'#F59E0B', // amber
|
||||
'#8B5CF6', // purple
|
||||
'#EC4899', // pink
|
||||
'#06B6D4', // cyan
|
||||
'#84CC16', // lime
|
||||
'#F97316', // orange
|
||||
'#6366F1', // indigo
|
||||
]
|
||||
|
||||
/** Gris pour les depenses sans tag : elles restent visibles - c'est le
|
||||
travail de tagging qui reste a faire - sans se disputer une couleur
|
||||
avec les postes reels. */
|
||||
const COULEUR_SANS_TAG = '#6B7280'
|
||||
|
||||
const parts = computed(() => props.data.filter((p) => (p.total_debit ?? 0) > 0).slice(0, 10))
|
||||
|
||||
const chartData = computed(() => ({
|
||||
labels: parts.value.map((p) => tronquer(p.tag_nom || 'Non taggue')),
|
||||
datasets: [{
|
||||
data: parts.value.map((p) => p.total_debit),
|
||||
backgroundColor: parts.value.map((p, i) =>
|
||||
p.tag_id === null || p.tag_id === undefined ? COULEUR_SANS_TAG : colors[i % colors.length]
|
||||
),
|
||||
borderColor: '#1F2937',
|
||||
borderWidth: 2
|
||||
}]
|
||||
}))
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right',
|
||||
labels: {
|
||||
color: '#9CA3AF',
|
||||
font: { size: 11 },
|
||||
boxWidth: 12,
|
||||
padding: 8
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx) => {
|
||||
const value = new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(ctx.raw)
|
||||
return ` ${value}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tronquer(nom) {
|
||||
return nom.substring(0, 25)
|
||||
}
|
||||
</script>
|
||||
196
frontend/src/components/lot/LoyerChart.vue
Normal file
196
frontend/src/components/lot/LoyerChart.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4 flex-wrap gap-2">
|
||||
<h3 class="text-sm font-medium text-white">Loyer mois par mois</h3>
|
||||
<div class="flex items-center gap-4 text-xs flex-wrap">
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="w-3 h-3 rounded bg-green-400"></span>
|
||||
<span class="text-gray-400">Loyer hors charges</span>
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="w-3 h-3 rounded bg-slate-500"></span>
|
||||
<span class="text-gray-400">Charges</span>
|
||||
</span>
|
||||
<span v-if="aDesProratas" class="flex items-center gap-1">
|
||||
<span class="w-3 h-3 rounded bg-violet-400"></span>
|
||||
<span class="text-gray-400">Prorata</span>
|
||||
</span>
|
||||
<span v-if="aUneSurface" class="flex items-center gap-1">
|
||||
<span class="w-3 h-1.5 rounded bg-amber-400"></span>
|
||||
<span class="text-gray-400">€/m²</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!serie.length" class="h-64 flex items-center justify-center text-gray-500">
|
||||
Aucun loyer facturé sur ce lot.
|
||||
</div>
|
||||
|
||||
<div v-else class="h-72">
|
||||
<Bar :data="donneesGraphe" :options="options" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { Bar } from 'vue-chartjs'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarController,
|
||||
BarElement,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Tooltip,
|
||||
Legend
|
||||
} from 'chart.js'
|
||||
import { formatMoisAnnee } from '../../utils/format'
|
||||
|
||||
// `LineController` en plus des éléments : la courbe du €/m² partage le graphe
|
||||
// des barres, et Chart.js refuse un type de dataset dont le contrôleur n'a pas
|
||||
// été enregistré — les autres graphes de l'application n'en mélangent aucun.
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarController,
|
||||
BarElement,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Tooltip,
|
||||
Legend
|
||||
)
|
||||
|
||||
const props = defineProps({
|
||||
serie: { type: Array, required: true, default: () => [] },
|
||||
surface: { type: Number, default: null }
|
||||
})
|
||||
|
||||
const aUneSurface = computed(() => props.surface != null && props.surface > 0)
|
||||
const aDesProratas = computed(() => props.serie.some((point) => point.prorata != null))
|
||||
|
||||
const EUROS = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' })
|
||||
|
||||
// Les mois vides restent `null` et non 0 : Chart.js laisse alors un blanc, qui
|
||||
// est la lecture juste d'une vacance. Un zéro dessinerait une barre au sol,
|
||||
// c'est-à-dire un loyer nul — ce qu'aucun compte rendu ne dit.
|
||||
const donneesGraphe = computed(() => {
|
||||
const jeux = [
|
||||
{
|
||||
label: 'Loyer',
|
||||
data: props.serie.map((point) => point.loyer),
|
||||
backgroundColor: 'rgba(74, 222, 128, 0.8)',
|
||||
borderRadius: 3,
|
||||
stack: 'facture',
|
||||
order: 3
|
||||
},
|
||||
{
|
||||
label: 'Charges',
|
||||
data: props.serie.map((point) => point.charges),
|
||||
backgroundColor: 'rgba(100, 116, 139, 0.7)',
|
||||
borderRadius: 3,
|
||||
stack: 'facture',
|
||||
order: 3
|
||||
}
|
||||
]
|
||||
|
||||
if (aDesProratas.value) {
|
||||
jeux.push({
|
||||
label: 'Prorata',
|
||||
data: props.serie.map((point) => point.prorata),
|
||||
backgroundColor: 'rgba(167, 139, 250, 0.85)',
|
||||
borderRadius: 3,
|
||||
stack: 'facture',
|
||||
order: 3
|
||||
})
|
||||
}
|
||||
|
||||
// Le loyer au m² se lit sur son propre axe : mis à la même échelle que des
|
||||
// centaines d'euros, sa courbe serait collée au zéro.
|
||||
if (aUneSurface.value) {
|
||||
jeux.push({
|
||||
type: 'line',
|
||||
label: '€/m²',
|
||||
data: props.serie.map((point) => point.loyer_m2),
|
||||
yAxisID: 'y1',
|
||||
borderColor: 'rgb(251, 191, 36)',
|
||||
backgroundColor: 'rgb(251, 191, 36)',
|
||||
borderWidth: 2,
|
||||
pointRadius: 2,
|
||||
tension: 0,
|
||||
spanGaps: false,
|
||||
order: 1
|
||||
})
|
||||
}
|
||||
|
||||
return { labels: props.serie.map((point) => formatMoisAnnee(point.mois)), datasets: jeux }
|
||||
})
|
||||
|
||||
const options = computed(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: 'rgb(31, 41, 55)',
|
||||
borderColor: 'rgb(75, 85, 99)',
|
||||
borderWidth: 1,
|
||||
titleColor: 'rgb(255, 255, 255)',
|
||||
bodyColor: 'rgb(156, 163, 175)',
|
||||
padding: 12,
|
||||
callbacks: {
|
||||
label: (contexte) => {
|
||||
if (contexte.raw == null) return null
|
||||
const valeur =
|
||||
contexte.dataset.label === '€/m²'
|
||||
? `${contexte.raw.toFixed(2).replace('.', ',')} €/m²`
|
||||
: EUROS.format(contexte.raw)
|
||||
return `${contexte.dataset.label} : ${valeur}`
|
||||
},
|
||||
// Un mois sans loyer plein se lit différemment selon qu'il est vacant
|
||||
// ou en changement de locataire : le dire dans l'infobulle évite de
|
||||
// laisser le lecteur deviner devant un trou.
|
||||
afterBody: (contextes) => {
|
||||
const point = props.serie[contextes[0].dataIndex]
|
||||
if (!point) return null
|
||||
const notes = []
|
||||
if (point.en_transition) notes.push('Changement de locataire')
|
||||
else if (point.loyer == null) notes.push('Aucun loyer facturé')
|
||||
if (point.reparti) notes.push('Réparti depuis une facturation pluri-mensuelle')
|
||||
return notes.length ? notes : null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
grid: { display: false },
|
||||
ticks: { color: 'rgb(156, 163, 175)', font: { size: 10 }, maxRotation: 0 }
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
grid: { color: 'rgb(55, 65, 81)' },
|
||||
ticks: {
|
||||
color: 'rgb(156, 163, 175)',
|
||||
font: { size: 11 },
|
||||
callback: (valeur) => (valeur >= 1000 ? `${(valeur / 1000).toFixed(1)}k` : valeur)
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
display: aUneSurface.value,
|
||||
position: 'right',
|
||||
grid: { display: false },
|
||||
ticks: {
|
||||
color: 'rgb(251, 191, 36)',
|
||||
font: { size: 11 },
|
||||
callback: (valeur) => `${valeur} €`
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
</script>
|
||||
313
frontend/src/components/lot/LoyerLot.vue
Normal file
313
frontend/src/components/lot/LoyerLot.vue
Normal file
@@ -0,0 +1,313 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Loyer</h2>
|
||||
<router-link
|
||||
v-if="!aUneSurface"
|
||||
to="/logements"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
Saisir la surface
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Le loyer en vigueur, son niveau au m², sa dernière révision. Chaque
|
||||
case garde sa place même vide : un tableau qui perd une colonne selon
|
||||
le lot empêche de comparer deux fiches. -->
|
||||
<div class="card-body grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div v-for="tuile in tuiles" :key="tuile.libelle">
|
||||
<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>
|
||||
|
||||
<div class="card-body pt-0">
|
||||
<LoyerChart :serie="loyer.serie" :surface="loyer.surface" />
|
||||
|
||||
<!-- La courbe suit la période choisie, les tuiles du dessus non : le dire
|
||||
ici évite de lire « depuis mai 26 » au-dessus d'un graphe qui
|
||||
commence en avril et d'y voir une contradiction. -->
|
||||
<p v-if="loyer.mois_masques" class="form-hint mt-3">
|
||||
Courbe limitée à la période choisie :
|
||||
{{ loyer.mois_masques }}
|
||||
{{ loyer.mois_masques > 1 ? 'mois antérieurs ne sont pas tracés' : 'mois antérieur n\'est pas tracé' }}.
|
||||
Le loyer en vigueur, sa date d'effet et la comparaison au parc restent lus
|
||||
sur tout l'historique.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Comparaison au parc : les médianes excluent le lot lui-même, et
|
||||
l'effectif accompagne toujours le chiffre. -->
|
||||
<div v-if="loyer.parc" class="card-body pt-0">
|
||||
<h3 class="text-sm font-medium text-white mb-3">
|
||||
Au mètre carré, face au parc
|
||||
<span class="text-gray-500 font-normal">· {{ formatMoisAnnee(loyer.parc.mois) }}</span>
|
||||
</h3>
|
||||
|
||||
<div v-if="!aUneSurface" class="form-hint">
|
||||
Sans surface saisie sur ce lot, il n'y a rien à comparer. La fiche se complète
|
||||
depuis
|
||||
<router-link
|
||||
to="/logements"
|
||||
class="text-blue-400 hover:text-blue-300 transition-colors underline"
|
||||
>Logements</router-link>.
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div v-for="repere in reperes" :key="repere.libelle" class="bg-gray-950 rounded p-3">
|
||||
<div class="text-xs text-gray-400 uppercase tracking-wide">{{ repere.libelle }}</div>
|
||||
<div class="text-lg font-semibold" :class="repere.classe">{{ repere.valeur }}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">{{ repere.detail }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="loyer.parc.sans_surface" class="form-hint mt-3">
|
||||
{{ loyer.parc.sans_surface }}
|
||||
{{ loyer.parc.sans_surface > 1 ? 'lots loués ce mois-là n\'ont' : 'lot loué ce mois-là n\'a' }}
|
||||
pas de surface sur sa fiche : {{ loyer.parc.sans_surface > 1 ? 'ils restent' : 'il reste' }}
|
||||
hors de ces médianes.
|
||||
</p>
|
||||
|
||||
<!-- Les médianes ci-dessus mêlent toutes les surfaces ; le nuage rend
|
||||
la comparaison à taille comparable, que le chiffre seul écrase. -->
|
||||
<div class="mt-6">
|
||||
<ParcScatter :nuage="loyer.parc.nuage" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Ce que la courbe ne peut pas porter reste visible : sans cette liste,
|
||||
des montants disparaîtraient de la page sans que rien ne le signale.
|
||||
Le bloc s'affiche aussi quand la période n'en laisse aucune, pour dire
|
||||
qu'il en existe. -->
|
||||
<div v-if="loyer.hors_courbe.length || loyer.hors_courbe_masquees" class="card-body pt-0">
|
||||
<!-- Le compteur disparaît quand la période ne laisse rien à lister : un
|
||||
« · 0 » se lirait comme une absence, alors que le texte annonce
|
||||
justement des lignes mises de côté. -->
|
||||
<h3 class="text-sm font-medium text-white mb-2">
|
||||
Hors de la courbe<template v-if="loyer.hors_courbe.length"> · {{ loyer.hors_courbe.length }}</template>
|
||||
</h3>
|
||||
<p class="form-hint mb-3">
|
||||
<template v-if="loyer.hors_courbe.length">
|
||||
Régularisations à cheval sur plusieurs mois sans en couvrir aucun entièrement.
|
||||
Les rattacher à un mois inventerait un loyer que le compte rendu ne porte pas ;
|
||||
elles figurent dans la chronologie ci-dessous.
|
||||
<template v-if="loyer.hors_courbe_masquees">
|
||||
{{ loyer.hors_courbe_masquees }}
|
||||
{{ loyer.hors_courbe_masquees > 1 ? 'autres sont antérieures' : 'autre est antérieure' }}
|
||||
à la période choisie.
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ loyer.hors_courbe_masquees }}
|
||||
{{ loyer.hors_courbe_masquees > 1 ? 'régularisations antérieures' : 'régularisation antérieure' }}
|
||||
à la période choisie : élargir la période
|
||||
{{ loyer.hors_courbe_masquees > 1 ? 'les ramène' : 'la ramène' }} ici.
|
||||
</template>
|
||||
</p>
|
||||
<table v-if="loyer.hors_courbe.length" class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Période</th>
|
||||
<th class="text-right">Montant</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(ligne, index) in loyer.hors_courbe" :key="index">
|
||||
<td class="text-gray-300">
|
||||
{{ formatDate(ligne.periode_debut) }} → {{ formatDate(ligne.periode_fin) }}
|
||||
</td>
|
||||
<td class="text-right" :class="ligne.montant < 0 ? 'text-red-400' : 'text-gray-300'">
|
||||
{{ formatMontantPrecis(ligne.montant) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import LoyerChart from './LoyerChart.vue'
|
||||
import ParcScatter from './ParcScatter.vue'
|
||||
import {
|
||||
formatDate,
|
||||
formatMoisAnnee,
|
||||
formatMontant,
|
||||
formatMontantPrecis
|
||||
} from '../../utils/format'
|
||||
|
||||
const props = defineProps({
|
||||
loyer: { type: Object, required: true }
|
||||
})
|
||||
|
||||
const aUneSurface = computed(() => props.loyer.surface != null && props.loyer.surface > 0)
|
||||
|
||||
/** "12,14 €/m²", ou le tiret des valeurs qu'on n'a pas. */
|
||||
function formatM2(valeur) {
|
||||
if (valeur == null) return '—'
|
||||
return `${valeur.toFixed(2).replace('.', ',')} €/m²`
|
||||
}
|
||||
|
||||
// Ce que la tuile de tête a le droit d'affirmer. Un palier qui s'arrête ne
|
||||
// suffit pas à dire qu'un lot est sorti de la gestion : entre deux baux, le
|
||||
// compte rendu ne porte qu'un prorata, et le lot est bel et bien reloué.
|
||||
const etatDuBail = computed(() => {
|
||||
const vigueur = props.loyer.en_vigueur
|
||||
if (!vigueur) return { libelle: 'Loyer', classe: 'text-gray-500', detail: '' }
|
||||
|
||||
if (vigueur.toujours_loue) {
|
||||
return {
|
||||
libelle: 'Loyer en vigueur',
|
||||
classe: 'text-green-400',
|
||||
detail: `depuis ${formatMoisAnnee(vigueur.depuis)}`
|
||||
}
|
||||
}
|
||||
|
||||
if (vigueur.reloue_depuis) {
|
||||
// Le montant reste celui du bail d'avant : le nouveau n'a encore été
|
||||
// facturé qu'au prorata, et en tirer un loyer mensuel l'inventerait.
|
||||
return {
|
||||
libelle: 'Dernier loyer plein',
|
||||
classe: 'text-gray-400',
|
||||
detail: `nouveau bail depuis ${formatMoisAnnee(vigueur.reloue_depuis)}`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
libelle: 'Dernier loyer connu',
|
||||
classe: 'text-gray-400',
|
||||
detail: vigueur.sortie_en
|
||||
? `arrêté en cours de ${formatMoisAnnee(vigueur.sortie_en)}`
|
||||
: `arrêté après ${formatMoisAnnee(vigueur.mois)}`
|
||||
}
|
||||
})
|
||||
|
||||
const tuiles = computed(() => {
|
||||
const vigueur = props.loyer.en_vigueur
|
||||
|
||||
if (!vigueur) {
|
||||
return [
|
||||
{ libelle: 'Loyer', valeur: '—', classe: 'text-gray-500', detail: 'aucun loyer facturé' },
|
||||
{ libelle: 'Au m²', valeur: '—', classe: 'text-gray-500', detail: surfaceDetail() },
|
||||
{ libelle: 'Dernière révision', valeur: '—', classe: 'text-gray-500' },
|
||||
{ libelle: 'Surface', valeur: surfaceValeur(), classe: 'text-gray-300' }
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
libelle: etatDuBail.value.libelle,
|
||||
valeur: formatMontant(vigueur.loyer),
|
||||
classe: etatDuBail.value.classe,
|
||||
// Un lot sorti de la gestion garde un loyer affiché : sans cette mention,
|
||||
// il se lirait comme une recette courante.
|
||||
detail: etatDuBail.value.detail
|
||||
},
|
||||
{
|
||||
libelle: 'Au m²',
|
||||
valeur: formatM2(vigueur.loyer_m2),
|
||||
classe: vigueur.loyer_m2 == null ? 'text-gray-500' : 'text-amber-400',
|
||||
detail: surfaceDetail()
|
||||
},
|
||||
{
|
||||
libelle: 'Dernière révision',
|
||||
valeur: vigueur.variation_pct == null ? '—' : formatPourcent(vigueur.variation_pct),
|
||||
classe: classeVariation(vigueur.variation_pct),
|
||||
detail:
|
||||
vigueur.precedent == null
|
||||
? 'aucune depuis le premier compte rendu'
|
||||
: `${formatMontant(vigueur.precedent)} → ${formatMontant(vigueur.loyer)} en ${formatMoisAnnee(vigueur.depuis)}`
|
||||
},
|
||||
{
|
||||
libelle: 'Surface',
|
||||
valeur: surfaceValeur(),
|
||||
classe: aUneSurface.value ? 'text-gray-300' : 'text-gray-600',
|
||||
detail: aUneSurface.value ? 'fiche du logement' : 'à saisir dans Logements'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
function surfaceValeur() {
|
||||
return aUneSurface.value ? `${props.loyer.surface} m²` : '—'
|
||||
}
|
||||
|
||||
function surfaceDetail() {
|
||||
return aUneSurface.value ? 'hors charges' : 'surface manquante'
|
||||
}
|
||||
|
||||
function formatPourcent(valeur) {
|
||||
const signe = valeur > 0 ? '+' : ''
|
||||
return `${signe}${valeur.toFixed(2).replace('.', ',')} %`
|
||||
}
|
||||
|
||||
function classeVariation(valeur) {
|
||||
if (valeur == null) return 'text-gray-500'
|
||||
if (valeur > 0) return 'text-green-400'
|
||||
if (valeur < 0) return 'text-red-400'
|
||||
return 'text-gray-300'
|
||||
}
|
||||
|
||||
/**
|
||||
* Écart du lot à une médiane, en pourcentage.
|
||||
*
|
||||
* L'écart dit ce que la médiane seule ne dit pas : savoir que le parc est à
|
||||
* 13,52 €/m² n'apprend rien tant qu'on n'a pas fait la soustraction.
|
||||
*/
|
||||
function ecartA(mediane) {
|
||||
const valeur = props.loyer.parc?.loyer_m2
|
||||
if (valeur == null || !mediane) return null
|
||||
return Math.round(((valeur - mediane) / mediane) * 1000) / 10
|
||||
}
|
||||
|
||||
/** Effectif trop faible pour qu'une médiane décrive autre chose que le hasard. */
|
||||
function effectifFragile(nombre) {
|
||||
return nombre > 0 && nombre < (props.loyer.parc?.effectif_faible ?? 3)
|
||||
}
|
||||
|
||||
function detailMediane(nombre) {
|
||||
if (!nombre) return 'aucun lot comparable'
|
||||
const lots = `${nombre} lot${nombre > 1 ? 's' : ''}`
|
||||
return effectifFragile(nombre) ? `${lots} — trop peu pour conclure` : lots
|
||||
}
|
||||
|
||||
const reperes = computed(() => {
|
||||
const parc = props.loyer.parc
|
||||
const ecartImmeuble = ecartA(parc.mediane_immeuble)
|
||||
const ecartType = ecartA(parc.mediane_type)
|
||||
|
||||
return [
|
||||
{
|
||||
libelle: 'Ce lot',
|
||||
valeur: formatM2(parc.loyer_m2),
|
||||
classe: 'text-amber-400',
|
||||
detail: 'loyer hors charges au m²'
|
||||
},
|
||||
{
|
||||
libelle: 'Médiane de l\'immeuble',
|
||||
valeur: formatM2(parc.mediane_immeuble),
|
||||
classe: effectifFragile(parc.nb_immeuble) ? 'text-gray-500' : 'text-gray-300',
|
||||
detail:
|
||||
ecartImmeuble == null
|
||||
? detailMediane(parc.nb_immeuble)
|
||||
: `${formatPourcent(ecartImmeuble)} · ${detailMediane(parc.nb_immeuble)}`
|
||||
},
|
||||
{
|
||||
// Le type vient de la fiche ou du PDF et n'a pas de forme grammaticale
|
||||
// fixe (« Studio », « Appartement T2 », « Loc. Commercial ») : le poser
|
||||
// après un point médian évite d'accorder un article sur une valeur libre.
|
||||
libelle: parc.type_compare ? `Médiane · ${parc.type_compare}` : 'Médiane du même type',
|
||||
valeur: formatM2(parc.mediane_type),
|
||||
classe: effectifFragile(parc.nb_type) ? 'text-gray-500' : 'text-gray-300',
|
||||
detail: parc.type_compare
|
||||
? ecartType == null
|
||||
? detailMediane(parc.nb_type)
|
||||
: `${formatPourcent(ecartType)} · ${detailMediane(parc.nb_type)}`
|
||||
: 'type du lot non renseigné'
|
||||
}
|
||||
]
|
||||
})
|
||||
</script>
|
||||
167
frontend/src/components/lot/ParcScatter.vue
Normal file
167
frontend/src/components/lot/ParcScatter.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||||
<h3 class="text-sm font-medium text-white">
|
||||
Le parc par surface
|
||||
<span class="text-gray-500 font-normal">· {{ nuage.length }} lots comparés</span>
|
||||
</h3>
|
||||
<div class="flex items-center gap-4 text-xs flex-wrap">
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="w-3 h-3 rounded-full bg-amber-400"></span>
|
||||
<span class="text-gray-400">Ce lot</span>
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="w-3 h-3 rounded-full bg-slate-400"></span>
|
||||
<span class="text-gray-400">Autres lots</span>
|
||||
</span>
|
||||
<span v-if="aDuCommercial" class="flex items-center gap-1">
|
||||
<span class="text-slate-400 leading-none">▲</span>
|
||||
<span class="text-gray-400">Local commercial</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Le loyer au m² décroît avec la surface : sans cette phrase, un lecteur
|
||||
pourrait lire une pente naturelle du marché comme une anomalie de
|
||||
gestion. -->
|
||||
<p class="form-hint mb-3">
|
||||
Un petit logement se loue plus cher au mètre carré qu'un grand. Ce qui se lit ici
|
||||
n'est donc pas la hauteur du point seule, mais sa position par rapport aux lots
|
||||
de surface voisine.
|
||||
</p>
|
||||
|
||||
<div v-if="nuage.length < 2" class="h-64 flex items-center justify-center text-gray-500 text-sm">
|
||||
Pas encore assez de surfaces saisies pour situer ce lot dans le parc.
|
||||
</div>
|
||||
|
||||
<div v-else class="h-72">
|
||||
<Scatter :data="donneesGraphe" :options="options" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Scatter } from 'vue-chartjs'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
ScatterController,
|
||||
Tooltip
|
||||
} from 'chart.js'
|
||||
|
||||
ChartJS.register(LinearScale, PointElement, ScatterController, Tooltip)
|
||||
|
||||
const props = defineProps({
|
||||
nuage: { type: Array, required: true, default: () => [] }
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
/**
|
||||
* Un local commercial ne se loue pas selon la même logique qu'un logement.
|
||||
* Le garder dans le nuage — le parc en compte — mais le marquer, pour qu'il ne
|
||||
* soit pas lu comme un comparable direct.
|
||||
*/
|
||||
function estCommercial(point) {
|
||||
return (point.type_lot ?? '').toLowerCase().includes('commercial')
|
||||
}
|
||||
|
||||
const aDuCommercial = computed(() => props.nuage.some(estCommercial))
|
||||
|
||||
const autres = computed(() => props.nuage.filter((point) => !point.est_ce_lot))
|
||||
const courant = computed(() => props.nuage.filter((point) => point.est_ce_lot))
|
||||
|
||||
/** Chart.js n'accepte que x/y ; le reste voyage pour l'infobulle et le clic. */
|
||||
function versPoints(points) {
|
||||
return points.map((point) => ({ x: point.surface, y: point.loyer_m2, lot: point }))
|
||||
}
|
||||
|
||||
const donneesGraphe = computed(() => ({
|
||||
datasets: [
|
||||
{
|
||||
label: 'Autres lots',
|
||||
data: versPoints(autres.value),
|
||||
backgroundColor: 'rgba(148, 163, 184, 0.75)',
|
||||
pointRadius: 6,
|
||||
pointHoverRadius: 8,
|
||||
pointStyle: autres.value.map((point) => (estCommercial(point) ? 'triangle' : 'circle'))
|
||||
},
|
||||
{
|
||||
label: 'Ce lot',
|
||||
data: versPoints(courant.value),
|
||||
backgroundColor: 'rgb(251, 191, 36)',
|
||||
borderColor: 'rgb(253, 230, 138)',
|
||||
borderWidth: 2,
|
||||
pointRadius: 9,
|
||||
pointHoverRadius: 11,
|
||||
pointStyle: courant.value.map((point) => (estCommercial(point) ? 'triangle' : 'circle'))
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
const options = computed(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
// Un lot voisin repéré dans le nuage doit pouvoir s'ouvrir : sans cela, il
|
||||
// faudrait relever son numéro puis le chercher dans le sélecteur.
|
||||
onClick: (evenement, elements) => {
|
||||
const touche = elements[0]
|
||||
if (!touche) return
|
||||
const point = donneesGraphe.value.datasets[touche.datasetIndex].data[touche.index]
|
||||
if (point?.lot && !point.lot.est_ce_lot) router.push(`/lots/${point.lot.lot_id}`)
|
||||
},
|
||||
onHover: (evenement, elements) => {
|
||||
evenement.native.target.style.cursor = elements.length ? 'pointer' : 'default'
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: 'rgb(31, 41, 55)',
|
||||
borderColor: 'rgb(75, 85, 99)',
|
||||
borderWidth: 1,
|
||||
titleColor: 'rgb(255, 255, 255)',
|
||||
bodyColor: 'rgb(156, 163, 175)',
|
||||
padding: 12,
|
||||
callbacks: {
|
||||
title: (contextes) => {
|
||||
const lot = contextes[0].raw.lot
|
||||
return `Lot ${lot.numero}${lot.immeuble_code ? ` · ${lot.immeuble_code}` : ''}`
|
||||
},
|
||||
label: (contexte) => {
|
||||
const lot = contexte.raw.lot
|
||||
const lignes = [
|
||||
`${lot.surface} m² · ${lot.loyer_m2.toFixed(2).replace('.', ',')} €/m²`
|
||||
]
|
||||
if (lot.type_lot) lignes.push(lot.type_lot)
|
||||
if (!lot.est_ce_lot) lignes.push('Cliquer pour ouvrir sa fiche')
|
||||
return lignes
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'linear',
|
||||
title: { display: true, text: 'Surface (m²)', color: 'rgb(156, 163, 175)' },
|
||||
// L'axe part de zéro : tronquer l'origine étirerait les écarts de
|
||||
// surface et ferait paraître deux lots voisins bien plus éloignés.
|
||||
beginAtZero: true,
|
||||
grid: { color: 'rgb(55, 65, 81)' },
|
||||
ticks: { color: 'rgb(156, 163, 175)', font: { size: 11 } }
|
||||
},
|
||||
y: {
|
||||
title: { display: true, text: '€/m²', color: 'rgb(156, 163, 175)' },
|
||||
beginAtZero: true,
|
||||
grid: { color: 'rgb(55, 65, 81)' },
|
||||
ticks: {
|
||||
color: 'rgb(156, 163, 175)',
|
||||
font: { size: 11 },
|
||||
callback: (valeur) => `${valeur} €`
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
</script>
|
||||
@@ -101,25 +101,19 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
/** Etat de depart, tel que la page l'a lu dans l'URL. */
|
||||
filtresInitiaux: { type: Object, default: null }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['filter-change'])
|
||||
|
||||
const immeubles = ref([])
|
||||
|
||||
const filters = ref({
|
||||
immeuble_id: null,
|
||||
type_ligne: null,
|
||||
date_debut: null,
|
||||
date_fin: null,
|
||||
impayes_only: false,
|
||||
months: 12
|
||||
})
|
||||
const filters = ref({ ...filtresParDefaut(), ...(props.filtresInitiaux ?? {}) })
|
||||
|
||||
function emitFilters() {
|
||||
emit('filter-change', { ...filters.value })
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.value = {
|
||||
function filtresParDefaut() {
|
||||
return {
|
||||
immeuble_id: null,
|
||||
type_ligne: null,
|
||||
date_debut: null,
|
||||
@@ -127,6 +121,14 @@ function resetFilters() {
|
||||
impayes_only: false,
|
||||
months: 12
|
||||
}
|
||||
}
|
||||
|
||||
function emitFilters() {
|
||||
emit('filter-change', { ...filters.value })
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.value = filtresParDefaut()
|
||||
emitFilters()
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<router-link
|
||||
:to="{ path: '/documents/' + revenu.document_id + '/edit', query: { highlight: 'locataire', lot_numero: revenu.lot_numero, locataire_nom: revenu.locataire_nom } }"
|
||||
:to="lienEdition(revenu)"
|
||||
class="text-gray-500 hover:text-blue-400 transition-colors"
|
||||
title="Editer le document source"
|
||||
>
|
||||
@@ -140,6 +140,23 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
/** L'editeur recoit la vue d'ou l'on part, filtres compris : une fois
|
||||
l'enregistrement termine, il y ramene au lieu de la liste des documents. */
|
||||
function lienEdition(revenu) {
|
||||
return {
|
||||
path: `/documents/${revenu.document_id}/edit`,
|
||||
query: {
|
||||
highlight: 'locataire',
|
||||
lot_numero: revenu.lot_numero,
|
||||
locataire_nom: revenu.locataire_nom,
|
||||
retour: route.fullPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
revenus: {
|
||||
|
||||
@@ -16,17 +16,21 @@
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<FilterPanel @filter-change="onFiltersChange" />
|
||||
<FilterPanel :filtres-initiaux="currentFilters" @filter-change="onFiltersChange" />
|
||||
|
||||
<!-- KPI Cards -->
|
||||
<KpiCards :summary="summary" />
|
||||
|
||||
<!-- Charts Row -->
|
||||
<!-- Les deux repartitions cote a cote : meme total, deux decoupages. -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<CategoryChart :data="summary.by_category || []" />
|
||||
<MonthlyChart :data="summary.by_month || []" />
|
||||
<TagChart :data="summary.by_tag || []" />
|
||||
</div>
|
||||
|
||||
<!-- L'evolution mensuelle prend toute la largeur : un axe de temps
|
||||
serre est le premier a devenir illisible. -->
|
||||
<MonthlyChart :data="summary.by_month || []" />
|
||||
|
||||
<!-- Top Fournisseurs -->
|
||||
<TopFournisseursChart :data="summary.by_fournisseur || []" />
|
||||
|
||||
@@ -42,23 +46,22 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { SCHEMA_DEPENSES, ecrireFiltres, lireFiltres } from '../utils/filtresUrl.js'
|
||||
import FilterPanel from '../components/analytics/FilterPanel.vue'
|
||||
import KpiCards from '../components/analytics/KpiCards.vue'
|
||||
import CategoryChart from '../components/analytics/CategoryChart.vue'
|
||||
import TagChart from '../components/analytics/TagChart.vue'
|
||||
import MonthlyChart from '../components/analytics/MonthlyChart.vue'
|
||||
import TopFournisseursChart from '../components/analytics/TopFournisseursChart.vue'
|
||||
import DepensesTable from '../components/analytics/DepensesTable.vue'
|
||||
|
||||
// Current filters state
|
||||
let currentFilters = {
|
||||
immeuble_id: null,
|
||||
lot_id: null,
|
||||
categorie: null,
|
||||
tag_id: null,
|
||||
fournisseur: null,
|
||||
date_debut: null,
|
||||
date_fin: null
|
||||
}
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// Les filtres viennent de l'URL : ouvrir un lien filtre, recharger la page ou
|
||||
// revenir de l'editeur de document rend exactement la meme vue.
|
||||
const currentFilters = ref(lireFiltres(route.query, SCHEMA_DEPENSES))
|
||||
|
||||
const summary = ref({
|
||||
total_count: 0,
|
||||
@@ -81,7 +84,11 @@ const lastUpdate = ref(null)
|
||||
let debounceTimer = null
|
||||
|
||||
function onFiltersChange(newFilters) {
|
||||
currentFilters = { ...newFilters }
|
||||
currentFilters.value = { ...newFilters }
|
||||
|
||||
// `replace` et non `push` : le bouton Retour du navigateur doit quitter la
|
||||
// page, pas defaire les filtres un par un.
|
||||
router.replace({ query: ecrireFiltres(currentFilters.value, SCHEMA_DEPENSES) })
|
||||
|
||||
// Debounce
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
@@ -90,16 +97,17 @@ function onFiltersChange(newFilters) {
|
||||
}, 300)
|
||||
}
|
||||
|
||||
/** L'API attend les memes parametres que l'URL de la page : une seule ecriture
|
||||
des filtres, donc aucun moyen que la vue et la requete divergent. */
|
||||
function buildQueryParams() {
|
||||
const params = new URLSearchParams()
|
||||
const query = ecrireFiltres(currentFilters.value, SCHEMA_DEPENSES)
|
||||
|
||||
if (currentFilters.immeuble_id !== null) params.append('immeuble_id', currentFilters.immeuble_id)
|
||||
if (currentFilters.lot_id !== null) params.append('lot_id', currentFilters.lot_id)
|
||||
if (currentFilters.categorie !== null) params.append('categorie', currentFilters.categorie)
|
||||
if (currentFilters.tag_id !== null) params.append('tag_id', currentFilters.tag_id)
|
||||
if (currentFilters.fournisseur) params.append('fournisseur', currentFilters.fournisseur)
|
||||
if (currentFilters.date_debut) params.append('date_debut', currentFilters.date_debut)
|
||||
if (currentFilters.date_fin) params.append('date_fin', currentFilters.date_fin)
|
||||
for (const [cle, valeur] of Object.entries(query)) {
|
||||
for (const unitaire of Array.isArray(valeur) ? valeur : [valeur]) {
|
||||
params.append(cle, unitaire)
|
||||
}
|
||||
}
|
||||
|
||||
return params.toString()
|
||||
}
|
||||
@@ -109,7 +117,7 @@ async function loadData() {
|
||||
const summaryUrl = queryString ? `/api/analytics/depenses/summary?${queryString}` : '/api/analytics/depenses/summary'
|
||||
const depensesUrl = queryString ? `/api/analytics/depenses?${queryString}&limit=500` : '/api/analytics/depenses?limit=500'
|
||||
|
||||
console.log('Loading data with filters:', currentFilters)
|
||||
console.log('Loading data with filters:', currentFilters.value)
|
||||
console.log('Summary URL:', summaryUrl)
|
||||
|
||||
isLoadingDepenses.value = true
|
||||
|
||||
@@ -68,9 +68,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Corps : Split view PDF + Données -->
|
||||
<div v-else class="flex-1 flex overflow-hidden">
|
||||
<VoletsAjustables
|
||||
v-else
|
||||
cle-stockage="apercuPdf.largeurVolet"
|
||||
:largeur-defaut="50"
|
||||
classe-droite="bg-gray-950"
|
||||
>
|
||||
<!-- Gauche : PDF Preview -->
|
||||
<div class="w-2/5 border-r border-gray-700 flex flex-col">
|
||||
<template #gauche>
|
||||
<div class="flex-shrink-0 px-4 py-2 bg-gray-900 border-b border-gray-700">
|
||||
<span class="card-title">Aperçu PDF</span>
|
||||
</div>
|
||||
@@ -91,10 +96,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Droite : Édition ou Tagging -->
|
||||
<div class="w-3/5 flex flex-col bg-gray-950">
|
||||
<template #droite>
|
||||
<!-- Bandeau erreur de re-extraction -->
|
||||
<div v-if="reExtractError" class="flex-shrink-0 px-4 py-2 bg-red-500/10 border-b border-red-500/30 text-sm text-red-400">
|
||||
{{ reExtractError }}
|
||||
@@ -138,8 +143,8 @@
|
||||
<span>{{ saveError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VoletsAjustables>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -150,7 +155,9 @@ import PdfPreview from '../components/PdfPreview.vue'
|
||||
import JsonViewer from '../components/JsonViewer.vue'
|
||||
import TaggingStep from '../components/TaggingStep.vue'
|
||||
import ReExtractionDiff from '../components/ReExtractionDiff.vue'
|
||||
import VoletsAjustables from '../components/VoletsAjustables.vue'
|
||||
import { computeExtractionDiff } from '../utils/diffExtraction.js'
|
||||
import { cheminRetour } from '../utils/retour.js'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -167,6 +174,10 @@ const reExtractError = ref(null)
|
||||
const diff = ref(null)
|
||||
const previousData = ref(null)
|
||||
|
||||
// D'ou l'on vient, et donc ou repartir une fois l'edition terminee : la table
|
||||
// d'analyse avec ses filtres si elle l'a dit, la liste des documents sinon.
|
||||
const destinationRetour = computed(() => cheminRetour(route.query.retour, '/documents'))
|
||||
|
||||
const highlightInfo = computed(() => {
|
||||
const q = route.query
|
||||
if (q.highlight === 'locataire') return { type: 'locataire', lot_numero: q.lot_numero, locataire_nom: q.locataire_nom }
|
||||
@@ -196,7 +207,7 @@ async function loadDocument() {
|
||||
} catch (err) {
|
||||
console.error('Error loading document:', err)
|
||||
alert('Erreur lors du chargement du document: ' + err.message)
|
||||
router.push('/documents')
|
||||
router.push(destinationRetour.value)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@@ -214,7 +225,7 @@ function cancel() {
|
||||
const confirmed = confirm('Abandonner les modifications ?')
|
||||
if (!confirmed) return
|
||||
}
|
||||
router.push('/documents')
|
||||
router.push(destinationRetour.value)
|
||||
}
|
||||
|
||||
function goToTagging() {
|
||||
@@ -284,7 +295,7 @@ async function handleSave(depensesTags, shouldOverwrite) {
|
||||
|
||||
if (result.success) {
|
||||
// Succès - redirection vers la liste des documents
|
||||
router.push('/documents')
|
||||
router.push(destinationRetour.value)
|
||||
} else {
|
||||
throw new Error(result.message || 'Échec de la sauvegarde')
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<template>
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
<VoletsAjustables
|
||||
cle-stockage="apercuPdf.largeurVolet"
|
||||
:largeur-defaut="50"
|
||||
classe-droite="bg-gray-950"
|
||||
>
|
||||
<!-- Left panel: Upload or PDF Preview -->
|
||||
<div class="w-2/5 flex flex-col border-r border-gray-700">
|
||||
<template #gauche>
|
||||
<!-- Upload zone when no file -->
|
||||
<div v-if="!pdfFile" class="flex-1 flex items-center justify-center p-8 bg-gray-950">
|
||||
<div class="max-w-md w-full">
|
||||
@@ -36,10 +40,10 @@
|
||||
:file-name="pdfFile.name"
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Right panel: JSON Viewer or Tagging Step -->
|
||||
<div class="w-3/5 flex flex-col bg-gray-950">
|
||||
<template #droite>
|
||||
<!-- Extract button bar -->
|
||||
<div v-if="pdfFile && !extractedData && !isExtracting && !showTagging" class="flex-shrink-0 p-4 bg-gray-900 border-b border-gray-700">
|
||||
<button
|
||||
@@ -111,8 +115,8 @@
|
||||
:is-loading="isExtracting"
|
||||
class="flex-1 overflow-hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VoletsAjustables>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -122,6 +126,7 @@ import { pendingFile } from '../store'
|
||||
import PdfPreview from '../components/PdfPreview.vue'
|
||||
import JsonViewer from '../components/JsonViewer.vue'
|
||||
import TaggingStep from '../components/TaggingStep.vue'
|
||||
import VoletsAjustables from '../components/VoletsAjustables.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
|
||||
@@ -9,12 +9,20 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<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>
|
||||
|
||||
<select v-model="moisChoisis" class="input w-44">
|
||||
<option v-for="periode in PERIODES" :key="periode.libelle" :value="periode.mois">
|
||||
{{ periode.libelle }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="chargement" class="empty-state">
|
||||
@@ -58,9 +66,53 @@
|
||||
:key="nom"
|
||||
class="badge badge-neutral ml-2"
|
||||
>{{ nom }}</span>
|
||||
|
||||
<!-- Aucune date d'entrée ni de sortie n'est extraite : un locataire
|
||||
appartient à la période parce qu'un compte rendu de la période
|
||||
le porte. Ceux d'avant sont comptés, jamais effacés. -->
|
||||
<span v-if="analyse.identite.locataires_masques" class="ml-2 text-xs text-gray-500">
|
||||
+ {{ analyse.identite.locataires_masques }}
|
||||
{{ analyse.identite.locataires_masques > 1 ? 'occupants antérieurs' : 'occupant antérieur' }}
|
||||
à la période
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ce que la fenêtre retient, et surtout ce qu'elle laisse dehors :
|
||||
un filtre muet ferait passer un lot amputé pour un lot calme. -->
|
||||
<div v-if="fenetreActive" class="card card-body border-l-2 border-blue-500/50">
|
||||
<p class="text-sm text-gray-300">
|
||||
Du <span class="text-white">{{ formatDate(analyse.periode.debut) }}</span>
|
||||
au <span class="text-white">{{ formatDate(analyse.periode.fin) }}</span>
|
||||
<span class="text-gray-500">
|
||||
· calé sur le dernier compte rendu du lot, pas sur aujourd'hui
|
||||
</span>
|
||||
</p>
|
||||
<p class="form-hint mt-1">
|
||||
<template v-if="analyse.periode.lignes_masquees">
|
||||
{{ analyse.periode.lignes_masquees }}
|
||||
{{ analyse.periode.lignes_masquees > 1 ? 'lignes antérieures sont exclues' : 'ligne antérieure est exclue' }}
|
||||
des chiffres, de la chronologie et des intervenants ; la courbe du loyer
|
||||
suit la même période.
|
||||
<button class="text-blue-400 hover:text-blue-300 underline" @click="moisChoisis = null">
|
||||
Tout afficher
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
Aucune ligne du lot ne tombe hors de cette fenêtre : les chiffres sont
|
||||
ceux de tout son historique.
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Une fenêtre demandée sur un lot dont aucun compte rendu ne parle
|
||||
n'a rien sur quoi se caler : le dire vaut mieux qu'afficher des
|
||||
totaux à zéro sans raison apparente. -->
|
||||
<p v-else-if="moisChoisis !== null" class="form-hint">
|
||||
Aucun compte rendu ne porte de ligne sur ce lot : il n'y a pas de période à
|
||||
borner, et la fiche reste entière.
|
||||
</p>
|
||||
|
||||
<!-- 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">
|
||||
@@ -79,6 +131,10 @@
|
||||
quelle part revient à quel lot.
|
||||
</p>
|
||||
|
||||
<!-- Le loyer sur un axe de temps : c'est là que se voient une révision,
|
||||
une vacance ou un décrochage, que les totaux du dessus écrasent. -->
|
||||
<LoyerLot :loyer="analyse.loyer" />
|
||||
|
||||
<!-- Chronologie -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
@@ -225,6 +281,7 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import LoyerLot from '../components/lot/LoyerLot.vue'
|
||||
import { formatDate, formatMontant, formatMontantPrecis } from '../utils/format'
|
||||
|
||||
const API = import.meta.env.VITE_API_URL || ''
|
||||
@@ -239,6 +296,24 @@ const chargement = ref(true)
|
||||
const depensesSeules = ref(false)
|
||||
const detailsOuverts = reactive({})
|
||||
|
||||
// `null` en tête : la fiche s'ouvre sur tout l'historique, et c'est
|
||||
// l'utilisateur qui décide de restreindre. Les durées montent jusqu'à cinq ans,
|
||||
// au-delà desquels « tout » revient au même sur un parc suivi depuis 2024.
|
||||
const PERIODES = [
|
||||
{ mois: null, libelle: "Tout l'historique" },
|
||||
{ mois: 3, libelle: '3 derniers mois' },
|
||||
{ mois: 6, libelle: '6 derniers mois' },
|
||||
{ mois: 12, libelle: 'Dernière année' },
|
||||
{ mois: 24, libelle: '2 dernières années' },
|
||||
{ mois: 60, libelle: '5 dernières années' }
|
||||
]
|
||||
|
||||
const moisChoisis = ref(null)
|
||||
|
||||
// Une fenêtre n'est active que si le serveur a pu la caler : sur un lot sans
|
||||
// aucune ligne, il n'y a pas de dernier compte rendu et donc pas de bornes.
|
||||
const fenetreActive = computed(() => analyse.value?.periode?.debut != null)
|
||||
|
||||
const CHAMPS_IDENTITE = [
|
||||
{ cle: 'type_effectif', libelle: 'Type' },
|
||||
{ cle: 'surface', libelle: 'Surface', unite: ' m²' },
|
||||
@@ -352,27 +427,58 @@ async function chargerLots() {
|
||||
lots.value = await response.json()
|
||||
}
|
||||
|
||||
async function chargerAnalyse(lotId) {
|
||||
async function chargerAnalyse(lotId, mois) {
|
||||
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`)
|
||||
// Le filtre est appliqué côté serveur : les chiffres (taux de recouvrement,
|
||||
// restant dû) sortent de requêtes SQL que le navigateur ne peut pas rejouer,
|
||||
// et les recalculer ici les ferait diverger de la chronologie.
|
||||
const parametres = mois == null ? '' : `?mois=${mois}`
|
||||
const response = await fetch(`${API}/api/lots/${lotId}/analyse${parametres}`)
|
||||
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) => {
|
||||
// L'URL porte le lot et la période : une fiche se partage et se recharge telle
|
||||
// qu'on la lisait, filtre compris.
|
||||
watch([lotChoisi, moisChoisis], ([lotId, mois]) => {
|
||||
if (lotId == null) return
|
||||
if (String(lotId) !== route.params.id) router.replace(`/lots/${lotId}`)
|
||||
chargerAnalyse(lotId)
|
||||
|
||||
const query = mois == null ? {} : { mois: String(mois) }
|
||||
if (String(lotId) !== route.params.id || query.mois !== route.query.mois) {
|
||||
router.replace({ path: `/lots/${lotId}`, query })
|
||||
}
|
||||
chargerAnalyse(lotId, mois)
|
||||
})
|
||||
|
||||
/** Période lue dans l'URL, ramenée à une des durées proposées. */
|
||||
function moisDeLUrl() {
|
||||
const demande = Number(route.query.mois)
|
||||
return PERIODES.some((periode) => periode.mois === demande) ? demande : null
|
||||
}
|
||||
|
||||
// Passer d'un lot à l'autre reste la même route : Vue réutilise le composant
|
||||
// sans le remonter, et `onMounted` ne rejoue pas. Sans ce suivi, un clic dans
|
||||
// le nuage du parc changerait l'adresse en laissant la fiche du lot précédent
|
||||
// à l'écran — l'écart le plus trompeur qui soit entre l'URL et ce qu'on lit.
|
||||
// Le retour arrière du navigateur passe par le même chemin, période comprise.
|
||||
watch(
|
||||
[() => route.params.id, () => route.query.mois],
|
||||
([id]) => {
|
||||
const demande = Number(id)
|
||||
if (Number.isFinite(demande) && demande !== lotChoisi.value) lotChoisi.value = demande
|
||||
moisChoisis.value = moisDeLUrl()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await chargerLots()
|
||||
// Avant le lot : les deux changent dans le même tick, le watcher ne part
|
||||
// qu'une fois et la première requête porte déjà la période de l'URL.
|
||||
moisChoisis.value = moisDeLUrl()
|
||||
const demande = Number(route.params.id)
|
||||
lotChoisi.value = lots.value.some((lot) => lot.id === demande)
|
||||
? demande
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<RevenusFilterPanel @filter-change="onFiltersChange" />
|
||||
<RevenusFilterPanel :filtres-initiaux="currentFilters" @filter-change="onFiltersChange" />
|
||||
|
||||
<!-- KPI Cards -->
|
||||
<RevenusKpiCards :kpis="summary.kpis" />
|
||||
@@ -52,6 +52,8 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { SCHEMA_REVENUS, ecrireFiltres, lireFiltres } from '../utils/filtresUrl.js'
|
||||
import RevenusFilterPanel from '../components/revenus/RevenusFilterPanel.vue'
|
||||
import RevenusKpiCards from '../components/revenus/RevenusKpiCards.vue'
|
||||
import RevenusMonthlyChart from '../components/revenus/RevenusMonthlyChart.vue'
|
||||
@@ -59,15 +61,12 @@ import RevenusImmeubleChart from '../components/revenus/RevenusImmeubleChart.vue
|
||||
import TopImpayesList from '../components/revenus/TopImpayesList.vue'
|
||||
import RevenusTable from '../components/revenus/RevenusTable.vue'
|
||||
|
||||
// Current filters state
|
||||
let currentFilters = {
|
||||
immeuble_id: null,
|
||||
type_ligne: null,
|
||||
date_debut: null,
|
||||
date_fin: null,
|
||||
impayes_only: false,
|
||||
months: 12
|
||||
}
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// Les filtres vivent dans l'URL : la vue se recharge, se partage, et l'editeur
|
||||
// de document sait y ramener une fois l'enregistrement termine.
|
||||
const currentFilters = ref(lireFiltres(route.query, SCHEMA_REVENUS))
|
||||
|
||||
const summary = ref({
|
||||
kpis: {
|
||||
@@ -95,7 +94,11 @@ const lastUpdate = ref(null)
|
||||
let debounceTimer = null
|
||||
|
||||
function onFiltersChange(newFilters) {
|
||||
currentFilters = { ...newFilters }
|
||||
currentFilters.value = { ...newFilters }
|
||||
|
||||
// `replace` et non `push` : le bouton Retour du navigateur doit quitter la
|
||||
// page, pas defaire les filtres un par un.
|
||||
router.replace({ query: ecrireFiltres(currentFilters.value, SCHEMA_REVENUS) })
|
||||
|
||||
// Debounce
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
@@ -106,21 +109,23 @@ function onFiltersChange(newFilters) {
|
||||
|
||||
function buildSummaryParams() {
|
||||
const params = new URLSearchParams()
|
||||
const filtres = currentFilters.value
|
||||
|
||||
if (currentFilters.months) params.append('months', currentFilters.months)
|
||||
if (currentFilters.immeuble_id !== null) params.append('immeuble_id', currentFilters.immeuble_id)
|
||||
if (filtres.months) params.append('months', filtres.months)
|
||||
if (filtres.immeuble_id !== null) params.append('immeuble_id', filtres.immeuble_id)
|
||||
|
||||
return params.toString()
|
||||
}
|
||||
|
||||
function buildDetailsParams() {
|
||||
const params = new URLSearchParams()
|
||||
const filtres = currentFilters.value
|
||||
|
||||
if (currentFilters.immeuble_id !== null) params.append('immeuble_id', currentFilters.immeuble_id)
|
||||
if (currentFilters.type_ligne !== null) params.append('type_ligne', currentFilters.type_ligne)
|
||||
if (currentFilters.date_debut) params.append('date_debut', currentFilters.date_debut)
|
||||
if (currentFilters.date_fin) params.append('date_fin', currentFilters.date_fin)
|
||||
if (currentFilters.impayes_only) params.append('impayes_only', 'true')
|
||||
if (filtres.immeuble_id !== null) params.append('immeuble_id', filtres.immeuble_id)
|
||||
if (filtres.type_ligne !== null) params.append('type_ligne', filtres.type_ligne)
|
||||
if (filtres.date_debut) params.append('date_debut', filtres.date_debut)
|
||||
if (filtres.date_fin) params.append('date_fin', filtres.date_fin)
|
||||
if (filtres.impayes_only) params.append('impayes_only', 'true')
|
||||
params.append('limit', '500')
|
||||
|
||||
return params.toString()
|
||||
@@ -136,7 +141,7 @@ async function loadData() {
|
||||
const summaryUrl = summaryParams ? `/api/revenus/summary?${summaryParams}` : '/api/revenus/summary'
|
||||
const detailsUrl = detailsParams ? `/api/revenus/details?${detailsParams}` : '/api/revenus/details'
|
||||
|
||||
console.log('Loading revenus data with filters:', currentFilters)
|
||||
console.log('Loading revenus data with filters:', currentFilters.value)
|
||||
|
||||
try {
|
||||
const [summaryRes, detailsRes] = await Promise.all([
|
||||
|
||||
@@ -40,6 +40,13 @@
|
||||
@apply text-sm text-gray-400 mt-1;
|
||||
}
|
||||
|
||||
/* --- Navigation laterale ---------------------------------------------- */
|
||||
|
||||
/* Entree de menu : la couleur (actif / inactif) est posee par App.vue. */
|
||||
.nav-link {
|
||||
@apply flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg transition-colors;
|
||||
}
|
||||
|
||||
/* --- Cartes ----------------------------------------------------------- */
|
||||
|
||||
.card {
|
||||
|
||||
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
|
||||
}
|
||||
143
frontend/src/utils/filtresUrl.js
Normal file
143
frontend/src/utils/filtresUrl.js
Normal file
@@ -0,0 +1,143 @@
|
||||
// Les filtres d'une page d'analyse vivent dans son URL : une vue filtree se
|
||||
// recharge, se partage et se remet en signet, et le retour depuis l'editeur de
|
||||
// document n'a plus qu'une chaine a transporter.
|
||||
//
|
||||
// Chaque champ declare comment il se lit depuis la query et comment il s'y
|
||||
// ecrit. Ce qui vaut sa valeur par defaut n'est pas ecrit : l'URL ne porte que
|
||||
// ce qui a ete choisi, et reste lisible.
|
||||
|
||||
/** Un entier (identifiant). `tag_id=0` est une valeur, pas une absence. */
|
||||
export function entier({ param } = {}) {
|
||||
return {
|
||||
param,
|
||||
defaut: null,
|
||||
lire(brut) {
|
||||
const valeur = Number(premier(brut))
|
||||
return Number.isInteger(valeur) ? valeur : null
|
||||
},
|
||||
ecrire(valeur) {
|
||||
return valeur === null || valeur === undefined ? undefined : String(valeur)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Un texte libre choisi dans une liste fermee (categorie, type de ligne). */
|
||||
export function texte({ param } = {}) {
|
||||
return {
|
||||
param,
|
||||
defaut: null,
|
||||
lire(brut) {
|
||||
const valeur = premier(brut)
|
||||
return valeur ? valeur : null
|
||||
},
|
||||
ecrire(valeur) {
|
||||
return valeur ? valeur : undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Une date ISO. Un format inattendu est ignore plutot qu'affiche de travers. */
|
||||
export function date({ param } = {}) {
|
||||
return {
|
||||
param,
|
||||
defaut: null,
|
||||
lire(brut) {
|
||||
const valeur = premier(brut)
|
||||
return valeur && /^\d{4}-\d{2}-\d{2}$/.test(valeur) ? valeur : null
|
||||
},
|
||||
ecrire(valeur) {
|
||||
return valeur ? valeur : undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Un booleen a un seul sens : seul `true` s'ecrit, l'absence vaut faux. */
|
||||
export function booleen({ param } = {}) {
|
||||
return {
|
||||
param,
|
||||
defaut: false,
|
||||
lire(brut) {
|
||||
return premier(brut) === 'true'
|
||||
},
|
||||
ecrire(valeur) {
|
||||
return valeur ? 'true' : undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Un entier borne avec une valeur par defaut (nombre de mois d'historique). */
|
||||
export function entierBorne({ defaut, min, max, param } = {}) {
|
||||
return {
|
||||
param,
|
||||
defaut,
|
||||
lire(brut) {
|
||||
const valeur = Number(premier(brut))
|
||||
if (!Number.isInteger(valeur) || valeur < min || valeur > max) return defaut
|
||||
return valeur
|
||||
},
|
||||
ecrire(valeur) {
|
||||
return valeur === undefined || valeur === defaut ? undefined : String(valeur)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Une liste de textes, un parametre repete par valeur retenue. */
|
||||
export function liste({ param } = {}) {
|
||||
return {
|
||||
param,
|
||||
defaut: [],
|
||||
lire(brut) {
|
||||
if (brut === undefined || brut === null) return []
|
||||
const valeurs = Array.isArray(brut) ? brut : [brut]
|
||||
return valeurs.filter((valeur) => typeof valeur === 'string' && valeur !== '')
|
||||
},
|
||||
ecrire(valeur) {
|
||||
return valeur && valeur.length ? [...valeur] : undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Les filtres d'une page, reconstruits depuis la query d'une route. */
|
||||
export function lireFiltres(query, schema) {
|
||||
const filtres = {}
|
||||
for (const [nom, champ] of Object.entries(schema)) {
|
||||
filtres[nom] = champ.lire((query ?? {})[champ.param ?? nom])
|
||||
}
|
||||
return filtres
|
||||
}
|
||||
|
||||
/** La query correspondant a des filtres : seuls les choix explicites y figurent. */
|
||||
export function ecrireFiltres(filtres, schema) {
|
||||
const query = {}
|
||||
for (const [nom, champ] of Object.entries(schema)) {
|
||||
const ecrit = champ.ecrire(filtres[nom])
|
||||
if (ecrit !== undefined) query[champ.param ?? nom] = ecrit
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
/** vue-router rend une chaine ou un tableau selon le nombre d'occurrences. */
|
||||
function premier(brut) {
|
||||
return Array.isArray(brut) ? brut[0] : brut
|
||||
}
|
||||
|
||||
/** Filtres de la page Depenses. `fournisseur` se repete, comme cote API. */
|
||||
export const SCHEMA_DEPENSES = {
|
||||
immeuble_id: entier(),
|
||||
lot_id: entier(),
|
||||
categorie: texte(),
|
||||
tag_id: entier(),
|
||||
fournisseurs: liste({ param: 'fournisseur' }),
|
||||
date_debut: date(),
|
||||
date_fin: date(),
|
||||
}
|
||||
|
||||
/** Filtres de la page Recettes. */
|
||||
export const SCHEMA_REVENUS = {
|
||||
immeuble_id: entier(),
|
||||
type_ligne: texte(),
|
||||
date_debut: date(),
|
||||
date_fin: date(),
|
||||
impayes_only: booleen(),
|
||||
months: entierBorne({ defaut: 12, min: 3, max: 24 }),
|
||||
}
|
||||
@@ -54,3 +54,15 @@ export function formatMois(chaine) {
|
||||
const [, mois] = chaine.split('-')
|
||||
return MOIS_COURTS[parseInt(mois, 10) - 1] ?? chaine
|
||||
}
|
||||
|
||||
/**
|
||||
* "2026-06" → "juin 26", pour les séries qui traversent plusieurs années.
|
||||
*
|
||||
* `formatMois` suffit quand un titre porte déjà l'année ; sur trois ans de
|
||||
* comptes rendus, deux mois de juin se confondraient.
|
||||
*/
|
||||
export function formatMoisAnnee(chaine) {
|
||||
if (!chaine) return ''
|
||||
const [annee, mois] = chaine.split('-')
|
||||
return `${MOIS_COURTS[parseInt(mois, 10) - 1] ?? mois} ${annee.slice(2)}`
|
||||
}
|
||||
|
||||
22
frontend/src/utils/retour.js
Normal file
22
frontend/src/utils/retour.js
Normal file
@@ -0,0 +1,22 @@
|
||||
// Une table d'analyse envoie vers l'editeur de document la page ou revenir une
|
||||
// fois l'enregistrement termine, filtres compris. Cette destination arrive par
|
||||
// l'URL : elle est donc fabricable par n'importe qui, et n'est suivie que si
|
||||
// elle designe une page de l'application.
|
||||
|
||||
/**
|
||||
* La destination de retour portee par une query, ou `repli` si elle manque ou
|
||||
* ne designe pas un chemin interne.
|
||||
*
|
||||
* Sont refuses tout ce qui peut sortir du site : une URL absolue
|
||||
* (`https://ailleurs`), un chemin protocol-relatif (`//ailleurs`) que le
|
||||
* navigateur traite comme un domaine, et les contre-slashs dont certains
|
||||
* navigateurs font des slashs.
|
||||
*/
|
||||
export function cheminRetour(brut, repli = '/documents') {
|
||||
const valeur = Array.isArray(brut) ? brut[0] : brut
|
||||
if (typeof valeur !== 'string' || valeur === '') return repli
|
||||
if (!valeur.startsWith('/')) return repli
|
||||
if (valeur.startsWith('//')) return repli
|
||||
if (valeur.includes('\\')) return repli
|
||||
return 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
|
||||
}
|
||||
99
frontend/tests/filtresUrl.test.js
Normal file
99
frontend/tests/filtresUrl.test.js
Normal file
@@ -0,0 +1,99 @@
|
||||
// Les filtres d'une page d'analyse transitent par son URL. Deux exigences :
|
||||
// ce qu'on relit doit etre ce qu'on a choisi, et une query fabriquee a la main
|
||||
// ne doit jamais produire une vue incoherente.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
SCHEMA_DEPENSES,
|
||||
SCHEMA_REVENUS,
|
||||
ecrireFiltres,
|
||||
lireFiltres,
|
||||
} from '../src/utils/filtresUrl.js'
|
||||
|
||||
describe('filtres de la page Depenses', () => {
|
||||
it('rend une URL vide quand rien n est filtre', () => {
|
||||
const filtres = lireFiltres({}, SCHEMA_DEPENSES)
|
||||
|
||||
expect(filtres).toEqual({
|
||||
immeuble_id: null,
|
||||
lot_id: null,
|
||||
categorie: null,
|
||||
tag_id: null,
|
||||
fournisseurs: [],
|
||||
date_debut: null,
|
||||
date_fin: null,
|
||||
})
|
||||
expect(ecrireFiltres(filtres, SCHEMA_DEPENSES)).toEqual({})
|
||||
})
|
||||
|
||||
it('retrouve a l identique les filtres qu il a ecrits', () => {
|
||||
const choisis = {
|
||||
immeuble_id: 3,
|
||||
lot_id: 12,
|
||||
categorie: 'TRAVAUX',
|
||||
tag_id: 7,
|
||||
fournisseurs: ['PPR', 'MAILLET'],
|
||||
date_debut: '2026-01-01',
|
||||
date_fin: '2026-06-30',
|
||||
}
|
||||
|
||||
const query = ecrireFiltres(choisis, SCHEMA_DEPENSES)
|
||||
|
||||
expect(query.fournisseur).toEqual(['PPR', 'MAILLET'])
|
||||
expect(lireFiltres(query, SCHEMA_DEPENSES)).toEqual(choisis)
|
||||
})
|
||||
|
||||
it('garde « sans tag » qui vaut zero, la ou l absence de filtre vaut null', () => {
|
||||
// Le piege du filtre tag : 0 est un choix, pas une case vide.
|
||||
expect(lireFiltres({ tag_id: '0' }, SCHEMA_DEPENSES).tag_id).toBe(0)
|
||||
expect(ecrireFiltres({ tag_id: 0 }, SCHEMA_DEPENSES)).toEqual({ tag_id: '0' })
|
||||
expect(ecrireFiltres({ tag_id: null }, SCHEMA_DEPENSES)).toEqual({})
|
||||
})
|
||||
|
||||
it('accepte un fournisseur seul, que vue-router rend comme une chaine', () => {
|
||||
expect(lireFiltres({ fournisseur: 'PPR' }, SCHEMA_DEPENSES).fournisseurs).toEqual(['PPR'])
|
||||
})
|
||||
|
||||
it('ignore une valeur qui n a pas de sens plutot que d afficher de travers', () => {
|
||||
const filtres = lireFiltres(
|
||||
{ immeuble_id: 'abc', tag_id: '3.5', date_debut: '01/02/2026', fournisseur: '' },
|
||||
SCHEMA_DEPENSES
|
||||
)
|
||||
|
||||
expect(filtres.immeuble_id).toBeNull()
|
||||
expect(filtres.tag_id).toBeNull()
|
||||
expect(filtres.date_debut).toBeNull()
|
||||
expect(filtres.fournisseurs).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('filtres de la page Recettes', () => {
|
||||
it('n ecrit pas l historique quand il vaut sa valeur par defaut', () => {
|
||||
expect(ecrireFiltres(lireFiltres({}, SCHEMA_REVENUS), SCHEMA_REVENUS)).toEqual({})
|
||||
expect(lireFiltres({}, SCHEMA_REVENUS).months).toBe(12)
|
||||
})
|
||||
|
||||
it('retrouve a l identique les filtres qu il a ecrits', () => {
|
||||
const choisis = {
|
||||
immeuble_id: 2,
|
||||
type_ligne: 'loyer',
|
||||
date_debut: '2026-01-01',
|
||||
date_fin: null,
|
||||
impayes_only: true,
|
||||
months: 24,
|
||||
}
|
||||
|
||||
expect(lireFiltres(ecrireFiltres(choisis, SCHEMA_REVENUS), SCHEMA_REVENUS)).toEqual(choisis)
|
||||
})
|
||||
|
||||
it('ramene un historique hors bornes a sa valeur par defaut', () => {
|
||||
expect(lireFiltres({ months: '999' }, SCHEMA_REVENUS).months).toBe(12)
|
||||
expect(lireFiltres({ months: '0' }, SCHEMA_REVENUS).months).toBe(12)
|
||||
})
|
||||
|
||||
it('ne retient « impayes uniquement » que sur un oui explicite', () => {
|
||||
expect(lireFiltres({ impayes_only: 'true' }, SCHEMA_REVENUS).impayes_only).toBe(true)
|
||||
expect(lireFiltres({ impayes_only: 'oui' }, SCHEMA_REVENUS).impayes_only).toBe(false)
|
||||
expect(ecrireFiltres({ impayes_only: false }, SCHEMA_REVENUS)).toEqual({})
|
||||
})
|
||||
})
|
||||
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 €')
|
||||
})
|
||||
})
|
||||
44
frontend/tests/pdfPreview.test.js
Normal file
44
frontend/tests/pdfPreview.test.js
Normal file
@@ -0,0 +1,44 @@
|
||||
// L'apercu sert a relire le compte rendu ligne a ligne pendant qu'on corrige
|
||||
// l'extraction : ce qui compte est qu'il s'ouvre a une echelle lisible, pas
|
||||
// qu'il tienne entier dans le volet.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createSSRApp } from 'vue'
|
||||
import { renderToString } from 'vue/server-renderer'
|
||||
|
||||
import PdfPreview from '../src/components/PdfPreview.vue'
|
||||
|
||||
function rendu(proprietes = {}) {
|
||||
return renderToString(createSSRApp(PdfPreview, proprietes))
|
||||
}
|
||||
|
||||
describe('PdfPreview', () => {
|
||||
it('ouvre le document a l echelle 1:1', async () => {
|
||||
// Le zoom par defaut du lecteur integre tombe vers 35 % dans un volet
|
||||
// etroit, ce qui reduit les tableaux du CRG (7 pt) a 3 px de haut. A 100 %
|
||||
// ils font 9,3 px et redeviennent lisibles, quitte a devoir defiler.
|
||||
const html = await rendu({ url: '/api/documents/37/pdf' })
|
||||
|
||||
expect(html).toContain('/api/documents/37/pdf#zoom=100')
|
||||
})
|
||||
|
||||
it('annonce l absence de document plutot qu un cadre vide', async () => {
|
||||
const html = await rendu()
|
||||
|
||||
expect(html).toContain('Aucun PDF sélectionné')
|
||||
expect(html).not.toContain('<iframe')
|
||||
})
|
||||
|
||||
it('offre d ouvrir le PDF hors du volet', async () => {
|
||||
// Seule issue quand le moteur n'embarque pas de lecteur PDF : sans ce
|
||||
// lien, le volet resterait vide sans recours.
|
||||
const html = await rendu({ url: '/api/documents/37/pdf' })
|
||||
|
||||
expect(html).toContain('target="_blank"')
|
||||
})
|
||||
|
||||
it('affiche le nom du fichier relu', async () => {
|
||||
const html = await rendu({ url: '/api/documents/37/pdf', fileName: '2026 07 Servient.pdf' })
|
||||
|
||||
expect(html).toContain('2026 07 Servient.pdf')
|
||||
})
|
||||
})
|
||||
36
frontend/tests/retour.test.js
Normal file
36
frontend/tests/retour.test.js
Normal file
@@ -0,0 +1,36 @@
|
||||
// La destination de retour arrive par l'URL : elle est fabricable par
|
||||
// n'importe qui, et un lien pieges ne doit pas pouvoir renvoyer l'utilisateur
|
||||
// hors du site apres un enregistrement.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { cheminRetour } from '../src/utils/retour.js'
|
||||
|
||||
describe('cheminRetour', () => {
|
||||
it('rend la page d analyse avec ses filtres', () => {
|
||||
const vue = '/analytics?immeuble_id=3&fournisseur=PPR&tag_id=0'
|
||||
|
||||
expect(cheminRetour(vue)).toBe(vue)
|
||||
})
|
||||
|
||||
it('retombe sur la liste des documents quand rien n est demande', () => {
|
||||
expect(cheminRetour(undefined)).toBe('/documents')
|
||||
expect(cheminRetour('')).toBe('/documents')
|
||||
})
|
||||
|
||||
it('refuse ce qui sortirait du site', () => {
|
||||
// `//ailleurs.example` est un chemin protocol-relatif : le navigateur y lit
|
||||
// un domaine, pas une page de l'application.
|
||||
expect(cheminRetour('https://ailleurs.example')).toBe('/documents')
|
||||
expect(cheminRetour('//ailleurs.example')).toBe('/documents')
|
||||
expect(cheminRetour('/\\ailleurs.example')).toBe('/documents')
|
||||
expect(cheminRetour('analytics')).toBe('/documents')
|
||||
})
|
||||
|
||||
it('accepte le repli que l appelant lui donne', () => {
|
||||
expect(cheminRetour(null, '/revenus')).toBe('/revenus')
|
||||
})
|
||||
|
||||
it('ne retient que la premiere valeur quand le parametre est repete', () => {
|
||||
expect(cheminRetour(['/analytics', 'https://ailleurs.example'])).toBe('/analytics')
|
||||
})
|
||||
})
|
||||
49
frontend/tests/selectionMultiple.test.js
Normal file
49
frontend/tests/selectionMultiple.test.js
Normal file
@@ -0,0 +1,49 @@
|
||||
// Le declencheur du menu multi-selection est ce qui reste visible une fois le
|
||||
// menu referme : il doit dire l'etat du filtre sans qu'on ait a le rouvrir.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createSSRApp } from 'vue'
|
||||
import { renderToString } from 'vue/server-renderer'
|
||||
|
||||
import SelectionMultiple from '../src/components/SelectionMultiple.vue'
|
||||
|
||||
const FOURNISSEURS = [
|
||||
{ valeur: 'ACME', libelle: 'ACME', complement: '12' },
|
||||
{ valeur: 'BOREAL', libelle: 'BOREAL', complement: '3' },
|
||||
{ valeur: 'CERES', libelle: 'CERES', complement: '1' },
|
||||
]
|
||||
|
||||
function rendu(proprietes = {}) {
|
||||
return renderToString(
|
||||
createSSRApp(SelectionMultiple, {
|
||||
options: FOURNISSEURS,
|
||||
libelleVide: 'Tous',
|
||||
nomPluriel: 'fournisseurs',
|
||||
...proprietes,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
describe('SelectionMultiple', () => {
|
||||
it('annonce l absence de filtre quand rien n est coche', async () => {
|
||||
expect(await rendu({ modelValue: [] })).toContain('Tous')
|
||||
})
|
||||
|
||||
it('nomme le fournisseur quand il n y en a qu un', async () => {
|
||||
// Le compte seul (« 1 fournisseur ») cacherait lequel, alors que c'est
|
||||
// justement le cas ou la reponse tient dans le bouton.
|
||||
const html = await rendu({ modelValue: ['BOREAL'] })
|
||||
|
||||
expect(html).toContain('BOREAL')
|
||||
expect(html).not.toContain('1 fournisseurs')
|
||||
})
|
||||
|
||||
it('compte les fournisseurs des qu il y en a plusieurs', async () => {
|
||||
expect(await rendu({ modelValue: ['ACME', 'CERES'] })).toContain('2 fournisseurs')
|
||||
})
|
||||
|
||||
it('garde le menu ferme au premier rendu', async () => {
|
||||
// Une liste de dizaines de fournisseurs deployee d'entree recouvrirait la
|
||||
// page a chaque chargement.
|
||||
expect(await rendu({ modelValue: [] })).not.toContain('Rechercher')
|
||||
})
|
||||
})
|
||||
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({})
|
||||
})
|
||||
})
|
||||
81
frontend/tests/voletsAjustables.test.js
Normal file
81
frontend/tests/voletsAjustables.test.js
Normal file
@@ -0,0 +1,81 @@
|
||||
// La largeur du volet PDF est un reglage que l'utilisateur pose une fois et
|
||||
// retrouve ensuite : elle doit survivre au rechargement, rester dans des bornes
|
||||
// utilisables, et ne jamais empecher l'ecran de s'afficher si le stockage
|
||||
// du navigateur est indisponible.
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createSSRApp } from 'vue'
|
||||
import { renderToString } from 'vue/server-renderer'
|
||||
|
||||
import VoletsAjustables from '../src/components/VoletsAjustables.vue'
|
||||
|
||||
const CLE = 'apercuPdf.largeurVolet'
|
||||
|
||||
/** Remplace le stockage du navigateur, absent sous Node. */
|
||||
function poserStockage(valeur, { leve = false } = {}) {
|
||||
globalThis.localStorage = {
|
||||
getItem: () => {
|
||||
if (leve) throw new Error('stockage indisponible')
|
||||
return valeur
|
||||
},
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
}
|
||||
}
|
||||
|
||||
function rendu(proprietes = {}) {
|
||||
return renderToString(
|
||||
createSSRApp(VoletsAjustables, { cleStockage: CLE, largeurDefaut: 50, ...proprietes })
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete globalThis.localStorage
|
||||
})
|
||||
|
||||
describe('VoletsAjustables', () => {
|
||||
it('applique la largeur par defaut quand rien n est memorise', async () => {
|
||||
poserStockage(null)
|
||||
const html = await rendu()
|
||||
|
||||
expect(html).toContain('width:50%')
|
||||
})
|
||||
|
||||
it('reprend la largeur memorisee', async () => {
|
||||
poserStockage('65')
|
||||
const html = await rendu()
|
||||
|
||||
expect(html).toContain('width:65%')
|
||||
// Les deux volets se partagent la totalite : la poignee ne prend pas de
|
||||
// place dans le flux, sinon le second volet deborderait.
|
||||
expect(html).toContain('width:35%')
|
||||
})
|
||||
|
||||
it('ignore une largeur memorisee hors bornes', async () => {
|
||||
// Sans ce garde-fou, une valeur ecrite avant un changement de bornes
|
||||
// resterait coincee et rendrait un volet inutilisable.
|
||||
poserStockage('95')
|
||||
|
||||
expect(await rendu()).toContain('width:50%')
|
||||
})
|
||||
|
||||
it('ignore une valeur memorisee illisible', async () => {
|
||||
poserStockage('beaucoup')
|
||||
|
||||
expect(await rendu()).toContain('width:50%')
|
||||
})
|
||||
|
||||
it("s'affiche quand même si le stockage est indisponible", async () => {
|
||||
// Navigation privee ou stockage bloque : le reglage se perd, pas l'ecran.
|
||||
poserStockage(null, { leve: true })
|
||||
|
||||
expect(await rendu()).toContain('width:50%')
|
||||
})
|
||||
|
||||
it('expose une poignee de redimensionnement atteignable', async () => {
|
||||
poserStockage(null)
|
||||
const html = await rendu()
|
||||
|
||||
expect(html).toContain('role="separator"')
|
||||
expect(html).toContain('cursor-col-resize')
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@
|
||||
; idéal pour un poste personnel. Crée un raccourci Bureau et menu Démarrer.
|
||||
|
||||
#define AppName "Plesna Gérance"
|
||||
#define AppVersion "0.1.0"
|
||||
#define AppVersion "0.1.2"
|
||||
#define AppPublisher "Plesna"
|
||||
#define AppExeName "PlesnaGerance.exe"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "plesna-gerance"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
description = "Extracteur de comptes rendus de gérance Oralia/ICS"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
|
||||
182
scripts/release.py
Normal file
182
scripts/release.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""Pose une version : aligne les fichiers, commite, tague.
|
||||
|
||||
Le numéro de version est écrit dans cinq fichiers qui doivent rester d'accord
|
||||
(paquet Python, module, frontend, son lock, installeur Windows). Les tenir à
|
||||
jour à la main, c'est publier tôt ou tard un tag `v0.3.0` sur un code qui se
|
||||
déclare `0.1.0`. Ce script fait la mise à jour d'un bloc et refuse d'avancer au
|
||||
moindre doute plutôt que de produire une version à moitié cohérente.
|
||||
|
||||
uv run python scripts/release.py 0.2.0
|
||||
|
||||
Il s'arrête avant le `git push` : le tag reste local tant qu'il n'est pas
|
||||
poussé, et c'est le push qui déclenche la CI (images Docker + build Windows).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
RACINE = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Un fichier, le motif qui y porte la version, le remplacement, et le nombre
|
||||
# d'occurrences attendues. Le motif doit capturer la version dans son dernier
|
||||
# groupe pour que l'on puisse relire l'ancienne valeur ; le compte est verifie
|
||||
# a chaque passage, pour que le script s'arrete si un fichier change de forme
|
||||
# plutot que de laisser filer une version a moitie posee.
|
||||
PORTEURS_DE_VERSION = [
|
||||
(
|
||||
"pyproject.toml",
|
||||
re.compile(r'^version = "([^"]+)"$', re.M),
|
||||
'version = "{v}"',
|
||||
1,
|
||||
),
|
||||
(
|
||||
"src/plesna_gerance/__init__.py",
|
||||
re.compile(r'^__version__ = "([^"]+)"$', re.M),
|
||||
'__version__ = "{v}"',
|
||||
1,
|
||||
),
|
||||
(
|
||||
"frontend/package.json",
|
||||
re.compile(r'^ "version": "([^"]+)",$', re.M),
|
||||
' "version": "{v}",',
|
||||
1,
|
||||
),
|
||||
# Le lock porte la version du paquet racine a deux endroits (en-tete et
|
||||
# packages[""]). Les laisser en arriere fait diverger lock et package.json,
|
||||
# ce que `npm ci` peut refuser en CI selon la version de npm. On s'ancre sur
|
||||
# le nom du paquet, qui n'apparait qu'a ces deux endroits.
|
||||
(
|
||||
"frontend/package-lock.json",
|
||||
re.compile(
|
||||
r'(^(\s*)"name": "plesna-gerance-frontend",\n\s*"version": ")([^"]+)',
|
||||
re.M,
|
||||
),
|
||||
r"\g<1>{v}",
|
||||
2,
|
||||
),
|
||||
(
|
||||
"packaging/installer.iss",
|
||||
re.compile(r'^#define AppVersion "([^"]+)"$', re.M),
|
||||
'#define AppVersion "{v}"',
|
||||
1,
|
||||
),
|
||||
]
|
||||
|
||||
SEMVER = re.compile(r"^\d+\.\d+\.\d+$")
|
||||
|
||||
|
||||
class Refus(Exception):
|
||||
"""Condition non remplie : on s'arrête sans rien modifier."""
|
||||
|
||||
|
||||
def git(*args: str) -> str:
|
||||
"""Lance une commande git dans le dépôt et renvoie sa sortie."""
|
||||
resultat = subprocess.run(
|
||||
["git", "-C", str(RACINE), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if resultat.returncode != 0:
|
||||
raise Refus(f"git {' '.join(args)} a échoué :\n{resultat.stderr.strip()}")
|
||||
return resultat.stdout.strip()
|
||||
|
||||
|
||||
def verifier_le_depot(version: str, autoriser_hors_main: bool) -> None:
|
||||
"""Refuse de poser une version depuis un état de dépôt douteux."""
|
||||
branche = git("rev-parse", "--abbrev-ref", "HEAD")
|
||||
if branche != "main" and not autoriser_hors_main:
|
||||
raise Refus(
|
||||
f"branche courante « {branche} » : une version se pose sur main.\n"
|
||||
"Fusionner d'abord, ou forcer avec --autoriser-hors-main."
|
||||
)
|
||||
|
||||
if git("status", "--porcelain"):
|
||||
raise Refus(
|
||||
"l'arbre de travail n'est pas propre : commiter ou remiser d'abord.\n"
|
||||
"Une version doit correspondre à un état de code identifiable."
|
||||
)
|
||||
|
||||
if git("tag", "--list", f"v{version}"):
|
||||
raise Refus(
|
||||
f"le tag v{version} existe déjà. Choisir un numéro supérieur "
|
||||
"(un tag publié ne se réécrit pas)."
|
||||
)
|
||||
|
||||
|
||||
def appliquer_la_version(version: str) -> list[str]:
|
||||
"""Écrit la version dans les fichiers porteurs. Renvoie ceux qui ont changé."""
|
||||
modifies = []
|
||||
for chemin_relatif, motif, remplacement, attendues in PORTEURS_DE_VERSION:
|
||||
chemin = RACINE / chemin_relatif
|
||||
contenu = chemin.read_text(encoding="utf-8")
|
||||
|
||||
# Le dernier groupe capture la version elle-même, quel que soit le
|
||||
# nombre de groupes servant à l'ancrage.
|
||||
trouvees = [
|
||||
correspondance.groups()[-1] for correspondance in motif.finditer(contenu)
|
||||
]
|
||||
if len(trouvees) != attendues:
|
||||
raise Refus(
|
||||
f"{chemin_relatif} : {len(trouvees)} version(s) trouvée(s), "
|
||||
f"{attendues} attendue(s). Le fichier a changé de forme : "
|
||||
"corriger le motif dans scripts/release.py."
|
||||
)
|
||||
|
||||
if all(trouvee == version for trouvee in trouvees):
|
||||
continue
|
||||
|
||||
chemin.write_text(
|
||||
motif.sub(remplacement.format(v=version), contenu), encoding="utf-8"
|
||||
)
|
||||
modifies.append(chemin_relatif)
|
||||
|
||||
return modifies
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parseur = argparse.ArgumentParser(description=__doc__)
|
||||
parseur.add_argument("version", help="numéro de version, sans le v (ex. 0.2.0)")
|
||||
parseur.add_argument(
|
||||
"--autoriser-hors-main",
|
||||
action="store_true",
|
||||
help="pose la version depuis une autre branche (rattrapage)",
|
||||
)
|
||||
arguments = parseur.parse_args()
|
||||
|
||||
version = arguments.version.lstrip("v")
|
||||
if not SEMVER.match(version):
|
||||
print(
|
||||
f"Version « {arguments.version} » invalide : attendu MAJEUR.MINEUR.CORRECTIF "
|
||||
"(ex. 0.2.0).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
try:
|
||||
verifier_le_depot(version, arguments.autoriser_hors_main)
|
||||
modifies = appliquer_la_version(version)
|
||||
|
||||
if modifies:
|
||||
git("add", *modifies)
|
||||
git("commit", "-m", f"chore: passe en version {version}")
|
||||
print(f"Version écrite dans : {', '.join(modifies)}")
|
||||
else:
|
||||
print(f"Les fichiers déclarent déjà {version} : aucun commit de version.")
|
||||
|
||||
git("tag", "-a", f"v{version}", "-m", f"Version {version}")
|
||||
except Refus as refus:
|
||||
print(f"Publication interrompue : {refus}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"Tag v{version} posé sur {git('rev-parse', '--short', 'HEAD')}.")
|
||||
print("\nRien n'est publié tant que le tag n'est pas poussé :")
|
||||
print(f" git push origin main v{version}")
|
||||
print("\nLe push déclenche la CI : images Docker taguées puis build Windows.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -5,7 +5,7 @@ Ce package extrait les informations structurées des PDFs de comptes rendus
|
||||
de gérance générés par le logiciel de gestion immobilière Oralia/ICS.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.1.2"
|
||||
|
||||
from .extractor import extract_compte_rendu
|
||||
|
||||
|
||||
@@ -25,6 +25,47 @@ from ..schemas.models import (
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["analytics"])
|
||||
|
||||
#: Valeur de `tag_id` demandant les depenses sans tag. C'est la meme clef que
|
||||
#: celle employee pour agreger les non taggues dans `by_tag` : « aucun tag » est
|
||||
#: un choix de filtre a part entiere, pas l'absence de filtre (`tag_id` omis).
|
||||
SANS_TAG = 0
|
||||
|
||||
|
||||
def _appliquer_filtres(
|
||||
stmt,
|
||||
*,
|
||||
immeuble_id: int | None,
|
||||
lot_id: int | None,
|
||||
tag_id: int | None,
|
||||
categorie: str | None,
|
||||
fournisseurs: list[str] | None,
|
||||
date_debut: date | None,
|
||||
date_fin: date | None,
|
||||
):
|
||||
"""Applique les filtres communs a la table et au resume.
|
||||
|
||||
Les deux endpoints doivent voir exactement le meme perimetre : les totaux du
|
||||
resume ne veulent rien dire s'ils portent sur d'autres lignes que la table.
|
||||
"""
|
||||
if immeuble_id is not None:
|
||||
stmt = stmt.where(Depense.immeuble_id == immeuble_id)
|
||||
if lot_id is not None:
|
||||
stmt = stmt.where(Depense.lot_id == lot_id)
|
||||
if tag_id is not None:
|
||||
if tag_id == SANS_TAG:
|
||||
stmt = stmt.where(Depense.tag_id.is_(None))
|
||||
else:
|
||||
stmt = stmt.where(Depense.tag_id == tag_id)
|
||||
if categorie is not None:
|
||||
stmt = stmt.where(Depense.categorie == categorie)
|
||||
if fournisseurs:
|
||||
stmt = stmt.where(Depense.fournisseur.in_(fournisseurs))
|
||||
if date_debut is not None:
|
||||
stmt = stmt.where(Document.date >= date_debut)
|
||||
if date_fin is not None:
|
||||
stmt = stmt.where(Document.date <= date_fin)
|
||||
return stmt
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Reference data endpoints (for filters)
|
||||
@@ -180,9 +221,11 @@ async def list_tags_with_stats(
|
||||
async def get_depenses(
|
||||
immeuble_id: int | None = Query(None, description="Filtrer par immeuble"),
|
||||
lot_id: int | None = Query(None, description="Filtrer par lot"),
|
||||
tag_id: int | None = Query(None, description="Filtrer par tag"),
|
||||
tag_id: int | None = Query(None, description="Filtrer par tag (0 = sans tag)"),
|
||||
categorie: str | None = Query(None, description="Filtrer par categorie"),
|
||||
fournisseur: str | None = Query(None, description="Filtrer par fournisseur"),
|
||||
fournisseur: list[str] | None = Query(
|
||||
None, description="Fournisseurs retenus (parametre repetable)"
|
||||
),
|
||||
date_debut: date | None = Query(None, description="Date de debut (YYYY-MM-DD)"),
|
||||
date_fin: date | None = Query(None, description="Date de fin (YYYY-MM-DD)"),
|
||||
limit: int = Query(500, description="Nombre maximum de resultats"),
|
||||
@@ -194,9 +237,10 @@ async def get_depenses(
|
||||
Filtres disponibles:
|
||||
- **immeuble_id**: ID de l'immeuble
|
||||
- **lot_id**: ID du lot
|
||||
- **tag_id**: ID du tag
|
||||
- **tag_id**: ID du tag, ou 0 pour les depenses sans tag
|
||||
- **categorie**: Categorie de depense
|
||||
- **fournisseur**: Nom du fournisseur
|
||||
- **fournisseur**: nom exact d'un fournisseur, repetable pour en retenir
|
||||
plusieurs (`?fournisseur=A&fournisseur=B`)
|
||||
- **date_debut**: Date de debut (incluse)
|
||||
- **date_fin**: Date de fin (incluse)
|
||||
- **limit**: Nombre max de resultats (defaut: 500)
|
||||
@@ -219,21 +263,16 @@ async def get_depenses(
|
||||
.order_by(Document.date.desc(), Depense.id.desc())
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if immeuble_id is not None:
|
||||
stmt = stmt.where(Depense.immeuble_id == immeuble_id)
|
||||
if lot_id is not None:
|
||||
stmt = stmt.where(Depense.lot_id == lot_id)
|
||||
if tag_id is not None:
|
||||
stmt = stmt.where(Depense.tag_id == tag_id)
|
||||
if categorie is not None:
|
||||
stmt = stmt.where(Depense.categorie == categorie)
|
||||
if fournisseur is not None:
|
||||
stmt = stmt.where(Depense.fournisseur.ilike(f"%{fournisseur}%"))
|
||||
if date_debut is not None:
|
||||
stmt = stmt.where(Document.date >= date_debut)
|
||||
if date_fin is not None:
|
||||
stmt = stmt.where(Document.date <= date_fin)
|
||||
stmt = _appliquer_filtres(
|
||||
stmt,
|
||||
immeuble_id=immeuble_id,
|
||||
lot_id=lot_id,
|
||||
tag_id=tag_id,
|
||||
categorie=categorie,
|
||||
fournisseurs=fournisseur,
|
||||
date_debut=date_debut,
|
||||
date_fin=date_fin,
|
||||
)
|
||||
|
||||
stmt = stmt.limit(limit).offset(offset)
|
||||
|
||||
@@ -271,9 +310,11 @@ async def get_depenses(
|
||||
async def get_depenses_summary(
|
||||
immeuble_id: int | None = Query(None, description="Filtrer par immeuble"),
|
||||
lot_id: int | None = Query(None, description="Filtrer par lot"),
|
||||
tag_id: int | None = Query(None, description="Filtrer par tag"),
|
||||
tag_id: int | None = Query(None, description="Filtrer par tag (0 = sans tag)"),
|
||||
categorie: str | None = Query(None, description="Filtrer par categorie"),
|
||||
fournisseur: str | None = Query(None, description="Filtrer par fournisseur"),
|
||||
fournisseur: list[str] | None = Query(
|
||||
None, description="Fournisseurs retenus (parametre repetable)"
|
||||
),
|
||||
date_debut: date | None = Query(None, description="Date de debut (YYYY-MM-DD)"),
|
||||
date_fin: date | None = Query(None, description="Date de fin (YYYY-MM-DD)"),
|
||||
session: Session = Depends(get_session),
|
||||
@@ -289,22 +330,16 @@ async def get_depenses_summary(
|
||||
- Top fournisseurs
|
||||
"""
|
||||
# Base query with filters
|
||||
base_stmt = select(Depense).join(Document, Depense.document_id == Document.id)
|
||||
|
||||
if immeuble_id is not None:
|
||||
base_stmt = base_stmt.where(Depense.immeuble_id == immeuble_id)
|
||||
if lot_id is not None:
|
||||
base_stmt = base_stmt.where(Depense.lot_id == lot_id)
|
||||
if tag_id is not None:
|
||||
base_stmt = base_stmt.where(Depense.tag_id == tag_id)
|
||||
if categorie is not None:
|
||||
base_stmt = base_stmt.where(Depense.categorie == categorie)
|
||||
if fournisseur is not None:
|
||||
base_stmt = base_stmt.where(Depense.fournisseur.ilike(f"%{fournisseur}%"))
|
||||
if date_debut is not None:
|
||||
base_stmt = base_stmt.where(Document.date >= date_debut)
|
||||
if date_fin is not None:
|
||||
base_stmt = base_stmt.where(Document.date <= date_fin)
|
||||
base_stmt = _appliquer_filtres(
|
||||
select(Depense).join(Document, Depense.document_id == Document.id),
|
||||
immeuble_id=immeuble_id,
|
||||
lot_id=lot_id,
|
||||
tag_id=tag_id,
|
||||
categorie=categorie,
|
||||
fournisseurs=fournisseur,
|
||||
date_debut=date_debut,
|
||||
date_fin=date_fin,
|
||||
)
|
||||
|
||||
# Get all matching depenses
|
||||
result = session.execute(base_stmt)
|
||||
|
||||
@@ -14,12 +14,43 @@ Deux limites sont assumées plutôt que contournées :
|
||||
- **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.
|
||||
|
||||
Le bloc `loyer` fait exception à ce cumul : il remet le loyer sur un axe de
|
||||
temps, seule façon de voir une révision, une vacance ou un décrochage que les
|
||||
totaux écrasent. Ses règles vivent dans `services.loyers`, et la comparaison au
|
||||
parc les rejoue à l'identique pour les autres lots — situer un chiffre par
|
||||
rapport à des chiffres obtenus autrement ne voudrait rien dire.
|
||||
|
||||
Le loyer au mètre carré vient de la surface saisie sur la fiche du logement.
|
||||
Aucun compte rendu n'en porte : tant qu'elle manque, le ratio reste `null` et la
|
||||
page renvoie vers la saisie plutôt que d'afficher un zéro.
|
||||
|
||||
Une **fenêtre de temps** optionnelle (`mois`) restreint les chiffres, la
|
||||
chronologie, les intervenants, les locataires et la courbe du loyer. Trois
|
||||
règles la gouvernent :
|
||||
|
||||
- **elle est calée sur le dernier compte rendu du lot**, pas sur aujourd'hui.
|
||||
Un lot dont l'extraction s'arrête il y a huit mois afficherait sinon une page
|
||||
vide sur « 3 derniers mois », ce qui se lirait comme une absence d'activité ;
|
||||
- **elle coupe la courbe du loyer, sans la recalculer**. Les paliers, la
|
||||
dernière révision et le mois de comparaison au parc restent lus sur toute la
|
||||
série : « depuis mai 26 » doit désigner la révision qui a fixé ce loyer, pas
|
||||
la borne du filtre, et la médiane du parc ne peut pas changer de mois de
|
||||
référence à chaque changement de période ;
|
||||
- **elle ne s'applique pas au restant dû**, qui est un stock et non un flux :
|
||||
le borner ferait disparaître une dette bien réelle dès qu'aucun compte rendu
|
||||
ne tombe dans la fenêtre (cf. `services.revenus_query`).
|
||||
|
||||
Sans `mois`, tout l'historique est rendu — le défaut ne cache rien. Avec, la
|
||||
réponse porte `periode`, qui dit les bornes retenues et combien de lignes
|
||||
restent dehors : un filtre doit annoncer ce qu'il masque.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
import calendar
|
||||
from datetime import date, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -33,6 +64,17 @@ from ...database.models import (
|
||||
Revenu,
|
||||
Tag,
|
||||
)
|
||||
from ...services.loyers import (
|
||||
EFFECTIF_MEDIANE_FIABLE,
|
||||
MoisLoue,
|
||||
loyer_au_m2,
|
||||
mediane,
|
||||
mois_de,
|
||||
paliers,
|
||||
parc_du_mois,
|
||||
serie_du_lot,
|
||||
variation,
|
||||
)
|
||||
from ...services.referentiel import type_effectif
|
||||
from ...services.revenus_query import (
|
||||
TYPE_LIGNE_REPORT,
|
||||
@@ -44,6 +86,26 @@ from ...services.revenus_query import (
|
||||
router = APIRouter(prefix="/api", tags=["lots"])
|
||||
|
||||
|
||||
class Periode(BaseModel):
|
||||
"""La fenêtre de temps réellement appliquée, et ce qu'elle laisse dehors.
|
||||
|
||||
Renvoyée même quand rien n'est filtré : la page affiche ainsi toujours
|
||||
l'étendue de ce qu'elle montre, plutôt que de le laisser deviner.
|
||||
"""
|
||||
|
||||
#: Nombre de mois demandé, `None` pour tout l'historique.
|
||||
mois: int | None = None
|
||||
#: Bornes retenues, incluses. `None` des deux côtés sans filtre.
|
||||
debut: date | None = None
|
||||
fin: date | None = None
|
||||
#: Date du dernier compte rendu portant une ligne de ce lot — l'origine sur
|
||||
#: laquelle la fenêtre est calée. `None` pour un lot sans aucune ligne.
|
||||
ancre: date | None = None
|
||||
#: Lignes de chronologie que la fenêtre écarte. Une fenêtre qui masque
|
||||
#: quarante opérations doit le dire, sans quoi le lot paraîtrait calme.
|
||||
lignes_masquees: int = 0
|
||||
|
||||
|
||||
class LotIdentite(BaseModel):
|
||||
"""Qui est ce lot : son rattachement, et ce que sa fiche en dit."""
|
||||
|
||||
@@ -62,9 +124,14 @@ class LotIdentite(BaseModel):
|
||||
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.
|
||||
#: Noms portés par les comptes rendus de la période. Les dates d'entrée et
|
||||
#: de sortie ne sont pas extraites : le seul rattachement au temps dont on
|
||||
#: dispose est le compte rendu où le nom figure. L'ordre n'a pas de sens.
|
||||
locataires: list[str] = []
|
||||
#: Locataires du lot qu'aucun compte rendu de la période ne porte. Comptés
|
||||
#: pour qu'une fiche filtrée n'ait pas l'air de n'avoir jamais eu qu'un
|
||||
#: occupant.
|
||||
locataires_masques: int = 0
|
||||
|
||||
|
||||
class LotChiffres(BaseModel):
|
||||
@@ -135,23 +202,257 @@ class Intervenant(BaseModel):
|
||||
derniere_date: date | None = None
|
||||
|
||||
|
||||
class PointLoyer(BaseModel):
|
||||
"""Un mois de la courbe du loyer."""
|
||||
|
||||
mois: str # "2026-06"
|
||||
#: Loyer hors charges du mois plein, `None` si aucune ligne ne le couvre.
|
||||
loyer: float | None = None
|
||||
charges: float | None = None
|
||||
#: Facturé au prorata ce mois-là (entrée, sortie, avoir), hors du loyer.
|
||||
prorata: float | None = None
|
||||
#: Loyer au m², `None` sans surface saisie.
|
||||
loyer_m2: float | None = None
|
||||
#: Le loyer vient d'une ligne pluri-mensuelle répartie (bail trimestriel).
|
||||
reparti: bool = False
|
||||
#: Mois facturé seulement au prorata : un changement de locataire, pas une
|
||||
#: vacance. Sans cette distinction, les deux se ressemblent sur la courbe.
|
||||
en_transition: bool = False
|
||||
|
||||
|
||||
class LigneHorsCourbe(BaseModel):
|
||||
"""Une ligne de loyer qu'aucun mois ne peut revendiquer.
|
||||
|
||||
Régularisation rétroactive chevauchant plusieurs mois. Rendue à part pour
|
||||
que la somme de la courbe et de ces lignes retrouve le facturé total.
|
||||
"""
|
||||
|
||||
periode_debut: date | None = None
|
||||
periode_fin: date | None = None
|
||||
montant: float = 0.0
|
||||
|
||||
|
||||
class LoyerEnVigueur(BaseModel):
|
||||
"""Le dernier loyer connu, et depuis quand il tient."""
|
||||
|
||||
mois: str
|
||||
loyer: float
|
||||
loyer_m2: float | None = None
|
||||
#: Premier mois du palier courant : la date d'effet de la dernière révision.
|
||||
depuis: str
|
||||
#: Loyer d'avant la dernière révision, et l'écart en %. `None` si le lot
|
||||
#: n'a connu qu'un seul niveau depuis le premier compte rendu.
|
||||
precedent: float | None = None
|
||||
variation_pct: float | None = None
|
||||
#: Le dernier compte rendu de l'immeuble porte encore ce loyer. Faux pour
|
||||
#: un lot dont le bail s'est arrêté : son dernier loyer est une archive, et
|
||||
#: l'afficher comme courant ferait croire à une recette qui n'existe plus.
|
||||
#:
|
||||
#: Faux aussi pendant une relocation, où le loyer plein d'avant n'a plus
|
||||
#: cours et celui d'après n'est pas encore facturé : `reloue_depuis` sépare
|
||||
#: alors ce lot d'un lot réellement sorti de la gestion.
|
||||
toujours_loue: bool = True
|
||||
#: Mois d'un prorata d'entrée postérieur à ce loyer : un nouveau bail a
|
||||
#: commencé, et seuls les jours qu'il couvre sont facturés. Sans ce champ,
|
||||
#: la fiche annoncerait un loyer « arrêté » sur un lot qui vient d'être
|
||||
#: reloué.
|
||||
reloue_depuis: str | None = None
|
||||
#: Mois d'un prorata de sortie postérieur à ce loyer, quand aucune entrée
|
||||
#: ne suit : la location s'arrête en cours de mois, pas à la fin du palier.
|
||||
sortie_en: str | None = None
|
||||
|
||||
|
||||
class PointParc(BaseModel):
|
||||
"""Un lot du parc, placé par sa surface et son loyer au m²."""
|
||||
|
||||
lot_id: int
|
||||
numero: str
|
||||
immeuble_code: str | None = None
|
||||
type_lot: str | None = None
|
||||
surface: float
|
||||
loyer_m2: float
|
||||
#: Le lot dont on regarde la fiche. Il figure dans le nuage — s'y voir situé
|
||||
#: est tout l'objet — mais reste hors des médianes, qu'il tirerait vers lui.
|
||||
est_ce_lot: bool = False
|
||||
|
||||
|
||||
class ComparaisonParc(BaseModel):
|
||||
"""Le loyer au m² du lot situé face aux lots comparables.
|
||||
|
||||
Comparé sur le dernier mois loué du lot, et sur ce seul mois : rapprocher
|
||||
un loyer de 2026 de loyers de 2024 mesurerait l'inflation autant que
|
||||
l'écart entre deux biens.
|
||||
|
||||
Deux lectures cohabitent, et la seconde corrige la première : les médianes
|
||||
résument le parc en un chiffre, mais mélangent toutes les surfaces ; le
|
||||
nuage garde la surface en abscisse, seule façon de voir si un lot est cher
|
||||
*pour sa taille*.
|
||||
"""
|
||||
|
||||
mois: str
|
||||
loyer_m2: float | None = None
|
||||
|
||||
mediane_immeuble: float | None = None
|
||||
nb_immeuble: int = 0
|
||||
mediane_type: float | None = None
|
||||
nb_type: int = 0
|
||||
type_compare: str | None = None
|
||||
|
||||
#: Lots loués ce mois-là dont la fiche n'a pas de surface : ils ne peuvent
|
||||
#: pas être comparés. Compté et affiché, sans quoi une médiane sur huit
|
||||
#: lots passerait pour une médiane sur tout le parc.
|
||||
sans_surface: int = 0
|
||||
#: Effectif en dessous duquel la médiane décrit surtout le hasard.
|
||||
effectif_faible: int = EFFECTIF_MEDIANE_FIABLE
|
||||
|
||||
#: Tout le parc comparable, surface comprise, lot courant inclus et
|
||||
#: signalé. Trié par surface : le nuage se lit de gauche à droite.
|
||||
nuage: list[PointParc] = []
|
||||
|
||||
|
||||
class LotLoyer(BaseModel):
|
||||
"""Le loyer du lot dans le temps, et ce qu'il vaut au mètre carré.
|
||||
|
||||
La courbe suit la fenêtre choisie ; `en_vigueur` et `parc` non. Ces deux-là
|
||||
décrivent l'état courant : réduits à la fenêtre, « depuis mai 26 » daterait
|
||||
de la borne du filtre au lieu de la révision qui l'a fixé, et la médiane du
|
||||
parc changerait de mois de référence à chaque changement de période.
|
||||
"""
|
||||
|
||||
surface: float | None = None
|
||||
serie: list[PointLoyer] = []
|
||||
hors_courbe: list[LigneHorsCourbe] = []
|
||||
en_vigueur: LoyerEnVigueur | None = None
|
||||
parc: ComparaisonParc | None = None
|
||||
|
||||
#: Premier mois tracé quand une fenêtre est active, `None` sans filtre.
|
||||
depuis_mois: str | None = None
|
||||
#: Mois de loyer antérieurs à la fenêtre : connus, mais hors de la courbe.
|
||||
mois_masques: int = 0
|
||||
#: Lignes hors-courbe antérieures à la fenêtre, également non listées.
|
||||
hors_courbe_masquees: int = 0
|
||||
|
||||
|
||||
class LotAnalyseResponse(BaseModel):
|
||||
"""Fiche complète d'un lot."""
|
||||
|
||||
identite: LotIdentite
|
||||
chiffres: LotChiffres
|
||||
loyer: LotLoyer
|
||||
chronologie: list[LigneChronologie]
|
||||
intervenants: list[Intervenant]
|
||||
#: Fenêtre appliquée aux chiffres, à la chronologie et aux intervenants —
|
||||
#: jamais au loyer, ni au restant dû.
|
||||
periode: Periode = Field(default_factory=Periode)
|
||||
|
||||
|
||||
def _identite(session: Session, lot: Lot) -> LotIdentite:
|
||||
def _recule(reference: date, mois: int) -> date:
|
||||
"""La même date, `mois` mois plus tôt.
|
||||
|
||||
Le 31 mars reculé d'un mois donne le 28 février : un mois calendaire n'a pas
|
||||
de durée fixe, et l'arithmétique en jours ferait dériver la borne.
|
||||
"""
|
||||
total = reference.year * 12 + reference.month - 1 - mois
|
||||
annee, index = divmod(total, 12)
|
||||
jour = min(reference.day, calendar.monthrange(annee, index + 1)[1])
|
||||
return date(annee, index + 1, jour)
|
||||
|
||||
|
||||
def _ancre(session: Session, lot: Lot) -> date | None:
|
||||
"""Date du dernier compte rendu portant une ligne de ce lot.
|
||||
|
||||
Recettes et dépenses comptent toutes deux : un lot sorti de la location
|
||||
peut n'avoir plus que des travaux, et caler la fenêtre sur ses seuls loyers
|
||||
la ferait finir avant ses dernières opérations.
|
||||
"""
|
||||
dernieres = [
|
||||
session.execute(
|
||||
select(func.max(Document.date))
|
||||
.join(table, table.document_id == Document.id)
|
||||
.where(table.lot_id == lot.id)
|
||||
).scalar()
|
||||
for table in (Revenu, Depense)
|
||||
]
|
||||
connues = [date_ for date_ in dernieres if date_ is not None]
|
||||
return max(connues) if connues else None
|
||||
|
||||
|
||||
def _fenetre(session: Session, lot: Lot, mois: int | None) -> Periode:
|
||||
"""Fenêtre demandée, ramenée aux dates que la base peut honorer.
|
||||
|
||||
Sans `mois`, ou sur un lot dont aucun compte rendu ne parle, il n'y a rien
|
||||
à borner : la période reste ouverte plutôt que de se rabattre sur
|
||||
aujourd'hui, qui viderait la fiche sans l'expliquer.
|
||||
"""
|
||||
ancre = _ancre(session, lot)
|
||||
if mois is None or ancre is None:
|
||||
return Periode(mois=mois, ancre=ancre)
|
||||
|
||||
# Le lendemain du même jour `mois` mois plus tôt : trois mois avant le
|
||||
# 15 février commencent le 16 novembre, sinon le compte rendu du 15 novembre
|
||||
# entrerait dans une fenêtre de trois mois et en ferait quatre.
|
||||
debut = _recule(ancre, mois) + timedelta(days=1)
|
||||
return Periode(mois=mois, debut=debut, fin=ancre, ancre=ancre)
|
||||
|
||||
|
||||
def _borner(stmt, periode: Periode):
|
||||
"""Restreint une requête à la fenêtre, sur la date du compte rendu.
|
||||
|
||||
La requête doit déjà joindre `Document` : les dépenses comme les recettes
|
||||
ne portent pas de date propre, elles empruntent celle du document qui les
|
||||
a émises — la seule date qu'un compte rendu garantisse.
|
||||
"""
|
||||
if periode.debut is not None:
|
||||
stmt = stmt.where(Document.date >= periode.debut)
|
||||
if periode.fin is not None:
|
||||
stmt = stmt.where(Document.date <= periode.fin)
|
||||
return stmt
|
||||
|
||||
|
||||
def _locataires(session: Session, lot: Lot, periode: Periode) -> tuple[list[str], int]:
|
||||
"""Occupants du lot sur la période, et nombre de ceux qu'elle écarte.
|
||||
|
||||
Aucune date d'entrée ni de sortie n'est extraite des comptes rendus : le
|
||||
seul rattachement au temps disponible est le document où le nom figure. Un
|
||||
locataire appartient donc à la période si un compte rendu de la fenêtre
|
||||
porte une de ses lignes.
|
||||
|
||||
Sans fenêtre, la liste reste celle de la table : un locataire enregistré
|
||||
dont aucune ligne n'a été rattachée — il en existe en base — disparaîtrait
|
||||
sinon de la vue par défaut, qui ne filtre justement rien.
|
||||
"""
|
||||
tous = list(
|
||||
session.execute(
|
||||
select(Locataire.nom)
|
||||
.where(Locataire.lot_id == lot.id)
|
||||
.order_by(Locataire.nom)
|
||||
).scalars()
|
||||
)
|
||||
if periode.debut is None and periode.fin is None:
|
||||
return tous, 0
|
||||
|
||||
presents = list(
|
||||
session.execute(
|
||||
_borner(
|
||||
select(Locataire.nom)
|
||||
.join(Revenu, Revenu.locataire_id == Locataire.id)
|
||||
.join(Document, Revenu.document_id == Document.id)
|
||||
.where(Locataire.lot_id == lot.id),
|
||||
periode,
|
||||
)
|
||||
.distinct()
|
||||
.order_by(Locataire.nom)
|
||||
).scalars()
|
||||
)
|
||||
return presents, len(tous) - len(presents)
|
||||
|
||||
|
||||
def _identite(session: Session, lot: Lot, periode: Periode) -> 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()
|
||||
noms, masques = _locataires(session, lot, periode)
|
||||
|
||||
return LotIdentite(
|
||||
id=lot.id,
|
||||
@@ -165,18 +466,24 @@ def _identite(session: Session, lot: Lot) -> LotIdentite:
|
||||
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),
|
||||
locataires=noms,
|
||||
locataires_masques=masques,
|
||||
)
|
||||
|
||||
|
||||
def _chiffres(session: Session, lot: Lot) -> LotChiffres:
|
||||
def _chiffres(session: Session, lot: Lot, periode: Periode) -> 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.
|
||||
|
||||
La fenêtre borne les flux et rien d'autre. `restant_du` reste lu sur toute
|
||||
l'histoire : c'est la dette au dernier compte rendu, et la borner
|
||||
l'annulerait dès qu'aucun document ne tombe dans la fenêtre — un lot
|
||||
devrait alors 0 € tout en devant 49 000 €.
|
||||
"""
|
||||
flux = flux_par(Revenu.lot_id)
|
||||
flux = flux_par(Revenu.lot_id, periode.debut, periode.fin)
|
||||
dette = restant_du_par(Revenu.lot_id)
|
||||
|
||||
recettes = session.execute(
|
||||
@@ -194,20 +501,31 @@ def _chiffres(session: Session, lot: Lot) -> LotChiffres:
|
||||
).scalar()
|
||||
|
||||
depenses = session.execute(
|
||||
_borner(
|
||||
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)
|
||||
)
|
||||
.join(Document, Depense.document_id == Document.id)
|
||||
.where(Depense.lot_id == lot.id),
|
||||
periode,
|
||||
)
|
||||
).one()
|
||||
debit, credit, deductible, locatif, nb_operations = depenses
|
||||
|
||||
# Charges de l'immeuble laissées hors des lots, pour situer le solde.
|
||||
# Charges de l'immeuble laissées hors des lots, pour situer le solde. Elles
|
||||
# suivent la fenêtre : la page les annonce « sur la période ».
|
||||
commun = session.execute(
|
||||
select(func.coalesce(func.sum(Depense.debit), 0.0)).where(
|
||||
_borner(
|
||||
select(func.coalesce(func.sum(Depense.debit), 0.0))
|
||||
.join(Document, Depense.document_id == Document.id)
|
||||
.where(
|
||||
Depense.immeuble_id == lot.immeuble_id, Depense.lot_id.is_(None)
|
||||
),
|
||||
periode,
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
@@ -233,6 +551,204 @@ def _chiffres(session: Session, lot: Lot) -> LotChiffres:
|
||||
)
|
||||
|
||||
|
||||
def _comparaison(
|
||||
session: Session, lot: Lot, mois: str, loyer_m2: float | None
|
||||
) -> ComparaisonParc:
|
||||
"""Situe le loyer au m² du lot parmi les lots comparables du même mois."""
|
||||
parc = parc_du_mois(session, mois)
|
||||
type_lot = type_effectif(lot)
|
||||
|
||||
meme_immeuble = [
|
||||
autre.loyer_m2
|
||||
for autre in parc.comparables
|
||||
if autre.immeuble_id == lot.immeuble_id and autre.lot_id != lot.id
|
||||
]
|
||||
# Le type se compare à travers tout le parc : deux immeubles ne donnent
|
||||
# jamais assez de T2 pour qu'une médiane par immeuble et par type ait un
|
||||
# sens. Le lot lui-même est exclu des deux, sans quoi il tirerait vers lui
|
||||
# la médiane à laquelle on le compare.
|
||||
meme_type = [
|
||||
autre.loyer_m2
|
||||
for autre in parc.comparables
|
||||
if type_lot is not None
|
||||
and autre.type_lot == type_lot
|
||||
and autre.lot_id != lot.id
|
||||
]
|
||||
|
||||
# Le nuage prend tout le parc, sans filtre d'immeuble ni de type : avec une
|
||||
# dizaine de fiches renseignées, restreindre le viderait, et c'est la
|
||||
# surface — portée par l'abscisse — qui rend deux lots comparables.
|
||||
nuage = [
|
||||
PointParc(
|
||||
lot_id=autre.lot_id,
|
||||
numero=autre.numero,
|
||||
immeuble_code=autre.immeuble_code,
|
||||
type_lot=autre.type_lot,
|
||||
surface=autre.surface,
|
||||
loyer_m2=autre.loyer_m2,
|
||||
est_ce_lot=autre.lot_id == lot.id,
|
||||
)
|
||||
for autre in sorted(parc.comparables, key=lambda autre: autre.surface)
|
||||
]
|
||||
|
||||
return ComparaisonParc(
|
||||
mois=mois,
|
||||
loyer_m2=loyer_m2,
|
||||
mediane_immeuble=mediane(meme_immeuble),
|
||||
nb_immeuble=len(meme_immeuble),
|
||||
mediane_type=mediane(meme_type),
|
||||
nb_type=len(meme_type),
|
||||
type_compare=type_lot,
|
||||
sans_surface=parc.sans_surface,
|
||||
nuage=nuage,
|
||||
)
|
||||
|
||||
|
||||
def _premier_mois(periode: Periode) -> str | None:
|
||||
"""Premier mois que la courbe trace, `None` sans fenêtre.
|
||||
|
||||
Une fenêtre de trois mois finissant en juillet trace mai, juin, juillet :
|
||||
le mois de la borne haute compte pour un. Reculer de `mois` pleins en
|
||||
ajouterait un quatrième, et le graphe démentirait son propre libellé.
|
||||
"""
|
||||
if periode.mois is None or periode.fin is None:
|
||||
return None
|
||||
return mois_de(_recule(periode.fin, periode.mois - 1))
|
||||
|
||||
|
||||
def _mois_de_fin(ligne) -> str | None:
|
||||
"""Mois où s'achève une ligne écartée de la courbe, `None` s'il manque."""
|
||||
borne = ligne.periode_fin or ligne.periode_debut
|
||||
return mois_de(borne) if borne is not None else None
|
||||
|
||||
|
||||
def _apres_le_palier(
|
||||
serie: list[MoisLoue], mois_fin: str
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Ce que les mois postérieurs au dernier loyer plein disent du bail.
|
||||
|
||||
Un palier qui s'arrête ne dit pas que le lot est vide : entre deux baux, le
|
||||
compte rendu ne porte qu'un prorata, et un mois de transition n'ouvre pas de
|
||||
palier. Lu sans lui, un lot reloué le 8 du mois passe pour sorti de la
|
||||
gestion.
|
||||
|
||||
L'entrée l'emporte sur la sortie parce qu'elle vient après : un locataire
|
||||
part le 10 mars, un autre entre le 8 juillet, et c'est le second qui décrit
|
||||
l'état du lot. Sans entrée, la dernière sortie donne le mois où la location
|
||||
s'arrête vraiment — plus tard que la fin du palier, qu'elle déborde.
|
||||
|
||||
Returns:
|
||||
`(reloue_depuis, sortie_en)`, chacun `None` quand rien ne l'établit.
|
||||
"""
|
||||
suivants = [point for point in serie if point.mois > mois_fin]
|
||||
entrees = [point.mois for point in suivants if point.entree]
|
||||
if entrees:
|
||||
return entrees[-1], None
|
||||
|
||||
sorties = [point.mois for point in suivants if point.sortie]
|
||||
return None, sorties[-1] if sorties else None
|
||||
|
||||
|
||||
def _loyer(session: Session, lot: Lot, periode: Periode) -> LotLoyer:
|
||||
"""Le loyer du lot mois par mois, son niveau actuel et sa place au m².
|
||||
|
||||
Toute la logique de répartition vit dans `services.loyers` : la comparaison
|
||||
au parc rejoue exactement le même calcul pour les autres lots, sans quoi
|
||||
elle situerait un chiffre par rapport à des chiffres obtenus autrement.
|
||||
|
||||
La courbe est tracée sur la fenêtre, mais **calculée sur tout l'historique**
|
||||
puis coupée : les paliers, la dernière révision et le mois de comparaison au
|
||||
parc sortent de la série entière. Les recalculer sur les seuls mois affichés
|
||||
ferait dater la révision de la borne du filtre.
|
||||
|
||||
Rien n'est coupé après la fenêtre : un bail trimestriel facturé d'avance
|
||||
porte des mois postérieurs au dernier compte rendu, et les retirer ferait
|
||||
croire que le lot cesse d'être loué.
|
||||
"""
|
||||
fiche = lot.caracteristiques
|
||||
surface = fiche.surface if fiche else None
|
||||
|
||||
serie = serie_du_lot(session, lot.id)
|
||||
depuis = _premier_mois(periode)
|
||||
|
||||
mois_traces = [
|
||||
point for point in serie.mois if depuis is None or point.mois >= depuis
|
||||
]
|
||||
|
||||
points = [
|
||||
PointLoyer(
|
||||
mois=point.mois,
|
||||
loyer=point.loyer,
|
||||
charges=point.charges,
|
||||
prorata=point.prorata,
|
||||
loyer_m2=loyer_au_m2(point.loyer, surface),
|
||||
reparti=point.reparti,
|
||||
en_transition=point.en_transition,
|
||||
)
|
||||
for point in mois_traces
|
||||
]
|
||||
|
||||
# Une régularisation suit la courbe : elle est retenue quand la période
|
||||
# qu'elle couvre atteint la fenêtre. Une ligne de 2024 listée sous une
|
||||
# courbe qui commence en 2026 n'aurait rien à quoi se rapporter.
|
||||
retenues = [
|
||||
ligne
|
||||
for ligne in serie.ecartees
|
||||
if depuis is None or _mois_de_fin(ligne) is None or _mois_de_fin(ligne) >= depuis
|
||||
]
|
||||
|
||||
hors_courbe = [
|
||||
LigneHorsCourbe(
|
||||
periode_debut=ligne.periode_debut,
|
||||
periode_fin=ligne.periode_fin,
|
||||
montant=round(ligne.loyers, 2),
|
||||
)
|
||||
for ligne in retenues
|
||||
]
|
||||
|
||||
niveaux = paliers(serie.mois)
|
||||
en_vigueur = None
|
||||
parc = None
|
||||
|
||||
if niveaux:
|
||||
courant = niveaux[-1]
|
||||
precedent = niveaux[-2].loyer if len(niveaux) > 1 else None
|
||||
|
||||
# Le dernier compte rendu de l'immeuble donne l'actualité : un lot dont
|
||||
# le loyer s'arrête avant lui n'est plus loué.
|
||||
dernier_cr = session.execute(
|
||||
select(func.max(Document.date)).where(
|
||||
Document.immeuble_id == lot.immeuble_id
|
||||
)
|
||||
).scalar()
|
||||
|
||||
reloue_depuis, sortie_en = _apres_le_palier(serie.mois, courant.mois_fin)
|
||||
|
||||
en_vigueur = LoyerEnVigueur(
|
||||
mois=courant.mois_fin,
|
||||
loyer=courant.loyer,
|
||||
loyer_m2=loyer_au_m2(courant.loyer, surface),
|
||||
depuis=courant.mois_debut,
|
||||
precedent=precedent,
|
||||
variation_pct=variation(precedent, courant.loyer),
|
||||
toujours_loue=dernier_cr is None or courant.mois_fin >= mois_de(dernier_cr),
|
||||
reloue_depuis=reloue_depuis,
|
||||
sortie_en=sortie_en,
|
||||
)
|
||||
parc = _comparaison(session, lot, courant.mois_fin, en_vigueur.loyer_m2)
|
||||
|
||||
return LotLoyer(
|
||||
surface=surface,
|
||||
serie=points,
|
||||
hors_courbe=hors_courbe,
|
||||
en_vigueur=en_vigueur,
|
||||
parc=parc,
|
||||
depuis_mois=depuis,
|
||||
mois_masques=len(serie.mois) - len(mois_traces),
|
||||
hors_courbe_masquees=len(serie.ecartees) - len(retenues),
|
||||
)
|
||||
|
||||
|
||||
def _libelle_recette(revenu: Revenu) -> str:
|
||||
"""Ce que la ligne de recette dit d'elle-même.
|
||||
|
||||
@@ -297,7 +813,28 @@ def _chronologie(session: Session, lot: Lot) -> list[LigneChronologie]:
|
||||
return lignes
|
||||
|
||||
|
||||
def _intervenants(session: Session, lot: Lot) -> list[Intervenant]:
|
||||
def _restreindre(
|
||||
lignes: list[LigneChronologie], periode: Periode
|
||||
) -> tuple[list[LigneChronologie], int]:
|
||||
"""Lignes de la fenêtre, et nombre de celles qu'elle laisse dehors.
|
||||
|
||||
Filtré en Python plutôt qu'en SQL : les lignes sont déjà chargées, un lot
|
||||
en porte quelques dizaines, et c'est ce qui donne le compte des masquées
|
||||
sans requête supplémentaire — ce compte est ce qui rend le filtre honnête.
|
||||
"""
|
||||
if periode.debut is None and periode.fin is None:
|
||||
return lignes, 0
|
||||
|
||||
gardees = [
|
||||
ligne
|
||||
for ligne in lignes
|
||||
if (periode.debut is None or ligne.date >= periode.debut)
|
||||
and (periode.fin is None or ligne.date <= periode.fin)
|
||||
]
|
||||
return gardees, len(lignes) - len(gardees)
|
||||
|
||||
|
||||
def _intervenants(session: Session, lot: Lot, periode: Periode) -> 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
|
||||
@@ -316,6 +853,7 @@ def _intervenants(session: Session, lot: Lot) -> list[Intervenant]:
|
||||
)
|
||||
|
||||
rows = session.execute(
|
||||
_borner(
|
||||
select(
|
||||
Depense.fournisseur,
|
||||
func.count(Depense.id),
|
||||
@@ -323,7 +861,9 @@ def _intervenants(session: Session, lot: Lot) -> list[Intervenant]:
|
||||
func.max(Document.date),
|
||||
)
|
||||
.join(Document, Depense.document_id == Document.id)
|
||||
.where(Depense.lot_id == lot.id, Depense.fournisseur.is_not(None))
|
||||
.where(Depense.lot_id == lot.id, Depense.fournisseur.is_not(None)),
|
||||
periode,
|
||||
)
|
||||
.group_by(Depense.fournisseur)
|
||||
.order_by(montant_net.desc())
|
||||
).all()
|
||||
@@ -342,22 +882,41 @@ def _intervenants(session: Session, lot: Lot) -> list[Intervenant]:
|
||||
@router.get("/lots/{lot_id}/analyse", response_model=LotAnalyseResponse)
|
||||
async def analyser_lot(
|
||||
lot_id: int,
|
||||
mois: int | None = Query(
|
||||
None,
|
||||
ge=1,
|
||||
description="Nombre de mois a retenir avant le dernier compte rendu du lot",
|
||||
),
|
||||
session: Session = Depends(get_session),
|
||||
) -> LotAnalyseResponse:
|
||||
"""Tout ce que les comptes rendus portent sur un lot.
|
||||
|
||||
- **lot_id**: ID du lot
|
||||
- **mois**: fenêtre optionnelle, comptée à rebours du dernier compte rendu
|
||||
du lot. Absente, tout l'historique est rendu — c'est le défaut, pour
|
||||
qu'aucun filtre implicite ne cache d'opérations.
|
||||
|
||||
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.
|
||||
La fenêtre borne les chiffres, la chronologie, les intervenants, les
|
||||
locataires et la courbe du loyer. Le restant dû, le loyer en vigueur et la
|
||||
comparaison au parc y échappent, pour les raisons données en tête de
|
||||
module. Ce qu'elle écarte est compté : `periode.lignes_masquees` pour la
|
||||
chronologie, `loyer.mois_masques` pour la courbe,
|
||||
`identite.locataires_masques` pour les occupants.
|
||||
"""
|
||||
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),
|
||||
periode = _fenetre(session, lot, mois)
|
||||
chronologie, periode.lignes_masquees = _restreindre(
|
||||
_chronologie(session, lot), periode
|
||||
)
|
||||
|
||||
return LotAnalyseResponse(
|
||||
identite=_identite(session, lot, periode),
|
||||
chiffres=_chiffres(session, lot, periode),
|
||||
loyer=_loyer(session, lot, periode),
|
||||
chronologie=chronologie,
|
||||
intervenants=_intervenants(session, lot, periode),
|
||||
periode=periode,
|
||||
)
|
||||
|
||||
@@ -51,6 +51,32 @@ def _wait_until_ready(base_url: str, timeout: float = 30.0) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _activer_lecteur_pdf_qt() -> None:
|
||||
"""Active le lecteur PDF interne de Qt WebEngine (backend Linux).
|
||||
|
||||
L'aperçu du compte rendu s'appuie sur le lecteur PDF du moteur, via une
|
||||
``<iframe>``. Qt laisse ``PdfViewerEnabled`` à False et le conditionne à
|
||||
``PluginsEnabled`` : sans les deux, le volet n'affiche qu'un bouton
|
||||
« Ouvrir » au lieu du document. pywebview n'expose pas ces réglages et
|
||||
construit son propre profil, donc on les pose sur la vue une fois la
|
||||
fenêtre chargée — l'``<iframe>`` n'apparaît qu'à l'ouverture d'un document,
|
||||
donc bien après.
|
||||
|
||||
Sans objet sous Windows (WebView2 embarque déjà son lecteur) ; l'appel y
|
||||
est neutre puisque le backend Qt n'est alors pas importé.
|
||||
"""
|
||||
try:
|
||||
from webview.platforms.qt import BrowserView, QWebEngineSettings
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
attribut = QWebEngineSettings.WebAttribute
|
||||
for fenetre in BrowserView.instances.values():
|
||||
reglages = fenetre.webview.page().settings()
|
||||
reglages.setAttribute(attribut.PluginsEnabled, True)
|
||||
reglages.setAttribute(attribut.PdfViewerEnabled, True)
|
||||
|
||||
|
||||
def run() -> None:
|
||||
"""Lance le serveur puis la fenêtre native. Bloque jusqu'à fermeture."""
|
||||
port = _find_free_port()
|
||||
@@ -75,9 +101,16 @@ def run() -> None:
|
||||
# clic droit -> « Inspecter » (ou « Inspect element ») pour ouvrir la console.
|
||||
debug = os.environ.get("PLESNA_DEBUG", "").lower() in ("1", "true", "yes")
|
||||
|
||||
webview.create_window(WINDOW_TITLE, base_url, width=1280, height=860)
|
||||
fenetre = webview.create_window(WINDOW_TITLE, base_url, width=1280, height=860)
|
||||
fenetre.events.loaded += _activer_lecteur_pdf_qt
|
||||
try:
|
||||
webview.start(debug=debug)
|
||||
# private_mode=False : pywebview part sinon sur un profil éphémère qui
|
||||
# ne conserve ni cookies ni localStorage. Or l'interface y range ses
|
||||
# réglages — largeur du volet d'aperçu — et le lecteur PDF du moteur y
|
||||
# garde l'état de sa barre latérale de vignettes. En mode privé, tout
|
||||
# cela se réinitialise à chaque lancement. L'application ne charge que
|
||||
# son propre serveur local : rien de tiers n'est stocké au passage.
|
||||
webview.start(debug=debug, private_mode=False)
|
||||
finally:
|
||||
# Fermeture de la fenêtre -> arrêt propre du serveur.
|
||||
server.should_exit = True
|
||||
|
||||
421
src/plesna_gerance/services/loyers.py
Normal file
421
src/plesna_gerance/services/loyers.py
Normal file
@@ -0,0 +1,421 @@
|
||||
"""Le loyer d'un lot mois par mois, et ce qu'il vaut au mètre carré.
|
||||
|
||||
Le reste de la fiche d'un lot cumule tout l'historique en un chiffre. Ce module
|
||||
fait l'inverse : il remet le loyer sur un axe de temps, seule façon de voir une
|
||||
révision, une vacance ou un décrochage.
|
||||
|
||||
**L'axe est le mois loué, pas le mois du compte rendu.** Un loyer de mars
|
||||
facturé en mars et un rappel de mars facturé en avril ne décrivent pas le même
|
||||
mois ; classer par date de document mettrait le second sur avril et ferait un
|
||||
faux creux suivi d'un faux pic.
|
||||
|
||||
Trois formes de lignes cohabitent dans ``revenus`` sous le même
|
||||
``type_ligne = "loyer"``, et les confondre fausse la courbe :
|
||||
|
||||
- le **loyer d'un mois** (01/03 → 31/03), cas courant ;
|
||||
- le **loyer d'un trimestre** (01/10 → 31/12), forme réelle des baux
|
||||
commerciaux du parc. Le laisser sur son seul mois de début creuserait deux
|
||||
mois sur trois ; il est donc réparti à parts égales sur les mois couverts.
|
||||
Ce n'est pas une clé de répartition inventée : la période est portée par le
|
||||
compte rendu, et diviser un montant par le nombre de mois qu'il couvre ne
|
||||
suppose rien de plus que ce que la ligne dit déjà ;
|
||||
- le **prorata** (22/06 → 30/06 à l'entrée d'un locataire, 18/10 → 18/10 pour
|
||||
un avoir), qui ne vaut pas un mois plein. Il est rattaché à son mois mais
|
||||
compté à part du loyer : additionner les deux ferait passer un mois de
|
||||
changement de locataire pour un mois à loyer effondré, et un mois annulé par
|
||||
avoir pour un mois où le loyer aurait baissé.
|
||||
|
||||
La frontière tient à la période : commencer un premier de mois et finir un
|
||||
dernier de mois, c'est couvrir des mois entiers, donc porter un loyer. Tout le
|
||||
reste est un prorata, rattaché à son mois quand il y tient — et écarté quand il
|
||||
chevauche plusieurs mois sans les couvrir (14/03 → 31/08, régularisation), car
|
||||
aucun mois ne peut alors le revendiquer.
|
||||
|
||||
Un mois sans loyer plein mais avec un prorata n'est donc pas une vacance : c'est
|
||||
un mois de transition, et la fiche le distingue au lieu de le confondre avec un
|
||||
trou. La même frontière dit dans quel sens il penche : un prorata qui court
|
||||
jusqu'au dernier jour du mois ouvre un bail, un prorata qui part du premier sans
|
||||
l'atteindre en ferme un. C'est ce qui sépare un lot reloué le 8 d'un lot sorti
|
||||
de la gestion, là où le palier de loyer s'arrête dans les deux cas.
|
||||
|
||||
Le mètre carré vient de la fiche saisie (`lot_caracteristiques.surface`), pas
|
||||
des comptes rendus qui l'ignorent. Sans surface, le ratio vaut ``None`` et non
|
||||
zéro : la page montre le trou plutôt que d'afficher un loyer au m² de 0 €.
|
||||
"""
|
||||
|
||||
from calendar import monthrange
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database.models import Immeuble, Lot, LotCaracteristiques, Revenu
|
||||
from ..services.referentiel import TYPE_LOT_EFFECTIF, joindre_fiche
|
||||
|
||||
#: Type des lignes portant un loyer. Les rappels et les régularisations
|
||||
#: (`rappel_loyer`, `divers`) décrivent des rattrapages, pas le loyer d'un mois :
|
||||
#: la chronologie de la fiche les montre déjà, la courbe les laisse dehors.
|
||||
TYPE_LIGNE_LOYER = "loyer"
|
||||
|
||||
#: Sous ce nombre de lots comparables, une médiane décrit surtout le hasard.
|
||||
#: Elle reste calculée et affichée avec son effectif — le lecteur tranche.
|
||||
EFFECTIF_MEDIANE_FIABLE = 3
|
||||
|
||||
|
||||
def mois_de(jour: date) -> str:
|
||||
"""Le mois d'une date, au format ``2026-01``."""
|
||||
return f"{jour.year:04d}-{jour.month:02d}"
|
||||
|
||||
|
||||
def mois_suivant(mois: str) -> str:
|
||||
"""Le mois d'après, même format."""
|
||||
annee, numero = (int(part) for part in mois.split("-"))
|
||||
return f"{annee + 1:04d}-01" if numero == 12 else f"{annee:04d}-{numero + 1:02d}"
|
||||
|
||||
|
||||
def mois_entiers(debut: date | None, fin: date | None) -> list[str]:
|
||||
"""Les mois entiers que couvre la période, vide si elle en couvre aucun.
|
||||
|
||||
Une période part du premier jour d'un mois et s'arrête au dernier jour d'un
|
||||
mois : elle couvre alors ces mois-là, un ou plusieurs. Sinon, c'est un
|
||||
prorata, et aucun mois ne lui appartient entièrement.
|
||||
"""
|
||||
if debut is None or fin is None or fin < debut:
|
||||
return []
|
||||
if debut.day != 1 or fin.day != monthrange(fin.year, fin.month)[1]:
|
||||
return []
|
||||
|
||||
mois, dernier = mois_de(debut), mois_de(fin)
|
||||
couverts = [mois]
|
||||
while mois != dernier:
|
||||
mois = mois_suivant(mois)
|
||||
couverts.append(mois)
|
||||
return couverts
|
||||
|
||||
|
||||
def est_entree(debut: date, fin: date, loyers: float | None) -> bool:
|
||||
"""Vrai si ce prorata ouvre un bail qui court encore le mois suivant.
|
||||
|
||||
Un prorata qui s'arrête au dernier jour du mois sans avoir commencé le
|
||||
premier facture la fin du mois : quelqu'un est entré en cours de route. La
|
||||
location ne s'arrête donc pas là, même si aucun loyer plein ne suit encore.
|
||||
|
||||
Un montant négatif est écarté : un avoir annule une facturation, il
|
||||
n'ouvre pas un bail — et il porte parfois la même période qu'elle.
|
||||
"""
|
||||
if not loyers or loyers <= 0:
|
||||
return False
|
||||
return debut.day != 1 and fin.day == monthrange(fin.year, fin.month)[1]
|
||||
|
||||
|
||||
def est_sortie(debut: date, fin: date, loyers: float | None) -> bool:
|
||||
"""Vrai si ce prorata ferme un bail en cours de mois.
|
||||
|
||||
Miroir de `est_entree` : partir du premier jour sans atteindre le dernier,
|
||||
c'est facturer le début du mois et s'arrêter.
|
||||
"""
|
||||
if not loyers or loyers <= 0:
|
||||
return False
|
||||
return debut.day == 1 and fin.day != monthrange(fin.year, fin.month)[1]
|
||||
|
||||
|
||||
@dataclass
|
||||
class MoisLoue:
|
||||
"""Ce qu'un mois a été facturé, charges et proratas à part."""
|
||||
|
||||
mois: str
|
||||
#: Loyer hors charges pour un mois plein. `None` quand aucune ligne ne
|
||||
#: couvre le mois entier : le lot est vacant, sorti de la gestion, en
|
||||
#: changement de locataire, ou son compte rendu manque. Zéro dirait « loué
|
||||
#: gratuitement », ce qu'aucun document ne dit.
|
||||
loyer: float | None = None
|
||||
#: Provisions sur charges appelées avec le loyer plein.
|
||||
charges: float | None = None
|
||||
#: Facturé au prorata sur ce mois : entrée ou sortie en cours de mois,
|
||||
#: avoir. `None` s'il n'y en a pas. Tenu hors du loyer, qui doit rester
|
||||
#: comparable d'un mois à l'autre.
|
||||
prorata: float | None = None
|
||||
#: Vrai quand le loyer vient d'une ligne pluri-mensuelle répartie.
|
||||
reparti: bool = False
|
||||
#: Vrai quand un prorata du mois court jusqu'à son dernier jour sans partir
|
||||
#: du premier : un bail commence en cours de mois et continue après lui.
|
||||
entree: bool = False
|
||||
#: Vrai quand un prorata du mois part de son premier jour sans l'achever :
|
||||
#: un bail s'arrête en cours de mois.
|
||||
sortie: bool = False
|
||||
|
||||
@property
|
||||
def en_transition(self) -> bool:
|
||||
"""Mois sans loyer plein mais facturé au prorata : pas une vacance."""
|
||||
return self.loyer is None and self.prorata is not None
|
||||
|
||||
|
||||
@dataclass
|
||||
class LigneEcartee:
|
||||
"""Une ligne de loyer qu'aucun mois ne peut revendiquer.
|
||||
|
||||
Elle chevauche plusieurs mois sans en couvrir aucun entièrement : une
|
||||
régularisation rétroactive. La rendre telle quelle laisse le lecteur la
|
||||
rapprocher de la chronologie, où elle figure aussi.
|
||||
"""
|
||||
|
||||
periode_debut: date | None
|
||||
periode_fin: date | None
|
||||
loyers: float
|
||||
charges: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class Serie:
|
||||
"""La suite des mois loués d'un lot, et ce qui n'a pas pu y entrer."""
|
||||
|
||||
mois: list[MoisLoue] = field(default_factory=list)
|
||||
ecartees: list[LigneEcartee] = field(default_factory=list)
|
||||
|
||||
|
||||
def repartir(lignes) -> Serie:
|
||||
"""Range des lignes de loyer sur l'axe des mois.
|
||||
|
||||
Args:
|
||||
lignes: itérable de ``(periode_debut, periode_fin, loyers, provisions)``
|
||||
|
||||
Returns:
|
||||
La série des mois observés, du plus ancien au plus récent, trous
|
||||
compris ; et les lignes écartées faute de couvrir des mois entiers.
|
||||
"""
|
||||
cumul: dict[str, MoisLoue] = {}
|
||||
ecartees: list[LigneEcartee] = []
|
||||
|
||||
for debut, fin, loyers, provisions in lignes:
|
||||
couverts = mois_entiers(debut, fin)
|
||||
|
||||
if not couverts:
|
||||
# Un prorata tient dans un mois ; au-delà, c'est une régularisation
|
||||
# rétroactive qu'aucun mois ne peut recevoir.
|
||||
if debut is not None and fin is not None and mois_de(debut) == mois_de(fin):
|
||||
point = cumul.setdefault(mois_de(debut), MoisLoue(mois=mois_de(debut)))
|
||||
point.prorata = (point.prorata or 0.0) + (loyers or 0.0)
|
||||
point.entree = point.entree or est_entree(debut, fin, loyers)
|
||||
point.sortie = point.sortie or est_sortie(debut, fin, loyers)
|
||||
else:
|
||||
ecartees.append(
|
||||
LigneEcartee(
|
||||
periode_debut=debut,
|
||||
periode_fin=fin,
|
||||
loyers=loyers or 0.0,
|
||||
charges=provisions or 0.0,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
part_loyer = (loyers or 0.0) / len(couverts)
|
||||
part_charges = (provisions or 0.0) / len(couverts)
|
||||
|
||||
for mois in couverts:
|
||||
point = cumul.setdefault(mois, MoisLoue(mois=mois))
|
||||
point.loyer = (point.loyer or 0.0) + part_loyer
|
||||
point.charges = (point.charges or 0.0) + part_charges
|
||||
point.reparti = point.reparti or len(couverts) > 1
|
||||
|
||||
if not cumul:
|
||||
return Serie(ecartees=ecartees)
|
||||
|
||||
# Les mois sans aucune ligne restent dans la suite, à `None` : un trou dans
|
||||
# la courbe se voit, un mois absent de l'axe passerait inaperçu.
|
||||
serie: list[MoisLoue] = []
|
||||
mois, dernier = min(cumul), max(cumul)
|
||||
while True:
|
||||
point = cumul.get(mois, MoisLoue(mois=mois))
|
||||
if point.loyer is not None:
|
||||
point.loyer = round(point.loyer, 2)
|
||||
point.charges = round(point.charges or 0.0, 2)
|
||||
if point.prorata is not None:
|
||||
point.prorata = round(point.prorata, 2)
|
||||
serie.append(point)
|
||||
if mois == dernier:
|
||||
break
|
||||
mois = mois_suivant(mois)
|
||||
|
||||
return Serie(mois=serie, ecartees=ecartees)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Palier:
|
||||
"""Une période pendant laquelle le loyer n'a pas bougé."""
|
||||
|
||||
mois_debut: str
|
||||
mois_fin: str
|
||||
loyer: float
|
||||
|
||||
|
||||
def paliers(serie: list[MoisLoue]) -> list[Palier]:
|
||||
"""Découpe la série aux changements de loyer.
|
||||
|
||||
Un mois sans loyer plein ferme le palier en cours : reprendre au même
|
||||
montant après une vacance, c'est un nouveau bail qui se trouve tomber au
|
||||
même prix, pas un loyer qui n'aurait jamais bougé.
|
||||
|
||||
Le centime tranche l'égalité — les montants viennent de divisions par le
|
||||
nombre de mois d'un trimestre, où un tiers d'euro ne retombe pas juste.
|
||||
"""
|
||||
trouves: list[Palier] = []
|
||||
|
||||
for point in serie:
|
||||
if point.loyer is None:
|
||||
continue
|
||||
en_cours = trouves[-1] if trouves else None
|
||||
continu = (
|
||||
en_cours is not None
|
||||
and abs(en_cours.loyer - point.loyer) < 0.01
|
||||
and mois_suivant(en_cours.mois_fin) == point.mois
|
||||
)
|
||||
if continu:
|
||||
en_cours.mois_fin = point.mois
|
||||
else:
|
||||
trouves.append(
|
||||
Palier(mois_debut=point.mois, mois_fin=point.mois, loyer=point.loyer)
|
||||
)
|
||||
|
||||
return trouves
|
||||
|
||||
|
||||
def variation(depuis: float | None, vers: float | None) -> float | None:
|
||||
"""Écart en pourcentage entre deux loyers, `None` si l'un manque."""
|
||||
if not depuis or vers is None:
|
||||
return None
|
||||
return round((vers - depuis) / abs(depuis) * 100, 2)
|
||||
|
||||
|
||||
def loyer_au_m2(loyer: float | None, surface: float | None) -> float | None:
|
||||
"""Loyer mensuel hors charges rapporté au mètre carré.
|
||||
|
||||
`None` dès qu'un des deux manque : la fiche du lot n'est pas saisie, ou le
|
||||
mois n'a pas de loyer. Une surface nulle ou négative est traitée comme
|
||||
absente — elle ne peut venir que d'une saisie fautive.
|
||||
"""
|
||||
if loyer is None or surface is None or surface <= 0:
|
||||
return None
|
||||
return round(loyer / surface, 2)
|
||||
|
||||
|
||||
def mediane(valeurs: list[float]) -> float | None:
|
||||
"""Médiane d'un échantillon, `None` s'il est vide.
|
||||
|
||||
Médiane et non moyenne : sur trente lots, un local commercial suffit à
|
||||
tirer une moyenne loin de ce que paye un appartement.
|
||||
"""
|
||||
if not valeurs:
|
||||
return None
|
||||
ordonnees = sorted(valeurs)
|
||||
milieu = len(ordonnees) // 2
|
||||
if len(ordonnees) % 2:
|
||||
return round(ordonnees[milieu], 2)
|
||||
return round((ordonnees[milieu - 1] + ordonnees[milieu]) / 2, 2)
|
||||
|
||||
|
||||
def _lignes_de_loyer(session: Session, lot_id: int | None = None):
|
||||
"""Les lignes de loyer, par lot : période et montants.
|
||||
|
||||
Sans `lot_id`, tout le parc en une requête — la comparaison a besoin des
|
||||
trente lots à la fois, et les interroger un par un ferait trente allers.
|
||||
"""
|
||||
stmt = select(
|
||||
Revenu.lot_id,
|
||||
Revenu.periode_debut,
|
||||
Revenu.periode_fin,
|
||||
Revenu.loyers,
|
||||
Revenu.provisions,
|
||||
).where(Revenu.type_ligne == TYPE_LIGNE_LOYER)
|
||||
if lot_id is not None:
|
||||
stmt = stmt.where(Revenu.lot_id == lot_id)
|
||||
|
||||
par_lot: dict[int, list] = {}
|
||||
for ligne_lot, debut, fin, loyers, provisions in session.execute(stmt):
|
||||
par_lot.setdefault(ligne_lot, []).append((debut, fin, loyers, provisions))
|
||||
return par_lot
|
||||
|
||||
|
||||
def serie_du_lot(session: Session, lot_id: int) -> Serie:
|
||||
"""Les mois loués d'un lot, du premier au dernier connu."""
|
||||
return repartir(_lignes_de_loyer(session, lot_id).get(lot_id, []))
|
||||
|
||||
|
||||
@dataclass
|
||||
class LotComparable:
|
||||
"""Un lot du parc ramené à son loyer au m² sur un mois donné.
|
||||
|
||||
Porte sa surface autant que son ratio : le loyer au m² décroît fortement
|
||||
avec la taille du logement — sur le parc, un studio de 21 m² se loue près du
|
||||
double au m² d'un T3 de 106 m². Une comparaison qui perd la surface compare
|
||||
donc des choses qui n'ont pas à l'être.
|
||||
"""
|
||||
|
||||
lot_id: int
|
||||
immeuble_id: int
|
||||
immeuble_code: str | None
|
||||
numero: str
|
||||
type_lot: str | None
|
||||
surface: float
|
||||
loyer_m2: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class Parc:
|
||||
"""Ce que le parc permet de comparer, et ce qu'il ne permet pas."""
|
||||
|
||||
comparables: list[LotComparable] = field(default_factory=list)
|
||||
#: Lots loués ce mois-là mais dont la fiche n'a pas de surface. Ils ne
|
||||
#: peuvent pas entrer dans une comparaison au m² ; les taire ferait passer
|
||||
#: une médiane sur trois lots pour une médiane sur tout le parc.
|
||||
sans_surface: int = 0
|
||||
|
||||
|
||||
def parc_du_mois(session: Session, mois: str) -> Parc:
|
||||
"""Loyer au m² de chaque lot loué le mois donné.
|
||||
|
||||
Passe par la même répartition que la fiche d'un lot : une médiane calculée
|
||||
autrement que les chiffres qu'elle situe ne voudrait rien dire.
|
||||
"""
|
||||
lots = session.execute(
|
||||
joindre_fiche(
|
||||
select(
|
||||
Lot.id,
|
||||
Lot.immeuble_id,
|
||||
Immeuble.code,
|
||||
Lot.numero,
|
||||
TYPE_LOT_EFFECTIF,
|
||||
LotCaracteristiques.surface,
|
||||
).join(Immeuble, Immeuble.id == Lot.immeuble_id)
|
||||
)
|
||||
).all()
|
||||
|
||||
# Toutes les lignes de loyer du parc en une requête : les répartir lot par
|
||||
# lot depuis la base ferait une trentaine d'allers pour un seul affichage.
|
||||
lignes_par_lot = _lignes_de_loyer(session)
|
||||
|
||||
parc = Parc()
|
||||
for lot_id, immeuble_id, immeuble_code, numero, type_lot, surface in lots:
|
||||
serie = repartir(lignes_par_lot.get(lot_id, []))
|
||||
point = next((p for p in serie.mois if p.mois == mois), None)
|
||||
if point is None or point.loyer is None or point.loyer <= 0:
|
||||
continue
|
||||
|
||||
ratio = loyer_au_m2(point.loyer, surface)
|
||||
if ratio is None:
|
||||
parc.sans_surface += 1
|
||||
continue
|
||||
|
||||
parc.comparables.append(
|
||||
LotComparable(
|
||||
lot_id=lot_id,
|
||||
immeuble_id=immeuble_id,
|
||||
immeuble_code=immeuble_code,
|
||||
numero=numero,
|
||||
type_lot=type_lot,
|
||||
surface=surface,
|
||||
loyer_m2=ratio,
|
||||
)
|
||||
)
|
||||
|
||||
return parc
|
||||
@@ -28,6 +28,10 @@ from sqlalchemy import and_, func, select
|
||||
from ..database.models import Document, Revenu
|
||||
|
||||
#: Type des lignes qui reportent le solde du compte rendu précédent.
|
||||
#:
|
||||
#: Repris tel quel côté frontend (``utils/totauxLocataire.js``), où il sépare le
|
||||
#: solde antérieur des loyers de la période. Les deux décrivent la sortie du
|
||||
#: même parser et doivent bouger ensemble.
|
||||
TYPE_LIGNE_REPORT = "solde_anterieur"
|
||||
|
||||
|
||||
|
||||
131
tests/test_analytics_filtres.py
Normal file
131
tests/test_analytics_filtres.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""Filtres de la page Depenses : plusieurs fournisseurs, et l'absence de tag.
|
||||
|
||||
Le filtre fournisseur cherchait une sous-chaine, ce qui interdisait d'en
|
||||
comparer deux et retenait au passage leurs homonymes. Il retient desormais une
|
||||
liste de noms exacts. Le filtre tag, lui, ne savait pas demander « ce qui n'est
|
||||
pas encore tague » - c'est pourtant la question qui amorce le travail de
|
||||
tagging.
|
||||
|
||||
Ces tests verrouillent aussi l'accord entre la table et le resume : les deux
|
||||
endpoints doivent voir le meme perimetre, sinon les totaux affiches ne sont pas
|
||||
ceux des lignes listees.
|
||||
"""
|
||||
|
||||
import copy
|
||||
|
||||
import pytest
|
||||
|
||||
from plesna_gerance.database.models import Depense, Tag
|
||||
from plesna_gerance.database.service import DatabaseService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def depenses_de_trois_fournisseurs(db_session, sample_data):
|
||||
"""Quatre depenses : ACME (2), ACME SUD (1), BOREAL (1).
|
||||
|
||||
« ACME SUD » est la pour verifier qu'une selection sur « ACME » ne
|
||||
l'emporte pas au passage, ce que faisait l'ancienne recherche.
|
||||
"""
|
||||
donnees = copy.deepcopy(sample_data)
|
||||
donnees["recapitulatif_operations"] = [
|
||||
{
|
||||
"categorie": "DEPENSES_LOCATIVES",
|
||||
"fournisseur": "ACME",
|
||||
"description": "Nettoyage janvier",
|
||||
"montants": {"debit": 50.0},
|
||||
},
|
||||
{
|
||||
"categorie": "DEPENSES_LOCATIVES",
|
||||
"fournisseur": "ACME",
|
||||
"description": "Nettoyage fevrier",
|
||||
"montants": {"debit": 60.0},
|
||||
},
|
||||
{
|
||||
"categorie": "TRAVAUX",
|
||||
"fournisseur": "ACME SUD",
|
||||
"description": "Reprise peinture",
|
||||
"montants": {"debit": 200.0},
|
||||
},
|
||||
{
|
||||
"categorie": "TRAVAUX",
|
||||
"fournisseur": "BOREAL",
|
||||
"description": "Toiture",
|
||||
"montants": {"debit": 300.0},
|
||||
},
|
||||
]
|
||||
DatabaseService(db_session).save_document(data=donnees)
|
||||
return donnees
|
||||
|
||||
|
||||
def _noms(lignes):
|
||||
"""Les fournisseurs des lignes retournees, tries pour comparer sans l'ordre."""
|
||||
return sorted(ligne["fournisseur"] for ligne in lignes)
|
||||
|
||||
|
||||
def test_retient_les_fournisseurs_demandes(api_client, depenses_de_trois_fournisseurs):
|
||||
reponse = api_client.get(
|
||||
"/api/analytics/depenses?fournisseur=ACME&fournisseur=BOREAL"
|
||||
)
|
||||
|
||||
assert reponse.status_code == 200
|
||||
assert _noms(reponse.json()) == ["ACME", "ACME", "BOREAL"]
|
||||
|
||||
|
||||
def test_un_fournisseur_selectionne_exclut_ses_homonymes(
|
||||
api_client, depenses_de_trois_fournisseurs
|
||||
):
|
||||
"""« ACME » ne doit plus ramener « ACME SUD » : la selection est exacte."""
|
||||
reponse = api_client.get("/api/analytics/depenses?fournisseur=ACME")
|
||||
|
||||
assert _noms(reponse.json()) == ["ACME", "ACME"]
|
||||
|
||||
|
||||
def test_sans_fournisseur_demande_tout_reste_visible(
|
||||
api_client, depenses_de_trois_fournisseurs
|
||||
):
|
||||
assert len(api_client.get("/api/analytics/depenses").json()) == 4
|
||||
|
||||
|
||||
def test_le_resume_porte_sur_les_memes_lignes_que_la_table(
|
||||
api_client, depenses_de_trois_fournisseurs
|
||||
):
|
||||
requete = "fournisseur=ACME&fournisseur=BOREAL"
|
||||
lignes = api_client.get(f"/api/analytics/depenses?{requete}").json()
|
||||
resume = api_client.get(f"/api/analytics/depenses/summary?{requete}").json()
|
||||
|
||||
assert resume["total_count"] == len(lignes)
|
||||
assert resume["total_debit"] == pytest.approx(
|
||||
sum(ligne["debit"] for ligne in lignes)
|
||||
)
|
||||
assert resume["total_debit"] == pytest.approx(410.0)
|
||||
|
||||
|
||||
def test_filtre_les_depenses_sans_tag(
|
||||
api_client, db_session, depenses_de_trois_fournisseurs
|
||||
):
|
||||
"""tag_id=0 demande les depenses non taggees, la ou l'omettre les prend toutes."""
|
||||
tag = db_session.query(Tag).order_by(Tag.id).first()
|
||||
taggee = db_session.query(Depense).filter(Depense.fournisseur == "BOREAL").one()
|
||||
taggee.tag_id = tag.id
|
||||
db_session.flush()
|
||||
|
||||
sans_tag = api_client.get("/api/analytics/depenses?tag_id=0").json()
|
||||
avec_ce_tag = api_client.get(f"/api/analytics/depenses?tag_id={tag.id}").json()
|
||||
toutes = api_client.get("/api/analytics/depenses").json()
|
||||
|
||||
assert _noms(sans_tag) == ["ACME", "ACME", "ACME SUD"]
|
||||
assert _noms(avec_ce_tag) == ["BOREAL"]
|
||||
assert len(toutes) == len(sans_tag) + len(avec_ce_tag)
|
||||
|
||||
|
||||
def test_le_resume_par_tag_montre_les_non_taggees(
|
||||
api_client, depenses_de_trois_fournisseurs
|
||||
):
|
||||
"""La masse non taggee reste une part du camembert, pas un trou."""
|
||||
resume = api_client.get("/api/analytics/depenses/summary").json()
|
||||
|
||||
non_taggees = [part for part in resume["by_tag"] if part["tag_id"] is None]
|
||||
assert len(non_taggees) == 1
|
||||
assert non_taggees[0]["count"] == 4
|
||||
assert non_taggees[0]["total_debit"] == pytest.approx(610.0)
|
||||
|
||||
@@ -8,7 +8,13 @@ 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.models import (
|
||||
Document,
|
||||
Immeuble,
|
||||
Locataire,
|
||||
Lot,
|
||||
LotCaracteristiques,
|
||||
)
|
||||
from plesna_gerance.database.service import DatabaseService
|
||||
|
||||
|
||||
@@ -223,3 +229,539 @@ def test_le_total_d_un_intervenant_est_celui_de_ses_lignes(api_client, donnees):
|
||||
round(sum(ligne["montant"] for ligne in lignes), 2)
|
||||
== intervenant["montant"]
|
||||
)
|
||||
|
||||
|
||||
def test_le_loyer_se_lit_mois_par_mois(api_client, donnees):
|
||||
"""Deux comptes rendus, deux mois : la fiche les remet sur un axe de temps."""
|
||||
_, lot = donnees
|
||||
|
||||
loyer = api_client.get(f"/api/lots/{lot.id}/analyse").json()["loyer"]
|
||||
|
||||
assert [point["mois"] for point in loyer["serie"]] == ["2024-01", "2024-02"]
|
||||
assert [point["loyer"] for point in loyer["serie"]] == [500.0, 500.0]
|
||||
assert loyer["en_vigueur"]["loyer"] == 500.0
|
||||
assert loyer["en_vigueur"]["depuis"] == "2024-01"
|
||||
# Un seul niveau depuis le premier compte rendu : aucune révision à montrer.
|
||||
assert loyer["en_vigueur"]["precedent"] is None
|
||||
|
||||
|
||||
def test_sans_surface_saisie_le_loyer_au_m2_reste_vide(api_client, donnees):
|
||||
"""Le ratio manquant se voit ; un zéro laisserait croire à un loyer nul."""
|
||||
_, lot = donnees
|
||||
|
||||
loyer = api_client.get(f"/api/lots/{lot.id}/analyse").json()["loyer"]
|
||||
|
||||
assert loyer["surface"] is None
|
||||
assert all(point["loyer_m2"] is None for point in loyer["serie"])
|
||||
assert loyer["en_vigueur"]["loyer_m2"] is None
|
||||
|
||||
|
||||
def test_la_surface_saisie_allume_le_loyer_au_m2(api_client, db_session, donnees):
|
||||
"""La fiche saisie est la seule source de surface : aucun PDF n'en porte."""
|
||||
_, lot = donnees
|
||||
db_session.add(LotCaracteristiques(lot_id=lot.id, surface=50.0))
|
||||
db_session.commit()
|
||||
|
||||
loyer = api_client.get(f"/api/lots/{lot.id}/analyse").json()["loyer"]
|
||||
|
||||
assert loyer["surface"] == 50.0
|
||||
assert loyer["en_vigueur"]["loyer_m2"] == 10.0
|
||||
|
||||
|
||||
def test_sans_periode_demandee_rien_n_est_borne(api_client, donnees):
|
||||
"""Le défaut ne filtre pas : la page s'ouvre sur tout l'historique.
|
||||
|
||||
Un filtre par défaut cacherait des opérations dès l'arrivée sur la fiche,
|
||||
sans que rien ne le signale.
|
||||
"""
|
||||
_, lot = donnees
|
||||
|
||||
periode = api_client.get(f"/api/lots/{lot.id}/analyse").json()["periode"]
|
||||
|
||||
assert periode["mois"] is None
|
||||
assert periode["debut"] is None
|
||||
assert periode["fin"] is None
|
||||
assert periode["lignes_masquees"] == 0
|
||||
# L'ancre est renvoyée quand même : la page sait sur quoi une fenêtre se
|
||||
# calerait avant même d'en demander une.
|
||||
assert periode["ancre"] == "2024-02-15"
|
||||
|
||||
|
||||
def test_la_fenetre_se_cale_sur_le_dernier_compte_rendu_du_lot(api_client, donnees):
|
||||
"""Comptée à rebours des données, jamais d'aujourd'hui.
|
||||
|
||||
Ces comptes rendus datent de 2024 : une fenêtre calée sur la date du jour
|
||||
viderait la fiche et ferait passer un lot documenté pour un lot sans
|
||||
activité.
|
||||
"""
|
||||
_, lot = donnees
|
||||
|
||||
periode = api_client.get(f"/api/lots/{lot.id}/analyse?mois=1").json()["periode"]
|
||||
|
||||
assert periode["fin"] == "2024-02-15"
|
||||
assert periode["debut"] == "2024-01-16"
|
||||
|
||||
|
||||
def test_une_fenetre_d_un_mois_ne_retient_qu_un_compte_rendu(api_client, donnees):
|
||||
"""Un mois de fenêtre, un compte rendu : le précédent tombe dehors.
|
||||
|
||||
Bornes incluses des deux côtés, le compte rendu du 15 janvier entrerait
|
||||
dans une fenêtre d'un mois finissant le 15 février — elle en couvrirait
|
||||
deux.
|
||||
"""
|
||||
_, lot = donnees
|
||||
|
||||
analyse = api_client.get(f"/api/lots/{lot.id}/analyse?mois=1").json()
|
||||
|
||||
dates = {ligne["date"] for ligne in analyse["chronologie"]}
|
||||
assert dates == {"2024-02-15"}
|
||||
# Le premier compte rendu portait un loyer et une opération de nettoyage
|
||||
# d'immeuble ; seules ses lignes de lot comptent ici.
|
||||
assert analyse["periode"]["lignes_masquees"] == 1
|
||||
|
||||
|
||||
def test_les_chiffres_suivent_la_fenetre(api_client, donnees):
|
||||
"""Facturé, dépenses et charges communes se recalculent sur la période."""
|
||||
_, lot = donnees
|
||||
|
||||
chiffres = api_client.get(f"/api/lots/{lot.id}/analyse?mois=1").json()["chiffres"]
|
||||
|
||||
# Le seul loyer du second compte rendu, sans celui de janvier.
|
||||
assert chiffres["facture"] == 500.0
|
||||
assert chiffres["encaisse"] == 200.0
|
||||
assert chiffres["nb_operations"] == 3
|
||||
# Le nettoyage de l'immeuble datait du premier compte rendu.
|
||||
assert chiffres["depenses_immeuble_non_reparties"] == 0.0
|
||||
|
||||
|
||||
def test_le_restant_du_echappe_a_la_fenetre(api_client, donnees):
|
||||
"""Un stock ne se borne pas : la dette reste celle du dernier compte rendu.
|
||||
|
||||
La ramener à la fenêtre l'annulerait dès qu'aucun compte rendu n'y tombe —
|
||||
un lot afficherait 0 € dû tout en devant plusieurs milliers.
|
||||
"""
|
||||
_, lot = donnees
|
||||
|
||||
chiffres = api_client.get(f"/api/lots/{lot.id}/analyse?mois=1").json()["chiffres"]
|
||||
|
||||
assert chiffres["restant_du"] == 600.0
|
||||
|
||||
|
||||
def test_les_intervenants_suivent_la_fenetre(api_client, donnees):
|
||||
"""Le tableau des entreprises décrit la même période que la chronologie."""
|
||||
_, lot = donnees
|
||||
|
||||
analyse = api_client.get(f"/api/lots/{lot.id}/analyse?mois=1").json()
|
||||
|
||||
# Invariant du dépliage, sous fenêtre comme sans : le détail doit retrouver
|
||||
# le total. Les deux viennent de calculs séparés — un agrégat SQL borné
|
||||
# d'un côté, les lignes filtrées de l'autre — et une fenêtre appliquée d'un
|
||||
# seul côté les ferait diverger sans que rien ne le signale.
|
||||
assert analyse["intervenants"]
|
||||
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"]
|
||||
)
|
||||
|
||||
|
||||
def test_la_courbe_du_loyer_suit_la_fenetre(api_client, donnees):
|
||||
"""Le graphe se limite aux mois de la période, et dit ce qu'il ne trace pas.
|
||||
|
||||
Le mois de la borne haute compte pour un : une fenêtre d'un mois finissant
|
||||
en février trace février seul, sans quoi le graphe démentirait son libellé.
|
||||
"""
|
||||
_, lot = donnees
|
||||
|
||||
loyer = api_client.get(f"/api/lots/{lot.id}/analyse?mois=1").json()["loyer"]
|
||||
|
||||
assert [point["mois"] for point in loyer["serie"]] == ["2024-02"]
|
||||
assert loyer["depuis_mois"] == "2024-02"
|
||||
assert loyer["mois_masques"] == 1
|
||||
|
||||
|
||||
def test_le_loyer_en_vigueur_ignore_la_fenetre(api_client, donnees):
|
||||
"""Les paliers restent lus sur toute la série, même courbe tronquée.
|
||||
|
||||
Recalculés sur les seuls mois tracés, « depuis » daterait de la borne du
|
||||
filtre : ce loyer semblerait révisé en février alors qu'il n'a jamais
|
||||
bougé depuis janvier.
|
||||
"""
|
||||
_, lot = donnees
|
||||
|
||||
loyer = api_client.get(f"/api/lots/{lot.id}/analyse?mois=1").json()["loyer"]
|
||||
|
||||
assert loyer["en_vigueur"]["depuis"] == "2024-01"
|
||||
assert loyer["en_vigueur"]["loyer"] == 500.0
|
||||
# La comparaison au parc garde son mois de référence : le dernier mois loué.
|
||||
assert loyer["parc"]["mois"] == "2024-02"
|
||||
|
||||
|
||||
def test_sans_fenetre_la_courbe_reste_entiere(api_client, donnees):
|
||||
"""Le défaut ne coupe rien, et ne prétend pas avoir coupé."""
|
||||
_, lot = donnees
|
||||
|
||||
loyer = api_client.get(f"/api/lots/{lot.id}/analyse").json()["loyer"]
|
||||
|
||||
assert [point["mois"] for point in loyer["serie"]] == ["2024-01", "2024-02"]
|
||||
assert loyer["depuis_mois"] is None
|
||||
assert loyer["mois_masques"] == 0
|
||||
|
||||
|
||||
def test_la_fenetre_ne_garde_que_les_occupants_de_la_periode(
|
||||
api_client, db_session, donnees, sample_data
|
||||
):
|
||||
"""Un locataire appartient à la période si un compte rendu l'y porte.
|
||||
|
||||
Aucune date d'entrée ni de sortie n'est extraite : le document où le nom
|
||||
figure est le seul rattachement au temps dont on dispose.
|
||||
"""
|
||||
_, lot = donnees
|
||||
DatabaseService(db_session).save_document(
|
||||
data={
|
||||
**sample_data,
|
||||
"metadata": {
|
||||
**sample_data["metadata"],
|
||||
"document": {
|
||||
"reference": "REF003",
|
||||
"date": "2024-03-15",
|
||||
"type": "COMPTE RENDU DE GESTION",
|
||||
},
|
||||
},
|
||||
"situation_locataires": [
|
||||
{
|
||||
"lot": {"numero": "01", "type": "Appartement"},
|
||||
"locataire": {"nom": "MARTIN"},
|
||||
"lignes": [
|
||||
{
|
||||
"type": "loyer",
|
||||
"periode": {"debut": "2024-03-01", "fin": "2024-03-31"},
|
||||
"loyers": 500.0,
|
||||
"total": 500.0,
|
||||
"regles": 500.0,
|
||||
"impayes": 0.0,
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
identite = api_client.get(f"/api/lots/{lot.id}/analyse?mois=1").json()["identite"]
|
||||
|
||||
assert identite["locataires"] == ["MARTIN"]
|
||||
# DUPONT n'est pas effacé pour autant : la fiche dit qu'il en manque un.
|
||||
assert identite["locataires_masques"] == 1
|
||||
|
||||
entier = api_client.get(f"/api/lots/{lot.id}/analyse").json()["identite"]
|
||||
assert entier["locataires"] == ["DUPONT", "MARTIN"]
|
||||
assert entier["locataires_masques"] == 0
|
||||
|
||||
|
||||
def test_sans_fenetre_un_locataire_sans_ligne_reste_liste(
|
||||
api_client, db_session, donnees
|
||||
):
|
||||
"""La vue par défaut ne filtre rien, pas même par les lignes rattachées.
|
||||
|
||||
La base porte des locataires dont aucune ligne ne dépend ; les faire
|
||||
disparaître de la fiche entière serait un filtre que personne n'a demandé.
|
||||
"""
|
||||
_, lot = donnees
|
||||
db_session.add(Locataire(lot_id=lot.id, nom="ORPHELIN"))
|
||||
db_session.commit()
|
||||
|
||||
identite = api_client.get(f"/api/lots/{lot.id}/analyse").json()["identite"]
|
||||
|
||||
assert identite["locataires"] == ["DUPONT", "ORPHELIN"]
|
||||
|
||||
|
||||
def test_un_lot_sans_ligne_garde_une_periode_ouverte(api_client, db_session, donnees):
|
||||
"""Rien à quoi caler la fenêtre : la fiche s'ouvre au lieu d'échouer."""
|
||||
immeuble, _ = donnees
|
||||
vide = Lot(immeuble_id=immeuble.id, numero="99")
|
||||
db_session.add(vide)
|
||||
db_session.commit()
|
||||
|
||||
analyse = api_client.get(f"/api/lots/{vide.id}/analyse?mois=3").json()
|
||||
|
||||
assert analyse["periode"]["ancre"] is None
|
||||
assert analyse["periode"]["debut"] is None
|
||||
assert analyse["chronologie"] == []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def parc(db_session, sample_data):
|
||||
"""Un compte rendu portant trois lots, dont un sans surface saisie.
|
||||
|
||||
Se situer suppose des voisins : le nuage n'a de sens qu'à plusieurs. Les
|
||||
surfaces sont volontairement contrastées (20 m² à 15 €/m², 50 m² à 10 €/m²)
|
||||
pour reproduire la pente du parc réel, où le petit se loue plus cher au m².
|
||||
"""
|
||||
|
||||
def locataire(numero, nom, loyer):
|
||||
return {
|
||||
"lot": {"numero": numero, "type": "Appartement"},
|
||||
"locataire": {"nom": nom},
|
||||
"lignes": [
|
||||
{
|
||||
"type": "loyer",
|
||||
"periode": {"debut": "2024-01-01", "fin": "2024-01-31"},
|
||||
"loyers": loyer,
|
||||
"total": loyer,
|
||||
"regles": loyer,
|
||||
"impayes": 0.0,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
DatabaseService(db_session).save_document(
|
||||
data={
|
||||
**sample_data,
|
||||
"situation_locataires": [
|
||||
locataire("01", "DUPONT", 500.0),
|
||||
locataire("02", "MARTIN", 300.0),
|
||||
locataire("03", "DURAND", 700.0),
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
immeuble = db_session.query(Immeuble).filter(Immeuble.code == "IMM1").one()
|
||||
lots = {
|
||||
lot.numero: lot
|
||||
for lot in db_session.query(Lot).filter(Lot.immeuble_id == immeuble.id)
|
||||
}
|
||||
|
||||
db_session.add(LotCaracteristiques(lot_id=lots["01"].id, surface=50.0))
|
||||
db_session.add(LotCaracteristiques(lot_id=lots["02"].id, surface=20.0))
|
||||
# Le lot 03 reste sans fiche : c'est le cas majoritaire en base.
|
||||
db_session.commit()
|
||||
return lots
|
||||
|
||||
|
||||
def test_le_nuage_situe_le_lot_parmi_ses_voisins(api_client, parc):
|
||||
"""Trié par surface, le lot courant présent et signalé.
|
||||
|
||||
Il figure dans le nuage — s'y voir situé est tout l'objet — alors qu'il est
|
||||
exclu des médianes, qu'il tirerait vers lui.
|
||||
"""
|
||||
nuage = api_client.get(f"/api/lots/{parc['01'].id}/analyse").json()["loyer"][
|
||||
"parc"
|
||||
]["nuage"]
|
||||
|
||||
assert [(point["surface"], point["loyer_m2"]) for point in nuage] == [
|
||||
(20.0, 15.0),
|
||||
(50.0, 10.0),
|
||||
]
|
||||
assert [point["est_ce_lot"] for point in nuage] == [False, True]
|
||||
assert nuage[0]["numero"] == "02"
|
||||
|
||||
|
||||
def test_un_lot_sans_surface_n_entre_pas_dans_le_nuage(api_client, parc):
|
||||
"""Sans surface, aucune abscisse : le lot ne peut pas être placé.
|
||||
|
||||
Il n'est pas pour autant oublié — `sans_surface` le compte, et la page le
|
||||
dit sous les médianes.
|
||||
"""
|
||||
comparaison = api_client.get(f"/api/lots/{parc['01'].id}/analyse").json()["loyer"][
|
||||
"parc"
|
||||
]
|
||||
|
||||
assert len(comparaison["nuage"]) == 2
|
||||
assert parc["03"].id not in [point["lot_id"] for point in comparaison["nuage"]]
|
||||
assert comparaison["sans_surface"] == 1
|
||||
|
||||
|
||||
def test_le_nuage_garde_le_lot_courant_meme_seul(api_client, db_session, donnees):
|
||||
"""Seul lot mesuré du parc : le nuage le porte quand même.
|
||||
|
||||
Le vider dans ce cas ferait disparaître le point qu'on cherche justement à
|
||||
situer, et la page ne dirait plus rien du lot ouvert.
|
||||
"""
|
||||
_, lot = donnees
|
||||
db_session.add(LotCaracteristiques(lot_id=lot.id, surface=50.0))
|
||||
db_session.commit()
|
||||
|
||||
nuage = api_client.get(f"/api/lots/{lot.id}/analyse").json()["loyer"]["parc"][
|
||||
"nuage"
|
||||
]
|
||||
|
||||
assert len(nuage) == 1
|
||||
assert nuage[0]["est_ce_lot"] is True
|
||||
|
||||
|
||||
def test_la_comparaison_compte_les_lots_qu_elle_ne_peut_pas_voir(
|
||||
api_client, db_session, donnees
|
||||
):
|
||||
"""Un lot sans surface ne peut pas entrer dans une médiane au m².
|
||||
|
||||
Taire ces lots ferait passer une médiane sur une poignée de lots pour une
|
||||
médiane sur tout le parc — c'est le chiffre, et non son effectif, qui
|
||||
tromperait.
|
||||
"""
|
||||
_, lot = donnees
|
||||
db_session.add(LotCaracteristiques(lot_id=lot.id, surface=50.0))
|
||||
db_session.commit()
|
||||
|
||||
parc = api_client.get(f"/api/lots/{lot.id}/analyse").json()["loyer"]["parc"]
|
||||
|
||||
assert parc["mois"] == "2024-02"
|
||||
assert parc["loyer_m2"] == 10.0
|
||||
# Seul lot de la base : rien à quoi le comparer, et la médiane ne se
|
||||
# rabat pas sur lui-même.
|
||||
assert parc["mediane_immeuble"] is None
|
||||
assert parc["nb_immeuble"] == 0
|
||||
assert parc["sans_surface"] == 0
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bail_qui_change(db_session, sample_data):
|
||||
"""Un lot dont le bail s'arrête en cours de mois, puis reprend au suivant.
|
||||
|
||||
C'est la chronologie du lot 15 du parc : deux mois pleins, une sortie le
|
||||
10 mars, quatre mois vides, puis une entrée le 8 juillet facturée au seul
|
||||
prorata. Le dernier compte rendu ne porte donc aucun loyer plein, alors que
|
||||
le lot est bel et bien reloué.
|
||||
"""
|
||||
|
||||
def compte_rendu(reference, date_cr, lignes):
|
||||
return {
|
||||
**sample_data,
|
||||
"metadata": {
|
||||
**sample_data["metadata"],
|
||||
"document": {
|
||||
"reference": reference,
|
||||
"date": date_cr,
|
||||
"type": "COMPTE RENDU DE GESTION",
|
||||
},
|
||||
},
|
||||
"situation_locataires": [
|
||||
{
|
||||
"lot": {"numero": "01", "type": "Appartement"},
|
||||
"locataire": {"nom": lignes["nom"]},
|
||||
"lignes": [
|
||||
{
|
||||
"type": "loyer",
|
||||
"periode": {
|
||||
"debut": lignes["debut"],
|
||||
"fin": lignes["fin"],
|
||||
},
|
||||
"loyers": lignes["loyers"],
|
||||
"total": lignes["loyers"],
|
||||
"regles": lignes["loyers"],
|
||||
"impayes": 0.0,
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
"recapitulatif_operations": [],
|
||||
}
|
||||
|
||||
service = DatabaseService(db_session)
|
||||
for reference, date_cr, lignes in [
|
||||
(
|
||||
"CR01",
|
||||
"2026-01-26",
|
||||
{
|
||||
"nom": "SORTANT",
|
||||
"debut": "2026-01-01",
|
||||
"fin": "2026-01-31",
|
||||
"loyers": 1447.05,
|
||||
},
|
||||
),
|
||||
(
|
||||
"CR02",
|
||||
"2026-02-26",
|
||||
{
|
||||
"nom": "SORTANT",
|
||||
"debut": "2026-02-01",
|
||||
"fin": "2026-02-28",
|
||||
"loyers": 1447.05,
|
||||
},
|
||||
),
|
||||
(
|
||||
"CR03",
|
||||
"2026-03-23",
|
||||
{
|
||||
"nom": "SORTANT",
|
||||
"debut": "2026-03-01",
|
||||
"fin": "2026-03-10",
|
||||
"loyers": 482.35,
|
||||
},
|
||||
),
|
||||
(
|
||||
"CR04",
|
||||
"2026-07-28",
|
||||
{
|
||||
"nom": "ENTRANT",
|
||||
"debut": "2026-07-08",
|
||||
"fin": "2026-07-31",
|
||||
"loyers": 1111.67,
|
||||
},
|
||||
),
|
||||
]:
|
||||
service.save_document(data=compte_rendu(reference, date_cr, lignes))
|
||||
|
||||
immeuble = db_session.query(Immeuble).filter(Immeuble.code == "IMM1").one()
|
||||
lot = db_session.query(Lot).filter(Lot.immeuble_id == immeuble.id).one()
|
||||
return lot
|
||||
|
||||
|
||||
def test_un_lot_reloue_au_prorata_n_est_pas_un_lot_arrete(api_client, bail_qui_change):
|
||||
"""Le palier s'arrête en février, le lot est reloué en juillet.
|
||||
|
||||
Sans cette lecture, la fiche annonce « arrêté après févr. » sur un lot qui
|
||||
vient de retrouver un locataire — et la courbe juste dessous, où la barre
|
||||
de juillet est bien là, la contredit.
|
||||
"""
|
||||
vigueur = api_client.get(f"/api/lots/{bail_qui_change.id}/analyse").json()["loyer"][
|
||||
"en_vigueur"
|
||||
]
|
||||
|
||||
assert vigueur["reloue_depuis"] == "2026-07"
|
||||
assert vigueur["sortie_en"] is None
|
||||
# Le montant reste celui du bail précédent : le nouveau n'a été facturé
|
||||
# qu'au prorata, et en tirer un loyer mensuel l'inventerait.
|
||||
assert vigueur["mois"] == "2026-02"
|
||||
assert vigueur["loyer"] == 1447.05
|
||||
assert vigueur["toujours_loue"] is False
|
||||
|
||||
|
||||
def test_une_sortie_en_cours_de_mois_deborde_le_dernier_palier(
|
||||
api_client, db_session, bail_qui_change
|
||||
):
|
||||
"""Sans relocation, c'est la sortie qui date la fin de la location.
|
||||
|
||||
Le dernier loyer plein est celui de février, mais le lot est resté loué
|
||||
jusqu'au 10 mars : dire « arrêté après févr. » avancerait la sortie d'un
|
||||
mois.
|
||||
"""
|
||||
dernier = (
|
||||
db_session.query(Document).order_by(Document.date.desc()).first()
|
||||
)
|
||||
db_session.delete(dernier)
|
||||
db_session.commit()
|
||||
|
||||
vigueur = api_client.get(f"/api/lots/{bail_qui_change.id}/analyse").json()["loyer"][
|
||||
"en_vigueur"
|
||||
]
|
||||
|
||||
assert vigueur["sortie_en"] == "2026-03"
|
||||
assert vigueur["reloue_depuis"] is None
|
||||
|
||||
|
||||
def test_un_loyer_qui_s_arrete_net_reste_un_loyer_arrete(api_client, donnees):
|
||||
"""Aucun prorata après le dernier palier : rien ne nuance l'arrêt."""
|
||||
_, lot = donnees
|
||||
|
||||
vigueur = api_client.get(f"/api/lots/{lot.id}/analyse").json()["loyer"][
|
||||
"en_vigueur"
|
||||
]
|
||||
|
||||
assert vigueur["reloue_depuis"] is None
|
||||
assert vigueur["sortie_en"] is None
|
||||
|
||||
304
tests/test_loyers.py
Normal file
304
tests/test_loyers.py
Normal file
@@ -0,0 +1,304 @@
|
||||
"""Tests de la mise en temps du loyer.
|
||||
|
||||
Ce que ces tests protègent, c'est la lecture d'une courbe : un mois vacant, un
|
||||
mois de changement de locataire et un mois à loyer plein doivent rester trois
|
||||
choses différentes. Les cas ne sont pas inventés — ils viennent tous du parc
|
||||
réel (bail commercial trimestriel, relocation en cours de mois, avoir annulant
|
||||
un loyer, régularisation rétroactive à cheval sur six mois).
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from plesna_gerance.services.loyers import (
|
||||
loyer_au_m2,
|
||||
mediane,
|
||||
mois_entiers,
|
||||
mois_suivant,
|
||||
paliers,
|
||||
repartir,
|
||||
variation,
|
||||
)
|
||||
|
||||
|
||||
def ligne(debut: str, fin: str, loyers: float, provisions: float = 0.0):
|
||||
"""Une ligne de loyer telle que la base la porte."""
|
||||
return (date.fromisoformat(debut), date.fromisoformat(fin), loyers, provisions)
|
||||
|
||||
|
||||
class TestMoisEntiers:
|
||||
"""Ce qui fait qu'une période porte un loyer plutôt qu'un prorata."""
|
||||
|
||||
def test_un_mois_plein_couvre_son_mois(self):
|
||||
assert mois_entiers(date(2026, 3, 1), date(2026, 3, 31)) == ["2026-03"]
|
||||
|
||||
def test_fevrier_se_termine_le_28_ou_le_29(self):
|
||||
assert mois_entiers(date(2025, 2, 1), date(2025, 2, 28)) == ["2025-02"]
|
||||
assert mois_entiers(date(2024, 2, 1), date(2024, 2, 29)) == ["2024-02"]
|
||||
|
||||
def test_un_trimestre_couvre_ses_trois_mois(self):
|
||||
assert mois_entiers(date(2025, 10, 1), date(2025, 12, 31)) == [
|
||||
"2025-10",
|
||||
"2025-11",
|
||||
"2025-12",
|
||||
]
|
||||
|
||||
def test_une_periode_partielle_ne_couvre_aucun_mois(self):
|
||||
assert mois_entiers(date(2024, 6, 22), date(2024, 6, 30)) == []
|
||||
assert mois_entiers(date(2025, 3, 14), date(2025, 8, 31)) == []
|
||||
|
||||
def test_une_periode_absente_ou_inversee_ne_couvre_rien(self):
|
||||
assert mois_entiers(None, date(2026, 3, 31)) == []
|
||||
assert mois_entiers(date(2026, 3, 31), date(2026, 3, 1)) == []
|
||||
|
||||
def test_le_passage_a_l_annee_suivante(self):
|
||||
assert mois_suivant("2025-12") == "2026-01"
|
||||
|
||||
|
||||
class TestRepartition:
|
||||
"""Comment les lignes se rangent sur l'axe des mois."""
|
||||
|
||||
def test_un_loyer_mensuel_va_dans_son_mois(self):
|
||||
serie = repartir([ligne("2026-03-01", "2026-03-31", 640.0, 31.0)])
|
||||
|
||||
assert [point.mois for point in serie.mois] == ["2026-03"]
|
||||
assert serie.mois[0].loyer == 640.0
|
||||
assert serie.mois[0].charges == 31.0
|
||||
assert serie.mois[0].reparti is False
|
||||
|
||||
def test_un_bail_trimestriel_se_repartit_sur_ses_mois(self):
|
||||
"""Sans répartition, deux mois sur trois d'un bail commercial seraient
|
||||
montrés vides alors que le local est loué."""
|
||||
serie = repartir([ligne("2025-10-01", "2025-12-31", 3963.27)])
|
||||
|
||||
assert [point.mois for point in serie.mois] == [
|
||||
"2025-10",
|
||||
"2025-11",
|
||||
"2025-12",
|
||||
]
|
||||
assert [point.loyer for point in serie.mois] == [1321.09, 1321.09, 1321.09]
|
||||
assert all(point.reparti for point in serie.mois)
|
||||
|
||||
def test_un_mois_sans_ligne_reste_vide_et_non_a_zero(self):
|
||||
"""Un loyer à zéro se lirait comme un logement prêté ; le trou dit la
|
||||
vacance, qui est ce que les comptes rendus montrent."""
|
||||
serie = repartir(
|
||||
[
|
||||
ligne("2024-01-01", "2024-01-31", 850.0),
|
||||
ligne("2024-04-01", "2024-04-30", 900.0),
|
||||
]
|
||||
)
|
||||
|
||||
assert [point.mois for point in serie.mois] == [
|
||||
"2024-01",
|
||||
"2024-02",
|
||||
"2024-03",
|
||||
"2024-04",
|
||||
]
|
||||
assert [point.loyer for point in serie.mois] == [850.0, None, None, 900.0]
|
||||
|
||||
def test_un_prorata_se_range_a_part_du_loyer(self):
|
||||
"""Le locataire entre le 22 : le mois est facturé, mais pas au prix
|
||||
d'un mois plein. Additionner les deux inventerait une baisse de loyer."""
|
||||
serie = repartir([ligne("2024-06-22", "2024-06-30", 417.0)])
|
||||
|
||||
point = serie.mois[0]
|
||||
assert point.mois == "2024-06"
|
||||
assert point.loyer is None
|
||||
assert point.prorata == 417.0
|
||||
assert point.en_transition is True
|
||||
|
||||
def test_un_mois_de_transition_n_est_pas_une_vacance(self):
|
||||
"""Sortie le 3, entrée le 14 : le mois est loué deux fois en morceaux.
|
||||
Le montrer vide le confondrait avec un mois sans locataire."""
|
||||
serie = repartir(
|
||||
[
|
||||
ligne("2025-03-01", "2025-03-03", 109.68),
|
||||
ligne("2025-03-14", "2025-03-31", 622.2),
|
||||
]
|
||||
)
|
||||
|
||||
point = serie.mois[0]
|
||||
assert point.loyer is None
|
||||
assert point.prorata == pytest.approx(731.88)
|
||||
assert point.en_transition is True
|
||||
|
||||
def test_un_avoir_ne_deforme_pas_le_loyer_du_mois(self):
|
||||
"""Le loyer d'octobre est annulé par un avoir. Le loyer contractuel
|
||||
reste 1390 ; l'avoir se lit à côté, sans creuser la courbe."""
|
||||
serie = repartir(
|
||||
[
|
||||
ligne("2024-10-01", "2024-10-31", 1390.0),
|
||||
ligne("2024-10-18", "2024-10-18", -1390.0),
|
||||
]
|
||||
)
|
||||
|
||||
point = serie.mois[0]
|
||||
assert point.loyer == 1390.0
|
||||
assert point.prorata == -1390.0
|
||||
assert point.en_transition is False
|
||||
|
||||
def test_un_prorata_qui_finit_le_mois_est_une_entree(self):
|
||||
"""Lot 15 du parc : le locataire entre le 8 juillet. La location ne
|
||||
s'arrête donc pas au dernier loyer plein, elle recommence."""
|
||||
serie = repartir([ligne("2026-07-08", "2026-07-31", 1111.67)])
|
||||
|
||||
point = serie.mois[0]
|
||||
assert point.entree is True
|
||||
assert point.sortie is False
|
||||
|
||||
def test_un_prorata_qui_ouvre_le_mois_est_une_sortie(self):
|
||||
"""Même lot, quatre mois plus tôt : le locataire part le 10 mars."""
|
||||
serie = repartir([ligne("2026-03-01", "2026-03-10", 482.35)])
|
||||
|
||||
point = serie.mois[0]
|
||||
assert point.sortie is True
|
||||
assert point.entree is False
|
||||
|
||||
def test_un_mois_qui_change_de_locataire_porte_les_deux(self):
|
||||
"""Sortie le 3, entrée le 14 : le mois ferme un bail et en ouvre un
|
||||
autre, et c'est l'entrée qui dit que le lot reste loué."""
|
||||
serie = repartir(
|
||||
[
|
||||
ligne("2025-03-01", "2025-03-03", 109.68),
|
||||
ligne("2025-03-14", "2025-03-31", 622.2),
|
||||
]
|
||||
)
|
||||
|
||||
point = serie.mois[0]
|
||||
assert point.entree is True
|
||||
assert point.sortie is True
|
||||
|
||||
def test_un_avoir_n_ouvre_ni_ne_ferme_de_bail(self):
|
||||
"""Un avoir porte la période qu'il annule : lu comme une entrée, il
|
||||
ferait croire à une relocation là où rien n'a été loué."""
|
||||
serie = repartir(
|
||||
[
|
||||
ligne("2024-10-15", "2024-10-31", -700.0),
|
||||
ligne("2024-11-18", "2024-11-18", -1390.0),
|
||||
]
|
||||
)
|
||||
|
||||
assert [point.entree for point in serie.mois] == [False, False]
|
||||
assert [point.sortie for point in serie.mois] == [False, False]
|
||||
|
||||
def test_un_mois_plein_n_est_ni_entree_ni_sortie(self):
|
||||
serie = repartir([ligne("2026-01-01", "2026-01-31", 1447.05)])
|
||||
|
||||
point = serie.mois[0]
|
||||
assert point.entree is False
|
||||
assert point.sortie is False
|
||||
|
||||
def test_une_regularisation_a_cheval_reste_hors_de_la_courbe(self):
|
||||
"""De mars à août sans couvrir un mois entier : l'étaler inventerait
|
||||
six demi-mois de loyer."""
|
||||
serie = repartir(
|
||||
[
|
||||
ligne("2025-09-01", "2025-09-30", 1023.0),
|
||||
ligne("2025-03-14", "2025-08-31", -418.55),
|
||||
]
|
||||
)
|
||||
|
||||
assert [point.mois for point in serie.mois] == ["2025-09"]
|
||||
assert len(serie.ecartees) == 1
|
||||
assert serie.ecartees[0].loyers == -418.55
|
||||
|
||||
def test_sans_aucune_ligne_la_serie_est_vide(self):
|
||||
assert repartir([]).mois == []
|
||||
|
||||
|
||||
class TestPaliers:
|
||||
"""Les révisions de loyer, lues dans la suite des mois."""
|
||||
|
||||
def test_un_loyer_stable_ne_fait_qu_un_palier(self):
|
||||
serie = repartir(
|
||||
[
|
||||
ligne("2026-01-01", "2026-01-31", 900.0),
|
||||
ligne("2026-02-01", "2026-02-28", 900.0),
|
||||
ligne("2026-03-01", "2026-03-31", 900.0),
|
||||
]
|
||||
)
|
||||
|
||||
niveaux = paliers(serie.mois)
|
||||
assert len(niveaux) == 1
|
||||
assert (niveaux[0].mois_debut, niveaux[0].mois_fin) == ("2026-01", "2026-03")
|
||||
|
||||
def test_une_revision_ouvre_un_palier(self):
|
||||
serie = repartir(
|
||||
[
|
||||
ligne("2025-12-01", "2025-12-31", 900.0),
|
||||
ligne("2026-01-01", "2026-01-31", 907.85),
|
||||
ligne("2026-02-01", "2026-02-28", 907.85),
|
||||
]
|
||||
)
|
||||
|
||||
niveaux = paliers(serie.mois)
|
||||
assert [n.loyer for n in niveaux] == [900.0, 907.85]
|
||||
assert niveaux[-1].mois_debut == "2026-01"
|
||||
|
||||
def test_une_vacance_coupe_le_palier_meme_a_loyer_egal(self):
|
||||
"""Relouer au même prix après trois mois vides, c'est un nouveau bail :
|
||||
dire que le loyer « n'a pas bougé depuis 2024 » serait faux."""
|
||||
serie = repartir(
|
||||
[
|
||||
ligne("2024-01-01", "2024-01-31", 850.0),
|
||||
ligne("2024-05-01", "2024-05-31", 850.0),
|
||||
]
|
||||
)
|
||||
|
||||
niveaux = paliers(serie.mois)
|
||||
assert len(niveaux) == 2
|
||||
assert niveaux[-1].mois_debut == "2024-05"
|
||||
|
||||
def test_les_centimes_d_un_trimestre_ne_creent_pas_de_faux_paliers(self):
|
||||
"""3963,27 divisé par trois ne retombe pas juste ; l'égalité se juge au
|
||||
centime, sinon chaque mois ouvrirait son propre palier."""
|
||||
serie = repartir(
|
||||
[
|
||||
ligne("2025-01-01", "2025-03-31", 3963.27),
|
||||
ligne("2025-04-01", "2025-06-30", 3963.27),
|
||||
]
|
||||
)
|
||||
|
||||
assert len(paliers(serie.mois)) == 1
|
||||
|
||||
|
||||
class TestLoyerAuM2:
|
||||
"""Le ratio, et ce qu'il refuse de calculer."""
|
||||
|
||||
def test_le_ratio_se_calcule_hors_charges(self):
|
||||
assert loyer_au_m2(640.0, 47.84) == 13.38
|
||||
|
||||
def test_sans_surface_il_n_y_a_pas_de_ratio(self):
|
||||
"""Zéro serait un loyer au m² nul ; `None` est un trou à combler."""
|
||||
assert loyer_au_m2(640.0, None) is None
|
||||
|
||||
def test_une_surface_absurde_vaut_une_surface_absente(self):
|
||||
assert loyer_au_m2(640.0, 0) is None
|
||||
assert loyer_au_m2(640.0, -10) is None
|
||||
|
||||
def test_un_mois_sans_loyer_n_a_pas_de_ratio(self):
|
||||
assert loyer_au_m2(None, 47.84) is None
|
||||
|
||||
|
||||
class TestMedianeEtVariation:
|
||||
"""Les deux agrégats qui situent un lot."""
|
||||
|
||||
def test_la_mediane_resiste_a_un_local_commercial(self):
|
||||
"""Une moyenne serait tirée par la valeur extrême ; c'est tout
|
||||
l'intérêt de la médiane sur un parc de trente lots."""
|
||||
assert mediane([11.0, 12.0, 13.0, 14.0, 90.0]) == 13.0
|
||||
|
||||
def test_la_mediane_d_un_effectif_pair_prend_le_milieu(self):
|
||||
assert mediane([10.0, 12.0, 14.0, 16.0]) == 13.0
|
||||
|
||||
def test_sans_lot_comparable_il_n_y_a_pas_de_mediane(self):
|
||||
assert mediane([]) is None
|
||||
|
||||
def test_la_variation_se_lit_en_pourcentage(self):
|
||||
assert variation(1023.0, 1031.06) == 0.79
|
||||
|
||||
def test_une_variation_sans_reference_n_existe_pas(self):
|
||||
assert variation(None, 900.0) is None
|
||||
assert variation(0.0, 900.0) is None
|
||||
Reference in New Issue
Block a user