diff --git a/frontend/src/components/lot/LoyerLot.vue b/frontend/src/components/lot/LoyerLot.vue index 8aa0955..ec26d16 100644 --- a/frontend/src/components/lot/LoyerLot.vue +++ b/frontend/src/components/lot/LoyerLot.vue @@ -152,6 +152,40 @@ function formatM2(valeur) { return `${valeur.toFixed(2).replace('.', ',')} €/m²` } +// Ce que la tuile de tête a le droit d'affirmer. Un palier qui s'arrête ne +// suffit pas à dire qu'un lot est sorti de la gestion : entre deux baux, le +// compte rendu ne porte qu'un prorata, et le lot est bel et bien reloué. +const etatDuBail = computed(() => { + const vigueur = props.loyer.en_vigueur + if (!vigueur) return { libelle: 'Loyer', classe: 'text-gray-500', detail: '' } + + if (vigueur.toujours_loue) { + return { + libelle: 'Loyer en vigueur', + classe: 'text-green-400', + detail: `depuis ${formatMoisAnnee(vigueur.depuis)}` + } + } + + if (vigueur.reloue_depuis) { + // Le montant reste celui du bail d'avant : le nouveau n'a encore été + // facturé qu'au prorata, et en tirer un loyer mensuel l'inventerait. + return { + libelle: 'Dernier loyer plein', + classe: 'text-gray-400', + detail: `nouveau bail depuis ${formatMoisAnnee(vigueur.reloue_depuis)}` + } + } + + return { + libelle: 'Dernier loyer connu', + classe: 'text-gray-400', + detail: vigueur.sortie_en + ? `arrêté en cours de ${formatMoisAnnee(vigueur.sortie_en)}` + : `arrêté après ${formatMoisAnnee(vigueur.mois)}` + } +}) + const tuiles = computed(() => { const vigueur = props.loyer.en_vigueur @@ -166,14 +200,12 @@ const tuiles = computed(() => { return [ { - libelle: vigueur.toujours_loue ? 'Loyer en vigueur' : 'Dernier loyer connu', + libelle: etatDuBail.value.libelle, valeur: formatMontant(vigueur.loyer), - classe: vigueur.toujours_loue ? 'text-green-400' : 'text-gray-400', + classe: etatDuBail.value.classe, // Un lot sorti de la gestion garde un loyer affiché : sans cette mention, // il se lirait comme une recette courante. - detail: vigueur.toujours_loue - ? `depuis ${formatMoisAnnee(vigueur.depuis)}` - : `arrêté après ${formatMoisAnnee(vigueur.mois)}` + detail: etatDuBail.value.detail }, { libelle: 'Au m²', diff --git a/src/plesna_gerance/api/routes/lot_analyse.py b/src/plesna_gerance/api/routes/lot_analyse.py index 57d5612..75681ef 100644 --- a/src/plesna_gerance/api/routes/lot_analyse.py +++ b/src/plesna_gerance/api/routes/lot_analyse.py @@ -66,6 +66,7 @@ from ...database.models import ( ) from ...services.loyers import ( EFFECTIF_MEDIANE_FIABLE, + MoisLoue, loyer_au_m2, mediane, mois_de, @@ -246,7 +247,19 @@ class LoyerEnVigueur(BaseModel): #: Le dernier compte rendu de l'immeuble porte encore ce loyer. Faux pour #: un lot dont le bail s'est arrêté : son dernier loyer est une archive, et #: l'afficher comme courant ferait croire à une recette qui n'existe plus. + #: + #: Faux aussi pendant une relocation, où le loyer plein d'avant n'a plus + #: cours et celui d'après n'est pas encore facturé : `reloue_depuis` sépare + #: alors ce lot d'un lot réellement sorti de la gestion. toujours_loue: bool = True + #: Mois d'un prorata d'entrée postérieur à ce loyer : un nouveau bail a + #: commencé, et seuls les jours qu'il couvre sont facturés. Sans ce champ, + #: la fiche annoncerait un loyer « arrêté » sur un lot qui vient d'être + #: reloué. + reloue_depuis: str | None = None + #: Mois d'un prorata de sortie postérieur à ce loyer, quand aucune entrée + #: ne suit : la location s'arrête en cours de mois, pas à la fin du palier. + sortie_en: str | None = None class PointParc(BaseModel): @@ -609,6 +622,33 @@ def _mois_de_fin(ligne) -> str | None: return mois_de(borne) if borne is not None else None +def _apres_le_palier( + serie: list[MoisLoue], mois_fin: str +) -> tuple[str | None, str | None]: + """Ce que les mois postérieurs au dernier loyer plein disent du bail. + + Un palier qui s'arrête ne dit pas que le lot est vide : entre deux baux, le + compte rendu ne porte qu'un prorata, et un mois de transition n'ouvre pas de + palier. Lu sans lui, un lot reloué le 8 du mois passe pour sorti de la + gestion. + + L'entrée l'emporte sur la sortie parce qu'elle vient après : un locataire + part le 10 mars, un autre entre le 8 juillet, et c'est le second qui décrit + l'état du lot. Sans entrée, la dernière sortie donne le mois où la location + s'arrête vraiment — plus tard que la fin du palier, qu'elle déborde. + + Returns: + `(reloue_depuis, sortie_en)`, chacun `None` quand rien ne l'établit. + """ + suivants = [point for point in serie if point.mois > mois_fin] + entrees = [point.mois for point in suivants if point.entree] + if entrees: + return entrees[-1], None + + sorties = [point.mois for point in suivants if point.sortie] + return None, sorties[-1] if sorties else None + + def _loyer(session: Session, lot: Lot, periode: Periode) -> LotLoyer: """Le loyer du lot mois par mois, son niveau actuel et sa place au m². @@ -682,6 +722,8 @@ def _loyer(session: Session, lot: Lot, periode: Periode) -> LotLoyer: ) ).scalar() + reloue_depuis, sortie_en = _apres_le_palier(serie.mois, courant.mois_fin) + en_vigueur = LoyerEnVigueur( mois=courant.mois_fin, loyer=courant.loyer, @@ -690,6 +732,8 @@ def _loyer(session: Session, lot: Lot, periode: Periode) -> LotLoyer: precedent=precedent, variation_pct=variation(precedent, courant.loyer), toujours_loue=dernier_cr is None or courant.mois_fin >= mois_de(dernier_cr), + reloue_depuis=reloue_depuis, + sortie_en=sortie_en, ) parc = _comparaison(session, lot, courant.mois_fin, en_vigueur.loyer_m2) diff --git a/src/plesna_gerance/services/loyers.py b/src/plesna_gerance/services/loyers.py index e231d2e..5afcf0d 100644 --- a/src/plesna_gerance/services/loyers.py +++ b/src/plesna_gerance/services/loyers.py @@ -33,7 +33,10 @@ aucun mois ne peut alors le revendiquer. Un mois sans loyer plein mais avec un prorata n'est donc pas une vacance : c'est un mois de transition, et la fiche le distingue au lieu de le confondre avec un -trou. +trou. La même frontière dit dans quel sens il penche : un prorata qui court +jusqu'au dernier jour du mois ouvre un bail, un prorata qui part du premier sans +l'atteindre en ferme un. C'est ce qui sépare un lot reloué le 8 d'un lot sorti +de la gestion, là où le palier de loyer s'arrête dans les deux cas. Le mètre carré vient de la fiche saisie (`lot_caracteristiques.surface`), pas des comptes rendus qui l'ignorent. Sans surface, le ratio vaut ``None`` et non @@ -91,6 +94,32 @@ def mois_entiers(debut: date | None, fin: date | None) -> list[str]: return couverts +def est_entree(debut: date, fin: date, loyers: float | None) -> bool: + """Vrai si ce prorata ouvre un bail qui court encore le mois suivant. + + Un prorata qui s'arrête au dernier jour du mois sans avoir commencé le + premier facture la fin du mois : quelqu'un est entré en cours de route. La + location ne s'arrête donc pas là, même si aucun loyer plein ne suit encore. + + Un montant négatif est écarté : un avoir annule une facturation, il + n'ouvre pas un bail — et il porte parfois la même période qu'elle. + """ + if not loyers or loyers <= 0: + return False + return debut.day != 1 and fin.day == monthrange(fin.year, fin.month)[1] + + +def est_sortie(debut: date, fin: date, loyers: float | None) -> bool: + """Vrai si ce prorata ferme un bail en cours de mois. + + Miroir de `est_entree` : partir du premier jour sans atteindre le dernier, + c'est facturer le début du mois et s'arrêter. + """ + if not loyers or loyers <= 0: + return False + return debut.day == 1 and fin.day != monthrange(fin.year, fin.month)[1] + + @dataclass class MoisLoue: """Ce qu'un mois a été facturé, charges et proratas à part.""" @@ -109,6 +138,12 @@ class MoisLoue: prorata: float | None = None #: Vrai quand le loyer vient d'une ligne pluri-mensuelle répartie. reparti: bool = False + #: Vrai quand un prorata du mois court jusqu'à son dernier jour sans partir + #: du premier : un bail commence en cours de mois et continue après lui. + entree: bool = False + #: Vrai quand un prorata du mois part de son premier jour sans l'achever : + #: un bail s'arrête en cours de mois. + sortie: bool = False @property def en_transition(self) -> bool: @@ -161,6 +196,8 @@ def repartir(lignes) -> Serie: if debut is not None and fin is not None and mois_de(debut) == mois_de(fin): point = cumul.setdefault(mois_de(debut), MoisLoue(mois=mois_de(debut))) point.prorata = (point.prorata or 0.0) + (loyers or 0.0) + point.entree = point.entree or est_entree(debut, fin, loyers) + point.sortie = point.sortie or est_sortie(debut, fin, loyers) else: ecartees.append( LigneEcartee( diff --git a/tests/test_lot_analyse.py b/tests/test_lot_analyse.py index 7b92f8a..f289ea0 100644 --- a/tests/test_lot_analyse.py +++ b/tests/test_lot_analyse.py @@ -9,6 +9,7 @@ regrouper des lignes que le compte rendu a émises séparément. import pytest from plesna_gerance.database.models import ( + Document, Immeuble, Locataire, Lot, @@ -616,3 +617,151 @@ def test_la_comparaison_compte_les_lots_qu_elle_ne_peut_pas_voir( assert parc["mediane_immeuble"] is None assert parc["nb_immeuble"] == 0 assert parc["sans_surface"] == 0 + + +@pytest.fixture +def bail_qui_change(db_session, sample_data): + """Un lot dont le bail s'arrête en cours de mois, puis reprend au suivant. + + C'est la chronologie du lot 15 du parc : deux mois pleins, une sortie le + 10 mars, quatre mois vides, puis une entrée le 8 juillet facturée au seul + prorata. Le dernier compte rendu ne porte donc aucun loyer plein, alors que + le lot est bel et bien reloué. + """ + + def compte_rendu(reference, date_cr, lignes): + return { + **sample_data, + "metadata": { + **sample_data["metadata"], + "document": { + "reference": reference, + "date": date_cr, + "type": "COMPTE RENDU DE GESTION", + }, + }, + "situation_locataires": [ + { + "lot": {"numero": "01", "type": "Appartement"}, + "locataire": {"nom": lignes["nom"]}, + "lignes": [ + { + "type": "loyer", + "periode": { + "debut": lignes["debut"], + "fin": lignes["fin"], + }, + "loyers": lignes["loyers"], + "total": lignes["loyers"], + "regles": lignes["loyers"], + "impayes": 0.0, + } + ], + } + ], + "recapitulatif_operations": [], + } + + service = DatabaseService(db_session) + for reference, date_cr, lignes in [ + ( + "CR01", + "2026-01-26", + { + "nom": "SORTANT", + "debut": "2026-01-01", + "fin": "2026-01-31", + "loyers": 1447.05, + }, + ), + ( + "CR02", + "2026-02-26", + { + "nom": "SORTANT", + "debut": "2026-02-01", + "fin": "2026-02-28", + "loyers": 1447.05, + }, + ), + ( + "CR03", + "2026-03-23", + { + "nom": "SORTANT", + "debut": "2026-03-01", + "fin": "2026-03-10", + "loyers": 482.35, + }, + ), + ( + "CR04", + "2026-07-28", + { + "nom": "ENTRANT", + "debut": "2026-07-08", + "fin": "2026-07-31", + "loyers": 1111.67, + }, + ), + ]: + service.save_document(data=compte_rendu(reference, date_cr, lignes)) + + immeuble = db_session.query(Immeuble).filter(Immeuble.code == "IMM1").one() + lot = db_session.query(Lot).filter(Lot.immeuble_id == immeuble.id).one() + return lot + + +def test_un_lot_reloue_au_prorata_n_est_pas_un_lot_arrete(api_client, bail_qui_change): + """Le palier s'arrête en février, le lot est reloué en juillet. + + Sans cette lecture, la fiche annonce « arrêté après févr. » sur un lot qui + vient de retrouver un locataire — et la courbe juste dessous, où la barre + de juillet est bien là, la contredit. + """ + vigueur = api_client.get(f"/api/lots/{bail_qui_change.id}/analyse").json()["loyer"][ + "en_vigueur" + ] + + assert vigueur["reloue_depuis"] == "2026-07" + assert vigueur["sortie_en"] is None + # Le montant reste celui du bail précédent : le nouveau n'a été facturé + # qu'au prorata, et en tirer un loyer mensuel l'inventerait. + assert vigueur["mois"] == "2026-02" + assert vigueur["loyer"] == 1447.05 + assert vigueur["toujours_loue"] is False + + +def test_une_sortie_en_cours_de_mois_deborde_le_dernier_palier( + api_client, db_session, bail_qui_change +): + """Sans relocation, c'est la sortie qui date la fin de la location. + + Le dernier loyer plein est celui de février, mais le lot est resté loué + jusqu'au 10 mars : dire « arrêté après févr. » avancerait la sortie d'un + mois. + """ + dernier = ( + db_session.query(Document).order_by(Document.date.desc()).first() + ) + db_session.delete(dernier) + db_session.commit() + + vigueur = api_client.get(f"/api/lots/{bail_qui_change.id}/analyse").json()["loyer"][ + "en_vigueur" + ] + + assert vigueur["sortie_en"] == "2026-03" + assert vigueur["reloue_depuis"] is None + + +def test_un_loyer_qui_s_arrete_net_reste_un_loyer_arrete(api_client, donnees): + """Aucun prorata après le dernier palier : rien ne nuance l'arrêt.""" + _, lot = donnees + + vigueur = api_client.get(f"/api/lots/{lot.id}/analyse").json()["loyer"][ + "en_vigueur" + ] + + assert vigueur["reloue_depuis"] is None + assert vigueur["sortie_en"] is None diff --git a/tests/test_loyers.py b/tests/test_loyers.py index 7d80304..94e1b6d 100644 --- a/tests/test_loyers.py +++ b/tests/test_loyers.py @@ -139,6 +139,57 @@ class TestRepartition: assert point.prorata == -1390.0 assert point.en_transition is False + def test_un_prorata_qui_finit_le_mois_est_une_entree(self): + """Lot 15 du parc : le locataire entre le 8 juillet. La location ne + s'arrête donc pas au dernier loyer plein, elle recommence.""" + serie = repartir([ligne("2026-07-08", "2026-07-31", 1111.67)]) + + point = serie.mois[0] + assert point.entree is True + assert point.sortie is False + + def test_un_prorata_qui_ouvre_le_mois_est_une_sortie(self): + """Même lot, quatre mois plus tôt : le locataire part le 10 mars.""" + serie = repartir([ligne("2026-03-01", "2026-03-10", 482.35)]) + + point = serie.mois[0] + assert point.sortie is True + assert point.entree is False + + def test_un_mois_qui_change_de_locataire_porte_les_deux(self): + """Sortie le 3, entrée le 14 : le mois ferme un bail et en ouvre un + autre, et c'est l'entrée qui dit que le lot reste loué.""" + serie = repartir( + [ + ligne("2025-03-01", "2025-03-03", 109.68), + ligne("2025-03-14", "2025-03-31", 622.2), + ] + ) + + point = serie.mois[0] + assert point.entree is True + assert point.sortie is True + + def test_un_avoir_n_ouvre_ni_ne_ferme_de_bail(self): + """Un avoir porte la période qu'il annule : lu comme une entrée, il + ferait croire à une relocation là où rien n'a été loué.""" + serie = repartir( + [ + ligne("2024-10-15", "2024-10-31", -700.0), + ligne("2024-11-18", "2024-11-18", -1390.0), + ] + ) + + assert [point.entree for point in serie.mois] == [False, False] + assert [point.sortie for point in serie.mois] == [False, False] + + def test_un_mois_plein_n_est_ni_entree_ni_sortie(self): + serie = repartir([ligne("2026-01-01", "2026-01-31", 1447.05)]) + + point = serie.mois[0] + assert point.entree is False + assert point.sortie is False + def test_une_regularisation_a_cheval_reste_hors_de_la_courbe(self): """De mars à août sans couvrir un mois entier : l'étaler inventerait six demi-mois de loyer."""