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:
@@ -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 ré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()
|
||||
|
||||
Reference in New Issue
Block a user