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:
2026-07-26 06:15:37 +02:00
parent 5f3a26e7d7
commit 43efea4945
8 changed files with 1924 additions and 73 deletions

View File

@@ -4,11 +4,30 @@
<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, i) in diff.metadata" :key="'m' + i" class="flex items-baseline gap-2">
<span class="text-gray-500 w-40 flex-shrink-0">{{ c.label }}</span>
<span class="text-red-400 line-through">{{ c.before }}</span>
<span class="text-gray-500"></span>
<span class="text-green-400 font-medium">{{ c.after }}</span>
<div v-for="c in diff.metadata" :key="c.id" 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)"
@change="toggle(c.id, $event.target.checked)"
class="w-3.5 h-3.5 rounded flex-shrink-0 self-center"
/>
<span class="text-gray-500 w-40 flex-shrink-0">{{ c.label }}</span>
<span class="text-red-400" :class="accepted(c.id) && 'line-through'">{{ c.before }}</span>
<span class="text-gray-500"></span>
<span v-if="!selectable || !c.editable" class="text-green-400 font-medium" :class="!accepted(c.id) && 'line-through opacity-50'">
{{ c.after }}
</span>
</component>
<input
v-if="selectable && c.editable"
type="text"
:value="displayValue(c)"
:disabled="!accepted(c.id)"
@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>
</div>
</div>
@@ -17,18 +36,74 @@
<div v-if="diff.locataires.length">
<div class="font-semibold text-amber-300 mb-1">Locataires</div>
<div class="space-y-1.5">
<div v-for="(loc, i) in diff.locataires" :key="'l' + i" class="bg-gray-900/70 border border-gray-700 rounded p-1.5">
<div v-for="(loc, i) in diff.locataires" :key="loc.id || 'l' + i" class="bg-gray-900/70 border border-gray-700 rounded p-1.5">
<div class="flex items-center gap-2 mb-0.5">
<input
v-if="selectable && loc.kind !== 'modified'"
type="checkbox"
:checked="accepted(loc.id)"
@change="toggle(loc.id, $event.target.checked)"
class="w-3.5 h-3.5 rounded flex-shrink-0"
/>
<span :class="kindClass(loc.kind)" class="text-[10px] uppercase font-semibold px-1 rounded">
{{ kindLabel(loc.kind) }}
</span>
<span class="font-medium text-gray-200">{{ loc.title }}</span>
</div>
<div v-for="(f, j) in loc.fields" :key="j" class="flex items-baseline gap-2 pl-2">
<span class="text-gray-500 w-24 flex-shrink-0">{{ f.label }}</span>
<span class="text-red-400 line-through break-all">{{ f.before }}</span>
<span class="text-gray-500"></span>
<span class="text-green-400 font-medium break-all">{{ f.after }}</span>
<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'">
<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"
/>
<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>
<span class="text-gray-500"></span>
<span v-if="!selectable || !c.editable" class="text-green-400 font-medium break-all" :class="!accepted(c.id) && 'line-through opacity-50'">
{{ c.after }}
</span>
</component>
<input
v-if="selectable && c.editable"
type="text"
:value="displayValue(c)"
:disabled="!accepted(c.id)"
@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>
<!-- Montants et lignes : un seul bloc, pour que les sommes restent justes -->
<div v-else class="pl-2">
<component :is="selectable ? 'label' : 'div'" class="flex items-center gap-2" :class="selectable && 'cursor-pointer'">
<input
v-if="selectable"
type="checkbox"
:checked="accepted(c.id)"
@change="toggle(c.id, $event.target.checked)"
class="w-3.5 h-3.5 rounded flex-shrink-0"
/>
<span class="text-gray-300 font-medium">{{ c.label }}</span>
<span class="text-gray-400">({{ c.fields.length }} changement{{ c.fields.length > 1 ? 's' : '' }})</span>
</component>
<div :class="['pl-5 space-y-0.5', selectable && !accepted(c.id) && 'opacity-40']">
<div v-for="(f, j) in c.fields" :key="j" class="flex items-baseline gap-2">
<span class="text-gray-500 w-24 flex-shrink-0">{{ f.label }}</span>
<span class="text-red-400 break-all" :class="accepted(c.id) && 'line-through'">{{ f.before }}</span>
<span class="text-gray-500"></span>
<span class="text-green-400 font-medium break-all" :class="!accepted(c.id) && 'line-through'">{{ f.after }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -38,16 +113,23 @@
<div v-if="diff.operations.length">
<div class="font-semibold text-amber-300 mb-1">Opérations</div>
<div class="space-y-1.5">
<div v-for="(op, i) in diff.operations" :key="'o' + i" class="bg-gray-900/70 border border-gray-700 rounded p-1.5">
<div class="flex items-center gap-2 mb-0.5">
<div v-for="(op, i) in diff.operations" :key="op.id || 'o' + i" class="bg-gray-900/70 border border-gray-700 rounded p-1.5">
<component :is="selectable ? 'label' : 'div'" class="flex items-center gap-2 mb-0.5" :class="selectable && 'cursor-pointer'">
<input
v-if="selectable"
type="checkbox"
:checked="accepted(op.id)"
@change="toggle(op.id, $event.target.checked)"
class="w-3.5 h-3.5 rounded flex-shrink-0"
/>
<span :class="kindClass(op.kind)" class="text-[10px] uppercase font-semibold px-1 rounded">
{{ kindLabel(op.kind) }}
</span>
<span v-if="op.index >= 0" class="text-gray-500">opération #{{ op.index + 1 }}</span>
</div>
<div class="pl-2">
<div class="text-red-400 line-through break-all">{{ op.before }}</div>
<div class="text-green-400 font-medium break-all">{{ op.after }}</div>
</component>
<div :class="['pl-2', selectable && !accepted(op.id) && 'opacity-40']">
<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>
</div>
</div>
@@ -56,13 +138,58 @@
</template>
<script setup>
defineProps({
import { computed } from 'vue'
const props = defineProps({
diff: {
type: Object,
required: true,
},
// Absente : affichage seul. Fournie : chaque changement devient cochable, et
// les champs textuels retenus deviennent modifiables.
selection: {
type: Object,
default: null,
},
})
const emit = defineEmits(['update:selection'])
const selectable = computed(() => props.selection !== null)
function accepted(id) {
if (!selectable.value) return true
const state = props.selection[id]
return state ? state.accepted !== false : true
}
function stateOf(id) {
return props.selection[id] || { accepted: true }
}
function toggle(id, value) {
emit('update:selection', { ...props.selection, [id]: { ...stateOf(id), accepted: value } })
}
// Valeur affichée dans le champ de saisie : celle déjà retenue, sinon celle
// proposée par la nouvelle extraction.
function displayValue(change) {
const state = props.selection?.[change.id]
const value = state && state.value !== undefined ? state.value : change.afterValue
return value == null ? '' : String(value)
}
function edit(change, raw) {
// Un champ numérique en base doit le rester (montant du solde, par exemple).
const value = typeof change.afterValue === 'number' && raw.trim() !== '' && !Number.isNaN(Number(raw))
? Number(raw)
: raw
emit('update:selection', {
...props.selection,
[change.id]: { ...stateOf(change.id), accepted: true, value },
})
}
function kindLabel(kind) {
if (kind === 'added') return 'ajouté'
if (kind === 'removed') return 'supprimé'

View File

@@ -111,8 +111,9 @@
<input
type="checkbox"
v-model="item.selected"
:disabled="isApplying"
class="w-4 h-4 rounded flex-shrink-0"
:disabled="isApplying || item.counts.accepted === 0"
:title="item.counts.accepted === 0 ? 'Aucune modification retenue pour ce document' : ''"
class="w-4 h-4 rounded flex-shrink-0 disabled:opacity-40"
/>
<div class="flex-1 min-w-0">
@@ -134,6 +135,13 @@
<span v-if="item.diff.summary.operations" class="px-2 py-0.5 text-xs rounded bg-amber-500/20 text-amber-300">
{{ item.diff.summary.operations }} opération{{ item.diff.summary.operations > 1 ? 's' : '' }}
</span>
<span
v-if="item.counts.accepted < item.counts.total"
class="px-2 py-0.5 text-xs rounded bg-orange-500/20 text-orange-300"
title="Une partie des modifications a été écartée"
>
{{ item.counts.accepted }}/{{ item.counts.total }} retenues
</span>
</div>
<!-- État des tags -->
@@ -168,9 +176,34 @@
</div>
</div>
<!-- Détail : différences + tags à confirmer -->
<!-- Détail : différences (cochables) + tags à confirmer -->
<div v-if="openDetails[item.id]" class="bg-amber-50 border-t border-amber-300 px-4 py-3 space-y-4">
<ExtractionDiffDetails :diff="item.diff" />
<div class="flex items-center justify-between text-xs">
<span class="text-amber-800">
{{ item.counts.accepted }} modification{{ item.counts.accepted > 1 ? 's' : '' }}
retenue{{ item.counts.accepted > 1 ? 's' : '' }} sur {{ item.counts.total }}
</span>
<div class="flex items-center gap-2">
<button
@click="setAllChanges(item, true)"
class="px-2 py-1 rounded text-amber-700 hover:bg-amber-100 transition-colors"
>
Tout retenir
</button>
<button
@click="setAllChanges(item, false)"
class="px-2 py-1 rounded text-amber-700 hover:bg-amber-100 transition-colors"
>
Tout écarter
</button>
</div>
</div>
<ExtractionDiffDetails
:diff="item.diff"
:selection="item.selection"
@update:selection="updateSelection(item, $event)"
/>
<div v-if="item.tagsToReview.length">
<div class="font-semibold text-amber-800 text-xs mb-2">
@@ -268,7 +301,8 @@
{{ applyError }}
</template>
<template v-else>
Les documents sélectionnés seront enregistrés avec la nouvelle extraction.
Seules les modifications retenues seront enregistrées ({{ acceptedInSelection }} sur
{{ totalInSelection }} pour la sélection).
</template>
</div>
<button
@@ -287,7 +321,8 @@
import { computed, onMounted, onBeforeUnmount, reactive, ref } from 'vue'
import ExtractionDiffDetails from '../components/ExtractionDiffDetails.vue'
import TagAutocomplete from '../components/TagAutocomplete.vue'
import { computeExtractionDiff } from '../utils/diffExtraction.js'
import { computeExtractionDiff, listChangeIds } from '../utils/diffExtraction.js'
import { countAccepted, mergeExtraction } from '../utils/mergeExtraction.js'
import { fillPredictions, remapTags, toDepensesTags } from '../utils/tagRemap.js'
// Ordre de grandeur mesuré d'une extraction, avant toute mesure réelle.
@@ -321,6 +356,12 @@ const problemItems = computed(() =>
items.value.filter((i) => ['sans_pdf', 'erreur'].includes(i.status))
)
const selectedItems = computed(() => changedItems.value.filter((i) => i.selected))
const acceptedInSelection = computed(() =>
selectedItems.value.reduce((sum, i) => sum + i.counts.accepted, 0)
)
const totalInSelection = computed(() =>
selectedItems.value.reduce((sum, i) => sum + i.counts.total, 0)
)
const allSelected = computed(
() => changedItems.value.length > 0 && selectedItems.value.length === changedItems.value.length
)
@@ -386,7 +427,13 @@ function resetItems() {
has_pdf: doc.has_pdf,
status: 'attente',
diff: null,
previousData: null,
previousTags: [],
newData: null,
// État des cases du détail, par identifiant de changement.
selection: {},
changeIds: [],
counts: { accepted: 0, total: 0 },
tags: {},
tagStats: { reported: 0, predicted: 0, untagged: 0 },
tagsToReview: [],
@@ -431,6 +478,7 @@ async function scanOne(item) {
if (!response.ok) throw new Error(result.detail || 'Échec de la ré-extraction')
const diff = computeExtractionDiff(result.previous_data, result.re_extracted_data)
item.previousData = result.previous_data
item.newData = result.re_extracted_data
item.diff = diff
@@ -439,6 +487,12 @@ async function scanOne(item) {
return
}
// Toutes les modifications sont retenues par défaut : le cas courant reste
// à un clic, décocher relève de l'exception.
item.changeIds = listChangeIds(diff)
item.selection = {}
item.counts = countAccepted(item.changeIds, {})
await prepareTags(item, result)
item.status = 'modifie'
item.selected = true
@@ -461,6 +515,7 @@ async function prepareTags(item, result) {
const predicted = await fillPredictions(tags, newOps, missing)
item.previousTags = result.depenses_tags || []
item.tags = tags
item.tagStats = {
reported,
@@ -471,6 +526,31 @@ async function prepareTags(item, result) {
item.tagsToReview = missing
}
// Les tags ont été choisis sur la liste d'opérations ré-extraite ; écarter un
// ajout ou refuser une suppression change cette liste. On les reporte donc une
// dernière fois, par signature, sur les opérations effectivement enregistrées.
function finalTags(item, mergedData) {
const newOps = item.newData.recapitulatif_operations || []
const previousOps = item.previousData?.recapitulatif_operations || []
const finalOps = mergedData.recapitulatif_operations || []
const chosen = Object.entries(item.tags).map(([index, t]) => ({
index: Number(index),
tag_id: t.tag_id,
tag_nom: t.tag_nom,
}))
const { tags, missing } = remapTags(newOps, chosen, finalOps)
// Une opération dont on a refusé la suppression revient de l'ancienne
// extraction : son tag aussi, il n'est pas dans les choix ci-dessus.
if (missing.length) {
const { tags: restored } = remapTags(previousOps, item.previousTags, finalOps)
for (const i of missing) if (restored[i]) tags[i] = restored[i]
}
return toDepensesTags(tags)
}
function setTag(item, opIndex, tagId) {
if (tagId == null) {
delete item.tags[opIndex]
@@ -494,6 +574,22 @@ function refreshTagStats(item) {
}
}
function updateSelection(item, selection) {
item.selection = selection
item.counts = countAccepted(item.changeIds, selection)
// Plus rien à retenir : réenregistrer le document n'aurait aucun effet.
if (item.counts.accepted === 0) item.selected = false
}
function setAllChanges(item, accepted) {
const selection = {}
for (const id of item.changeIds) {
// Une valeur saisie à la main est conservée si le changement reste retenu.
selection[id] = { ...(item.selection[id] || {}), accepted }
}
updateSelection(item, selection)
}
function stopScan() {
abortScan = true
controller?.abort()
@@ -527,13 +623,14 @@ async function applySelection() {
let failures = 0
for (const item of targets) {
try {
const data = mergeExtraction(item.previousData, item.newData, item.diff, item.selection)
const response = await fetch(`/api/documents/${item.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source_file: item.source_file,
data: item.newData,
depenses_tags: toDepensesTags(item.tags),
data,
depenses_tags: finalTags(item, data),
}),
})
const result = await response.json()

View File

@@ -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 à lenregistrement' : 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
}

View 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)
}

View 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 }
}