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:
@@ -79,6 +79,15 @@ const props = defineProps({
|
|||||||
inputClass: {
|
inputClass: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: ''
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* D'où vient la valeur, pour l'infobulle. Les trois écrans qui emploient ce
|
||||||
|
* champ éditent aujourd'hui de l'extraction, d'où 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(() => {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
20
frontend/src/utils/chemin.js
Normal file
20
frontend/src/utils/chemin.js
Normal 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
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 n’a 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
|
||||||
|
|||||||
@@ -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 n’a été extraite', () => {
|
it('signale un lot dont aucune ligne n’a é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 l’extrait, 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 d’arrondi sous le centime', () => {
|
it('tolère un écart d’arrondi sous le centime', () => {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user