Compare commits

..

4 Commits

Author SHA1 Message Date
2ee8e101b8 chore: aligne le verrou uv sur la version 0.1.1
All checks were successful
Build and Publish Docker Image / Tests (push) Successful in 2m19s
Build and Publish Docker Image / Build App Image (push) Successful in 1m57s
Build and Publish Docker Image / Build Summary (push) Successful in 3s
Le passage en 0.1.1 avait laissé le verrou sur 0.1.0 ; `uv sync` le
corrige de lui-même à chaque installation, et la correction se
retrouvait donc dans l'arbre de travail de qui installait le projet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:39:14 +02:00
288b67c26f feat: montre sur la fiche le loyer mois par mois et sa place dans le parc
Un bloc « Loyer » s'intercale entre les totaux et la chronologie. Il
porte le loyer en vigueur, sa date d'effet, le prix au mètre carré et la
dernière révision, puis deux graphiques.

Le premier suit le loyer mois par mois, charges empilées et prix au m²
sur son propre axe. Un mois vacant y reste un blanc, jamais une barre au
sol ; un mois de transition prend une couleur distincte et l'infobulle
dit lequel des deux on regarde. Ce que la courbe ne peut pas porter — une
régularisation à cheval sur plusieurs mois sans en couvrir aucun — est
listé dessous plutôt que rattaché de force à un mois.

Le second situe le lot dans le parc, surface en abscisse. Il corrige une
lecture que les médianes rendent fausse : le loyer au m² décroît
fortement avec la taille, si bien qu'un studio de 21 m² affiche +60 %
au-dessus de la médiane de son immeuble sans rien devoir à sa gestion.
Ce qui se lit n'est donc pas la hauteur d'un point, mais sa position
parmi les lots de surface voisine — et une phrase le dit au-dessus du
graphe. Les autres lots s'ouvrent d'un clic ; les locaux commerciaux
gardent leur place mais changent de forme, leur prix ne suivant pas la
même logique.

Sans surface saisie, chaque case reste vide et renvoie vers Logements :
la fiche montre le trou au lieu de le combler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:39:07 +02:00
b1ff0812c1 feat: met le loyer d'un lot sur un axe de temps et le rapporte au mètre carré
La fiche d'un lot cumulait tout son historique en un chiffre. Une
révision de loyer, une vacance ou un décrochage y étaient donc
invisibles. Le bloc `loyer` remet les lignes sur un axe de temps, dont
l'unité est le mois loué et non le mois du compte rendu : un rappel de
mars facturé en avril décrit mars.

Trois formes de lignes cohabitent sous le même `type_ligne = "loyer"`,
et les confondre fausse la courbe :

- le loyer d'un mois, cas courant ;
- le loyer d'un trimestre, forme réelle des baux commerciaux du parc,
  réparti sur les mois qu'il couvre — sans quoi deux mois sur trois
  paraîtraient vides alors que le local est loué ;
- le prorata d'entrée, de sortie ou l'avoir, rattaché à son mois mais
  compté à part. Les additionner ferait passer un mois de changement de
  locataire pour un mois à loyer effondré.

Un mois sans ligne reste vide plutôt qu'à zéro : zéro dirait « loué
gratuitement », ce qu'aucun compte rendu ne dit. Un mois facturé
seulement au prorata est signalé comme transition, pour ne pas se
confondre avec une vacance.

Le mètre carré vient de la fiche saisie, qu'aucun PDF ne porte : tant
qu'elle manque, le ratio reste nul et la page renvoie vers la saisie.
Les médianes qui situent le lot rejouent exactement la même répartition
pour les autres lots — les calculer autrement ne voudrait rien dire — et
s'accompagnent toujours de leur effectif et du nombre de lots exclus
faute de surface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:38:41 +02:00
fa54d523a5 fix: recharge la fiche quand l'URL change de lot
Passer d'un lot à un autre emprunte la même route : Vue réutilise le
composant sans le remonter, et `onMounted` ne rejoue pas. L'adresse
changeait donc en laissant à l'écran la fiche du lot précédent — l'écart
le plus trompeur qui soit entre l'URL et ce qu'on lit.

Le sélecteur ne montrait rien parce qu'il passe par `lotChoisi`, jamais
par l'URL. Un lien direct vers /lots/<id> depuis une fiche déjà ouverte
suffisait pourtant à produire le décalage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:38:25 +02:00
10 changed files with 1695 additions and 2 deletions

View 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">/</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>

