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>
This commit is contained in:
2026-07-26 06:43:02 +02:00
parent 43efea4945
commit a1da4fc97f
10 changed files with 268 additions and 86 deletions

View File

@@ -1,7 +1,11 @@
// 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 {
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.
@@ -13,13 +17,13 @@ const previous = {
},
situation_locataires: [
{
lot: { numero: '0007', type: 'Appartement' },
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: '0008', type: 'Appartement' },
lot: { numero: '08', type: 'Appartement' },
locataire: { nom: 'BESSON Léa' },
totaux: { total: 800, regles: 800, impayes: 0 },
lignes: [{ type: 'loyer', loyers: 800, total: 800 }],
@@ -31,9 +35,11 @@ const previous = {
],
}
// 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
// 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' },
@@ -42,7 +48,7 @@ const next = {
},
situation_locataires: [
{
lot: { numero: '07', type: 'Appartement' },
lot: { numero: '12', type: 'Appartement' },
locataire: { nom: 'LATAPY NINA Complément loyer' },
totaux: { total: 1250, regles: 1200, impayes: 50 },
lignes: [
@@ -80,33 +86,6 @@ describe('découpage du diff', () => {
expect(montants.fields.length).toBeGreaterThan(1)
})
it('marque comme imposée la seule réécriture de forme dun 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 lindex dorigine des opérations supprimées', () => {
const removed = diff.operations.find((o) => o.kind === 'removed')
expect(removed.oldIndex).toBe(1) // TOTAL / elec
@@ -125,7 +104,7 @@ describe('fusion sélective', () => {
const latapy = merged.situation_locataires[0]
expect(latapy.locataire.nom).toBe('LATAPY NINA')
expect(latapy.lot.numero).toBe('07')
expect(latapy.lot.numero).toBe('12')
expect(latapy.totaux.total).toBe(1250) // montants acceptés
})
@@ -136,7 +115,7 @@ describe('fusion sélective', () => {
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.lot.numero).toBe('12')
expect(latapy.locataire.nom).toBe('LATAPY NINA Complément loyer')
})
@@ -214,6 +193,36 @@ describe('locataires ajoutés ou supprimés', () => {
})
})
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)