feat: valide la re-extraction modification par modification
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>
This commit is contained in:
134
frontend/src/utils/mergeExtraction.js
Normal file
134
frontend/src/utils/mergeExtraction.js
Normal file
@@ -0,0 +1,134 @@
|
||||
// Fusion sélective de deux extractions.
|
||||
//
|
||||
// Le balayage de ré-extraction n'impose pas de tout prendre ou tout laisser :
|
||||
// l'utilisateur retient les changements un par un. Le document enregistré est
|
||||
// donc construit ici, en partant des données actuelles et en n'y appliquant que
|
||||
// les changements retenus — éventuellement avec une valeur saisie à la main.
|
||||
//
|
||||
// `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.
|
||||
|
||||
function clone(value) {
|
||||
return value == null ? value : JSON.parse(JSON.stringify(value))
|
||||
}
|
||||
|
||||
function isAccepted(selection, id) {
|
||||
const state = 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) {
|
||||
const state = selection?.[change.id]
|
||||
if (state && state.value !== undefined) return state.value
|
||||
return change.afterValue
|
||||
}
|
||||
|
||||
function setPath(obj, path, value) {
|
||||
const keys = path.split('.')
|
||||
let target = obj
|
||||
for (const key of keys.slice(0, -1)) {
|
||||
if (target[key] == null || typeof target[key] !== 'object') target[key] = {}
|
||||
target = target[key]
|
||||
}
|
||||
target[keys.at(-1)] = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit les données à enregistrer.
|
||||
*
|
||||
* @param {Object} previous données actuellement en base
|
||||
* @param {Object} next données ré-extraites
|
||||
* @param {Object} diff sortie de computeExtractionDiff(previous, next)
|
||||
* @param {Object} selection état des cases, par identifiant de changement
|
||||
*/
|
||||
export function mergeExtraction(previous, next, diff, selection = {}) {
|
||||
const merged = clone(previous) || {}
|
||||
const nextData = next || {}
|
||||
|
||||
// --- 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)) {
|
||||
setPath(merged.metadata, change.path, chosenValue(selection, change))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Locataires : alignés par index, comme dans le diff.
|
||||
const previousLoc = previous?.situation_locataires || []
|
||||
const nextLoc = nextData.situation_locataires || []
|
||||
const byIndex = new Map(diff.locataires.map((loc) => [loc.index, loc]))
|
||||
const locataires = []
|
||||
for (let i = 0; i < Math.max(previousLoc.length, nextLoc.length); i++) {
|
||||
const entry = byIndex.get(i)
|
||||
|
||||
// Locataire inchangé : l'ancienne et la nouvelle version sont équivalentes.
|
||||
if (!entry) {
|
||||
const kept = i < previousLoc.length ? previousLoc[i] : nextLoc[i]
|
||||
if (kept) locataires.push(clone(kept))
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.kind === 'added') {
|
||||
if (isAccepted(selection, entry.id)) locataires.push(clone(nextLoc[i]))
|
||||
continue
|
||||
}
|
||||
if (entry.kind === 'removed') {
|
||||
// Refuser la suppression, c'est conserver le locataire actuel.
|
||||
if (!isAccepted(selection, entry.id)) locataires.push(clone(previousLoc[i]))
|
||||
continue
|
||||
}
|
||||
|
||||
const kept = clone(previousLoc[i])
|
||||
for (const change of entry.changes) {
|
||||
if (!isApplied(selection, change)) continue
|
||||
if (change.kind === 'montants') {
|
||||
// Les chiffres d'un locataire viennent en bloc du même parser.
|
||||
kept.totaux = clone(nextLoc[i]?.totaux)
|
||||
kept.lignes = clone(nextLoc[i]?.lignes)
|
||||
} else {
|
||||
setPath(kept, change.path, chosenValue(selection, change))
|
||||
}
|
||||
}
|
||||
locataires.push(kept)
|
||||
}
|
||||
merged.situation_locataires = locataires
|
||||
|
||||
// --- Opérations : on part de la nouvelle liste, dont on retire les ajouts
|
||||
// refusés, puis on y réinsère les suppressions refusées à leur place d'origine.
|
||||
const previousOps = previous?.recapitulatif_operations || []
|
||||
const refusedAdded = new Set(
|
||||
diff.operations
|
||||
.filter((op) => op.kind === 'added' && !isAccepted(selection, op.id))
|
||||
.map((op) => op.index)
|
||||
)
|
||||
const operations = []
|
||||
;(nextData.recapitulatif_operations || []).forEach((op, i) => {
|
||||
if (!refusedAdded.has(i)) operations.push(clone(op))
|
||||
})
|
||||
const restored = diff.operations
|
||||
.filter((op) => op.kind === 'removed' && !isAccepted(selection, op.id))
|
||||
.sort((x, y) => x.oldIndex - y.oldIndex)
|
||||
for (const op of restored) {
|
||||
operations.splice(Math.min(op.oldIndex, operations.length), 0, clone(previousOps[op.oldIndex]))
|
||||
}
|
||||
merged.recapitulatif_operations = operations
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
// Nombre de changements retenus sur le total, pour l'affichage.
|
||||
export function countAccepted(changeIds, selection = {}) {
|
||||
let accepted = 0
|
||||
for (const id of changeIds) if (isAccepted(selection, id)) accepted++
|
||||
return { accepted, total: changeIds.length }
|
||||
}
|
||||
Reference in New Issue
Block a user