refactor: donne une source unique aux colonnes du compte rendu

L'ordre et les libellés des colonnes étaient retapés à six endroits : l'en-tête
du tableau, deux boucles de cellules, la ligne « Totaux », la table des libellés
et la liste des colonnes recoupées. Ces copies avaient déjà divergé — le solde
antérieur, rendu hors de la boucle, était comparé aux lignes sans que son écart
puisse s'afficher dans sa cellule. `COLONNES_CRG` devient la seule déclaration,
et tout le reste en dérive ; l'écart du solde antérieur s'affiche du coup là où
on le cherche.

`ecartsAvecLignes` rapporte maintenant l'extrait, le calculé et ce qui manque
entre les deux. Le composant refaisait cette soustraction pour son infobulle,
avec un `|| 0` là où l'utilitaire emploie `Number` et `Number.isFinite` : deux
règles de coercition pour un même calcul, libres de diverger.

Le contenu déplié d'un lot passe de `v-show` à `v-if`. Le tableau des lignes
compte une centaine de champs éditables ; les garder montés pour la vingtaine de
lots d'un document faisait re-rendre à chaque frappe deux mille champs que
personne ne regardait.

`setNestedValue` était recopié à l'identique dans trois composants et
`formatCurrency` redéfini dans le composant alors que `utils/format.js` existe
et dit lui-même que les nouveaux affichages passent par lui. Le premier part
dans `utils/chemin.js`, le second cède la place à `formatMontantPrecis`, dont le
formateur `Intl` est construit une fois pour toutes.

`EditableField` affirmait en dur que sa valeur venait du compte rendu. C'est vrai
des trois écrans qui l'emploient aujourd'hui, mais un formulaire de saisie
manuelle mentirait sans le savoir : l'origine devient une prop, avec cette
valeur par défaut.

Le type des lignes de report est défini des deux côtés de l'application sans
lien entre eux ; chacun renvoie désormais à l'autre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 14:18:58 +02:00
parent 6a638df1ab
commit 8c959f5d5f
9 changed files with 186 additions and 150 deletions

View File

