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:
@@ -1,8 +1,17 @@
|
||||
// Comparaison de deux jeux de données d'extraction (avant / après re-extraction).
|
||||
//
|
||||
// Chaque jeu a la forme { metadata, situation_locataires, recapitulatif_operations }.
|
||||
// Retourne un résumé structuré des différences, destiné à la fois à l'affichage
|
||||
// (panneau de diff) et à la mise en évidence inline (anneaux « modifié »).
|
||||
// Retourne un résumé structuré des différences, destiné à trois usages :
|
||||
// - l'affichage (panneau de diff) via `label` / `before` / `after`, déjà formatés ;
|
||||
// - la mise en évidence inline (anneaux « modifié ») via les listes `changed*` ;
|
||||
// - l'application sélective (cf. mergeExtraction.js) via `id`, `path` et les
|
||||
// valeurs brutes `beforeValue` / `afterValue`.
|
||||
//
|
||||
// Maille des changements sélectionnables : un champ pour ce qui est textuel
|
||||
// (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
|
||||
|
||||
@@ -49,6 +58,27 @@ const METADATA_FIELDS = [
|
||||
['solde.date_arrete', 'Solde · date arrêté'],
|
||||
]
|
||||
|
||||
// Champs identitaires d'un locataire : du texte, sans lien arithmétique avec le
|
||||
// reste. Chacun se retient ou se refuse indépendamment.
|
||||
const LOCATAIRE_IDENTITE = [
|
||||
['locataire.nom', 'Nom'],
|
||||
['lot.numero', 'Lot'],
|
||||
['lot.type', 'Type'],
|
||||
]
|
||||
|
||||
// Champs chiffrés d'un locataire : liés entre eux (total = somme des postes,
|
||||
// impayés = total − réglés), donc regroupés en un seul changement.
|
||||
const LOCATAIRE_MONTANTS = [
|
||||
['totaux.loyers', 'Total loyers'],
|
||||
['totaux.taxes', 'Total taxes'],
|
||||
['totaux.provisions', 'Total provisions'],
|
||||
['totaux.divers', 'Total divers'],
|
||||
['totaux.solde_anterieur', 'Solde antérieur'],
|
||||
['totaux.total', 'Total'],
|
||||
['totaux.regles', 'Réglés'],
|
||||
['totaux.impayes', 'Impayés'],
|
||||
]
|
||||
|
||||
function ligneSig(l) {
|
||||
if (!l) return '∅'
|
||||
const p = l.periode || {}
|
||||
@@ -77,30 +107,55 @@ function diffLignes(before, after) {
|
||||
return changes
|
||||
}
|
||||
|
||||
function diffLocataire(before, after) {
|
||||
const fields = []
|
||||
const scalar = [
|
||||
['locataire.nom', 'Nom'],
|
||||
['lot.numero', 'Lot'],
|
||||
['lot.type', 'Type'],
|
||||
['totaux.loyers', 'Total loyers'],
|
||||
['totaux.taxes', 'Total taxes'],
|
||||
['totaux.provisions', 'Total provisions'],
|
||||
['totaux.divers', 'Total divers'],
|
||||
['totaux.solde_anterieur', 'Solde antérieur'],
|
||||
['totaux.total', 'Total'],
|
||||
['totaux.regles', 'Réglés'],
|
||||
['totaux.impayes', 'Impayés'],
|
||||
]
|
||||
for (const [path, label] of scalar) {
|
||||
// Changements d'un locataire modifié : un par champ identitaire, plus un bloc
|
||||
// unique pour l'ensemble des montants et des lignes.
|
||||
function diffLocataire(index, before, after) {
|
||||
const changes = []
|
||||
|
||||
for (const [path, label] of LOCATAIRE_IDENTITE) {
|
||||
const bv = get(before, path)
|
||||
const av = get(after, path)
|
||||
if (!valEq(bv, av)) {
|
||||
fields.push({ label, before: fmtValue(bv), after: fmtValue(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',
|
||||
path,
|
||||
label,
|
||||
before: fmtValue(bv),
|
||||
after: fmtValue(av),
|
||||
beforeValue: bv,
|
||||
afterValue: av,
|
||||
editable: !forced,
|
||||
forced,
|
||||
hint: forced ? 'Numéro normalisé automatiquement à l’enregistrement' : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
fields.push(...diffLignes(before?.lignes, after?.lignes))
|
||||
return fields
|
||||
|
||||
const montants = []
|
||||
for (const [path, label] of LOCATAIRE_MONTANTS) {
|
||||
const bv = get(before, path)
|
||||
const av = get(after, path)
|
||||
if (!valEq(bv, av)) {
|
||||
montants.push({ label, before: fmtValue(bv), after: fmtValue(av) })
|
||||
}
|
||||
}
|
||||
montants.push(...diffLignes(before?.lignes, after?.lignes))
|
||||
|
||||
if (montants.length) {
|
||||
changes.push({
|
||||
id: `loc:${index}:montants`,
|
||||
kind: 'montants',
|
||||
label: 'Montants et lignes',
|
||||
fields: montants,
|
||||
})
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
function locataireTitle(loc) {
|
||||
@@ -134,7 +189,17 @@ export function computeExtractionDiff(before, after) {
|
||||
const bv = get(b.metadata, path)
|
||||
const av = get(a.metadata, path)
|
||||
if (!valEq(bv, av)) {
|
||||
metadata.push({ label, before: fmtValue(bv), after: fmtValue(av) })
|
||||
metadata.push({
|
||||
id: `meta:${path}`,
|
||||
kind: 'champ',
|
||||
path,
|
||||
label,
|
||||
before: fmtValue(bv),
|
||||
after: fmtValue(av),
|
||||
beforeValue: bv,
|
||||
afterValue: av,
|
||||
editable: true,
|
||||
})
|
||||
changedMetadataPaths.push(path)
|
||||
}
|
||||
}
|
||||
@@ -149,14 +214,26 @@ export function computeExtractionDiff(before, after) {
|
||||
const ob = i < lb.length ? lb[i] : null
|
||||
const oa = i < la.length ? la[i] : null
|
||||
if (!ob && oa) {
|
||||
locataires.push({ index: i, kind: 'added', title: locataireTitle(oa), fields: [] })
|
||||
locataires.push({
|
||||
id: `loc:${i}:entier`,
|
||||
index: i,
|
||||
kind: 'added',
|
||||
title: locataireTitle(oa),
|
||||
changes: [],
|
||||
})
|
||||
changedLocataireIndices.push(i)
|
||||
} else if (ob && !oa) {
|
||||
locataires.push({ index: i, kind: 'removed', title: locataireTitle(ob), fields: [] })
|
||||
locataires.push({
|
||||
id: `loc:${i}:entier`,
|
||||
index: i,
|
||||
kind: 'removed',
|
||||
title: locataireTitle(ob),
|
||||
changes: [],
|
||||
})
|
||||
} else {
|
||||
const fields = diffLocataire(ob, oa)
|
||||
if (fields.length) {
|
||||
locataires.push({ index: i, kind: 'modified', title: locataireTitle(oa), fields })
|
||||
const changes = diffLocataire(i, ob, oa)
|
||||
if (changes.length) {
|
||||
locataires.push({ index: i, kind: 'modified', title: locataireTitle(oa), changes })
|
||||
changedLocataireIndices.push(i)
|
||||
}
|
||||
}
|
||||
@@ -184,17 +261,32 @@ export function computeExtractionDiff(before, after) {
|
||||
oldCounts.set(s, remaining - 1) // appariée avec une ancienne identique
|
||||
} else {
|
||||
changedOperationIndices.push(i)
|
||||
operations.push({ index: i, kind: 'added', before: '∅', after: fmtValue(s) })
|
||||
operations.push({
|
||||
id: `op:added:${i}`,
|
||||
index: i,
|
||||
oldIndex: -1,
|
||||
kind: 'added',
|
||||
before: '∅',
|
||||
after: fmtValue(s),
|
||||
})
|
||||
}
|
||||
newCounts.set(s, (newCounts.get(s) || 0) + 1)
|
||||
})
|
||||
// Anciennes opérations absentes de la nouvelle extraction -> supprimées.
|
||||
// `oldIndex` permet de les réinsérer à leur place si la suppression est refusée.
|
||||
const seen = new Map()
|
||||
ob.forEach((op) => {
|
||||
ob.forEach((op, i) => {
|
||||
const s = opSig(op)
|
||||
seen.set(s, (seen.get(s) || 0) + 1)
|
||||
if ((newCounts.get(s) || 0) < seen.get(s)) {
|
||||
operations.push({ index: -1, kind: 'removed', before: fmtValue(s), after: '∅' })
|
||||
operations.push({
|
||||
id: `op:removed:${i}`,
|
||||
index: -1,
|
||||
oldIndex: i,
|
||||
kind: 'removed',
|
||||
before: fmtValue(s),
|
||||
after: '∅',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -215,3 +307,17 @@ export function computeExtractionDiff(before, after) {
|
||||
summary,
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
export function listChangeIds(diff) {
|
||||
const ids = []
|
||||
for (const m of diff.metadata) ids.push(m.id)
|
||||
for (const loc of diff.locataires) {
|
||||
if (loc.kind === 'modified') ids.push(...loc.changes.map((c) => c.id))
|
||||
else ids.push(loc.id)
|
||||
}
|
||||
for (const op of diff.operations) ids.push(op.id)
|
||||
return ids
|
||||
}
|
||||
|
||||
21
frontend/src/utils/lots.js
Normal file
21
frontend/src/utils/lots.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// 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)
|
||||
}
|
||||
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