Files
pdf_oralia_vibe/frontend/tests/mergeExtraction.test.js
Bertrand Benjamin a1da4fc97f feat: annonce les reecritures que l'enregistrement imposera
Refuser une modification, c'est reenvoyer la valeur actuelle du document. Mais
l'enregistrement ramene les donnees a une forme canonique (aujourd'hui les
numeros de lot) : sur ces champs, le refus n'est pas conserve. L'interface le
laissait croire.

Plutot que de traiter le cas des lots en dur, le serveur devient seul juge de ce
qu'il stocke et l'annonce : `canonicalize_extraction` regroupe les reecritures
faites a la persistance, `save_document` et l'apercu passent par elle, et
/api/documents/{id}/re-extract renvoie `previous_canonical` a cote de
`previous_data`. Le front compare ces deux jeux et signale, sous chaque case
decochee, la valeur que l'enregistrement reecrira — sans connaitre aucune regle.
Une transformation ajoutee plus tard remontera donc toute seule dans l'interface.

Toutes les differences restent visibles et toutes les cases actives : voir ce
qui change et pouvoir se prononcer prime, quitte a etre prevenu que ce champ-la
reviendra reecrit.

Supprime au passage frontend/src/utils/lots.js, qui dupliquait la regle de
normalisation cote client.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 07:02:01 +02:00

233 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
computeServerOverrides,
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: '07', type: 'Appartement' },
locataire: { nom: 'LATAPY NINA' },
totaux: { total: 1200, regles: 1200, impayes: 0 },
lignes: [{ type: 'loyer', loyers: 500, total: 500 }],
},
{
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: 'TOTAL', description: 'elec', montants: { debit: 120 } },
],
}
// Nouvelle extraction : rattache le premier locataire à un autre lot, agrège un
// libellé dans son nom (régression du parser), change ses montants, ajoute une
// opération et en perd une autre.
// Les numéros de lot arrivent des deux côtés sous leur forme normalisée : le
// backend les normalise avant de renvoyer les données à comparer.
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: '12', 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('retient lindex dorigine 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('12')
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('12')
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 dorigine', () => {
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('najoute 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('réécritures imposées par lenregistrement', () => {
// Le serveur fournit les données actuelles et leur forme canonique ; le front
// en déduit ce qu'un refus ne pourrait pas conserver, sans connaître la règle.
const enBase = JSON.parse(JSON.stringify(previous))
enBase.situation_locataires[0].lot.numero = '0007' // écriture héritée
const canonique = JSON.parse(JSON.stringify(enBase))
canonique.situation_locataires[0].lot.numero = '07' // ce que le serveur écrirait
const d = computeExtractionDiff(enBase, next)
it('signale le champ que le serveur réécrira', () => {
const overrides = computeServerOverrides(d, enBase, canonique)
const lot = d.locataires[0].changes.find((c) => c.path === 'lot.numero')
expect(overrides[lot.id]).toBe('07')
})
it('ne signale rien pour les champs que le serveur laisse tels quels', () => {
const overrides = computeServerOverrides(d, enBase, canonique)
const nom = d.locataires[0].changes.find((c) => c.path === 'locataire.nom')
expect(nom.id in overrides).toBe(false)
expect(Object.keys(overrides)).toHaveLength(1)
})
it('ne signale rien quand le serveur ne réécrit rien', () => {
expect(computeServerOverrides(diff, previous, previous)).toEqual({})
})
})
describe('comptage pour laffichage', () => {
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)
})
})