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: {
type: String,
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
})
// Ce composant n'édite que des champs issus de l'extraction : le survol le dit,
// pour qu'on ne confonde jamais une valeur lue dans le PDF avec une valeur déduite.
// Le survol dit d'où vient la valeur, pour qu'on ne la confonde jamais avec une
// valeur déduite affichée à côté.
const titreSurvol = computed(() =>
isEmpty.value
? "Aucune valeur extraite du compte rendu — cliquer pour la saisir"
: 'Valeur extraite du compte rendu — cliquer pour la modifier'
? `Aucune valeur ${props.origine} — cliquer pour la saisir`
: `Valeur ${props.origine} — cliquer pour la modifier`
)
const inputType = computed(() => {

View File

@@ -177,6 +177,7 @@ import DataCard from './DataCard.vue'
import DataRow from './DataRow.vue'
import LocataireCard from './LocataireCard.vue'
import OperationCard from './OperationCard.vue'
import { setNestedValue } from '../utils/chemin'
const props = defineProps({
data: {
@@ -348,19 +349,6 @@ function cloneData() {
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
function updateMetadata(path, value) {
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"
>
<div class="text-sm font-semibold" :class="totalClass">
{{ formatCurrency(locataire.totaux?.total) }}
{{ formatMontantPrecis(locataire.totaux?.total) }}
</div>
<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' }}:
{{ formatCurrency(Math.abs(locataire.totaux.impayes)) }}
{{ formatMontantPrecis(Math.abs(locataire.totaux.impayes)) }}
</div>
</div>
<!-- Bouton supprimer -->
@@ -83,8 +83,11 @@
</div>
</div>
<!-- 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">
<!-- Content : le tableau du compte rendu, colonne pour colonne.
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">
<span class="text-xs text-gray-500 font-medium">Detail des lignes</span>
<button
@@ -104,13 +107,14 @@
<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">Periode</th>
<th class="text-right font-medium pb-1 px-2">Loyers</th>
<th class="text-right font-medium pb-1 px-2">Taxes</th>
<th class="text-right font-medium pb-1 px-2">Provisions</th>
<th class="text-left font-medium pb-1 px-2">Divers</th>
<th class="text-right font-medium pb-1 px-2">Total</th>
<th class="text-right font-medium pb-1 px-2">Regles</th>
<th class="text-right font-medium pb-1 px-2">Impaye</th>
<th
v-for="colonne in COLONNES_TABLEAU"
:key="colonne.champ"
class="font-medium pb-1 px-2"
:class="alignement(colonne)"
>
{{ colonne.libelle }}
</th>
<th class="pb-1"></th>
</tr>
</thead>
@@ -166,21 +170,14 @@
</div>
</td>
<td v-for="champ in ['loyers', 'taxes', 'provisions']" :key="champ" class="py-1 px-2 border-t border-gray-700/60">
<div v-if="champ !== 'loyers' || ligne.type !== 'solde_anterieur'" class="flex justify-end">
<EditableField
:modelValue="ligne[champ]"
@update:modelValue="updateLigneField(idx, champ, $event)"
type="currency"
displayClass="text-xs text-gray-200"
/>
</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 -->
<td
v-for="colonne in COLONNES_TABLEAU"
:key="colonne.champ"
class="py-1 px-2 border-t border-gray-700/60"
>
<!-- Divers : libelle puis montant, comme sur le compte rendu.
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">
<EditableField
:modelValue="ligne.divers?.libelle"
@update:modelValue="updateLigneField(idx, 'divers.libelle', $event)"
@@ -195,15 +192,16 @@
displayClass="text-xs text-gray-200"
/>
</div>
</td>
<td v-for="champ in ['total', 'regles', 'impayes']" :key="champ" class="py-1 px-2 border-t border-gray-700/60">
<div class="flex justify-end">
<!-- Le montant d'un report est rendu dans la colonne « Periode » -->
<div
v-else-if="colonne.champ !== 'loyers' || ligne.type !== 'solde_anterieur'"
class="flex justify-end"
>
<EditableField
:modelValue="ligne[champ]"
@update:modelValue="updateLigneField(idx, champ, $event)"
:modelValue="ligne[colonne.champ]"
@update:modelValue="updateLigneField(idx, colonne.champ, $event)"
type="currency"
:displayClass="`text-xs ${classeMontant(champ, ligne[champ])}`"
:displayClass="`text-xs ${classeMontant(colonne.champ, ligne[colonne.champ])}`"
/>
</div>
</td>
@@ -233,7 +231,12 @@
<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">
<!-- 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">
<span class="text-[10px] uppercase tracking-wide text-gray-500">Solde ant.</span>
<EditableField
@@ -242,31 +245,38 @@
type="currency"
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>
</td>
<!-- Une colonne qui ne somme pas est signalee sur toute la cellule :
c'est le premier endroit ou l'oeil verifie le compte rendu. -->
<td
v-for="champ in CHAMPS_TOTAUX"
:key="champ"
v-for="colonne in COLONNES_TABLEAU"
:key="colonne.champ"
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">
<EditableField
:modelValue="locataire.totaux?.[champ]"
@update:modelValue="updateField(`totaux.${champ}`, $event)"
:modelValue="locataire.totaux?.[colonne.champ]"
@update:modelValue="updateField(`totaux.${colonne.champ}`, $event)"
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. -->
<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"
:title="detailEcart(champ)"
:title="detailEcart(colonne.champ)"
>
calculé : {{ formatCurrency(ecarts[champ]) }}
calculé : {{ formatMontantPrecis(ecarts[colonne.champ].calcule) }}
</span>
</div>
</td>
@@ -289,7 +299,14 @@
<script setup>
import { ref, computed, watch, nextTick } from '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({
locataire: {
@@ -312,19 +329,9 @@ const props = defineProps({
const emit = defineEmits(['update:locataire', 'remove'])
// Colonnes de la ligne « Totaux », dans l'ordre du compte rendu.
const CHAMPS_TOTAUX = ['loyers', 'taxes', 'provisions', 'divers', 'total', 'regles', 'impayes']
const LIBELLES = {
solde_anterieur: 'Solde anterieur',
loyers: 'Loyers',
taxes: 'Taxes',
provisions: 'Provisions',
divers: 'Divers',
total: 'Total',
regles: 'Regles',
impayes: 'Impaye',
}
const LIBELLES = Object.fromEntries(
COLONNES_CRG.map(({ champ, libelle }) => [champ, libelle])
)
const expanded = ref(false)
const rootEl = ref(null)
@@ -360,12 +367,12 @@ const resumeEcarts = computed(() =>
function detailEcart(champ) {
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 (
`${LIBELLES[champ]}\n` +
`Extrait du compte rendu : ${formatCurrency(props.locataire.totaux?.[champ])}\n` +
`Calculé sur les ${nb} ligne(s) : ${formatCurrency(ecarts.value[champ])}\n` +
`Écart de ${formatCurrency(manquant)} : aucune ligne ne porte ce montant.`
`Extrait du compte rendu : ${formatMontantPrecis(extrait)}\n` +
`Calculé sur les ${nb} ligne(s) : ${formatMontantPrecis(calcule)}\n` +
`Écart de ${formatMontantPrecis(manquant)} : aucune ligne ne porte ce montant.`
)
}
@@ -383,9 +390,13 @@ function classeMontant(champ, valeur) {
return 'text-gray-200'
}
function formatCurrency(value) {
if (value === null || value === undefined) return '-'
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value)
function classeCellule(champ) {
return ecarts.value[champ] ? 'bg-amber-500/10 ring-1 ring-inset ring-amber-500/40' : ''
}
// 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")
@@ -395,30 +406,13 @@ function updateField(path, value) {
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
// correction de l'utilisateur ne doit rien declencher qu'il n'ait pas demande.
function updateLigneField(ligneIndex, path, value) {
const updated = JSON.parse(JSON.stringify(props.locataire))
if (!updated.lignes) updated.lignes = []
if (path.includes('.')) {
setNestedValue(updated.lignes[ligneIndex], path, value)
} else {
updated.lignes[ligneIndex][path] = value
}
setNestedValue(updated.lignes[ligneIndex], path, value)
emit('update:locataire', updated)
}

View File

@@ -194,6 +194,7 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import EditableField from './EditableField.vue'
import { setNestedValue } from '../utils/chemin'
const props = defineProps({
categorie: {
@@ -257,19 +258,6 @@ function formatCurrency(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
function updateOperationField(opIndex, path, value) {
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
* 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) }
}
}