Compare commits
12 Commits
2ee8e101b8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 233f000b2a | |||
| 24db55c46c | |||
| 552fc453dd | |||
| e2e2f5bc0e | |||
| b15f5c95b6 | |||
| faab7173bd | |||
| 78b9bbf129 | |||
| a5a1db9968 | |||
| e81cd5ffa4 | |||
| 7c19676400 | |||
| 24420027a5 | |||
| 005675728d |
282
frontend/package-lock.json
generated
282
frontend/package-lock.json
generated
@@ -1,15 +1,14 @@
|
||||
{
|
||||
"name": "plesna-gerance-frontend",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "plesna-gerance-frontend",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"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"
|
||||
@@ -568,271 +567,6 @@
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.0.tgz",
|
||||
"integrity": "sha512-Jqxcy1XOIqj+lH9sl1GT+il6GR3uQv13vI2mrwubP3uT8Olak2ClDrK2RnxlQKjwv8BRr4b3ug0YR7c6hBX8wg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "1.0.0",
|
||||
"@napi-rs/canvas-darwin-arm64": "1.0.0",
|
||||
"@napi-rs/canvas-darwin-x64": "1.0.0",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.0",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "1.0.0",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "1.0.0",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.0",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "1.0.0",
|
||||
"@napi-rs/canvas-linux-x64-musl": "1.0.0",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "1.0.0",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.0.tgz",
|
||||
"integrity": "sha512-3hNKJObUK7JsCF9aJlVCs1J0/KE/gGfZNeK8MO1ge6bB3aicr5walGme9t9No1f/oyk9GgvdAT/rjSdsx3gbIw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.0.tgz",
|
||||
"integrity": "sha512-ZIja19/BiGz2puhki+WUYSRriwFeFJ8Mi9eK3hZdSS85w4Y60cuEAJVhMCfKwswQkKkUtrnzdKMBuO7TupvexA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.0.tgz",
|
||||
"integrity": "sha512-hImggWc82jqZVpEsFR9S7PE9OQYjq/H/D7vwCGB6X1jRH+UVBP1+1niJTPBOat1B154T6GKK7/kcFtoWgjgFzQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.0.tgz",
|
||||
"integrity": "sha512-hlJRy6d+kWLKVOG/+1rEvNQVURZ0DxxRPJsLmEWwhwiXZUJc0BF5o9esALHSEP4CoJK4wChRtj3hnyBgVx2oWA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.0.tgz",
|
||||
"integrity": "sha512-5Hru4T3RXkosRQafcjelv7AUzw9mXqmGYsxnzeDDOWveFCJyEPMSJltvGCM+jfH98seOCbfwm9KyFg6Jm5FhAA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.0.tgz",
|
||||
"integrity": "sha512-LTUl9jS8WsLSUGaxQZKQkxfluOJRpgvBuxxdM4pYcjib+di8AU4OzQc6+L6SzGMLcKc9H0RAjojRatBhTMqYdg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.0.tgz",
|
||||
"integrity": "sha512-Iz931SAZf+WVDzpjk52Q3ffW3zw0YflFwEZMgs036Wfu1kX/LrwT9wGjsuSqyduqefUkl91/vTdAjn8hQu5ezA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.0.tgz",
|
||||
"integrity": "sha512-pFEQ5eFK4JusgN1K6KkO9DKP/Hi1WMJOkF8Ch03/khTc4bFbCKkCCsJG4YcOMOW9bI4XbT2/eMAWxhO0xaWgPA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.0.tgz",
|
||||
"integrity": "sha512-jnvr8NrLHiZ3NCiOKWqDbkI4Ah+QDrqtZ+sddPZBltEb1mQ2coSvCSJYfict+oAwcm0c970oTmVySpjKP/lnaA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.0.tgz",
|
||||
"integrity": "sha512-y2j9/Gfd5joqiqxdP/L1smqjQ+uAx3C4N0EC7bDHrnZEEH8ToM/OC5p3uHvtj4Lq591aHj+ArL01UDLNwT5HgQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.0.tgz",
|
||||
"integrity": "sha512-qwdhh9N6Gge/hC4pL9S1tQp0iKwhSl/dYjg7+RGp9k26iRGRi5MqqUyKGOXIWli0zOcuy5Y2wIH/jk2ry6i/jA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -2187,18 +1921,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "6.0.227",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.0.227.tgz",
|
||||
"integrity": "sha512-/P6M4SXw+70waMVLUM7rdRtvo+dEzqE1t6W/zQNvBETo2MaRa5rrvCcAYdfWGiUzadTgM0lJmRApUrW0d9zgKg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=22.13.0 || >=24"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "plesna-gerance-frontend",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -11,7 +11,6 @@
|
||||
},
|
||||
"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"
|
||||
|
||||
@@ -249,7 +249,7 @@ const copyLabel = ref('Copier')
|
||||
const expandedSections = reactive({
|
||||
metadata: true,
|
||||
locataires: true,
|
||||
operations: false
|
||||
operations: true
|
||||
})
|
||||
|
||||
// Highlight state
|
||||
|
||||
@@ -217,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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-else
|
||||
class="flex-1 flex items-center justify-center text-gray-500 text-sm"
|
||||
>
|
||||
Aucun PDF sélectionné
|
||||
<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"
|
||||
/>
|
||||
|
||||
<!-- 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
|
||||
}
|
||||
|
||||
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)
|
||||
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
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[PdfPreview] render error:', e?.message, e)
|
||||
if (token === renderToken) {
|
||||
error.value = "Impossible d'afficher le PDF."
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
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>
|
||||
@@ -24,6 +24,17 @@
|
||||
|
||||
<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
|
||||
@@ -68,17 +79,35 @@
|
||||
</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. -->
|
||||
<div v-if="loyer.hors_courbe.length" class="card-body pt-0">
|
||||
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 · {{ loyer.hors_courbe.length }}
|
||||
Hors de la courbe<template v-if="loyer.hors_courbe.length"> · {{ loyer.hors_courbe.length }}</template>
|
||||
</h3>
|
||||
<p class="form-hint mb-3">
|
||||
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.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 class="table">
|
||||
<table v-if="loyer.hors_courbe.length" class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Période</th>
|
||||
@@ -123,6 +152,40 @@ function formatM2(valeur) {
|
||||
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
|
||||
|
||||
@@ -137,14 +200,12 @@ const tuiles = computed(() => {
|
||||
|
||||
return [
|
||||
{
|
||||
libelle: vigueur.toujours_loue ? 'Loyer en vigueur' : 'Dernier loyer connu',
|
||||
libelle: etatDuBail.value.libelle,
|
||||
valeur: formatMontant(vigueur.loyer),
|
||||
classe: vigueur.toujours_loue ? 'text-green-400' : 'text-gray-400',
|
||||
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: vigueur.toujours_loue
|
||||
? `depuis ${formatMoisAnnee(vigueur.depuis)}`
|
||||
: `arrêté après ${formatMoisAnnee(vigueur.mois)}`
|
||||
detail: etatDuBail.value.detail
|
||||
},
|
||||
{
|
||||
libelle: 'Au m²',
|
||||
|
||||
@@ -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,8 +84,12 @@ 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)
|
||||
debounceTimer = setTimeout(() => {
|
||||
@@ -90,17 +97,18 @@ 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()
|
||||
|
||||
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)
|
||||
|
||||
const query = ecrireFiltres(currentFilters.value, SCHEMA_DEPENSES)
|
||||
|
||||
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>
|
||||
|
||||
<select v-model="lotChoisi" class="input w-64">
|
||||
<option v-for="lot in lots" :key="lot.id" :value="lot.id">
|
||||
{{ lot.numero }} — {{ lot.immeuble_denomination || lot.immeuble_code }}
|
||||
<template v-if="lot.type_effectif"> · {{ lot.type_effectif }}</template>
|
||||
</option>
|
||||
</select>
|
||||
<div 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">
|
||||
@@ -244,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²' },
|
||||
@@ -357,39 +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,
|
||||
(id) => {
|
||||
[() => 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,8 +94,12 @@ 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)
|
||||
debounceTimer = setTimeout(() => {
|
||||
@@ -106,23 +109,25 @@ function onFiltersChange(newFilters) {
|
||||
|
||||
function buildSummaryParams() {
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (currentFilters.months) params.append('months', currentFilters.months)
|
||||
if (currentFilters.immeuble_id !== null) params.append('immeuble_id', currentFilters.immeuble_id)
|
||||
|
||||
const filtres = currentFilters.value
|
||||
|
||||
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()
|
||||
|
||||
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')
|
||||
const filtres = currentFilters.value
|
||||
|
||||
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([
|
||||
|
||||
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 }),
|
||||
}
|
||||
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
|
||||
}
|
||||
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({})
|
||||
})
|
||||
})
|
||||
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')
|
||||
})
|
||||
})
|
||||
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.1"
|
||||
#define AppVersion "0.1.2"
|
||||
#define AppPublisher "Plesna"
|
||||
#define AppExeName "PlesnaGerance.exe"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "plesna-gerance"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
description = "Extracteur de comptes rendus de gérance Oralia/ICS"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
|
||||
@@ -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.1"
|
||||
__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)
|
||||
|
||||
@@ -24,12 +24,33 @@ 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
|
||||
|
||||
@@ -45,6 +66,7 @@ from ...database.models import (
|
||||
)
|
||||
from ...services.loyers import (
|
||||
EFFECTIF_MEDIANE_FIABLE,
|
||||
MoisLoue,
|
||||
loyer_au_m2,
|
||||
mediane,
|
||||
mois_de,
|
||||
@@ -64,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."""
|
||||
|
||||
@@ -82,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):
|
||||
@@ -200,7 +247,19 @@ class LoyerEnVigueur(BaseModel):
|
||||
#: 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):
|
||||
@@ -252,7 +311,13 @@ class ComparaisonParc(BaseModel):
|
||||
|
||||
|
||||
class LotLoyer(BaseModel):
|
||||
"""Le loyer du lot dans le temps, et ce qu'il vaut au mètre carré."""
|
||||
"""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] = []
|
||||
@@ -260,6 +325,13 @@ class LotLoyer(BaseModel):
|
||||
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."""
|
||||
@@ -269,16 +341,118 @@ class LotAnalyseResponse(BaseModel):
|
||||
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,
|
||||
@@ -292,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(
|
||||
@@ -321,20 +501,31 @@ def _chiffres(session: Session, lot: Lot) -> LotChiffres:
|
||||
).scalar()
|
||||
|
||||
depenses = session.execute(
|
||||
select(
|
||||
func.coalesce(func.sum(Depense.debit), 0.0),
|
||||
func.coalesce(func.sum(Depense.credit), 0.0),
|
||||
func.coalesce(func.sum(Depense.deductible), 0.0),
|
||||
func.coalesce(func.sum(Depense.locatif), 0.0),
|
||||
func.count(Depense.id),
|
||||
).where(Depense.lot_id == lot.id)
|
||||
_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),
|
||||
)
|
||||
.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(
|
||||
Depense.immeuble_id == lot.immeuble_id, Depense.lot_id.is_(None)
|
||||
_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()
|
||||
|
||||
@@ -413,17 +604,76 @@ def _comparaison(
|
||||
)
|
||||
|
||||
|
||||
def _loyer(session: Session, lot: Lot) -> LotLoyer:
|
||||
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(
|
||||
@@ -435,7 +685,16 @@ def _loyer(session: Session, lot: Lot) -> LotLoyer:
|
||||
reparti=point.reparti,
|
||||
en_transition=point.en_transition,
|
||||
)
|
||||
for point in serie.mois
|
||||
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 = [
|
||||
@@ -444,7 +703,7 @@ def _loyer(session: Session, lot: Lot) -> LotLoyer:
|
||||
periode_fin=ligne.periode_fin,
|
||||
montant=round(ligne.loyers, 2),
|
||||
)
|
||||
for ligne in serie.ecartees
|
||||
for ligne in retenues
|
||||
]
|
||||
|
||||
niveaux = paliers(serie.mois)
|
||||
@@ -463,6 +722,8 @@ def _loyer(session: Session, lot: Lot) -> LotLoyer:
|
||||
)
|
||||
).scalar()
|
||||
|
||||
reloue_depuis, sortie_en = _apres_le_palier(serie.mois, courant.mois_fin)
|
||||
|
||||
en_vigueur = LoyerEnVigueur(
|
||||
mois=courant.mois_fin,
|
||||
loyer=courant.loyer,
|
||||
@@ -471,6 +732,8 @@ def _loyer(session: Session, lot: Lot) -> LotLoyer:
|
||||
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)
|
||||
|
||||
@@ -480,6 +743,9 @@ def _loyer(session: Session, lot: Lot) -> LotLoyer:
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
@@ -547,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
|
||||
@@ -566,14 +853,17 @@ def _intervenants(session: Session, lot: Lot) -> list[Intervenant]:
|
||||
)
|
||||
|
||||
rows = session.execute(
|
||||
select(
|
||||
Depense.fournisseur,
|
||||
func.count(Depense.id),
|
||||
montant_net,
|
||||
func.max(Document.date),
|
||||
_borner(
|
||||
select(
|
||||
Depense.fournisseur,
|
||||
func.count(Depense.id),
|
||||
montant_net,
|
||||
func.max(Document.date),
|
||||
)
|
||||
.join(Document, Depense.document_id == Document.id)
|
||||
.where(Depense.lot_id == lot.id, Depense.fournisseur.is_not(None)),
|
||||
periode,
|
||||
)
|
||||
.join(Document, Depense.document_id == Document.id)
|
||||
.where(Depense.lot_id == lot.id, Depense.fournisseur.is_not(None))
|
||||
.group_by(Depense.fournisseur)
|
||||
.order_by(montant_net.desc())
|
||||
).all()
|
||||
@@ -592,23 +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),
|
||||
loyer=_loyer(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
|
||||
|
||||
@@ -33,7 +33,10 @@ 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.
|
||||
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
|
||||
@@ -91,6 +94,32 @@ def mois_entiers(debut: date | None, fin: date | None) -> list[str]:
|
||||
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."""
|
||||
@@ -109,6 +138,12 @@ class MoisLoue:
|
||||
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:
|
||||
@@ -161,6 +196,8 @@ def repartir(lignes) -> Serie:
|
||||
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(
|
||||
|
||||
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, LotCaracteristiques
|
||||
from plesna_gerance.database.models import (
|
||||
Document,
|
||||
Immeuble,
|
||||
Locataire,
|
||||
Lot,
|
||||
LotCaracteristiques,
|
||||
)
|
||||
from plesna_gerance.database.service import DatabaseService
|
||||
|
||||
|
||||
@@ -262,6 +268,233 @@ def test_la_surface_saisie_allume_le_loyer_au_m2(api_client, db_session, donnees
|
||||
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.
|
||||
@@ -384,3 +617,151 @@ def test_la_comparaison_compte_les_lots_qu_elle_ne_peut_pas_voir(
|
||||
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
|
||||
|
||||
@@ -139,6 +139,57 @@ class TestRepartition:
|
||||
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."""
|
||||
|
||||
Reference in New Issue
Block a user