Compare commits
7 Commits
v0.1.0
...
2ee8e101b8
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ee8e101b8 | |||
| 288b67c26f | |||
| b1ff0812c1 | |||
| fa54d523a5 | |||
| 4fe2bf75c0 | |||
| c42e190aa7 | |||
| 62d6eb169c |
@@ -55,9 +55,11 @@ pointe de `main`.
|
|||||||
### Ce qu'est une version
|
### Ce qu'est une version
|
||||||
|
|
||||||
Un tag git `vMAJEUR.MINEUR.CORRECTIF` (semver), pose sur `main`. Le meme numero
|
Un tag git `vMAJEUR.MINEUR.CORRECTIF` (semver), pose sur `main`. Le meme numero
|
||||||
est ecrit dans quatre fichiers, que l'outillage tient d'accord entre eux :
|
est ecrit dans cinq fichiers, que l'outillage tient d'accord entre eux :
|
||||||
`pyproject.toml`, `src/plesna_gerance/__init__.py`, `frontend/package.json` et
|
`pyproject.toml`, `src/plesna_gerance/__init__.py`, `frontend/package.json`,
|
||||||
`packaging/installer.iss` (version affichee par l'installeur Windows).
|
`frontend/package-lock.json` (qui porte lui aussi la version du paquet racine,
|
||||||
|
sans quoi `npm ci` peut refuser de tourner en CI) et `packaging/installer.iss`
|
||||||
|
(version affichee par l'installeur Windows).
|
||||||
|
|
||||||
Quand incrementer quoi :
|
Quand incrementer quoi :
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,11 @@
|
|||||||
FROM node:22-alpine AS frontend
|
FROM node:22-alpine AS frontend
|
||||||
|
|
||||||
WORKDIR /frontend
|
WORKDIR /frontend
|
||||||
COPY frontend/package.json frontend/package-lock.json* ./
|
# `npm ci` (et non `npm install`) : l'image doit installer exactement l'arbre
|
||||||
RUN npm install
|
# du lock, sinon deux builds du même tag peuvent embarquer des dépendances
|
||||||
|
# différentes. Le lock n'est donc plus optionnel.
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
1139
frontend/package-lock.json
generated
1139
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "plesna-gerance-frontend",
|
"name": "plesna-gerance-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -17,11 +17,11 @@
|
|||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.0.4",
|
"@vitejs/plugin-vue": "^6.0.8",
|
||||||
"autoprefixer": "^10.4.18",
|
"autoprefixer": "^10.4.18",
|
||||||
"postcss": "^8.4.35",
|
"postcss": "^8.4.35",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"vite": "^5.1.6",
|
"vite": "^7.3.6",
|
||||||
"vitest": "^4.1.10"
|
"vitest": "^4.1.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
196
frontend/src/components/lot/LoyerChart.vue
Normal file
196
frontend/src/components/lot/LoyerChart.vue
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-4 flex-wrap gap-2">
|
||||||
|
<h3 class="text-sm font-medium text-white">Loyer mois par mois</h3>
|
||||||
|
<div class="flex items-center gap-4 text-xs flex-wrap">
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<span class="w-3 h-3 rounded bg-green-400"></span>
|
||||||
|
<span class="text-gray-400">Loyer hors charges</span>
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<span class="w-3 h-3 rounded bg-slate-500"></span>
|
||||||
|
<span class="text-gray-400">Charges</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="aDesProratas" class="flex items-center gap-1">
|
||||||
|
<span class="w-3 h-3 rounded bg-violet-400"></span>
|
||||||
|
<span class="text-gray-400">Prorata</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="aUneSurface" class="flex items-center gap-1">
|
||||||
|
<span class="w-3 h-1.5 rounded bg-amber-400"></span>
|
||||||
|
<span class="text-gray-400">€/m²</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!serie.length" class="h-64 flex items-center justify-center text-gray-500">
|
||||||
|
Aucun loyer facturé sur ce lot.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="h-72">
|
||||||
|
<Bar :data="donneesGraphe" :options="options" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { Bar } from 'vue-chartjs'
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarController,
|
||||||
|
BarElement,
|
||||||
|
LineController,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
Tooltip,
|
||||||
|
Legend
|
||||||
|
} from 'chart.js'
|
||||||
|
import { formatMoisAnnee } from '../../utils/format'
|
||||||
|
|
||||||
|
// `LineController` en plus des éléments : la courbe du €/m² partage le graphe
|
||||||
|
// des barres, et Chart.js refuse un type de dataset dont le contrôleur n'a pas
|
||||||
|
// été enregistré — les autres graphes de l'application n'en mélangent aucun.
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarController,
|
||||||
|
BarElement,
|
||||||
|
LineController,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
Tooltip,
|
||||||
|
Legend
|
||||||
|
)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
serie: { type: Array, required: true, default: () => [] },
|
||||||
|
surface: { type: Number, default: null }
|
||||||
|
})
|
||||||
|
|
||||||
|
const aUneSurface = computed(() => props.surface != null && props.surface > 0)
|
||||||
|
const aDesProratas = computed(() => props.serie.some((point) => point.prorata != null))
|
||||||
|
|
||||||
|
const EUROS = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' })
|
||||||
|
|
||||||
|
// Les mois vides restent `null` et non 0 : Chart.js laisse alors un blanc, qui
|
||||||
|
// est la lecture juste d'une vacance. Un zéro dessinerait une barre au sol,
|
||||||
|
// c'est-à-dire un loyer nul — ce qu'aucun compte rendu ne dit.
|
||||||
|
const donneesGraphe = computed(() => {
|
||||||
|
const jeux = [
|
||||||
|
{
|
||||||
|
label: 'Loyer',
|
||||||
|
data: props.serie.map((point) => point.loyer),
|
||||||
|
backgroundColor: 'rgba(74, 222, 128, 0.8)',
|
||||||
|
borderRadius: 3,
|
||||||
|
stack: 'facture',
|
||||||
|
order: 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Charges',
|
||||||
|
data: props.serie.map((point) => point.charges),
|
||||||
|
backgroundColor: 'rgba(100, 116, 139, 0.7)',
|
||||||
|
borderRadius: 3,
|
||||||
|
stack: 'facture',
|
||||||
|
order: 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
if (aDesProratas.value) {
|
||||||
|
jeux.push({
|
||||||
|
label: 'Prorata',
|
||||||
|
data: props.serie.map((point) => point.prorata),
|
||||||
|
backgroundColor: 'rgba(167, 139, 250, 0.85)',
|
||||||
|
borderRadius: 3,
|
||||||
|
stack: 'facture',
|
||||||
|
order: 3
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le loyer au m² se lit sur son propre axe : mis à la même échelle que des
|
||||||
|
// centaines d'euros, sa courbe serait collée au zéro.
|
||||||
|
if (aUneSurface.value) {
|
||||||
|
jeux.push({
|
||||||
|
type: 'line',
|
||||||
|
label: '€/m²',
|
||||||
|
data: props.serie.map((point) => point.loyer_m2),
|
||||||
|
yAxisID: 'y1',
|
||||||
|
borderColor: 'rgb(251, 191, 36)',
|
||||||
|
backgroundColor: 'rgb(251, 191, 36)',
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 2,
|
||||||
|
tension: 0,
|
||||||
|
spanGaps: false,
|
||||||
|
order: 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { labels: props.serie.map((point) => formatMoisAnnee(point.mois)), datasets: jeux }
|
||||||
|
})
|
||||||
|
|
||||||
|
const options = computed(() => ({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'rgb(31, 41, 55)',
|
||||||
|
borderColor: 'rgb(75, 85, 99)',
|
||||||
|
borderWidth: 1,
|
||||||
|
titleColor: 'rgb(255, 255, 255)',
|
||||||
|
bodyColor: 'rgb(156, 163, 175)',
|
||||||
|
padding: 12,
|
||||||
|
callbacks: {
|
||||||
|
label: (contexte) => {
|
||||||
|
if (contexte.raw == null) return null
|
||||||
|
const valeur =
|
||||||
|
contexte.dataset.label === '€/m²'
|
||||||
|
? `${contexte.raw.toFixed(2).replace('.', ',')} €/m²`
|
||||||
|
: EUROS.format(contexte.raw)
|
||||||
|
return `${contexte.dataset.label} : ${valeur}`
|
||||||
|
},
|
||||||
|
// Un mois sans loyer plein se lit différemment selon qu'il est vacant
|
||||||
|
// ou en changement de locataire : le dire dans l'infobulle évite de
|
||||||
|
// laisser le lecteur deviner devant un trou.
|
||||||
|
afterBody: (contextes) => {
|
||||||
|
const point = props.serie[contextes[0].dataIndex]
|
||||||
|
if (!point) return null
|
||||||
|
const notes = []
|
||||||
|
if (point.en_transition) notes.push('Changement de locataire')
|
||||||
|
else if (point.loyer == null) notes.push('Aucun loyer facturé')
|
||||||
|
if (point.reparti) notes.push('Réparti depuis une facturation pluri-mensuelle')
|
||||||
|
return notes.length ? notes : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
stacked: true,
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: { color: 'rgb(156, 163, 175)', font: { size: 10 }, maxRotation: 0 }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
stacked: true,
|
||||||
|
grid: { color: 'rgb(55, 65, 81)' },
|
||||||
|
ticks: {
|
||||||
|
color: 'rgb(156, 163, 175)',
|
||||||
|
font: { size: 11 },
|
||||||
|
callback: (valeur) => (valeur >= 1000 ? `${(valeur / 1000).toFixed(1)}k` : valeur)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y1: {
|
||||||
|
display: aUneSurface.value,
|
||||||
|
position: 'right',
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: {
|
||||||
|
color: 'rgb(251, 191, 36)',
|
||||||
|
font: { size: 11 },
|
||||||
|
callback: (valeur) => `${valeur} €`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
252
frontend/src/components/lot/LoyerLot.vue
Normal file
252
frontend/src/components/lot/LoyerLot.vue
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">Loyer</h2>
|
||||||
|
<router-link
|
||||||
|
v-if="!aUneSurface"
|
||||||
|
to="/logements"
|
||||||
|
class="btn btn-secondary btn-sm"
|
||||||
|
>
|
||||||
|
Saisir la surface
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Le loyer en vigueur, son niveau au m², sa dernière révision. Chaque
|
||||||
|
case garde sa place même vide : un tableau qui perd une colonne selon
|
||||||
|
le lot empêche de comparer deux fiches. -->
|
||||||
|
<div class="card-body grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div v-for="tuile in tuiles" :key="tuile.libelle">
|
||||||
|
<div class="text-xs text-gray-400 uppercase tracking-wide">{{ tuile.libelle }}</div>
|
||||||
|
<div class="text-2xl font-bold" :class="tuile.classe">{{ tuile.valeur }}</div>
|
||||||
|
<div v-if="tuile.detail" class="text-xs text-gray-500 mt-1">{{ tuile.detail }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body pt-0">
|
||||||
|
<LoyerChart :serie="loyer.serie" :surface="loyer.surface" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comparaison au parc : les médianes excluent le lot lui-même, et
|
||||||
|
l'effectif accompagne toujours le chiffre. -->
|
||||||
|
<div v-if="loyer.parc" class="card-body pt-0">
|
||||||
|
<h3 class="text-sm font-medium text-white mb-3">
|
||||||
|
Au mètre carré, face au parc
|
||||||
|
<span class="text-gray-500 font-normal">· {{ formatMoisAnnee(loyer.parc.mois) }}</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div v-if="!aUneSurface" class="form-hint">
|
||||||
|
Sans surface saisie sur ce lot, il n'y a rien à comparer. La fiche se complète
|
||||||
|
depuis
|
||||||
|
<router-link
|
||||||
|
to="/logements"
|
||||||
|
class="text-blue-400 hover:text-blue-300 transition-colors underline"
|
||||||
|
>Logements</router-link>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||||
|
<div v-for="repere in reperes" :key="repere.libelle" class="bg-gray-950 rounded p-3">
|
||||||
|
<div class="text-xs text-gray-400 uppercase tracking-wide">{{ repere.libelle }}</div>
|
||||||
|
<div class="text-lg font-semibold" :class="repere.classe">{{ repere.valeur }}</div>
|
||||||
|
<div class="text-xs text-gray-500 mt-1">{{ repere.detail }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="loyer.parc.sans_surface" class="form-hint mt-3">
|
||||||
|
{{ loyer.parc.sans_surface }}
|
||||||
|
{{ loyer.parc.sans_surface > 1 ? 'lots loués ce mois-là n\'ont' : 'lot loué ce mois-là n\'a' }}
|
||||||
|
pas de surface sur sa fiche : {{ loyer.parc.sans_surface > 1 ? 'ils restent' : 'il reste' }}
|
||||||
|
hors de ces médianes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Les médianes ci-dessus mêlent toutes les surfaces ; le nuage rend
|
||||||
|
la comparaison à taille comparable, que le chiffre seul écrase. -->
|
||||||
|
<div class="mt-6">
|
||||||
|
<ParcScatter :nuage="loyer.parc.nuage" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ce que la courbe ne peut pas porter reste visible : sans cette liste,
|
||||||
|
des montants disparaîtraient de la page sans que rien ne le signale. -->
|
||||||
|
<div v-if="loyer.hors_courbe.length" class="card-body pt-0">
|
||||||
|
<h3 class="text-sm font-medium text-white mb-2">
|
||||||
|
Hors de la courbe · {{ loyer.hors_courbe.length }}
|
||||||
|
</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.
|
||||||
|
</p>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Période</th>
|
||||||
|
<th class="text-right">Montant</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(ligne, index) in loyer.hors_courbe" :key="index">
|
||||||
|
<td class="text-gray-300">
|
||||||
|
{{ formatDate(ligne.periode_debut) }} → {{ formatDate(ligne.periode_fin) }}
|
||||||
|
</td>
|
||||||
|
<td class="text-right" :class="ligne.montant < 0 ? 'text-red-400' : 'text-gray-300'">
|
||||||
|
{{ formatMontantPrecis(ligne.montant) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import LoyerChart from './LoyerChart.vue'
|
||||||
|
import ParcScatter from './ParcScatter.vue'
|
||||||
|
import {
|
||||||
|
formatDate,
|
||||||
|
formatMoisAnnee,
|
||||||
|
formatMontant,
|
||||||
|
formatMontantPrecis
|
||||||
|
} from '../../utils/format'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
loyer: { type: Object, required: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
const aUneSurface = computed(() => props.loyer.surface != null && props.loyer.surface > 0)
|
||||||
|
|
||||||
|
/** "12,14 €/m²", ou le tiret des valeurs qu'on n'a pas. */
|
||||||
|
function formatM2(valeur) {
|
||||||
|
if (valeur == null) return '—'
|
||||||
|
return `${valeur.toFixed(2).replace('.', ',')} €/m²`
|
||||||
|
}
|
||||||
|
|
||||||
|
const tuiles = computed(() => {
|
||||||
|
const vigueur = props.loyer.en_vigueur
|
||||||
|
|
||||||
|
if (!vigueur) {
|
||||||
|
return [
|
||||||
|
{ libelle: 'Loyer', valeur: '—', classe: 'text-gray-500', detail: 'aucun loyer facturé' },
|
||||||
|
{ libelle: 'Au m²', valeur: '—', classe: 'text-gray-500', detail: surfaceDetail() },
|
||||||
|
{ libelle: 'Dernière révision', valeur: '—', classe: 'text-gray-500' },
|
||||||
|
{ libelle: 'Surface', valeur: surfaceValeur(), classe: 'text-gray-300' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
libelle: vigueur.toujours_loue ? 'Loyer en vigueur' : 'Dernier loyer connu',
|
||||||
|
valeur: formatMontant(vigueur.loyer),
|
||||||
|
classe: vigueur.toujours_loue ? 'text-green-400' : 'text-gray-400',
|
||||||
|
// 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)}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
libelle: 'Au m²',
|
||||||
|
valeur: formatM2(vigueur.loyer_m2),
|
||||||
|
classe: vigueur.loyer_m2 == null ? 'text-gray-500' : 'text-amber-400',
|
||||||
|
detail: surfaceDetail()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
libelle: 'Dernière révision',
|
||||||
|
valeur: vigueur.variation_pct == null ? '—' : formatPourcent(vigueur.variation_pct),
|
||||||
|
classe: classeVariation(vigueur.variation_pct),
|
||||||
|
detail:
|
||||||
|
vigueur.precedent == null
|
||||||
|
? 'aucune depuis le premier compte rendu'
|
||||||
|
: `${formatMontant(vigueur.precedent)} → ${formatMontant(vigueur.loyer)} en ${formatMoisAnnee(vigueur.depuis)}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
libelle: 'Surface',
|
||||||
|
valeur: surfaceValeur(),
|
||||||
|
classe: aUneSurface.value ? 'text-gray-300' : 'text-gray-600',
|
||||||
|
detail: aUneSurface.value ? 'fiche du logement' : 'à saisir dans Logements'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
function surfaceValeur() {
|
||||||
|
return aUneSurface.value ? `${props.loyer.surface} m²` : '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
function surfaceDetail() {
|
||||||
|
return aUneSurface.value ? 'hors charges' : 'surface manquante'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPourcent(valeur) {
|
||||||
|
const signe = valeur > 0 ? '+' : ''
|
||||||
|
return `${signe}${valeur.toFixed(2).replace('.', ',')} %`
|
||||||
|
}
|
||||||
|
|
||||||
|
function classeVariation(valeur) {
|
||||||
|
if (valeur == null) return 'text-gray-500'
|
||||||
|
if (valeur > 0) return 'text-green-400'
|
||||||
|
if (valeur < 0) return 'text-red-400'
|
||||||
|
return 'text-gray-300'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Écart du lot à une médiane, en pourcentage.
|
||||||
|
*
|
||||||
|
* L'écart dit ce que la médiane seule ne dit pas : savoir que le parc est à
|
||||||
|
* 13,52 €/m² n'apprend rien tant qu'on n'a pas fait la soustraction.
|
||||||
|
*/
|
||||||
|
function ecartA(mediane) {
|
||||||
|
const valeur = props.loyer.parc?.loyer_m2
|
||||||
|
if (valeur == null || !mediane) return null
|
||||||
|
return Math.round(((valeur - mediane) / mediane) * 1000) / 10
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Effectif trop faible pour qu'une médiane décrive autre chose que le hasard. */
|
||||||
|
function effectifFragile(nombre) {
|
||||||
|
return nombre > 0 && nombre < (props.loyer.parc?.effectif_faible ?? 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailMediane(nombre) {
|
||||||
|
if (!nombre) return 'aucun lot comparable'
|
||||||
|
const lots = `${nombre} lot${nombre > 1 ? 's' : ''}`
|
||||||
|
return effectifFragile(nombre) ? `${lots} — trop peu pour conclure` : lots
|
||||||
|
}
|
||||||
|
|
||||||
|
const reperes = computed(() => {
|
||||||
|
const parc = props.loyer.parc
|
||||||
|
const ecartImmeuble = ecartA(parc.mediane_immeuble)
|
||||||
|
const ecartType = ecartA(parc.mediane_type)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
libelle: 'Ce lot',
|
||||||
|
valeur: formatM2(parc.loyer_m2),
|
||||||
|
classe: 'text-amber-400',
|
||||||
|
detail: 'loyer hors charges au m²'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
libelle: 'Médiane de l\'immeuble',
|
||||||
|
valeur: formatM2(parc.mediane_immeuble),
|
||||||
|
classe: effectifFragile(parc.nb_immeuble) ? 'text-gray-500' : 'text-gray-300',
|
||||||
|
detail:
|
||||||
|
ecartImmeuble == null
|
||||||
|
? detailMediane(parc.nb_immeuble)
|
||||||
|
: `${formatPourcent(ecartImmeuble)} · ${detailMediane(parc.nb_immeuble)}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Le type vient de la fiche ou du PDF et n'a pas de forme grammaticale
|
||||||
|
// fixe (« Studio », « Appartement T2 », « Loc. Commercial ») : le poser
|
||||||
|
// après un point médian évite d'accorder un article sur une valeur libre.
|
||||||
|
libelle: parc.type_compare ? `Médiane · ${parc.type_compare}` : 'Médiane du même type',
|
||||||
|
valeur: formatM2(parc.mediane_type),
|
||||||
|
classe: effectifFragile(parc.nb_type) ? 'text-gray-500' : 'text-gray-300',
|
||||||
|
detail: parc.type_compare
|
||||||
|
? ecartType == null
|
||||||
|
? detailMediane(parc.nb_type)
|
||||||
|
: `${formatPourcent(ecartType)} · ${detailMediane(parc.nb_type)}`
|
||||||
|
: 'type du lot non renseigné'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
167
frontend/src/components/lot/ParcScatter.vue
Normal file
167
frontend/src/components/lot/ParcScatter.vue
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||||||
|
<h3 class="text-sm font-medium text-white">
|
||||||
|
Le parc par surface
|
||||||
|
<span class="text-gray-500 font-normal">· {{ nuage.length }} lots comparés</span>
|
||||||
|
</h3>
|
||||||
|
<div class="flex items-center gap-4 text-xs flex-wrap">
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<span class="w-3 h-3 rounded-full bg-amber-400"></span>
|
||||||
|
<span class="text-gray-400">Ce lot</span>
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<span class="w-3 h-3 rounded-full bg-slate-400"></span>
|
||||||
|
<span class="text-gray-400">Autres lots</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="aDuCommercial" class="flex items-center gap-1">
|
||||||
|
<span class="text-slate-400 leading-none">▲</span>
|
||||||
|
<span class="text-gray-400">Local commercial</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Le loyer au m² décroît avec la surface : sans cette phrase, un lecteur
|
||||||
|
pourrait lire une pente naturelle du marché comme une anomalie de
|
||||||
|
gestion. -->
|
||||||
|
<p class="form-hint mb-3">
|
||||||
|
Un petit logement se loue plus cher au mètre carré qu'un grand. Ce qui se lit ici
|
||||||
|
n'est donc pas la hauteur du point seule, mais sa position par rapport aux lots
|
||||||
|
de surface voisine.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="nuage.length < 2" class="h-64 flex items-center justify-center text-gray-500 text-sm">
|
||||||
|
Pas encore assez de surfaces saisies pour situer ce lot dans le parc.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="h-72">
|
||||||
|
<Scatter :data="donneesGraphe" :options="options" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Scatter } from 'vue-chartjs'
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
ScatterController,
|
||||||
|
Tooltip
|
||||||
|
} from 'chart.js'
|
||||||
|
|
||||||
|
ChartJS.register(LinearScale, PointElement, ScatterController, Tooltip)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
nuage: { type: Array, required: true, default: () => [] }
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un local commercial ne se loue pas selon la même logique qu'un logement.
|
||||||
|
* Le garder dans le nuage — le parc en compte — mais le marquer, pour qu'il ne
|
||||||
|
* soit pas lu comme un comparable direct.
|
||||||
|
*/
|
||||||
|
function estCommercial(point) {
|
||||||
|
return (point.type_lot ?? '').toLowerCase().includes('commercial')
|
||||||
|
}
|
||||||
|
|
||||||
|
const aDuCommercial = computed(() => props.nuage.some(estCommercial))
|
||||||
|
|
||||||
|
const autres = computed(() => props.nuage.filter((point) => !point.est_ce_lot))
|
||||||
|
const courant = computed(() => props.nuage.filter((point) => point.est_ce_lot))
|
||||||
|
|
||||||
|
/** Chart.js n'accepte que x/y ; le reste voyage pour l'infobulle et le clic. */
|
||||||
|
function versPoints(points) {
|
||||||
|
return points.map((point) => ({ x: point.surface, y: point.loyer_m2, lot: point }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const donneesGraphe = computed(() => ({
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Autres lots',
|
||||||
|
data: versPoints(autres.value),
|
||||||
|
backgroundColor: 'rgba(148, 163, 184, 0.75)',
|
||||||
|
pointRadius: 6,
|
||||||
|
pointHoverRadius: 8,
|
||||||
|
pointStyle: autres.value.map((point) => (estCommercial(point) ? 'triangle' : 'circle'))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Ce lot',
|
||||||
|
data: versPoints(courant.value),
|
||||||
|
backgroundColor: 'rgb(251, 191, 36)',
|
||||||
|
borderColor: 'rgb(253, 230, 138)',
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 9,
|
||||||
|
pointHoverRadius: 11,
|
||||||
|
pointStyle: courant.value.map((point) => (estCommercial(point) ? 'triangle' : 'circle'))
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
|
||||||
|
const options = computed(() => ({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
// Un lot voisin repéré dans le nuage doit pouvoir s'ouvrir : sans cela, il
|
||||||
|
// faudrait relever son numéro puis le chercher dans le sélecteur.
|
||||||
|
onClick: (evenement, elements) => {
|
||||||
|
const touche = elements[0]
|
||||||
|
if (!touche) return
|
||||||
|
const point = donneesGraphe.value.datasets[touche.datasetIndex].data[touche.index]
|
||||||
|
if (point?.lot && !point.lot.est_ce_lot) router.push(`/lots/${point.lot.lot_id}`)
|
||||||
|
},
|
||||||
|
onHover: (evenement, elements) => {
|
||||||
|
evenement.native.target.style.cursor = elements.length ? 'pointer' : 'default'
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'rgb(31, 41, 55)',
|
||||||
|
borderColor: 'rgb(75, 85, 99)',
|
||||||
|
borderWidth: 1,
|
||||||
|
titleColor: 'rgb(255, 255, 255)',
|
||||||
|
bodyColor: 'rgb(156, 163, 175)',
|
||||||
|
padding: 12,
|
||||||
|
callbacks: {
|
||||||
|
title: (contextes) => {
|
||||||
|
const lot = contextes[0].raw.lot
|
||||||
|
return `Lot ${lot.numero}${lot.immeuble_code ? ` · ${lot.immeuble_code}` : ''}`
|
||||||
|
},
|
||||||
|
label: (contexte) => {
|
||||||
|
const lot = contexte.raw.lot
|
||||||
|
const lignes = [
|
||||||
|
`${lot.surface} m² · ${lot.loyer_m2.toFixed(2).replace('.', ',')} €/m²`
|
||||||
|
]
|
||||||
|
if (lot.type_lot) lignes.push(lot.type_lot)
|
||||||
|
if (!lot.est_ce_lot) lignes.push('Cliquer pour ouvrir sa fiche')
|
||||||
|
return lignes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
type: 'linear',
|
||||||
|
title: { display: true, text: 'Surface (m²)', color: 'rgb(156, 163, 175)' },
|
||||||
|
// L'axe part de zéro : tronquer l'origine étirerait les écarts de
|
||||||
|
// surface et ferait paraître deux lots voisins bien plus éloignés.
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: { color: 'rgb(55, 65, 81)' },
|
||||||
|
ticks: { color: 'rgb(156, 163, 175)', font: { size: 11 } }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
title: { display: true, text: '€/m²', color: 'rgb(156, 163, 175)' },
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: { color: 'rgb(55, 65, 81)' },
|
||||||
|
ticks: {
|
||||||
|
color: 'rgb(156, 163, 175)',
|
||||||
|
font: { size: 11 },
|
||||||
|
callback: (valeur) => `${valeur} €`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
@@ -79,6 +79,10 @@
|
|||||||
quelle part revient à quel lot.
|
quelle part revient à quel lot.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Le loyer sur un axe de temps : c'est là que se voient une révision,
|
||||||
|
une vacance ou un décrochage, que les totaux du dessus écrasent. -->
|
||||||
|
<LoyerLot :loyer="analyse.loyer" />
|
||||||
|
|
||||||
<!-- Chronologie -->
|
<!-- Chronologie -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@@ -225,6 +229,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, watch, onMounted } from 'vue'
|
import { ref, reactive, computed, watch, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import LoyerLot from '../components/lot/LoyerLot.vue'
|
||||||
import { formatDate, formatMontant, formatMontantPrecis } from '../utils/format'
|
import { formatDate, formatMontant, formatMontantPrecis } from '../utils/format'
|
||||||
|
|
||||||
const API = import.meta.env.VITE_API_URL || ''
|
const API = import.meta.env.VITE_API_URL || ''
|
||||||
@@ -370,6 +375,18 @@ watch(lotChoisi, (lotId) => {
|
|||||||
chargerAnalyse(lotId)
|
chargerAnalyse(lotId)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
watch(
|
||||||
|
() => route.params.id,
|
||||||
|
(id) => {
|
||||||
|
const demande = Number(id)
|
||||||
|
if (Number.isFinite(demande) && demande !== lotChoisi.value) lotChoisi.value = demande
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
await chargerLots()
|
await chargerLots()
|
||||||
|
|||||||
@@ -54,3 +54,15 @@ export function formatMois(chaine) {
|
|||||||
const [, mois] = chaine.split('-')
|
const [, mois] = chaine.split('-')
|
||||||
return MOIS_COURTS[parseInt(mois, 10) - 1] ?? chaine
|
return MOIS_COURTS[parseInt(mois, 10) - 1] ?? chaine
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "2026-06" → "juin 26", pour les séries qui traversent plusieurs années.
|
||||||
|
*
|
||||||
|
* `formatMois` suffit quand un titre porte déjà l'année ; sur trois ans de
|
||||||
|
* comptes rendus, deux mois de juin se confondraient.
|
||||||
|
*/
|
||||||
|
export function formatMoisAnnee(chaine) {
|
||||||
|
if (!chaine) return ''
|
||||||
|
const [annee, mois] = chaine.split('-')
|
||||||
|
return `${MOIS_COURTS[parseInt(mois, 10) - 1] ?? mois} ${annee.slice(2)}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
; idéal pour un poste personnel. Crée un raccourci Bureau et menu Démarrer.
|
; idéal pour un poste personnel. Crée un raccourci Bureau et menu Démarrer.
|
||||||
|
|
||||||
#define AppName "Plesna Gérance"
|
#define AppName "Plesna Gérance"
|
||||||
#define AppVersion "0.1.0"
|
#define AppVersion "0.1.1"
|
||||||
#define AppPublisher "Plesna"
|
#define AppPublisher "Plesna"
|
||||||
#define AppExeName "PlesnaGerance.exe"
|
#define AppExeName "PlesnaGerance.exe"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "plesna-gerance"
|
name = "plesna-gerance"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
description = "Extracteur de comptes rendus de gérance Oralia/ICS"
|
description = "Extracteur de comptes rendus de gérance Oralia/ICS"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"""Pose une version : aligne les fichiers, commite, tague.
|
"""Pose une version : aligne les fichiers, commite, tague.
|
||||||
|
|
||||||
Le numéro de version est écrit à quatre endroits qui doivent rester d'accord
|
Le numéro de version est écrit dans cinq fichiers qui doivent rester d'accord
|
||||||
(paquet Python, module, frontend, installeur Windows). Les tenir à jour à la
|
(paquet Python, module, frontend, son lock, installeur Windows). Les tenir à
|
||||||
main, c'est publier tôt ou tard un tag `v0.3.0` sur un code qui se déclare
|
jour à la main, c'est publier tôt ou tard un tag `v0.3.0` sur un code qui se
|
||||||
`0.1.0`. Ce script fait la mise à jour d'un bloc et refuse d'avancer au moindre
|
déclare `0.1.0`. Ce script fait la mise à jour d'un bloc et refuse d'avancer au
|
||||||
doute plutôt que de produire une version à moitié cohérente.
|
moindre doute plutôt que de produire une version à moitié cohérente.
|
||||||
|
|
||||||
uv run python scripts/release.py 0.2.0
|
uv run python scripts/release.py 0.2.0
|
||||||
|
|
||||||
@@ -20,25 +20,48 @@ from pathlib import Path
|
|||||||
|
|
||||||
RACINE = Path(__file__).resolve().parent.parent
|
RACINE = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
# Un fichier, le motif qui y porte la version, et le remplacement. Le motif doit
|
# Un fichier, le motif qui y porte la version, le remplacement, et le nombre
|
||||||
# capturer la version dans le groupe 1 pour que l'on puisse relire l'ancienne
|
# d'occurrences attendues. Le motif doit capturer la version dans son dernier
|
||||||
# valeur, et matcher **exactement une fois** (vérifié plus bas).
|
# groupe pour que l'on puisse relire l'ancienne valeur ; le compte est verifie
|
||||||
|
# a chaque passage, pour que le script s'arrete si un fichier change de forme
|
||||||
|
# plutot que de laisser filer une version a moitie posee.
|
||||||
PORTEURS_DE_VERSION = [
|
PORTEURS_DE_VERSION = [
|
||||||
("pyproject.toml", re.compile(r'^version = "([^"]+)"$', re.M), 'version = "{v}"'),
|
(
|
||||||
|
"pyproject.toml",
|
||||||
|
re.compile(r'^version = "([^"]+)"$', re.M),
|
||||||
|
'version = "{v}"',
|
||||||
|
1,
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"src/plesna_gerance/__init__.py",
|
"src/plesna_gerance/__init__.py",
|
||||||
re.compile(r'^__version__ = "([^"]+)"$', re.M),
|
re.compile(r'^__version__ = "([^"]+)"$', re.M),
|
||||||
'__version__ = "{v}"',
|
'__version__ = "{v}"',
|
||||||
|
1,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"frontend/package.json",
|
"frontend/package.json",
|
||||||
re.compile(r'^ "version": "([^"]+)",$', re.M),
|
re.compile(r'^ "version": "([^"]+)",$', re.M),
|
||||||
' "version": "{v}",',
|
' "version": "{v}",',
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
# Le lock porte la version du paquet racine a deux endroits (en-tete et
|
||||||
|
# packages[""]). Les laisser en arriere fait diverger lock et package.json,
|
||||||
|
# ce que `npm ci` peut refuser en CI selon la version de npm. On s'ancre sur
|
||||||
|
# le nom du paquet, qui n'apparait qu'a ces deux endroits.
|
||||||
|
(
|
||||||
|
"frontend/package-lock.json",
|
||||||
|
re.compile(
|
||||||
|
r'(^(\s*)"name": "plesna-gerance-frontend",\n\s*"version": ")([^"]+)',
|
||||||
|
re.M,
|
||||||
|
),
|
||||||
|
r"\g<1>{v}",
|
||||||
|
2,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"packaging/installer.iss",
|
"packaging/installer.iss",
|
||||||
re.compile(r'^#define AppVersion "([^"]+)"$', re.M),
|
re.compile(r'^#define AppVersion "([^"]+)"$', re.M),
|
||||||
'#define AppVersion "{v}"',
|
'#define AppVersion "{v}"',
|
||||||
|
1,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -86,19 +109,23 @@ def verifier_le_depot(version: str, autoriser_hors_main: bool) -> None:
|
|||||||
def appliquer_la_version(version: str) -> list[str]:
|
def appliquer_la_version(version: str) -> list[str]:
|
||||||
"""Écrit la version dans les fichiers porteurs. Renvoie ceux qui ont changé."""
|
"""Écrit la version dans les fichiers porteurs. Renvoie ceux qui ont changé."""
|
||||||
modifies = []
|
modifies = []
|
||||||
for chemin_relatif, motif, remplacement in PORTEURS_DE_VERSION:
|
for chemin_relatif, motif, remplacement, attendues in PORTEURS_DE_VERSION:
|
||||||
chemin = RACINE / chemin_relatif
|
chemin = RACINE / chemin_relatif
|
||||||
contenu = chemin.read_text(encoding="utf-8")
|
contenu = chemin.read_text(encoding="utf-8")
|
||||||
|
|
||||||
occurrences = motif.findall(contenu)
|
# Le dernier groupe capture la version elle-même, quel que soit le
|
||||||
if len(occurrences) != 1:
|
# nombre de groupes servant à l'ancrage.
|
||||||
|
trouvees = [
|
||||||
|
correspondance.groups()[-1] for correspondance in motif.finditer(contenu)
|
||||||
|
]
|
||||||
|
if len(trouvees) != attendues:
|
||||||
raise Refus(
|
raise Refus(
|
||||||
f"{chemin_relatif} : {len(occurrences)} ligne(s) de version trouvée(s), "
|
f"{chemin_relatif} : {len(trouvees)} version(s) trouvée(s), "
|
||||||
"une seule attendue. Le fichier a changé de forme : "
|
f"{attendues} attendue(s). Le fichier a changé de forme : "
|
||||||
"corriger le motif dans scripts/release.py."
|
"corriger le motif dans scripts/release.py."
|
||||||
)
|
)
|
||||||
|
|
||||||
if occurrences[0] == version:
|
if all(trouvee == version for trouvee in trouvees):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
chemin.write_text(
|
chemin.write_text(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Ce package extrait les informations structurées des PDFs de comptes rendus
|
|||||||
de gérance générés par le logiciel de gestion immobilière Oralia/ICS.
|
de gérance générés par le logiciel de gestion immobilière Oralia/ICS.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.1.1"
|
||||||
|
|
||||||
from .extractor import extract_compte_rendu
|
from .extractor import extract_compte_rendu
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ Deux limites sont assumées plutôt que contournées :
|
|||||||
- **les lignes sont rendues telles qu'extraites**, sans regroupement ni
|
- **les lignes sont rendues telles qu'extraites**, sans regroupement ni
|
||||||
dédoublonnage. Un acompte et son solde restent deux lignes, parce que le
|
dédoublonnage. Un acompte et son solde restent deux lignes, parce que le
|
||||||
compte rendu les porte ainsi.
|
compte rendu les porte ainsi.
|
||||||
|
|
||||||
|
Le bloc `loyer` fait exception à ce cumul : il remet le loyer sur un axe de
|
||||||
|
temps, seule façon de voir une révision, une vacance ou un décrochage que les
|
||||||
|
totaux écrasent. Ses règles vivent dans `services.loyers`, et la comparaison au
|
||||||
|
parc les rejoue à l'identique pour les autres lots — situer un chiffre par
|
||||||
|
rapport à des chiffres obtenus autrement ne voudrait rien dire.
|
||||||
|
|
||||||
|
Le loyer au mètre carré vient de la surface saisie sur la fiche du logement.
|
||||||
|
Aucun compte rendu n'en porte : tant qu'elle manque, le ratio reste `null` et la
|
||||||
|
page renvoie vers la saisie plutôt que d'afficher un zéro.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
@@ -33,6 +43,16 @@ from ...database.models import (
|
|||||||
Revenu,
|
Revenu,
|
||||||
Tag,
|
Tag,
|
||||||
)
|
)
|
||||||
|
from ...services.loyers import (
|
||||||
|
EFFECTIF_MEDIANE_FIABLE,
|
||||||
|
loyer_au_m2,
|
||||||
|
mediane,
|
||||||
|
mois_de,
|
||||||
|
paliers,
|
||||||
|
parc_du_mois,
|
||||||
|
serie_du_lot,
|
||||||
|
variation,
|
||||||
|
)
|
||||||
from ...services.referentiel import type_effectif
|
from ...services.referentiel import type_effectif
|
||||||
from ...services.revenus_query import (
|
from ...services.revenus_query import (
|
||||||
TYPE_LIGNE_REPORT,
|
TYPE_LIGNE_REPORT,
|
||||||
@@ -135,11 +155,118 @@ class Intervenant(BaseModel):
|
|||||||
derniere_date: date | None = None
|
derniere_date: date | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PointLoyer(BaseModel):
|
||||||
|
"""Un mois de la courbe du loyer."""
|
||||||
|
|
||||||
|
mois: str # "2026-06"
|
||||||
|
#: Loyer hors charges du mois plein, `None` si aucune ligne ne le couvre.
|
||||||
|
loyer: float | None = None
|
||||||
|
charges: float | None = None
|
||||||
|
#: Facturé au prorata ce mois-là (entrée, sortie, avoir), hors du loyer.
|
||||||
|
prorata: float | None = None
|
||||||
|
#: Loyer au m², `None` sans surface saisie.
|
||||||
|
loyer_m2: float | None = None
|
||||||
|
#: Le loyer vient d'une ligne pluri-mensuelle répartie (bail trimestriel).
|
||||||
|
reparti: bool = False
|
||||||
|
#: Mois facturé seulement au prorata : un changement de locataire, pas une
|
||||||
|
#: vacance. Sans cette distinction, les deux se ressemblent sur la courbe.
|
||||||
|
en_transition: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class LigneHorsCourbe(BaseModel):
|
||||||
|
"""Une ligne de loyer qu'aucun mois ne peut revendiquer.
|
||||||
|
|
||||||
|
Régularisation rétroactive chevauchant plusieurs mois. Rendue à part pour
|
||||||
|
que la somme de la courbe et de ces lignes retrouve le facturé total.
|
||||||
|
"""
|
||||||
|
|
||||||
|
periode_debut: date | None = None
|
||||||
|
periode_fin: date | None = None
|
||||||
|
montant: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class LoyerEnVigueur(BaseModel):
|
||||||
|
"""Le dernier loyer connu, et depuis quand il tient."""
|
||||||
|
|
||||||
|
mois: str
|
||||||
|
loyer: float
|
||||||
|
loyer_m2: float | None = None
|
||||||
|
#: Premier mois du palier courant : la date d'effet de la dernière révision.
|
||||||
|
depuis: str
|
||||||
|
#: Loyer d'avant la dernière révision, et l'écart en %. `None` si le lot
|
||||||
|
#: n'a connu qu'un seul niveau depuis le premier compte rendu.
|
||||||
|
precedent: float | None = None
|
||||||
|
variation_pct: float | None = None
|
||||||
|
#: Le dernier compte rendu de l'immeuble porte encore ce loyer. Faux pour
|
||||||
|
#: un lot dont le bail s'est arrêté : son dernier loyer est une archive, et
|
||||||
|
#: l'afficher comme courant ferait croire à une recette qui n'existe plus.
|
||||||
|
toujours_loue: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class PointParc(BaseModel):
|
||||||
|
"""Un lot du parc, placé par sa surface et son loyer au m²."""
|
||||||
|
|
||||||
|
lot_id: int
|
||||||
|
numero: str
|
||||||
|
immeuble_code: str | None = None
|
||||||
|
type_lot: str | None = None
|
||||||
|
surface: float
|
||||||
|
loyer_m2: float
|
||||||
|
#: Le lot dont on regarde la fiche. Il figure dans le nuage — s'y voir situé
|
||||||
|
#: est tout l'objet — mais reste hors des médianes, qu'il tirerait vers lui.
|
||||||
|
est_ce_lot: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ComparaisonParc(BaseModel):
|
||||||
|
"""Le loyer au m² du lot situé face aux lots comparables.
|
||||||
|
|
||||||
|
Comparé sur le dernier mois loué du lot, et sur ce seul mois : rapprocher
|
||||||
|
un loyer de 2026 de loyers de 2024 mesurerait l'inflation autant que
|
||||||
|
l'écart entre deux biens.
|
||||||
|
|
||||||
|
Deux lectures cohabitent, et la seconde corrige la première : les médianes
|
||||||
|
résument le parc en un chiffre, mais mélangent toutes les surfaces ; le
|
||||||
|
nuage garde la surface en abscisse, seule façon de voir si un lot est cher
|
||||||
|
*pour sa taille*.
|
||||||
|
"""
|
||||||
|
|
||||||
|
mois: str
|
||||||
|
loyer_m2: float | None = None
|
||||||
|
|
||||||
|
mediane_immeuble: float | None = None
|
||||||
|
nb_immeuble: int = 0
|
||||||
|
mediane_type: float | None = None
|
||||||
|
nb_type: int = 0
|
||||||
|
type_compare: str | None = None
|
||||||
|
|
||||||
|
#: Lots loués ce mois-là dont la fiche n'a pas de surface : ils ne peuvent
|
||||||
|
#: pas être comparés. Compté et affiché, sans quoi une médiane sur huit
|
||||||
|
#: lots passerait pour une médiane sur tout le parc.
|
||||||
|
sans_surface: int = 0
|
||||||
|
#: Effectif en dessous duquel la médiane décrit surtout le hasard.
|
||||||
|
effectif_faible: int = EFFECTIF_MEDIANE_FIABLE
|
||||||
|
|
||||||
|
#: Tout le parc comparable, surface comprise, lot courant inclus et
|
||||||
|
#: signalé. Trié par surface : le nuage se lit de gauche à droite.
|
||||||
|
nuage: list[PointParc] = []
|
||||||
|
|
||||||
|
|
||||||
|
class LotLoyer(BaseModel):
|
||||||
|
"""Le loyer du lot dans le temps, et ce qu'il vaut au mètre carré."""
|
||||||
|
|
||||||
|
surface: float | None = None
|
||||||
|
serie: list[PointLoyer] = []
|
||||||
|
hors_courbe: list[LigneHorsCourbe] = []
|
||||||
|
en_vigueur: LoyerEnVigueur | None = None
|
||||||
|
parc: ComparaisonParc | None = None
|
||||||
|
|
||||||
|
|
||||||
class LotAnalyseResponse(BaseModel):
|
class LotAnalyseResponse(BaseModel):
|
||||||
"""Fiche complète d'un lot."""
|
"""Fiche complète d'un lot."""
|
||||||
|
|
||||||
identite: LotIdentite
|
identite: LotIdentite
|
||||||
chiffres: LotChiffres
|
chiffres: LotChiffres
|
||||||
|
loyer: LotLoyer
|
||||||
chronologie: list[LigneChronologie]
|
chronologie: list[LigneChronologie]
|
||||||
intervenants: list[Intervenant]
|
intervenants: list[Intervenant]
|
||||||
|
|
||||||
@@ -233,6 +360,129 @@ def _chiffres(session: Session, lot: Lot) -> LotChiffres:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _comparaison(
|
||||||
|
session: Session, lot: Lot, mois: str, loyer_m2: float | None
|
||||||
|
) -> ComparaisonParc:
|
||||||
|
"""Situe le loyer au m² du lot parmi les lots comparables du même mois."""
|
||||||
|
parc = parc_du_mois(session, mois)
|
||||||
|
type_lot = type_effectif(lot)
|
||||||
|
|
||||||
|
meme_immeuble = [
|
||||||
|
autre.loyer_m2
|
||||||
|
for autre in parc.comparables
|
||||||
|
if autre.immeuble_id == lot.immeuble_id and autre.lot_id != lot.id
|
||||||
|
]
|
||||||
|
# Le type se compare à travers tout le parc : deux immeubles ne donnent
|
||||||
|
# jamais assez de T2 pour qu'une médiane par immeuble et par type ait un
|
||||||
|
# sens. Le lot lui-même est exclu des deux, sans quoi il tirerait vers lui
|
||||||
|
# la médiane à laquelle on le compare.
|
||||||
|
meme_type = [
|
||||||
|
autre.loyer_m2
|
||||||
|
for autre in parc.comparables
|
||||||
|
if type_lot is not None
|
||||||
|
and autre.type_lot == type_lot
|
||||||
|
and autre.lot_id != lot.id
|
||||||
|
]
|
||||||
|
|
||||||
|
# Le nuage prend tout le parc, sans filtre d'immeuble ni de type : avec une
|
||||||
|
# dizaine de fiches renseignées, restreindre le viderait, et c'est la
|
||||||
|
# surface — portée par l'abscisse — qui rend deux lots comparables.
|
||||||
|
nuage = [
|
||||||
|
PointParc(
|
||||||
|
lot_id=autre.lot_id,
|
||||||
|
numero=autre.numero,
|
||||||
|
immeuble_code=autre.immeuble_code,
|
||||||
|
type_lot=autre.type_lot,
|
||||||
|
surface=autre.surface,
|
||||||
|
loyer_m2=autre.loyer_m2,
|
||||||
|
est_ce_lot=autre.lot_id == lot.id,
|
||||||
|
)
|
||||||
|
for autre in sorted(parc.comparables, key=lambda autre: autre.surface)
|
||||||
|
]
|
||||||
|
|
||||||
|
return ComparaisonParc(
|
||||||
|
mois=mois,
|
||||||
|
loyer_m2=loyer_m2,
|
||||||
|
mediane_immeuble=mediane(meme_immeuble),
|
||||||
|
nb_immeuble=len(meme_immeuble),
|
||||||
|
mediane_type=mediane(meme_type),
|
||||||
|
nb_type=len(meme_type),
|
||||||
|
type_compare=type_lot,
|
||||||
|
sans_surface=parc.sans_surface,
|
||||||
|
nuage=nuage,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _loyer(session: Session, lot: Lot) -> 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.
|
||||||
|
"""
|
||||||
|
fiche = lot.caracteristiques
|
||||||
|
surface = fiche.surface if fiche else None
|
||||||
|
|
||||||
|
serie = serie_du_lot(session, lot.id)
|
||||||
|
|
||||||
|
points = [
|
||||||
|
PointLoyer(
|
||||||
|
mois=point.mois,
|
||||||
|
loyer=point.loyer,
|
||||||
|
charges=point.charges,
|
||||||
|
prorata=point.prorata,
|
||||||
|
loyer_m2=loyer_au_m2(point.loyer, surface),
|
||||||
|
reparti=point.reparti,
|
||||||
|
en_transition=point.en_transition,
|
||||||
|
)
|
||||||
|
for point in serie.mois
|
||||||
|
]
|
||||||
|
|
||||||
|
hors_courbe = [
|
||||||
|
LigneHorsCourbe(
|
||||||
|
periode_debut=ligne.periode_debut,
|
||||||
|
periode_fin=ligne.periode_fin,
|
||||||
|
montant=round(ligne.loyers, 2),
|
||||||
|
)
|
||||||
|
for ligne in serie.ecartees
|
||||||
|
]
|
||||||
|
|
||||||
|
niveaux = paliers(serie.mois)
|
||||||
|
en_vigueur = None
|
||||||
|
parc = None
|
||||||
|
|
||||||
|
if niveaux:
|
||||||
|
courant = niveaux[-1]
|
||||||
|
precedent = niveaux[-2].loyer if len(niveaux) > 1 else None
|
||||||
|
|
||||||
|
# Le dernier compte rendu de l'immeuble donne l'actualité : un lot dont
|
||||||
|
# le loyer s'arrête avant lui n'est plus loué.
|
||||||
|
dernier_cr = session.execute(
|
||||||
|
select(func.max(Document.date)).where(
|
||||||
|
Document.immeuble_id == lot.immeuble_id
|
||||||
|
)
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
en_vigueur = LoyerEnVigueur(
|
||||||
|
mois=courant.mois_fin,
|
||||||
|
loyer=courant.loyer,
|
||||||
|
loyer_m2=loyer_au_m2(courant.loyer, surface),
|
||||||
|
depuis=courant.mois_debut,
|
||||||
|
precedent=precedent,
|
||||||
|
variation_pct=variation(precedent, courant.loyer),
|
||||||
|
toujours_loue=dernier_cr is None or courant.mois_fin >= mois_de(dernier_cr),
|
||||||
|
)
|
||||||
|
parc = _comparaison(session, lot, courant.mois_fin, en_vigueur.loyer_m2)
|
||||||
|
|
||||||
|
return LotLoyer(
|
||||||
|
surface=surface,
|
||||||
|
serie=points,
|
||||||
|
hors_courbe=hors_courbe,
|
||||||
|
en_vigueur=en_vigueur,
|
||||||
|
parc=parc,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _libelle_recette(revenu: Revenu) -> str:
|
def _libelle_recette(revenu: Revenu) -> str:
|
||||||
"""Ce que la ligne de recette dit d'elle-même.
|
"""Ce que la ligne de recette dit d'elle-même.
|
||||||
|
|
||||||
@@ -358,6 +608,7 @@ async def analyser_lot(
|
|||||||
return LotAnalyseResponse(
|
return LotAnalyseResponse(
|
||||||
identite=_identite(session, lot),
|
identite=_identite(session, lot),
|
||||||
chiffres=_chiffres(session, lot),
|
chiffres=_chiffres(session, lot),
|
||||||
|
loyer=_loyer(session, lot),
|
||||||
chronologie=_chronologie(session, lot),
|
chronologie=_chronologie(session, lot),
|
||||||
intervenants=_intervenants(session, lot),
|
intervenants=_intervenants(session, lot),
|
||||||
)
|
)
|
||||||
|
|||||||
384
src/plesna_gerance/services/loyers.py
Normal file
384
src/plesna_gerance/services/loyers.py
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
"""Le loyer d'un lot mois par mois, et ce qu'il vaut au mètre carré.
|
||||||
|
|
||||||
|
Le reste de la fiche d'un lot cumule tout l'historique en un chiffre. Ce module
|
||||||
|
fait l'inverse : il remet le loyer sur un axe de temps, seule façon de voir une
|
||||||
|
révision, une vacance ou un décrochage.
|
||||||
|
|
||||||
|
**L'axe est le mois loué, pas le mois du compte rendu.** Un loyer de mars
|
||||||
|
facturé en mars et un rappel de mars facturé en avril ne décrivent pas le même
|
||||||
|
mois ; classer par date de document mettrait le second sur avril et ferait un
|
||||||
|
faux creux suivi d'un faux pic.
|
||||||
|
|
||||||
|
Trois formes de lignes cohabitent dans ``revenus`` sous le même
|
||||||
|
``type_ligne = "loyer"``, et les confondre fausse la courbe :
|
||||||
|
|
||||||
|
- le **loyer d'un mois** (01/03 → 31/03), cas courant ;
|
||||||
|
- le **loyer d'un trimestre** (01/10 → 31/12), forme réelle des baux
|
||||||
|
commerciaux du parc. Le laisser sur son seul mois de début creuserait deux
|
||||||
|
mois sur trois ; il est donc réparti à parts égales sur les mois couverts.
|
||||||
|
Ce n'est pas une clé de répartition inventée : la période est portée par le
|
||||||
|
compte rendu, et diviser un montant par le nombre de mois qu'il couvre ne
|
||||||
|
suppose rien de plus que ce que la ligne dit déjà ;
|
||||||
|
- le **prorata** (22/06 → 30/06 à l'entrée d'un locataire, 18/10 → 18/10 pour
|
||||||
|
un avoir), qui ne vaut pas un mois plein. Il est rattaché à son mois mais
|
||||||
|
compté à part du loyer : additionner les deux ferait passer un mois de
|
||||||
|
changement de locataire pour un mois à loyer effondré, et un mois annulé par
|
||||||
|
avoir pour un mois où le loyer aurait baissé.
|
||||||
|
|
||||||
|
La frontière tient à la période : commencer un premier de mois et finir un
|
||||||
|
dernier de mois, c'est couvrir des mois entiers, donc porter un loyer. Tout le
|
||||||
|
reste est un prorata, rattaché à son mois quand il y tient — et écarté quand il
|
||||||
|
chevauche plusieurs mois sans les couvrir (14/03 → 31/08, régularisation), car
|
||||||
|
aucun mois ne peut alors le revendiquer.
|
||||||
|
|
||||||
|
Un mois sans loyer plein mais avec un prorata n'est donc pas une vacance : c'est
|
||||||
|
un mois de transition, et la fiche le distingue au lieu de le confondre avec un
|
||||||
|
trou.
|
||||||
|
|
||||||
|
Le mètre carré vient de la fiche saisie (`lot_caracteristiques.surface`), pas
|
||||||
|
des comptes rendus qui l'ignorent. Sans surface, le ratio vaut ``None`` et non
|
||||||
|
zéro : la page montre le trou plutôt que d'afficher un loyer au m² de 0 €.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from calendar import monthrange
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..database.models import Immeuble, Lot, LotCaracteristiques, Revenu
|
||||||
|
from ..services.referentiel import TYPE_LOT_EFFECTIF, joindre_fiche
|
||||||
|
|
||||||
|
#: Type des lignes portant un loyer. Les rappels et les régularisations
|
||||||
|
#: (`rappel_loyer`, `divers`) décrivent des rattrapages, pas le loyer d'un mois :
|
||||||
|
#: la chronologie de la fiche les montre déjà, la courbe les laisse dehors.
|
||||||
|
TYPE_LIGNE_LOYER = "loyer"
|
||||||
|
|
||||||
|
#: Sous ce nombre de lots comparables, une médiane décrit surtout le hasard.
|
||||||
|
#: Elle reste calculée et affichée avec son effectif — le lecteur tranche.
|
||||||
|
EFFECTIF_MEDIANE_FIABLE = 3
|
||||||
|
|
||||||
|
|
||||||
|
def mois_de(jour: date) -> str:
|
||||||
|
"""Le mois d'une date, au format ``2026-01``."""
|
||||||
|
return f"{jour.year:04d}-{jour.month:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def mois_suivant(mois: str) -> str:
|
||||||
|
"""Le mois d'après, même format."""
|
||||||
|
annee, numero = (int(part) for part in mois.split("-"))
|
||||||
|
return f"{annee + 1:04d}-01" if numero == 12 else f"{annee:04d}-{numero + 1:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def mois_entiers(debut: date | None, fin: date | None) -> list[str]:
|
||||||
|
"""Les mois entiers que couvre la période, vide si elle en couvre aucun.
|
||||||
|
|
||||||
|
Une période part du premier jour d'un mois et s'arrête au dernier jour d'un
|
||||||
|
mois : elle couvre alors ces mois-là, un ou plusieurs. Sinon, c'est un
|
||||||
|
prorata, et aucun mois ne lui appartient entièrement.
|
||||||
|
"""
|
||||||
|
if debut is None or fin is None or fin < debut:
|
||||||
|
return []
|
||||||
|
if debut.day != 1 or fin.day != monthrange(fin.year, fin.month)[1]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
mois, dernier = mois_de(debut), mois_de(fin)
|
||||||
|
couverts = [mois]
|
||||||
|
while mois != dernier:
|
||||||
|
mois = mois_suivant(mois)
|
||||||
|
couverts.append(mois)
|
||||||
|
return couverts
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MoisLoue:
|
||||||
|
"""Ce qu'un mois a été facturé, charges et proratas à part."""
|
||||||
|
|
||||||
|
mois: str
|
||||||
|
#: Loyer hors charges pour un mois plein. `None` quand aucune ligne ne
|
||||||
|
#: couvre le mois entier : le lot est vacant, sorti de la gestion, en
|
||||||
|
#: changement de locataire, ou son compte rendu manque. Zéro dirait « loué
|
||||||
|
#: gratuitement », ce qu'aucun document ne dit.
|
||||||
|
loyer: float | None = None
|
||||||
|
#: Provisions sur charges appelées avec le loyer plein.
|
||||||
|
charges: float | None = None
|
||||||
|
#: Facturé au prorata sur ce mois : entrée ou sortie en cours de mois,
|
||||||
|
#: avoir. `None` s'il n'y en a pas. Tenu hors du loyer, qui doit rester
|
||||||
|
#: comparable d'un mois à l'autre.
|
||||||
|
prorata: float | None = None
|
||||||
|
#: Vrai quand le loyer vient d'une ligne pluri-mensuelle répartie.
|
||||||
|
reparti: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def en_transition(self) -> bool:
|
||||||
|
"""Mois sans loyer plein mais facturé au prorata : pas une vacance."""
|
||||||
|
return self.loyer is None and self.prorata is not None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LigneEcartee:
|
||||||
|
"""Une ligne de loyer qu'aucun mois ne peut revendiquer.
|
||||||
|
|
||||||
|
Elle chevauche plusieurs mois sans en couvrir aucun entièrement : une
|
||||||
|
régularisation rétroactive. La rendre telle quelle laisse le lecteur la
|
||||||
|
rapprocher de la chronologie, où elle figure aussi.
|
||||||
|
"""
|
||||||
|
|
||||||
|
periode_debut: date | None
|
||||||
|
periode_fin: date | None
|
||||||
|
loyers: float
|
||||||
|
charges: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Serie:
|
||||||
|
"""La suite des mois loués d'un lot, et ce qui n'a pas pu y entrer."""
|
||||||
|
|
||||||
|
mois: list[MoisLoue] = field(default_factory=list)
|
||||||
|
ecartees: list[LigneEcartee] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def repartir(lignes) -> Serie:
|
||||||
|
"""Range des lignes de loyer sur l'axe des mois.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lignes: itérable de ``(periode_debut, periode_fin, loyers, provisions)``
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
La série des mois observés, du plus ancien au plus récent, trous
|
||||||
|
compris ; et les lignes écartées faute de couvrir des mois entiers.
|
||||||
|
"""
|
||||||
|
cumul: dict[str, MoisLoue] = {}
|
||||||
|
ecartees: list[LigneEcartee] = []
|
||||||
|
|
||||||
|
for debut, fin, loyers, provisions in lignes:
|
||||||
|
couverts = mois_entiers(debut, fin)
|
||||||
|
|
||||||
|
if not couverts:
|
||||||
|
# Un prorata tient dans un mois ; au-delà, c'est une régularisation
|
||||||
|
# rétroactive qu'aucun mois ne peut recevoir.
|
||||||
|
if debut is not None and fin is not None and mois_de(debut) == mois_de(fin):
|
||||||
|
point = cumul.setdefault(mois_de(debut), MoisLoue(mois=mois_de(debut)))
|
||||||
|
point.prorata = (point.prorata or 0.0) + (loyers or 0.0)
|
||||||
|
else:
|
||||||
|
ecartees.append(
|
||||||
|
LigneEcartee(
|
||||||
|
periode_debut=debut,
|
||||||
|
periode_fin=fin,
|
||||||
|
loyers=loyers or 0.0,
|
||||||
|
charges=provisions or 0.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
part_loyer = (loyers or 0.0) / len(couverts)
|
||||||
|
part_charges = (provisions or 0.0) / len(couverts)
|
||||||
|
|
||||||
|
for mois in couverts:
|
||||||
|
point = cumul.setdefault(mois, MoisLoue(mois=mois))
|
||||||
|
point.loyer = (point.loyer or 0.0) + part_loyer
|
||||||
|
point.charges = (point.charges or 0.0) + part_charges
|
||||||
|
point.reparti = point.reparti or len(couverts) > 1
|
||||||
|
|
||||||
|
if not cumul:
|
||||||
|
return Serie(ecartees=ecartees)
|
||||||
|
|
||||||
|
# Les mois sans aucune ligne restent dans la suite, à `None` : un trou dans
|
||||||
|
# la courbe se voit, un mois absent de l'axe passerait inaperçu.
|
||||||
|
serie: list[MoisLoue] = []
|
||||||
|
mois, dernier = min(cumul), max(cumul)
|
||||||
|
while True:
|
||||||
|
point = cumul.get(mois, MoisLoue(mois=mois))
|
||||||
|
if point.loyer is not None:
|
||||||
|
point.loyer = round(point.loyer, 2)
|
||||||
|
point.charges = round(point.charges or 0.0, 2)
|
||||||
|
if point.prorata is not None:
|
||||||
|
point.prorata = round(point.prorata, 2)
|
||||||
|
serie.append(point)
|
||||||
|
if mois == dernier:
|
||||||
|
break
|
||||||
|
mois = mois_suivant(mois)
|
||||||
|
|
||||||
|
return Serie(mois=serie, ecartees=ecartees)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Palier:
|
||||||
|
"""Une période pendant laquelle le loyer n'a pas bougé."""
|
||||||
|
|
||||||
|
mois_debut: str
|
||||||
|
mois_fin: str
|
||||||
|
loyer: float
|
||||||
|
|
||||||
|
|
||||||
|
def paliers(serie: list[MoisLoue]) -> list[Palier]:
|
||||||
|
"""Découpe la série aux changements de loyer.
|
||||||
|
|
||||||
|
Un mois sans loyer plein ferme le palier en cours : reprendre au même
|
||||||
|
montant après une vacance, c'est un nouveau bail qui se trouve tomber au
|
||||||
|
même prix, pas un loyer qui n'aurait jamais bougé.
|
||||||
|
|
||||||
|
Le centime tranche l'égalité — les montants viennent de divisions par le
|
||||||
|
nombre de mois d'un trimestre, où un tiers d'euro ne retombe pas juste.
|
||||||
|
"""
|
||||||
|
trouves: list[Palier] = []
|
||||||
|
|
||||||
|
for point in serie:
|
||||||
|
if point.loyer is None:
|
||||||
|
continue
|
||||||
|
en_cours = trouves[-1] if trouves else None
|
||||||
|
continu = (
|
||||||
|
en_cours is not None
|
||||||
|
and abs(en_cours.loyer - point.loyer) < 0.01
|
||||||
|
and mois_suivant(en_cours.mois_fin) == point.mois
|
||||||
|
)
|
||||||
|
if continu:
|
||||||
|
en_cours.mois_fin = point.mois
|
||||||
|
else:
|
||||||
|
trouves.append(
|
||||||
|
Palier(mois_debut=point.mois, mois_fin=point.mois, loyer=point.loyer)
|
||||||
|
)
|
||||||
|
|
||||||
|
return trouves
|
||||||
|
|
||||||
|
|
||||||
|
def variation(depuis: float | None, vers: float | None) -> float | None:
|
||||||
|
"""Écart en pourcentage entre deux loyers, `None` si l'un manque."""
|
||||||
|
if not depuis or vers is None:
|
||||||
|
return None
|
||||||
|
return round((vers - depuis) / abs(depuis) * 100, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def loyer_au_m2(loyer: float | None, surface: float | None) -> float | None:
|
||||||
|
"""Loyer mensuel hors charges rapporté au mètre carré.
|
||||||
|
|
||||||
|
`None` dès qu'un des deux manque : la fiche du lot n'est pas saisie, ou le
|
||||||
|
mois n'a pas de loyer. Une surface nulle ou négative est traitée comme
|
||||||
|
absente — elle ne peut venir que d'une saisie fautive.
|
||||||
|
"""
|
||||||
|
if loyer is None or surface is None or surface <= 0:
|
||||||
|
return None
|
||||||
|
return round(loyer / surface, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def mediane(valeurs: list[float]) -> float | None:
|
||||||
|
"""Médiane d'un échantillon, `None` s'il est vide.
|
||||||
|
|
||||||
|
Médiane et non moyenne : sur trente lots, un local commercial suffit à
|
||||||
|
tirer une moyenne loin de ce que paye un appartement.
|
||||||
|
"""
|
||||||
|
if not valeurs:
|
||||||
|
return None
|
||||||
|
ordonnees = sorted(valeurs)
|
||||||
|
milieu = len(ordonnees) // 2
|
||||||
|
if len(ordonnees) % 2:
|
||||||
|
return round(ordonnees[milieu], 2)
|
||||||
|
return round((ordonnees[milieu - 1] + ordonnees[milieu]) / 2, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def _lignes_de_loyer(session: Session, lot_id: int | None = None):
|
||||||
|
"""Les lignes de loyer, par lot : période et montants.
|
||||||
|
|
||||||
|
Sans `lot_id`, tout le parc en une requête — la comparaison a besoin des
|
||||||
|
trente lots à la fois, et les interroger un par un ferait trente allers.
|
||||||
|
"""
|
||||||
|
stmt = select(
|
||||||
|
Revenu.lot_id,
|
||||||
|
Revenu.periode_debut,
|
||||||
|
Revenu.periode_fin,
|
||||||
|
Revenu.loyers,
|
||||||
|
Revenu.provisions,
|
||||||
|
).where(Revenu.type_ligne == TYPE_LIGNE_LOYER)
|
||||||
|
if lot_id is not None:
|
||||||
|
stmt = stmt.where(Revenu.lot_id == lot_id)
|
||||||
|
|
||||||
|
par_lot: dict[int, list] = {}
|
||||||
|
for ligne_lot, debut, fin, loyers, provisions in session.execute(stmt):
|
||||||
|
par_lot.setdefault(ligne_lot, []).append((debut, fin, loyers, provisions))
|
||||||
|
return par_lot
|
||||||
|
|
||||||
|
|
||||||
|
def serie_du_lot(session: Session, lot_id: int) -> Serie:
|
||||||
|
"""Les mois loués d'un lot, du premier au dernier connu."""
|
||||||
|
return repartir(_lignes_de_loyer(session, lot_id).get(lot_id, []))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LotComparable:
|
||||||
|
"""Un lot du parc ramené à son loyer au m² sur un mois donné.
|
||||||
|
|
||||||
|
Porte sa surface autant que son ratio : le loyer au m² décroît fortement
|
||||||
|
avec la taille du logement — sur le parc, un studio de 21 m² se loue près du
|
||||||
|
double au m² d'un T3 de 106 m². Une comparaison qui perd la surface compare
|
||||||
|
donc des choses qui n'ont pas à l'être.
|
||||||
|
"""
|
||||||
|
|
||||||
|
lot_id: int
|
||||||
|
immeuble_id: int
|
||||||
|
immeuble_code: str | None
|
||||||
|
numero: str
|
||||||
|
type_lot: str | None
|
||||||
|
surface: float
|
||||||
|
loyer_m2: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Parc:
|
||||||
|
"""Ce que le parc permet de comparer, et ce qu'il ne permet pas."""
|
||||||
|
|
||||||
|
comparables: list[LotComparable] = field(default_factory=list)
|
||||||
|
#: Lots loués ce mois-là mais dont la fiche n'a pas de surface. Ils ne
|
||||||
|
#: peuvent pas entrer dans une comparaison au m² ; les taire ferait passer
|
||||||
|
#: une médiane sur trois lots pour une médiane sur tout le parc.
|
||||||
|
sans_surface: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def parc_du_mois(session: Session, mois: str) -> Parc:
|
||||||
|
"""Loyer au m² de chaque lot loué le mois donné.
|
||||||
|
|
||||||
|
Passe par la même répartition que la fiche d'un lot : une médiane calculée
|
||||||
|
autrement que les chiffres qu'elle situe ne voudrait rien dire.
|
||||||
|
"""
|
||||||
|
lots = session.execute(
|
||||||
|
joindre_fiche(
|
||||||
|
select(
|
||||||
|
Lot.id,
|
||||||
|
Lot.immeuble_id,
|
||||||
|
Immeuble.code,
|
||||||
|
Lot.numero,
|
||||||
|
TYPE_LOT_EFFECTIF,
|
||||||
|
LotCaracteristiques.surface,
|
||||||
|
).join(Immeuble, Immeuble.id == Lot.immeuble_id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Toutes les lignes de loyer du parc en une requête : les répartir lot par
|
||||||
|
# lot depuis la base ferait une trentaine d'allers pour un seul affichage.
|
||||||
|
lignes_par_lot = _lignes_de_loyer(session)
|
||||||
|
|
||||||
|
parc = Parc()
|
||||||
|
for lot_id, immeuble_id, immeuble_code, numero, type_lot, surface in lots:
|
||||||
|
serie = repartir(lignes_par_lot.get(lot_id, []))
|
||||||
|
point = next((p for p in serie.mois if p.mois == mois), None)
|
||||||
|
if point is None or point.loyer is None or point.loyer <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ratio = loyer_au_m2(point.loyer, surface)
|
||||||
|
if ratio is None:
|
||||||
|
parc.sans_surface += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
parc.comparables.append(
|
||||||
|
LotComparable(
|
||||||
|
lot_id=lot_id,
|
||||||
|
immeuble_id=immeuble_id,
|
||||||
|
immeuble_code=immeuble_code,
|
||||||
|
numero=numero,
|
||||||
|
type_lot=type_lot,
|
||||||
|
surface=surface,
|
||||||
|
loyer_m2=ratio,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return parc
|
||||||
@@ -8,7 +8,7 @@ regrouper des lignes que le compte rendu a émises séparément.
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from plesna_gerance.database.models import Immeuble, Lot
|
from plesna_gerance.database.models import Immeuble, Lot, LotCaracteristiques
|
||||||
from plesna_gerance.database.service import DatabaseService
|
from plesna_gerance.database.service import DatabaseService
|
||||||
|
|
||||||
|
|
||||||
@@ -223,3 +223,164 @@ def test_le_total_d_un_intervenant_est_celui_de_ses_lignes(api_client, donnees):
|
|||||||
round(sum(ligne["montant"] for ligne in lignes), 2)
|
round(sum(ligne["montant"] for ligne in lignes), 2)
|
||||||
== intervenant["montant"]
|
== intervenant["montant"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_le_loyer_se_lit_mois_par_mois(api_client, donnees):
|
||||||
|
"""Deux comptes rendus, deux mois : la fiche les remet sur un axe de temps."""
|
||||||
|
_, lot = donnees
|
||||||
|
|
||||||
|
loyer = api_client.get(f"/api/lots/{lot.id}/analyse").json()["loyer"]
|
||||||
|
|
||||||
|
assert [point["mois"] for point in loyer["serie"]] == ["2024-01", "2024-02"]
|
||||||
|
assert [point["loyer"] for point in loyer["serie"]] == [500.0, 500.0]
|
||||||
|
assert loyer["en_vigueur"]["loyer"] == 500.0
|
||||||
|
assert loyer["en_vigueur"]["depuis"] == "2024-01"
|
||||||
|
# Un seul niveau depuis le premier compte rendu : aucune révision à montrer.
|
||||||
|
assert loyer["en_vigueur"]["precedent"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_sans_surface_saisie_le_loyer_au_m2_reste_vide(api_client, donnees):
|
||||||
|
"""Le ratio manquant se voit ; un zéro laisserait croire à un loyer nul."""
|
||||||
|
_, lot = donnees
|
||||||
|
|
||||||
|
loyer = api_client.get(f"/api/lots/{lot.id}/analyse").json()["loyer"]
|
||||||
|
|
||||||
|
assert loyer["surface"] is None
|
||||||
|
assert all(point["loyer_m2"] is None for point in loyer["serie"])
|
||||||
|
assert loyer["en_vigueur"]["loyer_m2"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_la_surface_saisie_allume_le_loyer_au_m2(api_client, db_session, donnees):
|
||||||
|
"""La fiche saisie est la seule source de surface : aucun PDF n'en porte."""
|
||||||
|
_, lot = donnees
|
||||||
|
db_session.add(LotCaracteristiques(lot_id=lot.id, surface=50.0))
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
loyer = api_client.get(f"/api/lots/{lot.id}/analyse").json()["loyer"]
|
||||||
|
|
||||||
|
assert loyer["surface"] == 50.0
|
||||||
|
assert loyer["en_vigueur"]["loyer_m2"] == 10.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def parc(db_session, sample_data):
|
||||||
|
"""Un compte rendu portant trois lots, dont un sans surface saisie.
|
||||||
|
|
||||||
|
Se situer suppose des voisins : le nuage n'a de sens qu'à plusieurs. Les
|
||||||
|
surfaces sont volontairement contrastées (20 m² à 15 €/m², 50 m² à 10 €/m²)
|
||||||
|
pour reproduire la pente du parc réel, où le petit se loue plus cher au m².
|
||||||
|
"""
|
||||||
|
|
||||||
|
def locataire(numero, nom, loyer):
|
||||||
|
return {
|
||||||
|
"lot": {"numero": numero, "type": "Appartement"},
|
||||||
|
"locataire": {"nom": nom},
|
||||||
|
"lignes": [
|
||||||
|
{
|
||||||
|
"type": "loyer",
|
||||||
|
"periode": {"debut": "2024-01-01", "fin": "2024-01-31"},
|
||||||
|
"loyers": loyer,
|
||||||
|
"total": loyer,
|
||||||
|
"regles": loyer,
|
||||||
|
"impayes": 0.0,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
DatabaseService(db_session).save_document(
|
||||||
|
data={
|
||||||
|
**sample_data,
|
||||||
|
"situation_locataires": [
|
||||||
|
locataire("01", "DUPONT", 500.0),
|
||||||
|
locataire("02", "MARTIN", 300.0),
|
||||||
|
locataire("03", "DURAND", 700.0),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
immeuble = db_session.query(Immeuble).filter(Immeuble.code == "IMM1").one()
|
||||||
|
lots = {
|
||||||
|
lot.numero: lot
|
||||||
|
for lot in db_session.query(Lot).filter(Lot.immeuble_id == immeuble.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
db_session.add(LotCaracteristiques(lot_id=lots["01"].id, surface=50.0))
|
||||||
|
db_session.add(LotCaracteristiques(lot_id=lots["02"].id, surface=20.0))
|
||||||
|
# Le lot 03 reste sans fiche : c'est le cas majoritaire en base.
|
||||||
|
db_session.commit()
|
||||||
|
return lots
|
||||||
|
|
||||||
|
|
||||||
|
def test_le_nuage_situe_le_lot_parmi_ses_voisins(api_client, parc):
|
||||||
|
"""Trié par surface, le lot courant présent et signalé.
|
||||||
|
|
||||||
|
Il figure dans le nuage — s'y voir situé est tout l'objet — alors qu'il est
|
||||||
|
exclu des médianes, qu'il tirerait vers lui.
|
||||||
|
"""
|
||||||
|
nuage = api_client.get(f"/api/lots/{parc['01'].id}/analyse").json()["loyer"][
|
||||||
|
"parc"
|
||||||
|
]["nuage"]
|
||||||
|
|
||||||
|
assert [(point["surface"], point["loyer_m2"]) for point in nuage] == [
|
||||||
|
(20.0, 15.0),
|
||||||
|
(50.0, 10.0),
|
||||||
|
]
|
||||||
|
assert [point["est_ce_lot"] for point in nuage] == [False, True]
|
||||||
|
assert nuage[0]["numero"] == "02"
|
||||||
|
|
||||||
|
|
||||||
|
def test_un_lot_sans_surface_n_entre_pas_dans_le_nuage(api_client, parc):
|
||||||
|
"""Sans surface, aucune abscisse : le lot ne peut pas être placé.
|
||||||
|
|
||||||
|
Il n'est pas pour autant oublié — `sans_surface` le compte, et la page le
|
||||||
|
dit sous les médianes.
|
||||||
|
"""
|
||||||
|
comparaison = api_client.get(f"/api/lots/{parc['01'].id}/analyse").json()["loyer"][
|
||||||
|
"parc"
|
||||||
|
]
|
||||||
|
|
||||||
|
assert len(comparaison["nuage"]) == 2
|
||||||
|
assert parc["03"].id not in [point["lot_id"] for point in comparaison["nuage"]]
|
||||||
|
assert comparaison["sans_surface"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_le_nuage_garde_le_lot_courant_meme_seul(api_client, db_session, donnees):
|
||||||
|
"""Seul lot mesuré du parc : le nuage le porte quand même.
|
||||||
|
|
||||||
|
Le vider dans ce cas ferait disparaître le point qu'on cherche justement à
|
||||||
|
situer, et la page ne dirait plus rien du lot ouvert.
|
||||||
|
"""
|
||||||
|
_, lot = donnees
|
||||||
|
db_session.add(LotCaracteristiques(lot_id=lot.id, surface=50.0))
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
nuage = api_client.get(f"/api/lots/{lot.id}/analyse").json()["loyer"]["parc"][
|
||||||
|
"nuage"
|
||||||
|
]
|
||||||
|
|
||||||
|
assert len(nuage) == 1
|
||||||
|
assert nuage[0]["est_ce_lot"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_la_comparaison_compte_les_lots_qu_elle_ne_peut_pas_voir(
|
||||||
|
api_client, db_session, donnees
|
||||||
|
):
|
||||||
|
"""Un lot sans surface ne peut pas entrer dans une médiane au m².
|
||||||
|
|
||||||
|
Taire ces lots ferait passer une médiane sur une poignée de lots pour une
|
||||||
|
médiane sur tout le parc — c'est le chiffre, et non son effectif, qui
|
||||||
|
tromperait.
|
||||||
|
"""
|
||||||
|
_, lot = donnees
|
||||||
|
db_session.add(LotCaracteristiques(lot_id=lot.id, surface=50.0))
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
parc = api_client.get(f"/api/lots/{lot.id}/analyse").json()["loyer"]["parc"]
|
||||||
|
|
||||||
|
assert parc["mois"] == "2024-02"
|
||||||
|
assert parc["loyer_m2"] == 10.0
|
||||||
|
# Seul lot de la base : rien à quoi le comparer, et la médiane ne se
|
||||||
|
# rabat pas sur lui-même.
|
||||||
|
assert parc["mediane_immeuble"] is None
|
||||||
|
assert parc["nb_immeuble"] == 0
|
||||||
|
assert parc["sans_surface"] == 0
|
||||||
|
|||||||
253
tests/test_loyers.py
Normal file
253
tests/test_loyers.py
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
"""Tests de la mise en temps du loyer.
|
||||||
|
|
||||||
|
Ce que ces tests protègent, c'est la lecture d'une courbe : un mois vacant, un
|
||||||
|
mois de changement de locataire et un mois à loyer plein doivent rester trois
|
||||||
|
choses différentes. Les cas ne sont pas inventés — ils viennent tous du parc
|
||||||
|
réel (bail commercial trimestriel, relocation en cours de mois, avoir annulant
|
||||||
|
un loyer, régularisation rétroactive à cheval sur six mois).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from plesna_gerance.services.loyers import (
|
||||||
|
loyer_au_m2,
|
||||||
|
mediane,
|
||||||
|
mois_entiers,
|
||||||
|
mois_suivant,
|
||||||
|
paliers,
|
||||||
|
repartir,
|
||||||
|
variation,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ligne(debut: str, fin: str, loyers: float, provisions: float = 0.0):
|
||||||
|
"""Une ligne de loyer telle que la base la porte."""
|
||||||
|
return (date.fromisoformat(debut), date.fromisoformat(fin), loyers, provisions)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMoisEntiers:
|
||||||
|
"""Ce qui fait qu'une période porte un loyer plutôt qu'un prorata."""
|
||||||
|
|
||||||
|
def test_un_mois_plein_couvre_son_mois(self):
|
||||||
|
assert mois_entiers(date(2026, 3, 1), date(2026, 3, 31)) == ["2026-03"]
|
||||||
|
|
||||||
|
def test_fevrier_se_termine_le_28_ou_le_29(self):
|
||||||
|
assert mois_entiers(date(2025, 2, 1), date(2025, 2, 28)) == ["2025-02"]
|
||||||
|
assert mois_entiers(date(2024, 2, 1), date(2024, 2, 29)) == ["2024-02"]
|
||||||
|
|
||||||
|
def test_un_trimestre_couvre_ses_trois_mois(self):
|
||||||
|
assert mois_entiers(date(2025, 10, 1), date(2025, 12, 31)) == [
|
||||||
|
"2025-10",
|
||||||
|
"2025-11",
|
||||||
|
"2025-12",
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_une_periode_partielle_ne_couvre_aucun_mois(self):
|
||||||
|
assert mois_entiers(date(2024, 6, 22), date(2024, 6, 30)) == []
|
||||||
|
assert mois_entiers(date(2025, 3, 14), date(2025, 8, 31)) == []
|
||||||
|
|
||||||
|
def test_une_periode_absente_ou_inversee_ne_couvre_rien(self):
|
||||||
|
assert mois_entiers(None, date(2026, 3, 31)) == []
|
||||||
|
assert mois_entiers(date(2026, 3, 31), date(2026, 3, 1)) == []
|
||||||
|
|
||||||
|
def test_le_passage_a_l_annee_suivante(self):
|
||||||
|
assert mois_suivant("2025-12") == "2026-01"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepartition:
|
||||||
|
"""Comment les lignes se rangent sur l'axe des mois."""
|
||||||
|
|
||||||
|
def test_un_loyer_mensuel_va_dans_son_mois(self):
|
||||||
|
serie = repartir([ligne("2026-03-01", "2026-03-31", 640.0, 31.0)])
|
||||||
|
|
||||||
|
assert [point.mois for point in serie.mois] == ["2026-03"]
|
||||||
|
assert serie.mois[0].loyer == 640.0
|
||||||
|
assert serie.mois[0].charges == 31.0
|
||||||
|
assert serie.mois[0].reparti is False
|
||||||
|
|
||||||
|
def test_un_bail_trimestriel_se_repartit_sur_ses_mois(self):
|
||||||
|
"""Sans répartition, deux mois sur trois d'un bail commercial seraient
|
||||||
|
montrés vides alors que le local est loué."""
|
||||||
|
serie = repartir([ligne("2025-10-01", "2025-12-31", 3963.27)])
|
||||||
|
|
||||||
|
assert [point.mois for point in serie.mois] == [
|
||||||
|
"2025-10",
|
||||||
|
"2025-11",
|
||||||
|
"2025-12",
|
||||||
|
]
|
||||||
|
assert [point.loyer for point in serie.mois] == [1321.09, 1321.09, 1321.09]
|
||||||
|
assert all(point.reparti for point in serie.mois)
|
||||||
|
|
||||||
|
def test_un_mois_sans_ligne_reste_vide_et_non_a_zero(self):
|
||||||
|
"""Un loyer à zéro se lirait comme un logement prêté ; le trou dit la
|
||||||
|
vacance, qui est ce que les comptes rendus montrent."""
|
||||||
|
serie = repartir(
|
||||||
|
[
|
||||||
|
ligne("2024-01-01", "2024-01-31", 850.0),
|
||||||
|
ligne("2024-04-01", "2024-04-30", 900.0),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [point.mois for point in serie.mois] == [
|
||||||
|
"2024-01",
|
||||||
|
"2024-02",
|
||||||
|
"2024-03",
|
||||||
|
"2024-04",
|
||||||
|
]
|
||||||
|
assert [point.loyer for point in serie.mois] == [850.0, None, None, 900.0]
|
||||||
|
|
||||||
|
def test_un_prorata_se_range_a_part_du_loyer(self):
|
||||||
|
"""Le locataire entre le 22 : le mois est facturé, mais pas au prix
|
||||||
|
d'un mois plein. Additionner les deux inventerait une baisse de loyer."""
|
||||||
|
serie = repartir([ligne("2024-06-22", "2024-06-30", 417.0)])
|
||||||
|
|
||||||
|
point = serie.mois[0]
|
||||||
|
assert point.mois == "2024-06"
|
||||||
|
assert point.loyer is None
|
||||||
|
assert point.prorata == 417.0
|
||||||
|
assert point.en_transition is True
|
||||||
|
|
||||||
|
def test_un_mois_de_transition_n_est_pas_une_vacance(self):
|
||||||
|
"""Sortie le 3, entrée le 14 : le mois est loué deux fois en morceaux.
|
||||||
|
Le montrer vide le confondrait avec un mois sans locataire."""
|
||||||
|
serie = repartir(
|
||||||
|
[
|
||||||
|
ligne("2025-03-01", "2025-03-03", 109.68),
|
||||||
|
ligne("2025-03-14", "2025-03-31", 622.2),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
point = serie.mois[0]
|
||||||
|
assert point.loyer is None
|
||||||
|
assert point.prorata == pytest.approx(731.88)
|
||||||
|
assert point.en_transition is True
|
||||||
|
|
||||||
|
def test_un_avoir_ne_deforme_pas_le_loyer_du_mois(self):
|
||||||
|
"""Le loyer d'octobre est annulé par un avoir. Le loyer contractuel
|
||||||
|
reste 1390 ; l'avoir se lit à côté, sans creuser la courbe."""
|
||||||
|
serie = repartir(
|
||||||
|
[
|
||||||
|
ligne("2024-10-01", "2024-10-31", 1390.0),
|
||||||
|
ligne("2024-10-18", "2024-10-18", -1390.0),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
point = serie.mois[0]
|
||||||
|
assert point.loyer == 1390.0
|
||||||
|
assert point.prorata == -1390.0
|
||||||
|
assert point.en_transition is False
|
||||||
|
|
||||||
|
def test_une_regularisation_a_cheval_reste_hors_de_la_courbe(self):
|
||||||
|
"""De mars à août sans couvrir un mois entier : l'étaler inventerait
|
||||||
|
six demi-mois de loyer."""
|
||||||
|
serie = repartir(
|
||||||
|
[
|
||||||
|
ligne("2025-09-01", "2025-09-30", 1023.0),
|
||||||
|
ligne("2025-03-14", "2025-08-31", -418.55),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [point.mois for point in serie.mois] == ["2025-09"]
|
||||||
|
assert len(serie.ecartees) == 1
|
||||||
|
assert serie.ecartees[0].loyers == -418.55
|
||||||
|
|
||||||
|
def test_sans_aucune_ligne_la_serie_est_vide(self):
|
||||||
|
assert repartir([]).mois == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestPaliers:
|
||||||
|
"""Les révisions de loyer, lues dans la suite des mois."""
|
||||||
|
|
||||||
|
def test_un_loyer_stable_ne_fait_qu_un_palier(self):
|
||||||
|
serie = repartir(
|
||||||
|
[
|
||||||
|
ligne("2026-01-01", "2026-01-31", 900.0),
|
||||||
|
ligne("2026-02-01", "2026-02-28", 900.0),
|
||||||
|
ligne("2026-03-01", "2026-03-31", 900.0),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
niveaux = paliers(serie.mois)
|
||||||
|
assert len(niveaux) == 1
|
||||||
|
assert (niveaux[0].mois_debut, niveaux[0].mois_fin) == ("2026-01", "2026-03")
|
||||||
|
|
||||||
|
def test_une_revision_ouvre_un_palier(self):
|
||||||
|
serie = repartir(
|
||||||
|
[
|
||||||
|
ligne("2025-12-01", "2025-12-31", 900.0),
|
||||||
|
ligne("2026-01-01", "2026-01-31", 907.85),
|
||||||
|
ligne("2026-02-01", "2026-02-28", 907.85),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
niveaux = paliers(serie.mois)
|
||||||
|
assert [n.loyer for n in niveaux] == [900.0, 907.85]
|
||||||
|
assert niveaux[-1].mois_debut == "2026-01"
|
||||||
|
|
||||||
|
def test_une_vacance_coupe_le_palier_meme_a_loyer_egal(self):
|
||||||
|
"""Relouer au même prix après trois mois vides, c'est un nouveau bail :
|
||||||
|
dire que le loyer « n'a pas bougé depuis 2024 » serait faux."""
|
||||||
|
serie = repartir(
|
||||||
|
[
|
||||||
|
ligne("2024-01-01", "2024-01-31", 850.0),
|
||||||
|
ligne("2024-05-01", "2024-05-31", 850.0),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
niveaux = paliers(serie.mois)
|
||||||
|
assert len(niveaux) == 2
|
||||||
|
assert niveaux[-1].mois_debut == "2024-05"
|
||||||
|
|
||||||
|
def test_les_centimes_d_un_trimestre_ne_creent_pas_de_faux_paliers(self):
|
||||||
|
"""3963,27 divisé par trois ne retombe pas juste ; l'égalité se juge au
|
||||||
|
centime, sinon chaque mois ouvrirait son propre palier."""
|
||||||
|
serie = repartir(
|
||||||
|
[
|
||||||
|
ligne("2025-01-01", "2025-03-31", 3963.27),
|
||||||
|
ligne("2025-04-01", "2025-06-30", 3963.27),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(paliers(serie.mois)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoyerAuM2:
|
||||||
|
"""Le ratio, et ce qu'il refuse de calculer."""
|
||||||
|
|
||||||
|
def test_le_ratio_se_calcule_hors_charges(self):
|
||||||
|
assert loyer_au_m2(640.0, 47.84) == 13.38
|
||||||
|
|
||||||
|
def test_sans_surface_il_n_y_a_pas_de_ratio(self):
|
||||||
|
"""Zéro serait un loyer au m² nul ; `None` est un trou à combler."""
|
||||||
|
assert loyer_au_m2(640.0, None) is None
|
||||||
|
|
||||||
|
def test_une_surface_absurde_vaut_une_surface_absente(self):
|
||||||
|
assert loyer_au_m2(640.0, 0) is None
|
||||||
|
assert loyer_au_m2(640.0, -10) is None
|
||||||
|
|
||||||
|
def test_un_mois_sans_loyer_n_a_pas_de_ratio(self):
|
||||||
|
assert loyer_au_m2(None, 47.84) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestMedianeEtVariation:
|
||||||
|
"""Les deux agrégats qui situent un lot."""
|
||||||
|
|
||||||
|
def test_la_mediane_resiste_a_un_local_commercial(self):
|
||||||
|
"""Une moyenne serait tirée par la valeur extrême ; c'est tout
|
||||||
|
l'intérêt de la médiane sur un parc de trente lots."""
|
||||||
|
assert mediane([11.0, 12.0, 13.0, 14.0, 90.0]) == 13.0
|
||||||
|
|
||||||
|
def test_la_mediane_d_un_effectif_pair_prend_le_milieu(self):
|
||||||
|
assert mediane([10.0, 12.0, 14.0, 16.0]) == 13.0
|
||||||
|
|
||||||
|
def test_sans_lot_comparable_il_n_y_a_pas_de_mediane(self):
|
||||||
|
assert mediane([]) is None
|
||||||
|
|
||||||
|
def test_la_variation_se_lit_en_pourcentage(self):
|
||||||
|
assert variation(1023.0, 1031.06) == 0.79
|
||||||
|
|
||||||
|
def test_une_variation_sans_reference_n_existe_pas(self):
|
||||||
|
assert variation(None, 900.0) is None
|
||||||
|
assert variation(0.0, 900.0) is None
|
||||||
Reference in New Issue
Block a user