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

@@ -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
* 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
* 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'
/**
* 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. */
function montant(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`.
*
* 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`)
* @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) {
const liste = Array.isArray(lignes) ? lignes : []
const totaux = {
solde_anterieur: 0,
loyers: 0,
taxes: 0,
provisions: 0,
divers: 0,
total: 0,
regles: 0,
impayes: 0,
}
const totaux = Object.fromEntries(COLONNES_CRG.map(({ champ }) => [champ, 0]))
for (const ligne of liste) {
if (ligne?.type === TYPE_REPORT) {
@@ -74,18 +97,6 @@ export function totauxCalcules(lignes) {
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.
*
@@ -95,16 +106,19 @@ const CHAMPS_COMPARES = [
*
* @param {Object} extraits - `locataire.totaux`, tel que lu dans le PDF
* @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) {
const 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 (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) }
}
}