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

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,8 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"chart.js": "^4.4.1", "chart.js": "^4.4.1",
@@ -20,6 +21,7 @@
"autoprefixer": "^10.4.18", "autoprefixer": "^10.4.18",
"postcss": "^8.4.35", "postcss": "^8.4.35",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"vite": "^5.1.6" "vite": "^5.1.6",
"vitest": "^4.1.10"
} }
} }

View File

@@ -4,11 +4,30 @@
<div v-if="diff.metadata.length"> <div v-if="diff.metadata.length">
<div class="font-semibold text-amber-300 mb-1">Métadonnées</div> <div class="font-semibold text-amber-300 mb-1">Métadonnées</div>
<div class="space-y-0.5"> <div class="space-y-0.5">
<div v-for="(c, i) in diff.metadata" :key="'m' + i" class="flex items-baseline gap-2"> <div v-for="c in diff.metadata" :key="c.id" class="flex items-baseline gap-2">
<span class="text-gray-500 w-40 flex-shrink-0">{{ c.label }}</span> <component :is="selectable ? 'label' : 'span'" class="flex items-baseline gap-2 flex-1 min-w-0" :class="selectable && 'cursor-pointer'">
<span class="text-red-400 line-through">{{ c.before }}</span> <input
<span class="text-gray-500"></span> v-if="selectable"
<span class="text-green-400 font-medium">{{ c.after }}</span> 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> </div>
</div> </div>
@@ -17,18 +36,74 @@
<div v-if="diff.locataires.length"> <div v-if="diff.locataires.length">
<div class="font-semibold text-amber-300 mb-1">Locataires</div> <div class="font-semibold text-amber-300 mb-1">Locataires</div>
<div class="space-y-1.5"> <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"> <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"> <span :class="kindClass(loc.kind)" class="text-[10px] uppercase font-semibold px-1 rounded">
{{ kindLabel(loc.kind) }} {{ kindLabel(loc.kind) }}
</span> </span>
<span class="font-medium text-gray-200">{{ loc.title }}</span> <span class="font-medium text-gray-200">{{ loc.title }}</span>
</div> </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> <div v-for="c in loc.changes" :key="c.id">
<span class="text-red-400 line-through break-all">{{ f.before }}</span> <!-- Champ identitaire : cochable et modifiable individuellement -->
<span class="text-gray-500"></span> <div v-if="c.kind === 'champ'" class="flex items-baseline gap-2 pl-2">
<span class="text-green-400 font-medium break-all">{{ f.after }}</span> <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> </div>
</div> </div>
@@ -38,16 +113,23 @@
<div v-if="diff.operations.length"> <div v-if="diff.operations.length">
<div class="font-semibold text-amber-300 mb-1">Opérations</div> <div class="font-semibold text-amber-300 mb-1">Opérations</div>
<div class="space-y-1.5"> <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 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">
<div class="flex items-center gap-2 mb-0.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"> <span :class="kindClass(op.kind)" class="text-[10px] uppercase font-semibold px-1 rounded">
{{ kindLabel(op.kind) }} {{ kindLabel(op.kind) }}
</span> </span>
<span v-if="op.index >= 0" class="text-gray-500">opération #{{ op.index + 1 }}</span> <span v-if="op.index >= 0" class="text-gray-500">opération #{{ op.index + 1 }}</span>
</div> </component>
<div class="pl-2"> <div :class="['pl-2', selectable && !accepted(op.id) && 'opacity-40']">
<div class="text-red-400 line-through break-all">{{ op.before }}</div> <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">{{ op.after }}</div> <div class="text-green-400 font-medium break-all" :class="!accepted(op.id) && 'line-through'">{{ op.after }}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -56,13 +138,58 @@
</template> </template>
<script setup> <script setup>
defineProps({ import { computed } from 'vue'
const props = defineProps({
diff: { diff: {
type: Object, type: Object,
required: true, 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) { function kindLabel(kind) {
if (kind === 'added') return 'ajouté' if (kind === 'added') return 'ajouté'
if (kind === 'removed') return 'supprimé' if (kind === 'removed') return 'supprimé'

View File

@@ -111,8 +111,9 @@
<input <input
type="checkbox" type="checkbox"
v-model="item.selected" v-model="item.selected"
:disabled="isApplying" :disabled="isApplying || item.counts.accepted === 0"
class="w-4 h-4 rounded flex-shrink-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"> <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"> <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' : '' }} {{ item.diff.summary.operations }} opération{{ item.diff.summary.operations > 1 ? 's' : '' }}
</span> </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> </div>
<!-- État des tags --> <!-- État des tags -->
@@ -168,9 +176,34 @@
</div> </div>
</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"> <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 v-if="item.tagsToReview.length">
<div class="font-semibold text-amber-800 text-xs mb-2"> <div class="font-semibold text-amber-800 text-xs mb-2">
@@ -268,7 +301,8 @@
{{ applyError }} {{ applyError }}
</template> </template>
<template v-else> <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> </template>
</div> </div>
<button <button
@@ -287,7 +321,8 @@
import { computed, onMounted, onBeforeUnmount, reactive, ref } from 'vue' import { computed, onMounted, onBeforeUnmount, reactive, ref } from 'vue'
import ExtractionDiffDetails from '../components/ExtractionDiffDetails.vue' import ExtractionDiffDetails from '../components/ExtractionDiffDetails.vue'
import TagAutocomplete from '../components/TagAutocomplete.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' import { fillPredictions, remapTags, toDepensesTags } from '../utils/tagRemap.js'
// Ordre de grandeur mesuré d'une extraction, avant toute mesure réelle. // 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)) items.value.filter((i) => ['sans_pdf', 'erreur'].includes(i.status))
) )
const selectedItems = computed(() => changedItems.value.filter((i) => i.selected)) 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( const allSelected = computed(
() => changedItems.value.length > 0 && selectedItems.value.length === changedItems.value.length () => changedItems.value.length > 0 && selectedItems.value.length === changedItems.value.length
) )
@@ -386,7 +427,13 @@ function resetItems() {
has_pdf: doc.has_pdf, has_pdf: doc.has_pdf,
status: 'attente', status: 'attente',
diff: null, diff: null,
previousData: null,
previousTags: [],
newData: null, newData: null,
// État des cases du détail, par identifiant de changement.
selection: {},
changeIds: [],
counts: { accepted: 0, total: 0 },
tags: {}, tags: {},
tagStats: { reported: 0, predicted: 0, untagged: 0 }, tagStats: { reported: 0, predicted: 0, untagged: 0 },
tagsToReview: [], tagsToReview: [],
@@ -431,6 +478,7 @@ async function scanOne(item) {
if (!response.ok) throw new Error(result.detail || 'Échec de la ré-extraction') if (!response.ok) throw new Error(result.detail || 'Échec de la ré-extraction')
const diff = computeExtractionDiff(result.previous_data, result.re_extracted_data) const diff = computeExtractionDiff(result.previous_data, result.re_extracted_data)
item.previousData = result.previous_data
item.newData = result.re_extracted_data item.newData = result.re_extracted_data
item.diff = diff item.diff = diff
@@ -439,6 +487,12 @@ async function scanOne(item) {
return 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) await prepareTags(item, result)
item.status = 'modifie' item.status = 'modifie'
item.selected = true item.selected = true
@@ -461,6 +515,7 @@ async function prepareTags(item, result) {
const predicted = await fillPredictions(tags, newOps, missing) const predicted = await fillPredictions(tags, newOps, missing)
item.previousTags = result.depenses_tags || []
item.tags = tags item.tags = tags
item.tagStats = { item.tagStats = {
reported, reported,
@@ -471,6 +526,31 @@ async function prepareTags(item, result) {
item.tagsToReview = missing 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) { function setTag(item, opIndex, tagId) {
if (tagId == null) { if (tagId == null) {
delete item.tags[opIndex] 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() { function stopScan() {
abortScan = true abortScan = true
controller?.abort() controller?.abort()
@@ -527,13 +623,14 @@ async function applySelection() {
let failures = 0 let failures = 0
for (const item of targets) { for (const item of targets) {
try { try {
const data = mergeExtraction(item.previousData, item.newData, item.diff, item.selection)
const response = await fetch(`/api/documents/${item.id}`, { const response = await fetch(`/api/documents/${item.id}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
source_file: item.source_file, source_file: item.source_file,
data: item.newData, data,
depenses_tags: toDepensesTags(item.tags), depenses_tags: finalTags(item, data),
}), }),
}) })
const result = await response.json() 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). // Comparaison de deux jeux de données d'extraction (avant / après re-extraction).
// //
// Chaque jeu a la forme { metadata, situation_locataires, recapitulatif_operations }. // Chaque jeu a la forme { metadata, situation_locataires, recapitulatif_operations }.
// Retourne un résumé structuré des différences, destiné à la fois à l'affichage // Retourne un résumé structuré des différences, destiné à trois usages :
// (panneau de diff) et à la mise en évidence inline (anneaux « modifié »). // - 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 const EPS = 0.005
@@ -49,6 +58,27 @@ const METADATA_FIELDS = [
['solde.date_arrete', 'Solde · date arrêté'], ['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) { function ligneSig(l) {
if (!l) return '∅' if (!l) return '∅'
const p = l.periode || {} const p = l.periode || {}
@@ -77,30 +107,55 @@ function diffLignes(before, after) {
return changes return changes
} }
function diffLocataire(before, after) { // Changements d'un locataire modifié : un par champ identitaire, plus un bloc
const fields = [] // unique pour l'ensemble des montants et des lignes.
const scalar = [ function diffLocataire(index, before, after) {
['locataire.nom', 'Nom'], const changes = []
['lot.numero', 'Lot'],
['lot.type', 'Type'], for (const [path, label] of LOCATAIRE_IDENTITE) {
['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) {
const bv = get(before, path) const bv = get(before, path)
const av = get(after, path) const av = get(after, path)
if (!valEq(bv, av)) { 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) { function locataireTitle(loc) {
@@ -134,7 +189,17 @@ export function computeExtractionDiff(before, after) {
const bv = get(b.metadata, path) const bv = get(b.metadata, path)
const av = get(a.metadata, path) const av = get(a.metadata, path)
if (!valEq(bv, av)) { 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) changedMetadataPaths.push(path)
} }
} }
@@ -149,14 +214,26 @@ export function computeExtractionDiff(before, after) {
const ob = i < lb.length ? lb[i] : null const ob = i < lb.length ? lb[i] : null
const oa = i < la.length ? la[i] : null const oa = i < la.length ? la[i] : null
if (!ob && oa) { 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) changedLocataireIndices.push(i)
} else if (ob && !oa) { } 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 { } else {
const fields = diffLocataire(ob, oa) const changes = diffLocataire(i, ob, oa)
if (fields.length) { if (changes.length) {
locataires.push({ index: i, kind: 'modified', title: locataireTitle(oa), fields }) locataires.push({ index: i, kind: 'modified', title: locataireTitle(oa), changes })
changedLocataireIndices.push(i) changedLocataireIndices.push(i)
} }
} }
@@ -184,17 +261,32 @@ export function computeExtractionDiff(before, after) {
oldCounts.set(s, remaining - 1) // appariée avec une ancienne identique oldCounts.set(s, remaining - 1) // appariée avec une ancienne identique
} else { } else {
changedOperationIndices.push(i) 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) newCounts.set(s, (newCounts.get(s) || 0) + 1)
}) })
// Anciennes opérations absentes de la nouvelle extraction -> supprimées. // 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() const seen = new Map()
ob.forEach((op) => { ob.forEach((op, i) => {
const s = opSig(op) const s = opSig(op)
seen.set(s, (seen.get(s) || 0) + 1) seen.set(s, (seen.get(s) || 0) + 1)
if ((newCounts.get(s) || 0) < seen.get(s)) { 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, 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 }
}

View File

@@ -0,0 +1,223 @@
// 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 { countAccepted, mergeExtraction } from '../src/utils/mergeExtraction.js'
// Données en base, inspirées d'un compte rendu réel.
const previous = {
metadata: {
document: { reference: '33680000', date: '2026-06-22' },
immeuble: { code: '33689020', adresse: '4 RUE SERVIENT' },
solde: { montant: 11214.51, type: 'crediteur' },
},
situation_locataires: [
{
lot: { numero: '0007', 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' },
locataire: { nom: 'BESSON Léa' },
totaux: { total: 800, regles: 800, impayes: 0 },
lignes: [{ type: 'loyer', loyers: 800, total: 800 }],
},
],
recapitulatif_operations: [
{ categorie: 'DL', fournisseur: 'PPR', description: 'entretien', montants: { debit: 80 } },
{ categorie: 'DL', fournisseur: 'TOTAL', description: 'elec', montants: { debit: 120 } },
],
}
// 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
// opération et en perd une autre.
const next = {
metadata: {
document: { reference: '33680000', date: '2026-06-22' },
immeuble: { code: '33689020', adresse: '4 R. SERVIENT' },
solde: { montant: 11214.51, type: 'crediteur' },
},
situation_locataires: [
{
lot: { numero: '07', type: 'Appartement' },
locataire: { nom: 'LATAPY NINA Complément loyer' },
totaux: { total: 1250, regles: 1200, impayes: 50 },
lignes: [
{ type: 'loyer', loyers: 500, total: 500 },
{ type: 'complement', loyers: 50, total: 50 },
],
},
{
lot: { numero: '08', type: 'Appartement' },
locataire: { nom: 'BESSON Léa' },
totaux: { total: 800, regles: 800, impayes: 0 },
lignes: [{ type: 'loyer', loyers: 800, total: 800 }],
},
],
recapitulatif_operations: [
{ categorie: 'DL', fournisseur: 'PPR', description: 'entretien', montants: { debit: 80 } },
{ categorie: 'DL', fournisseur: 'NOUVEAU', description: 'ajout', montants: { debit: 42 } },
],
}
const diff = computeExtractionDiff(previous, next)
const merge = (selection) => mergeExtraction(previous, next, diff, selection)
describe('découpage du diff', () => {
it('sépare les champs identitaires du bloc de montants', () => {
const latapy = diff.locataires.find((l) => l.index === 0)
expect(latapy.changes.map((c) => c.id)).toEqual([
'loc:0:locataire.nom',
'loc:0:lot.numero',
'loc:0:montants',
])
// Le bloc de montants porte le détail, mais reste un seul changement.
const montants = latapy.changes.at(-1)
expect(montants.kind).toBe('montants')
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
})
})
describe('fusion sélective', () => {
it('sans sélection, reproduit la nouvelle extraction', () => {
expect(merge({})).toEqual(next)
})
it('refuse un nom tout en gardant la correction du numéro de lot', () => {
// Le cas qui motive la maille fine : la régression sur le nom est écartée,
// la correction du lot est conservée, sur le même locataire.
const merged = merge({ 'loc:0:locataire.nom': { accepted: false } })
const latapy = merged.situation_locataires[0]
expect(latapy.locataire.nom).toBe('LATAPY NINA')
expect(latapy.lot.numero).toBe('07')
expect(latapy.totaux.total).toBe(1250) // montants acceptés
})
it('garde les anciens montants et lignes quand le bloc est refusé', () => {
const merged = merge({ 'loc:0:montants': { accepted: false } })
const latapy = merged.situation_locataires[0]
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.locataire.nom).toBe('LATAPY NINA Complément loyer')
})
it('écrit la valeur saisie à la main plutôt que celle proposée', () => {
const merged = merge({
'loc:0:locataire.nom': { accepted: true, value: 'LATAPY Nina' },
'meta:immeuble.adresse': { accepted: true, value: '4 rue Servient' },
})
expect(merged.situation_locataires[0].locataire.nom).toBe('LATAPY Nina')
expect(merged.metadata.immeuble.adresse).toBe('4 rue Servient')
})
it('laisse une métadonnée refusée à sa valeur en base', () => {
const merged = merge({ 'meta:immeuble.adresse': { accepted: false } })
expect(merged.metadata.immeuble.adresse).toBe('4 RUE SERVIENT')
})
it('écarte une opération ajoutée dont on ne veut pas', () => {
const added = diff.operations.find((o) => o.kind === 'added')
const merged = merge({ [added.id]: { accepted: false } })
expect(merged.recapitulatif_operations.map((o) => o.fournisseur)).toEqual(['PPR'])
})
it('réinsère à sa place une opération dont on refuse la suppression', () => {
const removed = diff.operations.find((o) => o.kind === 'removed')
const merged = merge({ [removed.id]: { accepted: false } })
expect(merged.recapitulatif_operations.map((o) => o.fournisseur)).toEqual([
'PPR',
'TOTAL', // restaurée à son index d'origine
'NOUVEAU',
])
})
it('ne modifie pas les données dorigine', () => {
const avant = JSON.stringify(previous)
merge({ 'loc:0:montants': { accepted: false } })
expect(JSON.stringify(previous)).toBe(avant)
})
})
describe('locataires ajoutés ou supprimés', () => {
const avecAjout = {
...next,
situation_locataires: [
...next.situation_locataires,
{ lot: { numero: '09' }, locataire: { nom: 'NOUVEAU LOCATAIRE' }, totaux: {}, lignes: [] },
],
}
const diffAjout = computeExtractionDiff(previous, avecAjout)
it('najoute pas un locataire refusé', () => {
const merged = mergeExtraction(previous, avecAjout, diffAjout, {
'loc:2:entier': { accepted: false },
})
expect(merged.situation_locataires).toHaveLength(2)
})
it('conserve un locataire dont on refuse la disparition', () => {
const sansBesson = {
...next,
situation_locataires: next.situation_locataires.slice(0, 1),
}
const diffSuppr = computeExtractionDiff(previous, sansBesson)
const merged = mergeExtraction(previous, sansBesson, diffSuppr, {
'loc:1:entier': { accepted: false },
})
expect(merged.situation_locataires.map((l) => l.locataire.nom)).toEqual([
'LATAPY NINA Complément loyer',
'BESSON Léa',
])
})
})
describe('comptage pour laffichage', () => {
it('compte les changements retenus', () => {
const ids = listChangeIds(diff)
expect(countAccepted(ids, {})).toEqual({ accepted: ids.length, total: ids.length })
expect(countAccepted(ids, { [ids[0]]: { accepted: false } }).accepted).toBe(ids.length - 1)
})
})