@@ -79,6 +79,15 @@ const props = defineProps({
inputClass: { inputClass: {
type: String, type: String,
default: '' default: ''
},
/**
* D' vient la valeur, pour l'infobulle. Les trois écrans qui emploient ce
* champ éditent aujourd'hui de l'extraction, d' ce défaut ; une saisie
* manuelle (référentiel, formulaire) passerait son propre libellé.
*/
origine: {
type: String,
default: 'extraite du compte rendu'
} }
}) })
@@ -106,12 +115,12 @@ const displayValue = computed(() => {
return props.modelValue return props.modelValue
}) })
// Ce composant n'édite que des champs issus de l'extraction : le survol le dit, // Le survol dit d'où vient la valeur, pour qu'on ne la confonde jamais avec une
// pour qu'on ne confonde jamais une valeur lue dans le PDF avec une valeur déduite. // valeur déduite affichée à côté.
const titreSurvol = computed(() => const titreSurvol = computed(() =>
isEmpty.value isEmpty.value
? "Aucune valeur extraite du compte rendu — cliquer pour la saisir" ? `Aucune valeur ${props.origine} — cliquer pour la saisir`
: 'Valeur extraite du compte rendu — cliquer pour la modifier' : `Valeur ${props.origine} — cliquer pour la modifier`
) )
const inputType = computed(() => { const inputType = computed(() => {

View File

@@ -177,6 +177,7 @@ import DataCard from './DataCard.vue'
import DataRow from './DataRow.vue' import DataRow from './DataRow.vue'
import LocataireCard from './LocataireCard.vue' import LocataireCard from './LocataireCard.vue'
import OperationCard from './OperationCard.vue' import OperationCard from './OperationCard.vue'
import { setNestedValue } from '../utils/chemin'
const props = defineProps({ const props = defineProps({
data: { data: {
@@ -348,19 +349,6 @@ function cloneData() {
return JSON.parse(JSON.stringify(props.data)) return JSON.parse(JSON.stringify(props.data))
} }
// Helper pour setter une valeur nested
function setNestedValue(obj, path, value) {
const parts = path.split('.')
let current = obj
for (let i = 0; i < parts.length - 1; i++) {
if (!current[parts[i]]) {
current[parts[i]] = {}
}
current = current[parts[i]]
}
current[parts[parts.length - 1]] = value
}
// Mettre a jour les metadonnees // Mettre a jour les metadonnees
function updateMetadata(path, value) { function updateMetadata(path, value) {
const updated = cloneData() const updated = cloneData()

View File

@@ -63,11 +63,11 @@
title="Valeur extraite, reportée de la ligne Totaux — déplier le lot pour la modifier" title="Valeur extraite, reportée de la ligne Totaux — déplier le lot pour la modifier"
> >
<div class="text-sm font-semibold" :class="totalClass"> <div class="text-sm font-semibold" :class="totalClass">
{{ formatCurrency(locataire.totaux?.total) }} {{ formatMontantPrecis(locataire.totaux?.total) }}
</div> </div>
<div v-if="locataire.totaux?.impayes" class="text-xs" :class="locataire.totaux.impayes > 0 ? 'text-red-400' : 'text-blue-400'"> <div v-if="locataire.totaux?.impayes" class="text-xs" :class="locataire.totaux.impayes > 0 ? 'text-red-400' : 'text-blue-400'">
{{ locataire.totaux.impayes > 0 ? 'Impayes' : 'Trop-percu' }}: {{ locataire.totaux.impayes > 0 ? 'Impayes' : 'Trop-percu' }}:
{{ formatCurrency(Math.abs(locataire.totaux.impayes)) }} {{ formatMontantPrecis(Math.abs(locataire.totaux.impayes)) }}
</div> </div>
</div> </div>
<!-- Bouton supprimer --> <!-- Bouton supprimer -->
@@ -83,8 +83,11 @@
</div> </div>
</div> </div>
<!-- Content : le tableau du compte rendu, colonne pour colonne --> <!-- Content : le tableau du compte rendu, colonne pour colonne.
<div v-show="expanded" class="border-t border-gray-700 bg-gray-900/60 p-3"> Monte a l'ouverture (`v-if`) : un document compte une vingtaine de lots,
et garder replies une vingtaine de tableaux d'une centaine de champs
editables les ferait re-rendre a chaque frappe sans qu'on les voie. -->
<div v-if="expanded" class="border-t border-gray-700 bg-gray-900/60 p-3">
<div class="flex items-center justify-between mb-1"> <div class="flex items-center justify-between mb-1">
<span class="text-xs text-gray-500 font-medium">Detail des lignes</span> <span class="text-xs text-gray-500 font-medium">Detail des lignes</span>
<button <button
@@ -104,13 +107,14 @@
<tr class="text-[10px] uppercase tracking-wide text-gray-500"> <tr class="text-[10px] uppercase tracking-wide text-gray-500">
<th class="text-left font-medium pb-1 pr-2">Type</th> <th class="text-left font-medium pb-1 pr-2">Type</th>
<th class="text-left font-medium pb-1 pr-2">Periode</th> <th class="text-left font-medium pb-1 pr-2">Periode</th>
<th class="text-right font-medium pb-1 px-2">Loyers</th> <th
<th class="text-right font-medium pb-1 px-2">Taxes</th> v-for="colonne in COLONNES_TABLEAU"
<th class="text-right font-medium pb-1 px-2">Provisions</th> :key="colonne.champ"
<th class="text-left font-medium pb-1 px-2">Divers</th> class="font-medium pb-1 px-2"
<th class="text-right font-medium pb-1 px-2">Total</th> :class="alignement(colonne)"
<th class="text-right font-medium pb-1 px-2">Regles</th> >
<th class="text-right font-medium pb-1 px-2">Impaye</th> {{ colonne.libelle }}
</th>
<th class="pb-1"></th> <th class="pb-1"></th>
</tr> </tr>
</thead> </thead>
@@ -166,21 +170,14 @@
</div> </div>
</td> </td>
<td v-for="champ in ['loyers', 'taxes', 'provisions']" :key="champ" class="py-1 px-2 border-t border-gray-700/60"> <td
<div v-if="champ !== 'loyers' || ligne.type !== 'solde_anterieur'" class="flex justify-end"> v-for="colonne in COLONNES_TABLEAU"
<EditableField :key="colonne.champ"
:modelValue="ligne[champ]" class="py-1 px-2 border-t border-gray-700/60"
@update:modelValue="updateLigneField(idx, champ, $event)" >
type="currency" <!-- Divers : libelle puis montant, comme sur le compte rendu.
displayClass="text-xs text-gray-200" Le libelle reste vide tant qu'il n'y en a pas, mais cliquable. -->
/> <div v-if="colonne.champ === 'divers'" class="flex items-center justify-between gap-2">
</div>
</td>
<!-- Divers : libelle puis montant, comme sur le compte rendu -->
<td class="py-1 px-2 border-t border-gray-700/60">
<div class="flex items-center justify-between gap-2">
<!-- Cellule laissee vide, comme sur le compte rendu, mais cliquable -->
<EditableField <EditableField
:modelValue="ligne.divers?.libelle" :modelValue="ligne.divers?.libelle"
@update:modelValue="updateLigneField(idx, 'divers.libelle', $event)" @update:modelValue="updateLigneField(idx, 'divers.libelle', $event)"
@@ -195,15 +192,16 @@
displayClass="text-xs text-gray-200" displayClass="text-xs text-gray-200"
/> />
</div> </div>
</td> <!-- Le montant d'un report est rendu dans la colonne « Periode » -->
<div
<td v-for="champ in ['total', 'regles', 'impayes']" :key="champ" class="py-1 px-2 border-t border-gray-700/60"> v-else-if="colonne.champ !== 'loyers' || ligne.type !== 'solde_anterieur'"
<div class="flex justify-end"> class="flex justify-end"
>
<EditableField <EditableField
:modelValue="ligne[champ]" :modelValue="ligne[colonne.champ]"
@update:modelValue="updateLigneField(idx, champ, $event)" @update:modelValue="updateLigneField(idx, colonne.champ, $event)"
type="currency" type="currency"
:displayClass="`text-xs ${classeMontant(champ, ligne[champ])}`" :displayClass="`text-xs ${classeMontant(colonne.champ, ligne[colonne.champ])}`"
/> />
</div> </div>
</td> </td>
@@ -233,7 +231,12 @@
<tr class="bg-gray-800"> <tr class="bg-gray-800">
<td class="py-1 pr-2 border-t-2 border-gray-600 text-gray-300 font-medium">Totaux</td> <td class="py-1 pr-2 border-t-2 border-gray-600 text-gray-300 font-medium">Totaux</td>
<td class="py-1 pr-2 border-t-2 border-gray-600"> <!-- Le solde anterieur occupe la colonne « Periode », comme sur le
compte rendu, mais se recoupe comme les autres colonnes. -->
<td
class="py-1 pr-2 border-t-2 border-gray-600"
:class="classeCellule('solde_anterieur')"
>
<div class="flex items-center gap-1 whitespace-nowrap"> <div class="flex items-center gap-1 whitespace-nowrap">
<span class="text-[10px] uppercase tracking-wide text-gray-500">Solde ant.</span> <span class="text-[10px] uppercase tracking-wide text-gray-500">Solde ant.</span>
<EditableField <EditableField
@@ -242,31 +245,38 @@
type="currency" type="currency"
displayClass="text-xs text-gray-200" displayClass="text-xs text-gray-200"
/> />
<span
v-if="ecarts.solde_anterieur"
class="text-[10px] text-amber-500/90 italic cursor-help whitespace-nowrap"
:title="detailEcart('solde_anterieur')"
>
calculé : {{ formatMontantPrecis(ecarts.solde_anterieur.calcule) }}
</span>
</div> </div>
</td> </td>
<!-- Une colonne qui ne somme pas est signalee sur toute la cellule : <!-- Une colonne qui ne somme pas est signalee sur toute la cellule :
c'est le premier endroit ou l'oeil verifie le compte rendu. --> c'est le premier endroit ou l'oeil verifie le compte rendu. -->
<td <td
v-for="champ in CHAMPS_TOTAUX" v-for="colonne in COLONNES_TABLEAU"
:key="champ" :key="colonne.champ"
class="py-1 px-2 border-t-2 border-gray-600" class="py-1 px-2 border-t-2 border-gray-600"
:class="champ in ecarts ? 'bg-amber-500/10 ring-1 ring-inset ring-amber-500/40' : ''" :class="classeCellule(colonne.champ)"
> >
<div class="flex flex-col items-end"> <div class="flex flex-col items-end">
<EditableField <EditableField
:modelValue="locataire.totaux?.[champ]" :modelValue="locataire.totaux?.[colonne.champ]"
@update:modelValue="updateField(`totaux.${champ}`, $event)" @update:modelValue="updateField(`totaux.${colonne.champ}`, $event)"
type="currency" type="currency"
:displayClass="`text-xs font-semibold ${classeMontant(champ, locataire.totaux?.[champ])}`" :displayClass="`text-xs font-semibold ${classeMontant(colonne.champ, locataire.totaux?.[colonne.champ])}`"
/> />
<!-- Somme de la colonne. Ni pointille ni contraste : non modifiable. --> <!-- Somme de la colonne. Ni pointille ni contraste : non modifiable. -->
<span <span
v-if="champ in ecarts" v-if="ecarts[colonne.champ]"
class="text-[10px] text-amber-500/90 italic mt-0.5 cursor-help px-1 whitespace-nowrap" class="text-[10px] text-amber-500/90 italic mt-0.5 cursor-help px-1 whitespace-nowrap"
:title="detailEcart(champ)" :title="detailEcart(colonne.champ)"
> >
calculé : {{ formatCurrency(ecarts[champ]) }} calculé : {{ formatMontantPrecis(ecarts[colonne.champ].calcule) }}
</span> </span>
</div> </div>
</td> </td>
@@ -289,7 +299,14 @@
<script setup> <script setup>
import { ref, computed, watch, nextTick } from 'vue' import { ref, computed, watch, nextTick } from 'vue'
import EditableField from './EditableField.vue' import EditableField from './EditableField.vue'
import { totauxCalcules, ecartsAvecLignes } from '../utils/totauxLocataire' import { setNestedValue } from '../utils/chemin'
import { formatMontantPrecis } from '../utils/format'
import {
COLONNES_CRG,
COLONNES_TABLEAU,
ecartsAvecLignes,
totauxCalcules,
} from '../utils/totauxLocataire'
const props = defineProps({ const props = defineProps({
locataire: { locataire: {
@@ -312,19 +329,9 @@ const props = defineProps({
const emit = defineEmits(['update:locataire', 'remove']) const emit = defineEmits(['update:locataire', 'remove'])
// Colonnes de la ligne « Totaux », dans l'ordre du compte rendu. const LIBELLES = Object.fromEntries(
const CHAMPS_TOTAUX = ['loyers', 'taxes', 'provisions', 'divers', 'total', 'regles', 'impayes'] COLONNES_CRG.map(({ champ, libelle }) => [champ, libelle])
)
const LIBELLES = {
solde_anterieur: 'Solde anterieur',
loyers: 'Loyers',
taxes: 'Taxes',
provisions: 'Provisions',
divers: 'Divers',
total: 'Total',
regles: 'Regles',
impayes: 'Impaye',
}
const expanded = ref(false) const expanded = ref(false)
const rootEl = ref(null) const rootEl = ref(null)
@@ -360,12 +367,12 @@ const resumeEcarts = computed(() =>
function detailEcart(champ) { function detailEcart(champ) {
const nb = props.locataire.lignes?.length || 0 const nb = props.locataire.lignes?.length || 0
const manquant = (props.locataire.totaux?.[champ] || 0) - ecarts.value[champ] const { extrait, calcule, manquant } = ecarts.value[champ]
return ( return (
`${LIBELLES[champ]}\n` + `${LIBELLES[champ]}\n` +
`Extrait du compte rendu : ${formatCurrency(props.locataire.totaux?.[champ])}\n` + `Extrait du compte rendu : ${formatMontantPrecis(extrait)}\n` +
`Calculé sur les ${nb} ligne(s) : ${formatCurrency(ecarts.value[champ])}\n` + `Calculé sur les ${nb} ligne(s) : ${formatMontantPrecis(calcule)}\n` +
`Écart de ${formatCurrency(manquant)} : aucune ligne ne porte ce montant.` `Écart de ${formatMontantPrecis(manquant)} : aucune ligne ne porte ce montant.`
) )
} }
@@ -383,9 +390,13 @@ function classeMontant(champ, valeur) {
return 'text-gray-200' return 'text-gray-200'
} }
function formatCurrency(value) { function classeCellule(champ) {
if (value === null || value === undefined) return '-' return ecarts.value[champ] ? 'bg-amber-500/10 ring-1 ring-inset ring-amber-500/40' : ''
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value) }
// Le compte rendu aligne ses montants a droite et le libelle « Divers » a gauche.
function alignement(colonne) {
return colonne.champ === 'divers' ? 'text-left' : 'text-right'
} }
// Mettre a jour un champ nested (ex: "lot.numero", "totaux.loyers") // Mettre a jour un champ nested (ex: "lot.numero", "totaux.loyers")
@@ -395,30 +406,13 @@ function updateField(path, value) {
emit('update:locataire', updated) emit('update:locataire', updated)
} }
// Helper pour setter une valeur nested
function setNestedValue(obj, path, value) {
const parts = path.split('.')
let current = obj
for (let i = 0; i < parts.length - 1; i++) {
if (!current[parts[i]]) {
current[parts[i]] = {}
}
current = current[parts[i]]
}
current[parts[parts.length - 1]] = value
}
// Mettre a jour un champ d'une ligne. Aucun autre champ n'est touche : une // Mettre a jour un champ d'une ligne. Aucun autre champ n'est touche : une
// correction de l'utilisateur ne doit rien declencher qu'il n'ait pas demande. // correction de l'utilisateur ne doit rien declencher qu'il n'ait pas demande.
function updateLigneField(ligneIndex, path, value) { function updateLigneField(ligneIndex, path, value) {
const updated = JSON.parse(JSON.stringify(props.locataire)) const updated = JSON.parse(JSON.stringify(props.locataire))
if (!updated.lignes) updated.lignes = [] if (!updated.lignes) updated.lignes = []
if (path.includes('.')) { setNestedValue(updated.lignes[ligneIndex], path, value)
setNestedValue(updated.lignes[ligneIndex], path, value)
} else {
updated.lignes[ligneIndex][path] = value
}
emit('update:locataire', updated) emit('update:locataire', updated)
} }

View File

@@ -194,6 +194,7 @@
<script setup> <script setup>
import { ref, computed, watch, nextTick } from 'vue' import { ref, computed, watch, nextTick } from 'vue'
import EditableField from './EditableField.vue' import EditableField from './EditableField.vue'
import { setNestedValue } from '../utils/chemin'
const props = defineProps({ const props = defineProps({
categorie: { categorie: {
@@ -257,19 +258,6 @@ function formatCurrency(value) {
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value) return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value)
} }
// Helper pour setter une valeur nested
function setNestedValue(obj, path, value) {
const parts = path.split('.')
let current = obj
for (let i = 0; i < parts.length - 1; i++) {
if (!current[parts[i]]) {
current[parts[i]] = {}
}
current = current[parts[i]]
}
current[parts[parts.length - 1]] = value
}
// Mettre a jour un champ d'une operation // Mettre a jour un champ d'une operation
function updateOperationField(opIndex, path, value) { function updateOperationField(opIndex, path, value) {
const updatedOperations = JSON.parse(JSON.stringify(props.operations)) const updatedOperations = JSON.parse(JSON.stringify(props.operations))

View File

@@ -0,0 +1,20 @@
/**
* Écriture d'une valeur désignée par un chemin pointé (`"divers.montant"`).
*
* Les formulaires d'édition adressent leurs champs par ce chemin plutôt que par
* une référence : ils travaillent sur une copie fraîche de l'objet à chaque
* modification, et une référence prise avant la copie viserait l'ancien.
*/
/** Écrit `valeur` à `chemin`, en créant les objets intermédiaires manquants. */
export function setNestedValue(objet, chemin, valeur) {
const parties = chemin.split('.')
let courant = objet
for (let i = 0; i < parties.length - 1; i++) {
if (!courant[parties[i]]) {
courant[parties[i]] = {}
}
courant = courant[parties[i]]
}
courant[parties[parties.length - 1]] = valeur
}

View File

@@ -1,5 +1,5 @@
/** /**
* Recoupement des totaux d'un lot avec ses lignes. * Colonnes du compte rendu de gérance, et recoupement des totaux d'un lot.
* *
* La page d'édition n'affiche que de l'extraction, et chaque champ extrait y * La page d'édition n'affiche que de l'extraction, et chaque champ extrait y
* reste modifiable : c'est l'utilisateur qui tranche avant l'enregistrement. * reste modifiable : c'est l'utilisateur qui tranche avant l'enregistrement.
@@ -15,13 +15,43 @@
* *
* Sur les 388 lots des documents extraits, ce recoupement signale 4 lots, tous * Sur les 388 lots des documents extraits, ce recoupement signale 4 lots, tous
* de vraies extractions incomplètes. * de vraies extractions incomplètes.
*
* Le solde antérieur est reporté par le parser dans la colonne `loyers` d'une
* ligne dédiée ; le sommer avec les loyers de la période le compterait deux fois.
*/ */
/**
* Type des lignes qui reportent le solde du compte rendu précédent.
*
* Même valeur que `TYPE_LIGNE_REPORT` côté Python
* (`services/revenus_query.py`), où elle sépare les stocks des flux. Les deux
* définitions décrivent la sortie du même parser et doivent bouger ensemble.
*/
const TYPE_REPORT = 'solde_anterieur' const TYPE_REPORT = 'solde_anterieur'
/**
* Les colonnes du compte rendu, dans son ordre d'impression.
*
* Source unique de l'ordre et des libellés : le tableau d'édition, ses en-têtes
* et le recoupement des totaux s'en déduisent tous, plutôt que d'en tenir
* chacun sa copie.
*
* - `champ` : clé dans les totaux d'un lot
* - `champLigne` : clé correspondante sur une ligne, quand elle diffère
* - `entete` : le compte rendu ne donne pas de colonne au solde antérieur, il
* l'imprime dans la colonne « Période »
*/
export const COLONNES_CRG = [
{ champ: 'solde_anterieur', libelle: 'Solde anterieur', entete: false },
{ champ: 'loyers', libelle: 'Loyers' },
{ champ: 'taxes', libelle: 'Taxes' },
{ champ: 'provisions', libelle: 'Provisions' },
{ champ: 'divers', libelle: 'Divers', champLigne: 'divers.montant' },
{ champ: 'total', libelle: 'Total' },
{ champ: 'regles', libelle: 'Regles' },
{ champ: 'impayes', libelle: 'Impaye' },
]
/** Les colonnes qui ont un en-tête dans le tableau. */
export const COLONNES_TABLEAU = COLONNES_CRG.filter((c) => c.entete !== false)
/** Montant exploitable d'un champ : `null` et `undefined` valent zéro. */ /** Montant exploitable d'un champ : `null` et `undefined` valent zéro. */
function montant(valeur) { function montant(valeur) {
const nombre = Number(valeur) const nombre = Number(valeur)
@@ -36,22 +66,15 @@ function auCentime(valeur) {
/** /**
* Agrège les lignes d'un lot en un jeu de totaux de même forme que `totaux`. * Agrège les lignes d'un lot en un jeu de totaux de même forme que `totaux`.
* *
* Le solde antérieur est reporté par le parser dans la colonne `loyers` d'une
* ligne dédiée ; le sommer avec les loyers de la période le compterait deux fois.
*
* @param {Array} lignes - lignes du lot (`locataire.lignes`) * @param {Array} lignes - lignes du lot (`locataire.lignes`)
* @returns {Object} totaux déduits, tous champs présents et arrondis au centime * @returns {Object} totaux déduits, une clé par colonne, arrondis au centime
*/ */
export function totauxCalcules(lignes) { export function totauxCalcules(lignes) {
const liste = Array.isArray(lignes) ? lignes : [] const liste = Array.isArray(lignes) ? lignes : []
const totaux = Object.fromEntries(COLONNES_CRG.map(({ champ }) => [champ, 0]))
const totaux = {
solde_anterieur: 0,
loyers: 0,
taxes: 0,
provisions: 0,
divers: 0,
total: 0,
regles: 0,
impayes: 0,
}
for (const ligne of liste) { for (const ligne of liste) {
if (ligne?.type === TYPE_REPORT) { if (ligne?.type === TYPE_REPORT) {
@@ -74,18 +97,6 @@ export function totauxCalcules(lignes) {
return totaux return totaux
} }
/** Colonnes de la ligne « Totaux » que l'on sait recouper avec les lignes. */
const CHAMPS_COMPARES = [
'solde_anterieur',
'loyers',
'taxes',
'provisions',
'divers',
'total',
'regles',
'impayes',
]
/** /**
* Colonnes où la ligne « Totaux » extraite contredit les lignes extraites. * Colonnes où la ligne « Totaux » extraite contredit les lignes extraites.
* *
@@ -95,16 +106,19 @@ const CHAMPS_COMPARES = [
* *
* @param {Object} extraits - `locataire.totaux`, tel que lu dans le PDF * @param {Object} extraits - `locataire.totaux`, tel que lu dans le PDF
* @param {Object} calcules - sortie de `totauxCalcules` * @param {Object} calcules - sortie de `totauxCalcules`
* @returns {Object} par colonne divergente, la valeur déduite des lignes * @returns {Object} par colonne divergente, `{ extrait, calcule, manquant }`
*/ */
export function ecartsAvecLignes(extraits, calcules) { export function ecartsAvecLignes(extraits, calcules) {
const ecarts = {} const ecarts = {}
if (!extraits) return ecarts if (!extraits) return ecarts
for (const champ of CHAMPS_COMPARES) { for (const champ of Object.keys(calcules)) {
if (extraits[champ] === null || extraits[champ] === undefined) continue if (extraits[champ] === null || extraits[champ] === undefined) continue
if (Math.abs(montant(extraits[champ]) - montant(calcules[champ])) > 0.005) {
ecarts[champ] = calcules[champ] const extrait = montant(extraits[champ])
const calcule = montant(calcules[champ])
if (Math.abs(extrait - calcule) > 0.005) {
ecarts[champ] = { extrait, calcule, manquant: auCentime(extrait - calcule) }
} }
} }

View File

@@ -43,9 +43,10 @@ function lotCharlot(surcharges = {}) {
// Les montants sont formatés avec des espaces insécables (séparateur de milliers, // Les montants sont formatés avec des espaces insécables (séparateur de milliers,
// espace avant €) et le template en insère aux sauts de ligne : tous les blancs // espace avant €) et le template en insère aux sauts de ligne : tous les blancs
// sont ramenés à un espace simple pour pouvoir chercher un montant. // sont ramenés à un espace simple pour pouvoir chercher un montant.
// `highlighted` déplie la carte : le tableau n'est monté qu'à l'ouverture.
async function rendre(locataire) { async function rendre(locataire) {
const app = createSSRApp({ const app = createSSRApp({
render: () => h(LocataireCard, { locataire }), render: () => h(LocataireCard, { locataire, highlighted: true }),
}) })
const html = await renderToString(app) const html = await renderToString(app)
return html.replace(/\s+/g, ' ') return html.replace(/\s+/g, ' ')
@@ -109,6 +110,17 @@ describe('LocataireCard', () => {
expect(html).toContain('bg-amber-500/10') expect(html).toContain('bg-amber-500/10')
}) })
it('signale aussi le solde antérieur, qui na pas de colonne à lui', async () => {
// Il occupe la colonne « Période » de la ligne Totaux : son écart doit y
// apparaître comme celui des colonnes rendues par la boucle.
const lot = lotCharlot()
lot.totaux.solde_anterieur = 49170.47
const html = await rendre(lot)
expect(html).toContain('calculé : 0,00 €')
expect(html).toContain('A verifier')
})
it('dit au survol ce qui est extrait et ce qui est calculé', async () => { it('dit au survol ce qui est extrait et ce qui est calculé', async () => {
const lot = lotCharlot() const lot = lotCharlot()
lot.totaux.regles = 707.29 lot.totaux.regles = 707.29

View File

@@ -138,7 +138,10 @@ describe('ecartsAvecLignes', () => {
}, },
calcules calcules
) )
expect(ecarts).toEqual({ total: 0, regles: 0 }) expect(ecarts).toEqual({
total: { extrait: 707.29, calcule: 0, manquant: 707.29 },
regles: { extrait: 707.29, calcule: 0, manquant: 707.29 },
})
}) })
it('signale un divers absent des lignes extraites', () => { it('signale un divers absent des lignes extraites', () => {
@@ -146,18 +149,22 @@ describe('ecartsAvecLignes', () => {
{ type: 'divers', divers: { montant: -455 } }, { type: 'divers', divers: { montant: -455 } },
{ type: 'divers', divers: { montant: 23.02 } }, { type: 'divers', divers: { montant: 23.02 } },
]) ])
expect(ecartsAvecLignes({ divers: -123.22 }, calcules)).toEqual({ divers: -431.98 }) expect(ecartsAvecLignes({ divers: -123.22 }, calcules)).toEqual({
divers: { extrait: -123.22, calcule: -431.98, manquant: 308.76 },
})
}) })
it('signale un lot dont aucune ligne na été extraite', () => { it('signale un lot dont aucune ligne na été extraite', () => {
// Lot 08 de S_33680000_2025-08-26 : 100 € réglés, aucune ligne. // Lot 08 de S_33680000_2025-08-26 : 100 € réglés, aucune ligne.
const ecarts = ecartsAvecLignes({ loyers: 0, regles: 100 }, totauxCalcules([])) const ecarts = ecartsAvecLignes({ loyers: 0, regles: 100 }, totauxCalcules([]))
expect(ecarts).toEqual({ regles: 0 }) expect(ecarts).toEqual({ regles: { extrait: 100, calcule: 0, manquant: 100 } })
}) })
it('renvoie la valeur déduite des lignes, jamais celle du compte rendu', () => { it('rapporte lextrait, le calculé et ce qui manque entre les deux', () => {
const calcules = totauxCalcules([{ type: 'loyer', loyers: 500 }]) const calcules = totauxCalcules([{ type: 'loyer', loyers: 500 }])
expect(ecartsAvecLignes({ loyers: 800 }, calcules)).toEqual({ loyers: 500 }) expect(ecartsAvecLignes({ loyers: 800 }, calcules)).toEqual({
loyers: { extrait: 800, calcule: 500, manquant: 300 },
})
}) })
it('tolère un écart darrondi sous le centime', () => { it('tolère un écart darrondi sous le centime', () => {

View File

@@ -28,6 +28,10 @@ from sqlalchemy import and_, func, select
from ..database.models import Document, Revenu from ..database.models import Document, Revenu
#: Type des lignes qui reportent le solde du compte rendu précédent. #: Type des lignes qui reportent le solde du compte rendu précédent.
#:
#: Repris tel quel côté frontend (``utils/totauxLocataire.js``), où il sépare le
#: solde antérieur des loyers de la période. Les deux décrivent la sortie du
#: même parser et doivent bouger ensemble.
TYPE_LIGNE_REPORT = "solde_anterieur" TYPE_LIGNE_REPORT = "solde_anterieur"