View 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 , 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}` : '—'
}
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>

View 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 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>

View File

@@ -79,6 +79,10 @@
quelle part revient à quel lot.
</p>
<!-- Le loyer sur un axe de temps : c'est là que se voient une révision,
une vacance ou un décrochage, que les totaux du dessus écrasent. -->
<LoyerLot :loyer="analyse.loyer" />
<!-- Chronologie -->
<div class="card">
<div class="card-header">
@@ -225,6 +229,7 @@
<script setup>
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import LoyerLot from '../components/lot/LoyerLot.vue'
import { formatDate, formatMontant, formatMontantPrecis } from '../utils/format'
const API = import.meta.env.VITE_API_URL || ''
@@ -370,6 +375,18 @@ watch(lotChoisi, (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 () => {
try {
await chargerLots()

View File

@@ -54,3 +54,15 @@ export function formatMois(chaine) {
const [, mois] = chaine.split('-')
return MOIS_COURTS[parseInt(mois, 10) - 1] ?? chaine
}
/**
* "2026-06" → "juin 26", pour les séries qui traversent plusieurs années.
*
* `formatMois` suffit quand un titre porte déjà l'année ; sur trois ans de
* comptes rendus, deux mois de juin se confondraient.
*/
export function formatMoisAnnee(chaine) {
if (!chaine) return ''
const [annee, mois] = chaine.split('-')
return `${MOIS_COURTS[parseInt(mois, 10) - 1] ?? mois} ${annee.slice(2)}`
}

View File

@@ -14,6 +14,16 @@ Deux limites sont assumées plutôt que contournées :
- **les lignes sont rendues telles qu'extraites**, sans regroupement ni
dédoublonnage. Un acompte et son solde restent deux lignes, parce que le
compte rendu les porte ainsi.
Le bloc `loyer` fait exception à ce cumul : il remet le loyer sur un axe de
temps, seule façon de voir une révision, une vacance ou un décrochage que les
totaux écrasent. Ses règles vivent dans `services.loyers`, et la comparaison au
parc les rejoue à l'identique pour les autres lots — situer un chiffre par
rapport à des chiffres obtenus autrement ne voudrait rien dire.
Le loyer au mètre carré vient de la surface saisie sur la fiche du logement.
Aucun compte rendu n'en porte : tant qu'elle manque, le ratio reste `null` et la
page renvoie vers la saisie plutôt que d'afficher un zéro.
"""
from datetime import date
@@ -33,6 +43,16 @@ from ...database.models import (
Revenu,
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.revenus_query import (
TYPE_LIGNE_REPORT,
@@ -135,11 +155,118 @@ class Intervenant(BaseModel):
derniere_date: date | None = None
class PointLoyer(BaseModel):
"""Un mois de la courbe du loyer."""
mois: str # "2026-06"
#: Loyer hors charges du mois plein, `None` si aucune ligne ne le couvre.
loyer: float | None = None
charges: float | None = None
#: Facturé au prorata ce mois-là (entrée, sortie, avoir), hors du loyer.
prorata: float | None = None
#: Loyer au m², `None` sans surface saisie.
loyer_m2: float | None = None
#: Le loyer vient d'une ligne pluri-mensuelle répartie (bail trimestriel).
reparti: bool = False
#: Mois facturé seulement au prorata : un changement de locataire, pas une
#: vacance. Sans cette distinction, les deux se ressemblent sur la courbe.
en_transition: bool = False
class LigneHorsCourbe(BaseModel):
"""Une ligne de loyer qu'aucun mois ne peut revendiquer.
Régularisation rétroactive chevauchant plusieurs mois. Rendue à part pour
que la somme de la courbe et de ces lignes retrouve le facturé total.
"""
periode_debut: date | None = None
periode_fin: date | None = None
montant: float = 0.0
class LoyerEnVigueur(BaseModel):
"""Le dernier loyer connu, et depuis quand il tient."""
mois: str
loyer: float
loyer_m2: float | None = None
#: Premier mois du palier courant : la date d'effet de la dernière révision.
depuis: str
#: Loyer d'avant la dernière révision, et l'écart en %. `None` si le lot
#: n'a connu qu'un seul niveau depuis le premier compte rendu.
precedent: float | None = None
variation_pct: float | None = None
#: Le dernier compte rendu de l'immeuble porte encore ce loyer. Faux pour
#: un lot dont le bail s'est arrêté : son dernier loyer est une archive, et
#: l'afficher comme courant ferait croire à une recette qui n'existe plus.
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):
"""Fiche complète d'un lot."""
identite: LotIdentite
chiffres: LotChiffres
loyer: LotLoyer
chronologie: list[LigneChronologie]
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:
"""Ce que la ligne de recette dit d'elle-même.
@@ -358,6 +608,7 @@ async def analyser_lot(
return LotAnalyseResponse(
identite=_identite(session, lot),
chiffres=_chiffres(session, lot),
loyer=_loyer(session, lot),
chronologie=_chronologie(session, lot),
intervenants=_intervenants(session, lot),
)

View 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

View File

@@ -8,7 +8,7 @@ regrouper des lignes que le compte rendu a émises séparément.
import pytest
from plesna_gerance.database.models import Immeuble, Lot
from plesna_gerance.database.models import Immeuble, Lot, LotCaracteristiques
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)
== 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
View 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

2
uv.lock generated
View File

@@ -672,7 +672,7 @@ wheels = [
[[package]]
name = "plesna-gerance"
version = "0.1.0"
version = "0.1.1"
source = { editable = "." }
dependencies = [
{ name = "click" },