feat: rend la situation des locataires vérifiable colonne par colonne
La carte d'un lot n'affichait que quatre des huit colonnes du compte rendu, la colonne Total absente, et le bandeau replié montrait un total figé : corriger un règlement ne le changeait pas. Pire, les quatre agrégats modifiables ne sont jamais enregistrés — seules les lignes partent en base (`service.py`) — pendant que les colonnes réellement écrites, dont Regles et Impayé, n'étaient éditables nulle part. On corrigeait donc un champ sans effet, et pas celui qu'il fallait. Le détail des lignes reprend maintenant le tableau du compte rendu, colonne pour colonne, la ligne « Totaux » comprise en pied. Les dix champs d'une ligne et les huit de la ligne Totaux sont modifiables, sans exception : cette page sert à vérifier une extraction avant de l'enregistrer, l'utilisateur y a le dernier mot. Rien n'est plus recalculé à l'affichage ni reporté d'un champ sur un autre. Corriger une colonne ne déclenche que ce qui a été demandé — un total réécrit d'office effacerait sans le dire ce que le compte rendu porte. Un seul contrôle subsiste, en signalement pur : chaque colonne de la ligne « Totaux » est confrontée à la somme de cette même colonne sur les lignes. Le recoupement est celui que fait l'œil sur le tableau. Déduire le total des autres colonnes laissait passer le cas le plus parlant — une colonne Total qui ne somme visiblement pas, faute d'avoir extrait la valeur d'une ligne. Sur les 388 lots des documents extraits, ce contrôle signale quatre lots, tous de vraies extractions incomplètes : un règlement de 707,29 € qu'aucune ligne ne porte, un « divers » de 308,76 € sauté à un changement de page, deux lots réglés sans ligne. La colonne fautive passe en surbrillance et affiche la valeur calculée sous le montant extrait, sans jamais s'y substituer. Le solde antérieur quitte la colonne Loyers pour la colonne Période, où le compte rendu l'imprime. Compté à part sans être affiché à part, il donnait une colonne Loyers qui semblait ne pas sommer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -43,18 +43,31 @@
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- L'extraction ne se recoupe pas : a verifier en priorite -->
|
||||||
|
<span
|
||||||
|
v-if="aDesEcarts"
|
||||||
|
class="badge badge-warning text-[10px] uppercase tracking-wide"
|
||||||
|
:title="resumeEcarts"
|
||||||
|
>
|
||||||
|
A verifier
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="changed"
|
v-if="changed"
|
||||||
class="badge badge-warning text-[10px] uppercase tracking-wide"
|
class="badge badge-warning text-[10px] uppercase tracking-wide"
|
||||||
>
|
>
|
||||||
Modifié
|
Modifié
|
||||||
</span>
|
</span>
|
||||||
<div class="text-right">
|
<!-- Rappel de la ligne « Totaux » : elle se modifie dans le tableau, pas ici -->
|
||||||
|
<div
|
||||||
|
class="text-right"
|
||||||
|
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) }}
|
{{ formatCurrency(locataire.totaux?.total) }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="locataire.totaux?.impayes > 0" class="text-xs text-red-400">
|
<div v-if="locataire.totaux?.impayes" class="text-xs" :class="locataire.totaux.impayes > 0 ? 'text-red-400' : 'text-blue-400'">
|
||||||
Impayes: {{ formatCurrency(locataire.totaux?.impayes) }}
|
{{ locataire.totaux.impayes > 0 ? 'Impayes' : 'Trop-percu' }}:
|
||||||
|
{{ formatCurrency(Math.abs(locataire.totaux.impayes)) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Bouton supprimer -->
|
<!-- Bouton supprimer -->
|
||||||
@@ -70,155 +83,205 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- 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">
|
<div v-show="expanded" class="border-t border-gray-700 bg-gray-900/60 p-3">
|
||||||
<!-- Totaux editables -->
|
<div class="flex items-center justify-between mb-1">
|
||||||
<div class="grid grid-cols-4 gap-2 text-xs mb-3">
|
<span class="text-xs text-gray-500 font-medium">Detail des lignes</span>
|
||||||
<div class="text-center p-2 bg-gray-800 border border-gray-700 rounded">
|
|
||||||
<div class="text-gray-500 mb-1">Loyers</div>
|
|
||||||
<EditableField
|
|
||||||
:modelValue="locataire.totaux?.loyers"
|
|
||||||
@update:modelValue="updateField('totaux.loyers', $event)"
|
|
||||||
type="currency"
|
|
||||||
displayClass="font-semibold text-gray-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="text-center p-2 bg-gray-800 border border-gray-700 rounded">
|
|
||||||
<div class="text-gray-500 mb-1">Taxes</div>
|
|
||||||
<EditableField
|
|
||||||
:modelValue="locataire.totaux?.taxes"
|
|
||||||
@update:modelValue="updateField('totaux.taxes', $event)"
|
|
||||||
type="currency"
|
|
||||||
displayClass="font-semibold text-gray-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="text-center p-2 bg-gray-800 border border-gray-700 rounded">
|
|
||||||
<div class="text-gray-500 mb-1">Provisions</div>
|
|
||||||
<EditableField
|
|
||||||
:modelValue="locataire.totaux?.provisions"
|
|
||||||
@update:modelValue="updateField('totaux.provisions', $event)"
|
|
||||||
type="currency"
|
|
||||||
displayClass="font-semibold text-gray-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="text-center p-2 bg-gray-800 border border-gray-700 rounded">
|
|
||||||
<div class="text-gray-500 mb-1">Regles</div>
|
|
||||||
<EditableField
|
|
||||||
:modelValue="locataire.totaux?.regles"
|
|
||||||
@update:modelValue="updateField('totaux.regles', $event)"
|
|
||||||
type="currency"
|
|
||||||
displayClass="font-semibold text-green-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Lignes detail -->
|
|
||||||
<div v-if="locataire.lignes?.length" class="space-y-1">
|
|
||||||
<div class="flex items-center justify-between mb-1">
|
|
||||||
<span class="text-xs text-gray-500 font-medium">Detail des lignes</span>
|
|
||||||
<button
|
|
||||||
@click="addLigne"
|
|
||||||
class="flex items-center gap-1 px-2 py-1 text-xs text-blue-400 hover:bg-blue-500/10 rounded transition-colors"
|
|
||||||
>
|
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
|
||||||
</svg>
|
|
||||||
Ajouter
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-for="(ligne, idx) in locataire.lignes"
|
|
||||||
:key="idx"
|
|
||||||
class="text-xs bg-gray-800 border border-gray-700 rounded p-2"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-2">
|
|
||||||
<div class="flex items-center gap-2 flex-1">
|
|
||||||
<!-- Type de ligne -->
|
|
||||||
<select
|
|
||||||
:value="ligne.type"
|
|
||||||
@change="updateLigneField(idx, 'type', $event.target.value)"
|
|
||||||
class="px-1.5 py-0.5 rounded text-xs border border-gray-700 bg-gray-950 text-gray-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
<option value="loyer">loyer</option>
|
|
||||||
<option value="solde_anterieur">solde_anterieur</option>
|
|
||||||
<option value="rappel_loyer">rappel_loyer</option>
|
|
||||||
<option value="divers">divers</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<!-- Periode -->
|
|
||||||
<div class="flex items-center gap-1 text-gray-500">
|
|
||||||
<EditableField
|
|
||||||
:modelValue="ligne.periode?.debut"
|
|
||||||
@update:modelValue="updateLigneField(idx, 'periode.debut', $event)"
|
|
||||||
type="date"
|
|
||||||
placeholder="debut"
|
|
||||||
displayClass="text-xs"
|
|
||||||
/>
|
|
||||||
<span>-</span>
|
|
||||||
<EditableField
|
|
||||||
:modelValue="ligne.periode?.fin"
|
|
||||||
@update:modelValue="updateLigneField(idx, 'periode.fin', $event)"
|
|
||||||
type="date"
|
|
||||||
placeholder="fin"
|
|
||||||
displayClass="text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Libelle divers -->
|
|
||||||
<EditableField
|
|
||||||
v-if="ligne.type === 'divers'"
|
|
||||||
:modelValue="ligne.divers?.libelle"
|
|
||||||
@update:modelValue="updateLigneField(idx, 'divers.libelle', $event)"
|
|
||||||
type="text"
|
|
||||||
placeholder="libellé"
|
|
||||||
displayClass="text-xs text-gray-400 italic truncate"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<!-- Montant : divers -> montant divers ; sinon total (ou loyers) -->
|
|
||||||
<EditableField
|
|
||||||
v-if="ligne.type === 'divers'"
|
|
||||||
:modelValue="ligne.divers?.montant"
|
|
||||||
@update:modelValue="updateLigneField(idx, 'divers.montant', $event)"
|
|
||||||
type="currency"
|
|
||||||
displayClass="font-medium text-gray-200"
|
|
||||||
/>
|
|
||||||
<EditableField
|
|
||||||
v-else
|
|
||||||
:modelValue="ligne.total || ligne.loyers"
|
|
||||||
@update:modelValue="updateLigneField(idx, 'total', $event)"
|
|
||||||
type="currency"
|
|
||||||
displayClass="font-medium text-gray-200"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Bouton supprimer ligne -->
|
|
||||||
<button
|
|
||||||
@click="removeLigne(idx)"
|
|
||||||
class="p-1 text-gray-500 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
|
|
||||||
title="Supprimer cette ligne"
|
|
||||||
>
|
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bouton ajouter ligne si aucune -->
|
|
||||||
<div v-else class="text-center py-2">
|
|
||||||
<button
|
<button
|
||||||
@click="addLigne"
|
@click="addLigne"
|
||||||
class="flex items-center gap-1 px-3 py-1.5 text-xs text-blue-400 hover:bg-blue-500/10 rounded transition-colors mx-auto"
|
class="flex items-center gap-1 px-2 py-1 text-xs text-blue-400 hover:bg-blue-500/10 rounded transition-colors"
|
||||||
>
|
>
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
</svg>
|
</svg>
|
||||||
Ajouter une ligne
|
Ajouter
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-xs border-separate border-spacing-0">
|
||||||
|
<thead>
|
||||||
|
<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 class="pb-1"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="(ligne, idx) in locataire.lignes"
|
||||||
|
:key="idx"
|
||||||
|
class="bg-gray-800/60 hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
<td class="py-1 pr-2 border-t border-gray-700/60">
|
||||||
|
<select
|
||||||
|
:value="ligne.type"
|
||||||
|
@change="updateLigneField(idx, 'type', $event.target.value)"
|
||||||
|
class="px-1.5 py-0.5 rounded text-xs border border-gray-700 bg-gray-950 text-gray-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="loyer">loyer</option>
|
||||||
|
<option value="solde_anterieur">solde_anterieur</option>
|
||||||
|
<option value="rappel_loyer">rappel_loyer</option>
|
||||||
|
<option value="divers">divers</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Un report de solde porte son montant dans cette colonne sur le
|
||||||
|
compte rendu, pas dans « Loyers » : le tableau fait de meme,
|
||||||
|
sans quoi la colonne Loyers semblerait ne pas sommer. -->
|
||||||
|
<td class="py-1 pr-2 border-t border-gray-700/60">
|
||||||
|
<div v-if="ligne.type === 'solde_anterieur'" class="flex items-center gap-1 whitespace-nowrap">
|
||||||
|
<span class="text-gray-500">Solde Anterieur</span>
|
||||||
|
<EditableField
|
||||||
|
:modelValue="ligne.loyers"
|
||||||
|
@update:modelValue="updateLigneField(idx, 'loyers', $event)"
|
||||||
|
type="currency"
|
||||||
|
displayClass="text-xs text-gray-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex items-center gap-1 text-gray-500 whitespace-nowrap">
|
||||||
|
<EditableField
|
||||||
|
:modelValue="ligne.periode?.debut"
|
||||||
|
@update:modelValue="updateLigneField(idx, 'periode.debut', $event)"
|
||||||
|
type="date"
|
||||||
|
placeholder="debut"
|
||||||
|
displayClass="text-xs"
|
||||||
|
/>
|
||||||
|
<span>-</span>
|
||||||
|
<EditableField
|
||||||
|
:modelValue="ligne.periode?.fin"
|
||||||
|
@update:modelValue="updateLigneField(idx, 'periode.fin', $event)"
|
||||||
|
type="date"
|
||||||
|
placeholder="fin"
|
||||||
|
displayClass="text-xs"
|
||||||
|
/>
|
||||||
|
</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 -->
|
||||||
|
<EditableField
|
||||||
|
:modelValue="ligne.divers?.libelle"
|
||||||
|
@update:modelValue="updateLigneField(idx, 'divers.libelle', $event)"
|
||||||
|
type="text"
|
||||||
|
placeholder=""
|
||||||
|
displayClass="text-xs text-gray-400 italic"
|
||||||
|
/>
|
||||||
|
<EditableField
|
||||||
|
:modelValue="ligne.divers?.montant"
|
||||||
|
@update:modelValue="updateLigneField(idx, 'divers.montant', $event)"
|
||||||
|
type="currency"
|
||||||
|
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">
|
||||||
|
<EditableField
|
||||||
|
:modelValue="ligne[champ]"
|
||||||
|
@update:modelValue="updateLigneField(idx, champ, $event)"
|
||||||
|
type="currency"
|
||||||
|
:displayClass="`text-xs ${classeMontant(champ, ligne[champ])}`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="py-1 border-t border-gray-700/60">
|
||||||
|
<button
|
||||||
|
@click="removeLigne(idx)"
|
||||||
|
class="p-1 text-gray-500 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
|
||||||
|
title="Supprimer cette ligne"
|
||||||
|
>
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr v-if="!locataire.lignes?.length">
|
||||||
|
<td colspan="10" class="py-3 text-center text-gray-500 border-t border-gray-700/60">
|
||||||
|
Aucune ligne extraite pour ce lot
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
|
||||||
|
<!-- Ligne « Totaux » du compte rendu : extraite elle aussi, donc editable -->
|
||||||
|
<tfoot>
|
||||||
|
<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">
|
||||||
|
<div class="flex items-center gap-1 whitespace-nowrap">
|
||||||
|
<span class="text-[10px] uppercase tracking-wide text-gray-500">Solde ant.</span>
|
||||||
|
<EditableField
|
||||||
|
:modelValue="locataire.totaux?.solde_anterieur"
|
||||||
|
@update:modelValue="updateField('totaux.solde_anterieur', $event)"
|
||||||
|
type="currency"
|
||||||
|
displayClass="text-xs text-gray-200"
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
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' : ''"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-end">
|
||||||
|
<EditableField
|
||||||
|
:modelValue="locataire.totaux?.[champ]"
|
||||||
|
@update:modelValue="updateField(`totaux.${champ}`, $event)"
|
||||||
|
type="currency"
|
||||||
|
:displayClass="`text-xs font-semibold ${classeMontant(champ, locataire.totaux?.[champ])}`"
|
||||||
|
/>
|
||||||
|
<!-- Somme de la colonne. Ni pointille ni contraste : non modifiable. -->
|
||||||
|
<span
|
||||||
|
v-if="champ in ecarts"
|
||||||
|
class="text-[10px] text-amber-500/90 italic mt-0.5 cursor-help px-1 whitespace-nowrap"
|
||||||
|
:title="detailEcart(champ)"
|
||||||
|
>
|
||||||
|
calculé : {{ formatCurrency(ecarts[champ]) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="border-t-2 border-gray-600"></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="aDesEcarts" class="mt-2 text-[10px] text-gray-500 italic">
|
||||||
|
Colonne(s) en surbrillance : le montant du compte rendu ne correspond pas a ce que
|
||||||
|
totalisent les lignes ci-dessus (« calculé : … »), signe qu'une valeur n'a pas ete
|
||||||
|
extraite. Seuls les champs soulignes sont modifiables, et eux seuls partent en base.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -226,6 +289,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 { totauxCalcules, ecartsAvecLignes } from '../utils/totauxLocataire'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
locataire: {
|
locataire: {
|
||||||
@@ -248,6 +312,20 @@ 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 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 expanded = ref(false)
|
const expanded = ref(false)
|
||||||
const rootEl = ref(null)
|
const rootEl = ref(null)
|
||||||
const isHighlighted = ref(false)
|
const isHighlighted = ref(false)
|
||||||
@@ -265,6 +343,32 @@ watch(() => props.highlighted, (val) => {
|
|||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Tout ce qui est affiche vient de l'extraction. Les lignes sont neanmoins
|
||||||
|
// reagregees pour un seul usage : reperer les lots ou l'extraction se contredit.
|
||||||
|
// Ces valeurs ne sont ni enregistrees, ni reportees dans les champs.
|
||||||
|
const calcules = computed(() => totauxCalcules(props.locataire.lignes))
|
||||||
|
|
||||||
|
const ecarts = computed(() => ecartsAvecLignes(props.locataire.totaux, calcules.value))
|
||||||
|
|
||||||
|
const aDesEcarts = computed(() => Object.keys(ecarts.value).length > 0)
|
||||||
|
|
||||||
|
const resumeEcarts = computed(() =>
|
||||||
|
Object.keys(ecarts.value)
|
||||||
|
.map((champ) => detailEcart(champ))
|
||||||
|
.join('\n\n')
|
||||||
|
)
|
||||||
|
|
||||||
|
function detailEcart(champ) {
|
||||||
|
const nb = props.locataire.lignes?.length || 0
|
||||||
|
const manquant = (props.locataire.totaux?.[champ] || 0) - 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.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const totalClass = computed(() => {
|
const totalClass = computed(() => {
|
||||||
const total = props.locataire.totaux?.total || 0
|
const total = props.locataire.totaux?.total || 0
|
||||||
if (total > 0) return 'text-green-400'
|
if (total > 0) return 'text-green-400'
|
||||||
@@ -272,6 +376,13 @@ const totalClass = computed(() => {
|
|||||||
return 'text-gray-400'
|
return 'text-gray-400'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Les regles apparaissent en vert sur le compte rendu, les impayes en rouge.
|
||||||
|
function classeMontant(champ, valeur) {
|
||||||
|
if (champ === 'regles') return 'text-green-400'
|
||||||
|
if (champ === 'impayes') return valeur ? 'text-red-400' : 'text-gray-200'
|
||||||
|
return 'text-gray-200'
|
||||||
|
}
|
||||||
|
|
||||||
function formatCurrency(value) {
|
function formatCurrency(value) {
|
||||||
if (value === null || value === undefined) return '-'
|
if (value === null || value === undefined) return '-'
|
||||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value)
|
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value)
|
||||||
@@ -297,7 +408,8 @@ function setNestedValue(obj, path, value) {
|
|||||||
current[parts[parts.length - 1]] = value
|
current[parts[parts.length - 1]] = value
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mettre a jour un champ d'une ligne
|
// 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) {
|
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 = []
|
||||||
|
|||||||
112
frontend/src/utils/totauxLocataire.js
Normal file
112
frontend/src/utils/totauxLocataire.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Recoupement des totaux d'un lot avec ses lignes.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
* Ces fonctions ne corrigent donc rien — elles servent uniquement à repérer les
|
||||||
|
* lots où le compte rendu et les lignes extraites ne racontent pas la même
|
||||||
|
* histoire, signe que le parser a manqué ou déplacé un montant.
|
||||||
|
*
|
||||||
|
* Le recoupement est celui que l'œil fait sur le tableau : chaque colonne de la
|
||||||
|
* ligne « Totaux » est confrontée à la somme de cette même colonne sur les
|
||||||
|
* lignes, y compris les colonnes Total et Impayé. Les déduire des autres
|
||||||
|
* colonnes (total = loyers + taxes + provisions + divers) laisserait passer le
|
||||||
|
* cas le plus parlant — une colonne Total qui ne somme visiblement pas.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TYPE_REPORT = 'solde_anterieur'
|
||||||
|
|
||||||
|
/** Montant exploitable d'un champ : `null` et `undefined` valent zéro. */
|
||||||
|
function montant(valeur) {
|
||||||
|
const nombre = Number(valeur)
|
||||||
|
return Number.isFinite(nombre) ? nombre : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Arrondi au centime — sans lui, les sommes de flottants affichent 878,6400000001. */
|
||||||
|
function auCentime(valeur) {
|
||||||
|
return Math.round(valeur * 100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agrège les lignes d'un lot en un jeu de totaux de même forme que `totaux`.
|
||||||
|
*
|
||||||
|
* @param {Array} lignes - lignes du lot (`locataire.lignes`)
|
||||||
|
* @returns {Object} totaux déduits, tous champs présents et 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ligne of liste) {
|
||||||
|
if (ligne?.type === TYPE_REPORT) {
|
||||||
|
totaux.solde_anterieur += montant(ligne.loyers)
|
||||||
|
} else {
|
||||||
|
totaux.loyers += montant(ligne?.loyers)
|
||||||
|
}
|
||||||
|
totaux.taxes += montant(ligne?.taxes)
|
||||||
|
totaux.provisions += montant(ligne?.provisions)
|
||||||
|
totaux.divers += montant(ligne?.divers?.montant)
|
||||||
|
totaux.total += montant(ligne?.total)
|
||||||
|
totaux.regles += montant(ligne?.regles)
|
||||||
|
totaux.impayes += montant(ligne?.impayes)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const champ of Object.keys(totaux)) {
|
||||||
|
totaux[champ] = auCentime(totaux[champ])
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* Un écart trahit une extraction incomplète — un règlement que le compte rendu
|
||||||
|
* n'a ventilé sur aucune ligne, un « divers » sauté lors d'un changement de
|
||||||
|
* page. Il est signalé, jamais résorbé d'office.
|
||||||
|
*
|
||||||
|
* @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
|
||||||
|
*/
|
||||||
|
export function ecartsAvecLignes(extraits, calcules) {
|
||||||
|
const ecarts = {}
|
||||||
|
if (!extraits) return ecarts
|
||||||
|
|
||||||
|
for (const champ of CHAMPS_COMPARES) {
|
||||||
|
if (extraits[champ] === null || extraits[champ] === undefined) continue
|
||||||
|
if (Math.abs(montant(extraits[champ]) - montant(calcules[champ])) > 0.005) {
|
||||||
|
ecarts[champ] = calcules[champ]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ecarts
|
||||||
|
}
|
||||||
145
frontend/tests/locataireCard.test.js
Normal file
145
frontend/tests/locataireCard.test.js
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
// La carte locataire est la vue où l'on vérifie une extraction avant de
|
||||||
|
// l'enregistrer : elle ne doit afficher que des champs extraits, tous modifiables,
|
||||||
|
// et ne jamais substituer un calcul à ce que le compte rendu porte.
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { createSSRApp, h } from 'vue'
|
||||||
|
import { renderToString } from 'vue/server-renderer'
|
||||||
|
|
||||||
|
import LocataireCard from '../src/components/LocataireCard.vue'
|
||||||
|
|
||||||
|
// Lot 11 de M_33670000_2025-09-22, avec le règlement mal extrait observé sur un
|
||||||
|
// compte rendu ultérieur : le PDF annonce 7 878,00 € réglés pour 867,45 € dus.
|
||||||
|
function lotCharlot(surcharges = {}) {
|
||||||
|
return {
|
||||||
|
lot: { numero: '11', type: 'Appartement T3' },
|
||||||
|
locataire: { nom: 'CHARLOT ANDREE' },
|
||||||
|
lignes: [
|
||||||
|
{
|
||||||
|
type: 'loyer',
|
||||||
|
periode: { debut: '2025-09-01', fin: '2025-09-30' },
|
||||||
|
loyers: 798.45,
|
||||||
|
taxes: 0,
|
||||||
|
provisions: 69,
|
||||||
|
divers: { montant: 0, libelle: null },
|
||||||
|
total: 867.45,
|
||||||
|
regles: 867.45,
|
||||||
|
impayes: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totaux: {
|
||||||
|
solde_anterieur: 0,
|
||||||
|
loyers: 798.45,
|
||||||
|
taxes: 0,
|
||||||
|
provisions: 69,
|
||||||
|
divers: 0,
|
||||||
|
total: 867.45,
|
||||||
|
regles: 867.45,
|
||||||
|
impayes: 0,
|
||||||
|
},
|
||||||
|
...surcharges,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// sont ramenés à un espace simple pour pouvoir chercher un montant.
|
||||||
|
async function rendre(locataire) {
|
||||||
|
const app = createSSRApp({
|
||||||
|
render: () => h(LocataireCard, { locataire }),
|
||||||
|
})
|
||||||
|
const html = await renderToString(app)
|
||||||
|
return html.replace(/\s+/g, ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Les lignes seules, sans la ligne « Totaux » qui porte les valeurs déduites. */
|
||||||
|
function corpsDuTableau(html) {
|
||||||
|
return html.match(/<tbody>(.*)<\/tbody>/)[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LocataireCard', () => {
|
||||||
|
it('reprend les colonnes du compte rendu, ligne « Totaux » comprise', async () => {
|
||||||
|
const html = await rendre(lotCharlot())
|
||||||
|
for (const colonne of ['Loyers', 'Taxes', 'Provisions', 'Divers', 'Total', 'Regles', 'Impaye']) {
|
||||||
|
expect(html).toContain(colonne)
|
||||||
|
}
|
||||||
|
expect(html).toContain('Totaux')
|
||||||
|
expect(html).toContain('Solde ant.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('laisse la ligne porter le total du compte rendu, sans le recalculer', async () => {
|
||||||
|
// Lot 13 de M_33670000_2025-08-26 : colonne « total » vide sur cette ligne,
|
||||||
|
// le compte rendu la porte sur une autre ligne du même bloc.
|
||||||
|
const lot = lotCharlot()
|
||||||
|
lot.lignes[0].loyers = 997.02
|
||||||
|
lot.lignes[0].provisions = 64
|
||||||
|
lot.lignes[0].total = 0
|
||||||
|
|
||||||
|
const lignes = corpsDuTableau(await rendre(lot))
|
||||||
|
expect(lignes).toContain('997,02 €')
|
||||||
|
expect(lignes).toContain('64,00 €')
|
||||||
|
// La somme des colonnes n'a pas à apparaître : la ligne n'est pas déduite.
|
||||||
|
expect(lignes).not.toContain('1 061,02 €')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n’affiche aucune valeur déduite quand l’extraction se recoupe', async () => {
|
||||||
|
const html = await rendre(lotCharlot())
|
||||||
|
expect(html).not.toContain('calc.')
|
||||||
|
expect(html).not.toContain('A verifier')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('signale la colonne qui ne somme pas, sans toucher au montant extrait', async () => {
|
||||||
|
// Cas du lot 07 de S_33680000_2025-11-25 : la colonne Total des lignes reste
|
||||||
|
// à 0 alors que le compte rendu annonce 707,29 € pour le lot.
|
||||||
|
const lot = lotCharlot()
|
||||||
|
lot.lignes[0].total = 0
|
||||||
|
lot.totaux.total = 707.29
|
||||||
|
|
||||||
|
const html = await rendre(lot)
|
||||||
|
expect(html).toContain('A verifier')
|
||||||
|
expect(html).toContain('calculé : 0,00 €')
|
||||||
|
// Le montant du compte rendu reste affiché tel quel.
|
||||||
|
expect(html).toContain('707,29 €')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('met la colonne en défaut en évidence', async () => {
|
||||||
|
const lot = lotCharlot()
|
||||||
|
lot.totaux.regles = 707.29
|
||||||
|
|
||||||
|
const html = await rendre(lot)
|
||||||
|
expect(html).toContain('bg-amber-500/10')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dit au survol ce qui est extrait et ce qui est calculé', async () => {
|
||||||
|
const lot = lotCharlot()
|
||||||
|
lot.totaux.regles = 707.29
|
||||||
|
|
||||||
|
const html = await rendre(lot)
|
||||||
|
expect(html).toContain('Valeur extraite du compte rendu — cliquer pour la modifier')
|
||||||
|
expect(html).toContain('Extrait du compte rendu : 707,29 €')
|
||||||
|
expect(html).toContain('Calculé sur les 1 ligne(s) : 867,45 €')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('signale un lot dont aucune ligne n’a été extraite', async () => {
|
||||||
|
const lot = lotCharlot({ lignes: [] })
|
||||||
|
|
||||||
|
const html = await rendre(lot)
|
||||||
|
expect(html).toContain('Aucune ligne extraite')
|
||||||
|
expect(html).toContain('A verifier')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('montre l’impayé du compte rendu dans le bandeau replié', async () => {
|
||||||
|
const lot = lotCharlot()
|
||||||
|
lot.totaux.impayes = 67.45
|
||||||
|
|
||||||
|
const html = await rendre(lot)
|
||||||
|
expect(html).toContain('Impayes: 67,45 €')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('distingue un trop-perçu d’un impayé', async () => {
|
||||||
|
const lot = lotCharlot()
|
||||||
|
lot.totaux.impayes = -32.55
|
||||||
|
|
||||||
|
const html = await rendre(lot)
|
||||||
|
expect(html).toContain('Trop-percu: 32,55 €')
|
||||||
|
})
|
||||||
|
})
|
||||||
176
frontend/tests/totauxLocataire.test.js
Normal file
176
frontend/tests/totauxLocataire.test.js
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
// Le recoupement des totaux avec les lignes sert d'alerte sur les extractions
|
||||||
|
// incomplètes : il est éprouvé sur les configurations des comptes rendus réels.
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { ecartsAvecLignes, totauxCalcules } from '../src/utils/totauxLocataire.js'
|
||||||
|
|
||||||
|
// Lot 11 CHARLOT ANDREE : un loyer avec provision, réglé.
|
||||||
|
const ligneLoyer = {
|
||||||
|
type: 'loyer',
|
||||||
|
periode: { debut: '2026-06-01', fin: '2026-06-30' },
|
||||||
|
loyers: 809.64,
|
||||||
|
taxes: 0,
|
||||||
|
provisions: 69,
|
||||||
|
divers: { montant: 0, libelle: null },
|
||||||
|
total: 878.64,
|
||||||
|
regles: 878.64,
|
||||||
|
impayes: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('totauxCalcules', () => {
|
||||||
|
it('agrège colonne par colonne, comme l’œil sur le tableau', () => {
|
||||||
|
expect(totauxCalcules([ligneLoyer])).toEqual({
|
||||||
|
solde_anterieur: 0,
|
||||||
|
loyers: 809.64,
|
||||||
|
taxes: 0,
|
||||||
|
provisions: 69,
|
||||||
|
divers: 0,
|
||||||
|
total: 878.64,
|
||||||
|
regles: 878.64,
|
||||||
|
impayes: 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('somme la colonne « total » telle quelle, sans la déduire des autres', () => {
|
||||||
|
// Le compte rendu ne remplit cette colonne que sur une ligne par bloc : les
|
||||||
|
// blocs se recomposent à l'échelle du lot, c'est ce total-là qui fait foi.
|
||||||
|
const totaux = totauxCalcules([
|
||||||
|
{ type: 'loyer', loyers: 997.02, provisions: 64, total: 0 },
|
||||||
|
{ type: 'loyer', loyers: 500, total: 1561.02 },
|
||||||
|
])
|
||||||
|
expect(totaux.total).toBe(1561.02)
|
||||||
|
expect(totaux.loyers).toBe(1497.02)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('somme la colonne « impayé » plutôt que de la recalculer', () => {
|
||||||
|
const totaux = totauxCalcules([
|
||||||
|
{ type: 'loyer', loyers: 500, total: 500, regles: 400, impayes: 100 },
|
||||||
|
])
|
||||||
|
expect(totaux.impayes).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('range le report de solde à part sans le confondre avec les loyers', () => {
|
||||||
|
// Lot 03 du compte rendu de février : un report de 0,63 et un loyer réglé.
|
||||||
|
const totaux = totauxCalcules([
|
||||||
|
{ type: 'solde_anterieur', loyers: 0.63, total: 0.63, regles: 0, impayes: 0.63 },
|
||||||
|
{ type: 'loyer', loyers: 640, provisions: 31, total: 671, regles: 671, impayes: 0 },
|
||||||
|
])
|
||||||
|
expect(totaux.solde_anterieur).toBe(0.63)
|
||||||
|
expect(totaux.loyers).toBe(640)
|
||||||
|
expect(totaux.total).toBe(671.63)
|
||||||
|
expect(totaux.impayes).toBe(0.63)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('additionne les lignes de types différents', () => {
|
||||||
|
// Lot 09 TERRIER ADILE : régularisation de sortie, montants négatifs.
|
||||||
|
const totaux = totauxCalcules([
|
||||||
|
{ type: 'loyer', provisions: -6, total: 0 },
|
||||||
|
{ type: 'rappel_loyer', loyers: -138.98, total: 0 },
|
||||||
|
{ type: 'divers', divers: { montant: -455, libelle: 'Rembt dépot de garantie' }, total: 0 },
|
||||||
|
{ type: 'divers', divers: { montant: 23.02 }, total: -268.2, regles: -268.2 },
|
||||||
|
])
|
||||||
|
expect(totaux.loyers).toBe(-138.98)
|
||||||
|
expect(totaux.provisions).toBe(-6)
|
||||||
|
expect(totaux.divers).toBe(-431.98)
|
||||||
|
expect(totaux.total).toBe(-268.2)
|
||||||
|
expect(totaux.regles).toBe(-268.2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('traite les champs absents comme des zéros', () => {
|
||||||
|
const totaux = totauxCalcules([{ type: 'loyer', loyers: 500 }])
|
||||||
|
expect(totaux.loyers).toBe(500)
|
||||||
|
expect(totaux.total).toBe(0)
|
||||||
|
expect(totauxCalcules([]).total).toBe(0)
|
||||||
|
expect(totauxCalcules(undefined).total).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('arrondit au centime plutôt que de traîner les flottants', () => {
|
||||||
|
const totaux = totauxCalcules([
|
||||||
|
{ type: 'loyer', loyers: 0.1 },
|
||||||
|
{ type: 'loyer', loyers: 0.2 },
|
||||||
|
])
|
||||||
|
expect(totaux.loyers).toBe(0.3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ecartsAvecLignes', () => {
|
||||||
|
const extraitsCharlot = {
|
||||||
|
solde_anterieur: 0,
|
||||||
|
loyers: 809.64,
|
||||||
|
taxes: 0,
|
||||||
|
provisions: 69,
|
||||||
|
divers: 0,
|
||||||
|
total: 878.64,
|
||||||
|
regles: 878.64,
|
||||||
|
impayes: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
it('ne signale rien quand les lignes recoupent la ligne « Totaux »', () => {
|
||||||
|
const calcules = totauxCalcules([ligneLoyer])
|
||||||
|
expect(ecartsAvecLignes(extraitsCharlot, calcules)).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('signale une colonne Total qui ne somme pas, et le règlement qui manque avec', () => {
|
||||||
|
// Lot 07 de S_33680000_2025-11-25 : la dernière ligne a perdu ses colonnes
|
||||||
|
// Total et Regles à l'extraction, le compte rendu porte 707,29 € pour les deux.
|
||||||
|
const calcules = totauxCalcules([
|
||||||
|
{ type: 'loyer', loyers: -265.29, provisions: -50, total: 0, regles: 0 },
|
||||||
|
{ type: 'divers', divers: { montant: -87.85 }, total: -403.14, regles: -403.14 },
|
||||||
|
{ type: 'loyer', loyers: 265.29, provisions: 50, total: 0, regles: 0 },
|
||||||
|
{ type: 'divers', divers: { montant: 87.85 }, total: 403.14, regles: 403.14 },
|
||||||
|
{ type: 'loyer', loyers: 657.29, provisions: 50, total: 0, regles: 0 },
|
||||||
|
])
|
||||||
|
// Les colonnes de détail se recoupent, seules Total et Regles décrochent.
|
||||||
|
expect(calcules.loyers).toBe(657.29)
|
||||||
|
expect(calcules.provisions).toBe(50)
|
||||||
|
expect(calcules.divers).toBe(0)
|
||||||
|
|
||||||
|
const ecarts = ecartsAvecLignes(
|
||||||
|
{
|
||||||
|
solde_anterieur: 0,
|
||||||
|
loyers: 657.29,
|
||||||
|
taxes: 0,
|
||||||
|
provisions: 50,
|
||||||
|
divers: 0,
|
||||||
|
total: 707.29,
|
||||||
|
regles: 707.29,
|
||||||
|
impayes: 0,
|
||||||
|
},
|
||||||
|
calcules
|
||||||
|
)
|
||||||
|
expect(ecarts).toEqual({ total: 0, regles: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('signale un divers absent des lignes extraites', () => {
|
||||||
|
const calcules = totauxCalcules([
|
||||||
|
{ type: 'divers', divers: { montant: -455 } },
|
||||||
|
{ type: 'divers', divers: { montant: 23.02 } },
|
||||||
|
])
|
||||||
|
expect(ecartsAvecLignes({ divers: -123.22 }, calcules)).toEqual({ divers: -431.98 })
|
||||||
|
})
|
||||||
|
|
||||||
|
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.
|
||||||
|
const ecarts = ecartsAvecLignes({ loyers: 0, regles: 100 }, totauxCalcules([]))
|
||||||
|
expect(ecarts).toEqual({ regles: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renvoie la valeur déduite des lignes, jamais celle du compte rendu', () => {
|
||||||
|
const calcules = totauxCalcules([{ type: 'loyer', loyers: 500 }])
|
||||||
|
expect(ecartsAvecLignes({ loyers: 800 }, calcules)).toEqual({ loyers: 500 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tolère un écart d’arrondi sous le centime', () => {
|
||||||
|
const calcules = totauxCalcules([{ type: 'loyer', loyers: 100 }])
|
||||||
|
expect(ecartsAvecLignes({ loyers: 100.004 }, calcules)).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignore une colonne que le compte rendu ne renseigne pas', () => {
|
||||||
|
const calcules = totauxCalcules([{ type: 'loyer', loyers: 100 }])
|
||||||
|
expect(ecartsAvecLignes({ loyers: 100, taxes: null }, calcules)).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ne compare rien sans totaux extraits', () => {
|
||||||
|
expect(ecartsAvecLignes(null, totauxCalcules([ligneLoyer]))).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user