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

@@ -4,7 +4,8 @@
<div v-if="diff.metadata.length">
<div class="font-semibold text-amber-300 mb-1">Métadonnées</div>
<div class="space-y-0.5">
<div v-for="c in diff.metadata" :key="c.id" class="flex items-baseline gap-2">
<div v-for="c in diff.metadata" :key="c.id">
<div class="flex items-baseline gap-2">
<component :is="selectable ? 'label' : 'span'" class="flex items-baseline gap-2 flex-1 min-w-0" :class="selectable && 'cursor-pointer'">
<input
v-if="selectable"
@@ -28,6 +29,8 @@
@input="edit(c, $event.target.value)"
class="flex-1 min-w-0 px-1.5 py-0.5 rounded border border-gray-600 bg-gray-900 text-green-400 font-medium disabled:opacity-40 disabled:line-through focus:ring-1 focus:ring-amber-500 focus:border-amber-500 focus:outline-none"
/>
</div>
<p v-if="overrideLabel(c.id)" class="pl-6 text-orange-700"> {{ overrideLabel(c.id) }}</p>
</div>
</div>
</div>
@@ -50,19 +53,19 @@
</span>
<span class="font-medium text-gray-200">{{ loc.title }}</span>
</div>
<p v-if="loc.id && overrideLabel(loc.id)" class="pl-5 text-orange-700 mb-0.5"> {{ overrideLabel(loc.id) }}</p>
<div v-for="c in loc.changes" :key="c.id">
<!-- Champ identitaire : cochable et modifiable individuellement -->
<div v-if="c.kind === 'champ'" class="flex items-baseline gap-2 pl-2">
<component :is="selectable && !c.forced ? 'label' : 'span'" class="flex items-baseline gap-2 flex-1 min-w-0" :class="selectable && !c.forced && 'cursor-pointer'">
<div v-if="c.kind === 'champ'" class="pl-2">
<div class="flex items-baseline gap-2">
<component :is="selectable ? 'label' : 'span'" class="flex items-baseline gap-2 flex-1 min-w-0" :class="selectable && 'cursor-pointer'">
<input
v-if="selectable"
type="checkbox"
:checked="accepted(c.id)"
:disabled="c.forced"
:title="c.hint"
@change="toggle(c.id, $event.target.checked)"
class="w-3.5 h-3.5 rounded flex-shrink-0 self-center disabled:opacity-50"
class="w-3.5 h-3.5 rounded flex-shrink-0 self-center"
/>
<span class="text-gray-500 w-24 flex-shrink-0">{{ c.label }}</span>
<span class="text-red-400 break-all" :class="accepted(c.id) && 'line-through'">{{ c.before }}</span>
@@ -79,7 +82,8 @@
@input="edit(c, $event.target.value)"
class="flex-1 min-w-0 px-1.5 py-0.5 rounded border border-gray-600 bg-gray-900 text-green-400 font-medium disabled:opacity-40 disabled:line-through focus:ring-1 focus:ring-amber-500 focus:border-amber-500 focus:outline-none"
/>
<span v-if="selectable && c.forced" class="text-gray-400 italic flex-shrink-0">{{ c.hint }}</span>
</div>
<p v-if="overrideLabel(c.id)" class="pl-6 text-orange-700"> {{ overrideLabel(c.id) }}</p>
</div>
<!-- Montants et lignes : un seul bloc, pour que les sommes restent justes -->
@@ -103,6 +107,7 @@
<span class="text-green-400 font-medium break-all" :class="!accepted(c.id) && 'line-through'">{{ f.after }}</span>
</div>
</div>
<p v-if="overrideLabel(c.id)" class="pl-5 text-orange-700"> {{ overrideLabel(c.id) }}</p>
</div>
</div>
</div>
@@ -131,6 +136,7 @@
<div class="text-red-400 break-all" :class="accepted(op.id) && 'line-through'">{{ op.before }}</div>
<div class="text-green-400 font-medium break-all" :class="!accepted(op.id) && 'line-through'">{{ op.after }}</div>
</div>
<p v-if="overrideLabel(op.id)" class="pl-2 text-orange-700"> {{ overrideLabel(op.id) }}</p>
</div>
</div>
</div>
@@ -140,6 +146,8 @@
<script setup>
import { computed } from 'vue'
import { fmtValue } from '../utils/diffExtraction.js'
const props = defineProps({
diff: {
type: Object,
@@ -151,12 +159,27 @@ const props = defineProps({
type: Object,
default: null,
},
// Changements que l'enregistrement réécrira quoi qu'on décide, par
// identifiant → valeur imposée (cf. computeServerOverrides).
serverOverrides: {
type: Object,
default: () => ({}),
},
})
const emit = defineEmits(['update:selection'])
const selectable = computed(() => props.selection !== null)
// Texte d'alerte quand refuser un changement ne servirait à rien.
function overrideLabel(id) {
if (!selectable.value || accepted(id)) return null
if (!(id in props.serverOverrides)) return null
const value = props.serverOverrides[id]
const shown = value != null && typeof value === 'object' ? '' : ` « ${fmtValue(value)} »`
return `Lenregistrement réécrira cette valeur${shown}`
}
function accepted(id) {
if (!selectable.value) return true
const state = props.selection[id]

View File

@@ -202,6 +202,7 @@
<ExtractionDiffDetails
:diff="item.diff"
:selection="item.selection"
:server-overrides="item.serverOverrides"
@update:selection="updateSelection(item, $event)"
/>
@@ -321,7 +322,11 @@
import { computed, onMounted, onBeforeUnmount, reactive, ref } from 'vue'
import ExtractionDiffDetails from '../components/ExtractionDiffDetails.vue'
import TagAutocomplete from '../components/TagAutocomplete.vue'
import { computeExtractionDiff, listChangeIds } from '../utils/diffExtraction.js'
import {
computeExtractionDiff,
computeServerOverrides,
listChangeIds,
} from '../utils/diffExtraction.js'
import { countAccepted, mergeExtraction } from '../utils/mergeExtraction.js'
import { fillPredictions, remapTags, toDepensesTags } from '../utils/tagRemap.js'
@@ -432,6 +437,8 @@ function resetItems() {
newData: null,
// État des cases du détail, par identifiant de changement.
selection: {},
// Changements que l'enregistrement réécrira, quel que soit le choix.
serverOverrides: {},
changeIds: [],
counts: { accepted: 0, total: 0 },
tags: {},
@@ -492,6 +499,11 @@ async function scanOne(item) {
item.changeIds = listChangeIds(diff)
item.selection = {}
item.counts = countAccepted(item.changeIds, {})
item.serverOverrides = computeServerOverrides(
diff,
result.previous_data,
result.previous_canonical
)
await prepareTags(item, result)
item.status = 'modifie'

View File

@@ -11,8 +11,6 @@
// (nom, numéro de lot, métadonnées), un bloc pour les montants et lignes d'un
// locataire — les retenir séparément produirait des totaux qui ne somment plus.
import { sameLot } from './lots.js'
const EPS = 0.005
function numEq(a, b) {
@@ -116,10 +114,6 @@ function diffLocataire(index, before, after) {
const bv = get(before, path)
const av = get(after, path)
if (!valEq(bv, av)) {
// Une réécriture de forme du numéro de lot ("0001" → "01") est imposée par
// le backend, qui normalise tout enregistrement : inutile de proposer de
// la refuser, la valeur reviendrait normalisée.
const forced = path === 'lot.numero' && sameLot(bv, av)
changes.push({
id: `loc:${index}:${path}`,
kind: 'champ',
@@ -129,9 +123,7 @@ function diffLocataire(index, before, after) {
after: fmtValue(av),
beforeValue: bv,
afterValue: av,
editable: !forced,
forced,
hint: forced ? 'Numéro normalisé automatiquement à lenregistrement' : undefined,
editable: true,
})
}
}
@@ -308,6 +300,68 @@ export function computeExtractionDiff(before, after) {
}
}
/**
* Repère les changements que le serveur imposera même si on les refuse.
*
* Refuser un changement, c'est réenvoyer la valeur actuelle du document. Or
* l'enregistrement réécrit certaines valeurs sous une forme canonique : si la
* valeur actuelle n'est pas déjà canonique, la refuser ne changera rien.
*
* On le déduit en comparant les données actuelles à leur forme canonique, toutes
* deux fournies par le serveur : aucune règle de réécriture n'est connue ici, et
* une transformation ajoutée plus tard remonte sans modification de ce code.
*
* @returns {Object} identifiant de changement → valeur que le serveur écrira
*/
export function computeServerOverrides(diff, previous, canonical) {
const overrides = {}
if (!previous || !canonical) return overrides
const differs = (a, b) => JSON.stringify(a ?? null) !== JSON.stringify(b ?? null)
for (const change of diff.metadata) {
const actuel = get(previous.metadata, change.path)
const impose = get(canonical.metadata, change.path)
if (differs(actuel, impose)) overrides[change.id] = impose
}
const prevLoc = previous.situation_locataires || []
const canonLoc = canonical.situation_locataires || []
for (const loc of diff.locataires) {
const actuel = prevLoc[loc.index]
const impose = canonLoc[loc.index]
if (actuel === undefined) continue // locataire ajouté : rien à conserver
if (loc.kind !== 'modified') {
// Refuser, c'est conserver le locataire entier tel qu'il est en base.
if (differs(actuel, impose)) overrides[loc.id] = impose
continue
}
for (const change of loc.changes) {
if (change.kind === 'montants') {
if (differs(actuel?.totaux, impose?.totaux) || differs(actuel?.lignes, impose?.lignes)) {
overrides[change.id] = { totaux: impose?.totaux, lignes: impose?.lignes }
}
} else {
const a = get(actuel, change.path)
const b = get(impose, change.path)
if (differs(a, b)) overrides[change.id] = b
}
}
}
const prevOps = previous.recapitulatif_operations || []
const canonOps = canonical.recapitulatif_operations || []
for (const op of diff.operations) {
if (op.kind !== 'removed') continue // un ajout refusé n'est pas enregistré
if (differs(prevOps[op.oldIndex], canonOps[op.oldIndex])) {
overrides[op.id] = canonOps[op.oldIndex]
}
}
return overrides
}
// Identifiants de tous les changements sélectionnables, dans l'ordre d'affichage.
// Un locataire ajouté ou supprimé compte pour un ; un locataire modifié expose
// une entrée par champ identitaire, plus une pour ses montants.

View File

@@ -1,21 +0,0 @@
// Miroir de la normalisation des numéros de lot appliquée par le backend
// (src/plesna_gerance/utils/lots.py) : tout enregistrement y passe, quelle que
// soit la valeur envoyée. Le front en a besoin pour ne pas proposer de refuser
// un changement que le serveur réappliquera de toute façon.
const LOT_NUMERO_WIDTH = 2
const LOT_NUMERO_INCONNU = '00'
export function normalizeLotNumero(value) {
if (value == null) return null
const digits = String(value).replace(/\D/g, '')
if (!digits) return null
const significant = digits.replace(/^0+/, '')
if (!significant) return LOT_NUMERO_INCONNU
return significant.padStart(LOT_NUMERO_WIDTH, '0')
}
// Vrai quand deux écritures désignent le même lot ("0001" et "01").
export function sameLot(a, b) {
return normalizeLotNumero(a) === normalizeLotNumero(b)
}

View File

@@ -8,6 +8,12 @@
// `selection` associe l'identifiant d'un changement (cf. diffExtraction.js) à
// { accepted, value } ; une entrée absente vaut « retenu », pour que le cas
// courant (tout accepter) ne demande aucune initialisation.
//
// Limite connue : le backend normalise les numéros de lot de tout enregistrement
// (save_document → normalize_extraction_lots). Refuser une réécriture de forme
// « 0001 » → « 01 » produit bien « 0001 » ici, mais la base portera « 01 ».
// Choix assumé : mieux vaut voir toutes les différences et pouvoir se prononcer,
// même si ce champ-là revient normalisé.
function clone(value) {
return value == null ? value : JSON.parse(JSON.stringify(value))
@@ -18,13 +24,6 @@ function isAccepted(selection, id) {
return state ? state.accepted !== false : true
}
// Un changement imposé (normalisation appliquée par le backend) est appliqué
// quoi qu'il arrive : le refuser produirait un écart entre ce qu'affiche
// l'interface et ce qui finit en base.
function isApplied(selection, change) {
return change.forced === true || isAccepted(selection, change.id)
}
// Valeur à écrire pour un changement retenu : celle saisie à la main si elle
// existe, sinon celle proposée par la nouvelle extraction.
function chosenValue(selection, change) {
@@ -58,7 +57,7 @@ export function mergeExtraction(previous, next, diff, selection = {}) {
// --- Métadonnées : un champ retenu écrase le champ correspondant.
if (!merged.metadata) merged.metadata = {}
for (const change of diff.metadata) {
if (isApplied(selection, change)) {
if (isAccepted(selection, change.id)) {
setPath(merged.metadata, change.path, chosenValue(selection, change))
}
}
@@ -90,7 +89,7 @@ export function mergeExtraction(previous, next, diff, selection = {}) {
const kept = clone(previousLoc[i])
for (const change of entry.changes) {
if (!isApplied(selection, change)) continue
if (!isAccepted(selection, change.id)) continue
if (change.kind === 'montants') {
// Les chiffres d'un locataire viennent en bloc du même parser.
kept.totaux = clone(nextLoc[i]?.totaux)

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)