Le balayage n'imposait que le tout ou rien par document. Chaque modification a
desormais sa case : un champ de metadonnee, le nom ou le type d'un locataire,
une operation ajoutee ou supprimee. Les champs textuels retenus sont en outre
modifiables a la main, pour les cas ou ni l'ancienne ni la nouvelle valeur ne
convient. Tout reste coche par defaut : le cas courant tient toujours en un clic.
Les montants et lignes d'un locataire forment un seul bloc : les retenir
separement produirait un total ne correspondant plus a ses lignes, alors que ces
chiffres alimentent les revenus et les depenses.
Le document enregistre est construit par fusion (mergeExtraction) : on part des
donnees en base et on n'y applique que ce qui est retenu. Le diff porte donc
maintenant de quoi rejouer chaque changement (identifiant, chemin, valeurs
brutes) et l'index d'origine des operations supprimees, pour les remettre a leur
place si on refuse leur disparition. Les tags suivent la liste d'operations
finale, y compris ceux d'une operation restauree.
Une reecriture de forme d'un numero de lot ("0001" -> "01") est signalee comme
imposee et sa case verrouillee : le backend normalise tout enregistrement, une
case sans effet aurait laisse croire l'inverse.
Ajoute vitest : la fusion decide de ce qui est ecrit en base, elle est couverte
par 15 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
224 lines
8.5 KiB
JavaScript
224 lines
8.5 KiB
JavaScript
// La fusion décide de ce qui est écrit en base : elle est testée pièce par pièce.
|
||
import { describe, expect, it } from 'vitest'
|
||
|
||
import { computeExtractionDiff, listChangeIds } from '../src/utils/diffExtraction.js'
|
||
import { countAccepted, mergeExtraction } from '../src/utils/mergeExtraction.js'
|
||
|
||
// Données en base, inspirées d'un compte rendu réel.
|
||
const previous = {
|
||
metadata: {
|
||
document: { reference: '33680000', date: '2026-06-22' },
|
||
immeuble: { code: '33689020', adresse: '4 RUE SERVIENT' },
|
||
solde: { montant: 11214.51, type: 'crediteur' },
|
||
},
|
||
situation_locataires: [
|
||
{
|
||
lot: { numero: '0007', type: 'Appartement' },
|
||
locataire: { nom: 'LATAPY NINA' },
|
||
totaux: { total: 1200, regles: 1200, impayes: 0 },
|
||
lignes: [{ type: 'loyer', loyers: 500, total: 500 }],
|
||
},
|
||
{
|
||
lot: { numero: '0008', type: 'Appartement' },
|
||
locataire: { nom: 'BESSON Léa' },
|
||
totaux: { total: 800, regles: 800, impayes: 0 },
|
||
lignes: [{ type: 'loyer', loyers: 800, total: 800 }],
|
||
},
|
||
],
|
||
recapitulatif_operations: [
|
||
{ categorie: 'DL', fournisseur: 'PPR', description: 'entretien', montants: { debit: 80 } },
|
||
{ categorie: 'DL', fournisseur: 'TOTAL', description: 'elec', montants: { debit: 120 } },
|
||
],
|
||
}
|
||
|
||
// Nouvelle extraction : corrige les numéros de lot, mais agrège un libellé dans
|
||
// le nom du locataire (régression du parser), change des montants, ajoute une
|
||
// opération et en perd une autre.
|
||
const next = {
|
||
metadata: {
|
||
document: { reference: '33680000', date: '2026-06-22' },
|
||
immeuble: { code: '33689020', adresse: '4 R. SERVIENT' },
|
||
solde: { montant: 11214.51, type: 'crediteur' },
|
||
},
|
||
situation_locataires: [
|
||
{
|
||
lot: { numero: '07', type: 'Appartement' },
|
||
locataire: { nom: 'LATAPY NINA Complément loyer' },
|
||
totaux: { total: 1250, regles: 1200, impayes: 50 },
|
||
lignes: [
|
||
{ type: 'loyer', loyers: 500, total: 500 },
|
||
{ type: 'complement', loyers: 50, total: 50 },
|
||
],
|
||
},
|
||
{
|
||
lot: { numero: '08', type: 'Appartement' },
|
||
locataire: { nom: 'BESSON Léa' },
|
||
totaux: { total: 800, regles: 800, impayes: 0 },
|
||
lignes: [{ type: 'loyer', loyers: 800, total: 800 }],
|
||
},
|
||
],
|
||
recapitulatif_operations: [
|
||
{ categorie: 'DL', fournisseur: 'PPR', description: 'entretien', montants: { debit: 80 } },
|
||
{ categorie: 'DL', fournisseur: 'NOUVEAU', description: 'ajout', montants: { debit: 42 } },
|
||
],
|
||
}
|
||
|
||
const diff = computeExtractionDiff(previous, next)
|
||
const merge = (selection) => mergeExtraction(previous, next, diff, selection)
|
||
|
||
describe('découpage du diff', () => {
|
||
it('sépare les champs identitaires du bloc de montants', () => {
|
||
const latapy = diff.locataires.find((l) => l.index === 0)
|
||
expect(latapy.changes.map((c) => c.id)).toEqual([
|
||
'loc:0:locataire.nom',
|
||
'loc:0:lot.numero',
|
||
'loc:0:montants',
|
||
])
|
||
// Le bloc de montants porte le détail, mais reste un seul changement.
|
||
const montants = latapy.changes.at(-1)
|
||
expect(montants.kind).toBe('montants')
|
||
expect(montants.fields.length).toBeGreaterThan(1)
|
||
})
|
||
|
||
it('marque comme imposée la seule réécriture de forme d’un numéro de lot', () => {
|
||
// Le backend normalise tout numéro de lot à l'enregistrement : refuser
|
||
// "0007" → "07" n'aurait aucun effet, la case ne doit pas le laisser croire.
|
||
const lot = diff.locataires[0].changes.find((c) => c.path === 'lot.numero')
|
||
expect(lot.forced).toBe(true)
|
||
expect(lot.editable).toBe(false)
|
||
|
||
const merged = merge({ [lot.id]: { accepted: false } })
|
||
expect(merged.situation_locataires[0].lot.numero).toBe('07')
|
||
})
|
||
|
||
it('laisse refusable un vrai changement de lot', () => {
|
||
const autreLot = {
|
||
...next,
|
||
situation_locataires: [
|
||
{ ...next.situation_locataires[0], lot: { numero: '12', type: 'Appartement' } },
|
||
next.situation_locataires[1],
|
||
],
|
||
}
|
||
const d = computeExtractionDiff(previous, autreLot)
|
||
const lot = d.locataires[0].changes.find((c) => c.path === 'lot.numero')
|
||
|
||
expect(lot.forced).toBe(false) // "0007" et "12" ne sont pas le même lot
|
||
const merged = mergeExtraction(previous, autreLot, d, { [lot.id]: { accepted: false } })
|
||
expect(merged.situation_locataires[0].lot.numero).toBe('0007')
|
||
})
|
||
|
||
it('retient l’index d’origine des opérations supprimées', () => {
|
||
const removed = diff.operations.find((o) => o.kind === 'removed')
|
||
expect(removed.oldIndex).toBe(1) // TOTAL / elec
|
||
})
|
||
})
|
||
|
||
describe('fusion sélective', () => {
|
||
it('sans sélection, reproduit la nouvelle extraction', () => {
|
||
expect(merge({})).toEqual(next)
|
||
})
|
||
|
||
it('refuse un nom tout en gardant la correction du numéro de lot', () => {
|
||
// Le cas qui motive la maille fine : la régression sur le nom est écartée,
|
||
// la correction du lot est conservée, sur le même locataire.
|
||
const merged = merge({ 'loc:0:locataire.nom': { accepted: false } })
|
||
const latapy = merged.situation_locataires[0]
|
||
|
||
expect(latapy.locataire.nom).toBe('LATAPY NINA')
|
||
expect(latapy.lot.numero).toBe('07')
|
||
expect(latapy.totaux.total).toBe(1250) // montants acceptés
|
||
})
|
||
|
||
it('garde les anciens montants et lignes quand le bloc est refusé', () => {
|
||
const merged = merge({ 'loc:0:montants': { accepted: false } })
|
||
const latapy = merged.situation_locataires[0]
|
||
|
||
expect(latapy.totaux).toEqual(previous.situation_locataires[0].totaux)
|
||
expect(latapy.lignes).toEqual(previous.situation_locataires[0].lignes)
|
||
// Le refus des chiffres n'annule pas les champs identitaires acceptés.
|
||
expect(latapy.lot.numero).toBe('07')
|
||
expect(latapy.locataire.nom).toBe('LATAPY NINA Complément loyer')
|
||
})
|
||
|
||
it('écrit la valeur saisie à la main plutôt que celle proposée', () => {
|
||
const merged = merge({
|
||
'loc:0:locataire.nom': { accepted: true, value: 'LATAPY Nina' },
|
||
'meta:immeuble.adresse': { accepted: true, value: '4 rue Servient' },
|
||
})
|
||
|
||
expect(merged.situation_locataires[0].locataire.nom).toBe('LATAPY Nina')
|
||
expect(merged.metadata.immeuble.adresse).toBe('4 rue Servient')
|
||
})
|
||
|
||
it('laisse une métadonnée refusée à sa valeur en base', () => {
|
||
const merged = merge({ 'meta:immeuble.adresse': { accepted: false } })
|
||
expect(merged.metadata.immeuble.adresse).toBe('4 RUE SERVIENT')
|
||
})
|
||
|
||
it('écarte une opération ajoutée dont on ne veut pas', () => {
|
||
const added = diff.operations.find((o) => o.kind === 'added')
|
||
const merged = merge({ [added.id]: { accepted: false } })
|
||
|
||
expect(merged.recapitulatif_operations.map((o) => o.fournisseur)).toEqual(['PPR'])
|
||
})
|
||
|
||
it('réinsère à sa place une opération dont on refuse la suppression', () => {
|
||
const removed = diff.operations.find((o) => o.kind === 'removed')
|
||
const merged = merge({ [removed.id]: { accepted: false } })
|
||
|
||
expect(merged.recapitulatif_operations.map((o) => o.fournisseur)).toEqual([
|
||
'PPR',
|
||
'TOTAL', // restaurée à son index d'origine
|
||
'NOUVEAU',
|
||
])
|
||
})
|
||
|
||
it('ne modifie pas les données d’origine', () => {
|
||
const avant = JSON.stringify(previous)
|
||
merge({ 'loc:0:montants': { accepted: false } })
|
||
expect(JSON.stringify(previous)).toBe(avant)
|
||
})
|
||
})
|
||
|
||
describe('locataires ajoutés ou supprimés', () => {
|
||
const avecAjout = {
|
||
...next,
|
||
situation_locataires: [
|
||
...next.situation_locataires,
|
||
{ lot: { numero: '09' }, locataire: { nom: 'NOUVEAU LOCATAIRE' }, totaux: {}, lignes: [] },
|
||
],
|
||
}
|
||
const diffAjout = computeExtractionDiff(previous, avecAjout)
|
||
|
||
it('n’ajoute pas un locataire refusé', () => {
|
||
const merged = mergeExtraction(previous, avecAjout, diffAjout, {
|
||
'loc:2:entier': { accepted: false },
|
||
})
|
||
expect(merged.situation_locataires).toHaveLength(2)
|
||
})
|
||
|
||
it('conserve un locataire dont on refuse la disparition', () => {
|
||
const sansBesson = {
|
||
...next,
|
||
situation_locataires: next.situation_locataires.slice(0, 1),
|
||
}
|
||
const diffSuppr = computeExtractionDiff(previous, sansBesson)
|
||
const merged = mergeExtraction(previous, sansBesson, diffSuppr, {
|
||
'loc:1:entier': { accepted: false },
|
||
})
|
||
|
||
expect(merged.situation_locataires.map((l) => l.locataire.nom)).toEqual([
|
||
'LATAPY NINA Complément loyer',
|
||
'BESSON Léa',
|
||
])
|
||
})
|
||
})
|
||
|
||
describe('comptage pour l’affichage', () => {
|
||
it('compte les changements retenus', () => {
|
||
const ids = listChangeIds(diff)
|
||
expect(countAccepted(ids, {})).toEqual({ accepted: ids.length, total: ids.length })
|
||
expect(countAccepted(ids, { [ids[0]]: { accepted: false } }).accepted).toBe(ids.length - 1)
|
||
})
|
||
})
|