feat: extraction locataires et opérations par cellules de tableau

Remplace les parseurs texte+regex (fragiles sur l'alignement en colonnes des
PDF Oralia mal construits) par une extraction géométrique : reconstruction des
lignes visuelles par regroupement vertical tolérant des mots, puis affectation
de chaque valeur à sa colonne via les filets du tableau (pdfplumber find_tables).

Corrections apportées :
- locataires : montant/libellé « divers » dans la bonne colonne (plus le total
  cumulé du lot), nom de locataire correct (le logo/en-tête hors filets est
  ignoré), lignes multi-période et pages recollées.
- opérations : fournisseur séparé de la description (TOTALENERGIES ≠ DIDIER
  NETTOYAGE, PPR ≠ BOUVARD), colonne Déductible remplie, Débit/Crédit distingués,
  fournisseur des honoraires reporté sur le bloc, fragments de description
  recollés (LATAPY, AUDOUIN).
- code lot : gère « S10 - », « S 17 - » (espace) et « S01 SOLDE » (sans tiret).

Branchés dans extractor.py avec repli sur les anciens parseurs si un tableau n'a
pas de filets détectables. Validés par réconciliation comptable : locataires
124/124 lots, opérations 7/7 PDF et 25/25 catégories, au centime.

Ajoute le bouton « Relancer l'extraction » dans l'écran d'édition : re-extrait
depuis le PDF stocké et met en évidence les différences avec la version
précédente (panneau récapitulatif + anneaux « modifié » sur les cartes). Le diff
des opérations s'aligne par contenu (robuste aux changements d'ordre/nombre).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 21:29:04 +02:00
parent 2d8b2ff42f
commit c79ecc45ff
10 changed files with 1101 additions and 11 deletions

View File

@@ -116,6 +116,7 @@
:locataire="loc"
:index="idx"
:highlighted="highlightedLocataireIndex === idx"
:changed="changedLocataireSet.has(idx)"
@update:locataire="updateLocataire(idx, $event)"
@remove="removeLocataire(idx)"
/>
@@ -147,6 +148,7 @@
:categorie="group.categorie"
:operations="group.operations"
:highlightIndex="group.categorie === highlightOperationCategorie ? highlightOperationIndex : -1"
:changedIndices="changedOpByCategorie[group.categorie] || []"
@update:operations="updateOperationsGroup(group.categorie, $event)"
/>
@@ -187,6 +189,10 @@ const props = defineProps({
highlight: {
type: Object,
default: null
},
diff: {
type: Object,
default: null
}
})
@@ -213,6 +219,30 @@ const operationsGroupedByCategory = computed(() => {
return groups
})
// Indices de locataires modifiés par la re-extraction (anneau « modifié »).
const changedLocataireSet = computed(
() => new Set(props.diff?.changedLocataireIndices || [])
)
// Indices d'opérations modifiées, ramenés au repère (catégorie, index dans le groupe).
const changedOpByCategorie = computed(() => {
const result = {}
const operations = props.data?.data?.recapitulatif_operations
const changed = new Set(props.diff?.changedOperationIndices || [])
if (!operations || !changed.size) return result
const counters = {}
operations.forEach((op, absIdx) => {
const cat = op.categorie || 'AUTRES'
if (!(cat in counters)) counters[cat] = 0
if (changed.has(absIdx)) {
if (!result[cat]) result[cat] = []
result[cat].push(counters[cat])
}
counters[cat]++
})
return result
})
const copyLabel = ref('Copier')
const expandedSections = reactive({
metadata: true,
@@ -269,6 +299,17 @@ watch(
{ immediate: true }
)
// Déplier automatiquement les sections qui contiennent des différences.
watch(
() => props.diff,
(diff) => {
if (!diff) return
if (diff.summary?.metadata) expandedSections.metadata = true
if (diff.summary?.locataires) expandedSections.locataires = true
if (diff.summary?.operations) expandedSections.operations = true
}
)
function toggleSection(section) {
expandedSections[section] = !expandedSections[section]
}

View File

@@ -1,5 +1,5 @@
<template>
<div ref="rootEl" class="border rounded-lg overflow-hidden transition-all duration-300" :class="isHighlighted ? 'border-blue-400 ring-2 ring-blue-400 bg-blue-50' : 'border-gray-200'">
<div ref="rootEl" class="border rounded-lg overflow-hidden transition-all duration-300" :class="changed ? 'border-amber-400 ring-2 ring-amber-400 bg-amber-50' : (isHighlighted ? 'border-blue-400 ring-2 ring-blue-400 bg-blue-50' : 'border-gray-200')">
<!-- Header -->
<div class="w-full flex items-center justify-between px-3 py-2 bg-white hover:bg-gray-50 transition-colors">
<button
@@ -43,6 +43,12 @@
/>
</button>
<div class="flex items-center gap-2">
<span
v-if="changed"
class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 border border-amber-300"
>
Modifié
</span>
<div class="text-right">
<div class="text-sm font-semibold" :class="totalClass">
{{ formatCurrency(locataire.totaux?.total) }}
@@ -157,11 +163,29 @@
displayClass="text-xs"
/>
</div>
</div>
<div class="flex items-center gap-2">
<!-- Total -->
<!-- Libelle divers -->
<EditableField
v-if="ligne.type === 'divers'"
:modelValue="ligne.divers?.libelle"
@update:modelValue="updateLigneField(idx, 'divers.libelle', $event)"
type="text"
placeholder="libellé"
displayClass="text-xs text-gray-600 italic truncate"
/>
</div>
<div class="flex items-center gap-2">
<!-- Montant : divers -> montant divers ; sinon total (ou loyers) -->
<EditableField
v-if="ligne.type === 'divers'"
:modelValue="ligne.divers?.montant"
@update:modelValue="updateLigneField(idx, 'divers.montant', $event)"
type="currency"
displayClass="font-medium text-gray-800"
/>
<EditableField
v-else
:modelValue="ligne.total || ligne.loyers"
@update:modelValue="updateLigneField(idx, 'total', $event)"
type="currency"
@@ -215,6 +239,10 @@ const props = defineProps({
highlighted: {
type: Boolean,
default: false
},
changed: {
type: Boolean,
default: false
}
})

View File

@@ -21,6 +21,12 @@
</span>
</button>
<div class="flex items-center gap-2">
<span
v-if="changedIndices.length"
class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 border border-amber-300"
>
{{ changedIndices.length }} modifiée{{ changedIndices.length > 1 ? 's' : '' }}
</span>
<div class="text-sm font-semibold text-gray-700">
{{ formatCurrency(totalDebit) }}
</div>
@@ -45,7 +51,7 @@
:key="idx"
:ref="el => { if (el) opRefs[idx] = el }"
class="px-3 py-2 bg-white text-xs transition-all duration-300"
:class="highlightedIdx === idx ? 'ring-2 ring-blue-400 bg-blue-50' : ''"
:class="changedIndices.includes(idx) ? 'ring-2 ring-amber-400 bg-amber-50' : (highlightedIdx === idx ? 'ring-2 ring-blue-400 bg-blue-50' : '')"
>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0 space-y-1">
@@ -201,6 +207,10 @@ const props = defineProps({
highlightIndex: {
type: Number,
default: -1
},
changedIndices: {
type: Array,
default: () => []
}
})

View File

@@ -0,0 +1,132 @@
<template>
<div class="flex-shrink-0 border-b border-amber-300 bg-amber-50">
<!-- En-tête -->
<div class="flex items-center justify-between px-4 py-2">
<div class="flex items-center gap-2 text-sm">
<svg class="w-4 h-4 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span v-if="diff.summary.total === 0" class="font-medium text-amber-800">
Nouvelle extraction aucune différence avec la version précédente
</span>
<span v-else class="font-medium text-amber-800">
Nouvelle extraction {{ diff.summary.total }} différence{{ diff.summary.total > 1 ? 's' : '' }}
<span class="text-amber-600 font-normal">
({{ diff.summary.metadata }} métadonnée{{ diff.summary.metadata > 1 ? 's' : '' }},
{{ diff.summary.locataires }} locataire{{ diff.summary.locataires > 1 ? 's' : '' }},
{{ diff.summary.operations }} opération{{ diff.summary.operations > 1 ? 's' : '' }})
</span>
</span>
</div>
<div class="flex items-center gap-2">
<button
@click="$emit('revert')"
class="px-2 py-1 text-xs text-amber-700 hover:text-amber-900 hover:bg-amber-100 rounded transition-colors"
title="Restaurer les données d'avant la re-extraction"
>
Revenir à la version précédente
</button>
<button
v-if="diff.summary.total > 0"
@click="open = !open"
class="px-2 py-1 text-xs text-amber-700 hover:text-amber-900 hover:bg-amber-100 rounded transition-colors"
>
{{ open ? 'Masquer le détail' : 'Voir le détail' }}
</button>
<button
@click="$emit('close')"
class="p-1 text-amber-500 hover:text-amber-700 hover:bg-amber-100 rounded transition-colors"
title="Fermer"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<!-- Détail -->
<div v-if="open && diff.summary.total > 0" class="max-h-56 overflow-auto px-4 pb-3 space-y-3 text-xs">
<!-- Métadonnées -->
<div v-if="diff.metadata.length">
<div class="font-semibold text-amber-800 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-600 line-through">{{ c.before }}</span>
<span class="text-gray-400"></span>
<span class="text-green-700 font-medium">{{ c.after }}</span>
</div>
</div>
</div>
<!-- Locataires -->
<div v-if="diff.locataires.length">
<div class="font-semibold text-amber-800 mb-1">Locataires</div>
<div class="space-y-1.5">
<div v-for="(loc, i) in diff.locataires" :key="'l' + i" class="bg-white/60 rounded p-1.5">
<div class="flex items-center gap-2 mb-0.5">
<span :class="kindClass(loc.kind)" class="text-[10px] uppercase font-semibold px-1 rounded">
{{ kindLabel(loc.kind) }}
</span>
<span class="font-medium text-gray-700">{{ 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-600 line-through break-all">{{ f.before }}</span>
<span class="text-gray-400"></span>
<span class="text-green-700 font-medium break-all">{{ f.after }}</span>
</div>
</div>
</div>
</div>
<!-- Opérations -->
<div v-if="diff.operations.length">
<div class="font-semibold text-amber-800 mb-1">Opérations</div>
<div class="space-y-1.5">
<div v-for="(op, i) in diff.operations" :key="'o' + i" class="bg-white/60 rounded p-1.5">
<div class="flex items-center gap-2 mb-0.5">
<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-600 line-through break-all">{{ op.before }}</div>
<div class="text-green-700 font-medium break-all">{{ op.after }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
defineProps({
diff: {
type: Object,
required: true,
},
})
defineEmits(['revert', 'close'])
const open = ref(true)
function kindLabel(kind) {
if (kind === 'added') return 'ajouté'
if (kind === 'removed') return 'supprimé'
return 'modifié'
}
function kindClass(kind) {
if (kind === 'added') return 'bg-green-100 text-green-700'
if (kind === 'removed') return 'bg-red-100 text-red-700'
return 'bg-amber-100 text-amber-700'
}
</script>

View File

@@ -25,7 +25,24 @@
>
Annuler
</button>
<button
<button
v-if="!showTagging && extractedData"
@click="reExtract"
:disabled="isSaving || isReExtracting || !documentData?.has_pdf"
:title="documentData?.has_pdf ? 'Relancer l\'extraction depuis le PDF stocké' : 'PDF non disponible pour ce document'"
class="px-4 py-2 text-sm bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
<svg
class="w-4 h-4"
:class="{ 'animate-spin': isReExtracting }"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{{ isReExtracting ? 'Extraction…' : "Relancer l'extraction" }}
</button>
<button
v-if="!showTagging && extractedData"
@click="goToTagging"
:disabled="isSaving"
@@ -76,6 +93,19 @@
<!-- Droite : Édition ou Tagging -->
<div class="w-1/2 flex flex-col bg-gray-50">
<!-- Bandeau erreur de re-extraction -->
<div v-if="reExtractError" class="flex-shrink-0 px-4 py-2 bg-red-500/10 border-b border-red-500/30 text-sm text-red-600">
{{ reExtractError }}
</div>
<!-- Panneau des différences après re-extraction -->
<ReExtractionDiff
v-if="diff && !showTagging"
:diff="diff"
@revert="revertReExtract"
@close="diff = null"
/>
<!-- Vue 1 : JsonViewer (mode édition) -->
<JsonViewer
v-if="!showTagging && extractedData"
@@ -83,6 +113,7 @@
@update:data="extractedData = $event"
:is-loading="false"
:highlight="highlightInfo"
:diff="diff"
class="flex-1"
/>
@@ -116,6 +147,8 @@ import { useRouter, useRoute } from 'vue-router'
import PdfPreview from '../components/PdfPreview.vue'
import JsonViewer from '../components/JsonViewer.vue'
import TaggingStep from '../components/TaggingStep.vue'
import ReExtractionDiff from '../components/ReExtractionDiff.vue'
import { computeExtractionDiff } from '../utils/diffExtraction.js'
const router = useRouter()
const route = useRoute()
@@ -127,6 +160,10 @@ const isLoading = ref(true)
const showTagging = ref(false)
const isSaving = ref(false)
const saveError = ref(null)
const isReExtracting = ref(false)
const reExtractError = ref(null)
const diff = ref(null)
const previousData = ref(null)
const highlightInfo = computed(() => {
const q = route.query
@@ -183,6 +220,41 @@ function goToTagging() {
saveError.value = null
}
async function reExtract() {
isReExtracting.value = true
reExtractError.value = null
try {
const response = await fetch(`/api/documents/${documentId}/re-extract`, {
method: 'POST',
})
const result = await response.json()
if (!response.ok) {
throw new Error(result.detail || 'Échec de la re-extraction')
}
// Sauvegarder l'état actuel pour pouvoir revenir en arrière.
previousData.value = JSON.parse(JSON.stringify(extractedData.value.data))
const newData = result.re_extracted_data
diff.value = computeExtractionDiff(previousData.value, newData)
// Remplacer les données de travail par la nouvelle extraction.
extractedData.value = { ...extractedData.value, data: newData }
} catch (err) {
console.error('Re-extraction error:', err)
reExtractError.value = err.message || 'Une erreur est survenue lors de la re-extraction'
} finally {
isReExtracting.value = false
}
}
function revertReExtract() {
if (!previousData.value) return
extractedData.value = { ...extractedData.value, data: previousData.value }
previousData.value = null
diff.value = null
}
async function handleSave(depensesTags, shouldOverwrite) {
isSaving.value = true
saveError.value = null

View File

@@ -0,0 +1,215 @@
// 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é »).
const EPS = 0.005
function numEq(a, b) {
return Math.abs((Number(a) || 0) - (Number(b) || 0)) < EPS
}
function valEq(a, b) {
if (a == null && b == null) return true
if (typeof a === 'number' || typeof b === 'number') {
// Comparer numériquement si les deux ressemblent à des nombres.
const na = Number(a)
const nb = Number(b)
if (!Number.isNaN(na) && !Number.isNaN(nb)) return numEq(na, nb)
}
return String(a ?? '') === String(b ?? '')
}
export function fmtValue(v) {
if (v == null || v === '') return '∅'
return String(v)
}
function get(obj, path) {
return path.split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj)
}
const METADATA_FIELDS = [
['editeur.nom', 'Éditeur · nom'],
['editeur.siret', 'Éditeur · SIRET'],
['editeur.adresse', 'Éditeur · adresse'],
['editeur.telephone', 'Éditeur · téléphone'],
['destinataire.nom', 'Destinataire · nom'],
['destinataire.adresse', 'Destinataire · adresse'],
['document.reference', 'Document · référence'],
['document.date', 'Document · date'],
['document.type', 'Document · type'],
['immeuble.code', 'Immeuble · code'],
['immeuble.adresse', 'Immeuble · adresse'],
['immeuble.ville', 'Immeuble · ville'],
['immeuble.code_postal', 'Immeuble · code postal'],
['solde.montant', 'Solde · montant'],
['solde.type', 'Solde · type'],
['solde.date_arrete', 'Solde · date arrêté'],
]
function ligneSig(l) {
if (!l) return '∅'
const p = l.periode || {}
const d = l.divers || {}
const per = p.debut || p.fin ? `${p.debut || '?'}${p.fin || '?'}` : '—'
const divers = d.montant ? ` D:${d.montant}${d.libelle ? '(' + d.libelle + ')' : ''}` : ''
return (
`${l.type || '?'} ${per} ` +
`L:${l.loyers || 0} T:${l.taxes || 0} P:${l.provisions || 0}${divers} ` +
`=${l.total || 0} R:${l.regles || 0} I:${l.impayes || 0}`
)
}
function diffLignes(before, after) {
const a = before || []
const b = after || []
const changes = []
const max = Math.max(a.length, b.length)
for (let i = 0; i < max; i++) {
const sa = i < a.length ? ligneSig(a[i]) : null
const sb = i < b.length ? ligneSig(b[i]) : null
if (sa !== sb) {
changes.push({ label: `Ligne ${i + 1}`, before: fmtValue(sa), after: fmtValue(sb) })
}
}
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) {
const bv = get(before, path)
const av = get(after, path)
if (!valEq(bv, av)) {
fields.push({ label, before: fmtValue(bv), after: fmtValue(av) })
}
}
fields.push(...diffLignes(before?.lignes, after?.lignes))
return fields
}
function locataireTitle(loc) {
if (!loc) return '?'
const lot = loc.lot?.numero ? `Lot ${loc.lot.numero}` : 'Lot ?'
const nom = loc.locataire?.nom || 'sans nom'
return `${lot}${nom}`
}
function opSig(op) {
if (!op) return '∅'
const m = op.montants || {}
return (
`${op.categorie || '?'} | ${op.fournisseur || '?'} | ${op.description || ''} | ` +
`d:${m.debit || 0} c:${m.credit || 0} tva:${m.tva || 0} ` +
`loc:${m.locatif || 0} ded:${m.deductible || 0}`
)
}
// Compare deux extractions et retourne le diff structuré.
export function computeExtractionDiff(before, after) {
const b = before || {}
const a = after || {}
// --- Métadonnées ---
const metadata = []
const changedMetadataPaths = []
for (const [path, label] of METADATA_FIELDS) {
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) })
changedMetadataPaths.push(path)
}
}
// --- Locataires (alignés par index : même PDF, même ordre) ---
const locataires = []
const changedLocataireIndices = []
const lb = b.situation_locataires || []
const la = a.situation_locataires || []
const maxLoc = Math.max(lb.length, la.length)
for (let i = 0; i < maxLoc; i++) {
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: [] })
changedLocataireIndices.push(i)
} else if (ob && !oa) {
locataires.push({ index: i, kind: 'removed', title: locataireTitle(ob), fields: [] })
} else {
const fields = diffLocataire(ob, oa)
if (fields.length) {
locataires.push({ index: i, kind: 'modified', title: locataireTitle(oa), fields })
changedLocataireIndices.push(i)
}
}
}
// --- Opérations (alignées par contenu, pas par index) ---
// Les opérations peuvent changer d'ordre ou de nombre entre deux extractions ;
// on compare donc par signature (multiset) plutôt que position par position.
const operations = []
const changedOperationIndices = []
const ob = b.recapitulatif_operations || []
const oa = a.recapitulatif_operations || []
const oldCounts = new Map()
for (const op of ob) {
const s = opSig(op)
oldCounts.set(s, (oldCounts.get(s) || 0) + 1)
}
// Nouvelles opérations absentes de l'ancienne extraction -> à surligner.
const newCounts = new Map()
oa.forEach((op, i) => {
const s = opSig(op)
const remaining = oldCounts.get(s) || 0
if (remaining > 0) {
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) })
}
newCounts.set(s, (newCounts.get(s) || 0) + 1)
})
// Anciennes opérations absentes de la nouvelle extraction -> supprimées.
const seen = new Map()
ob.forEach((op) => {
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: '∅' })
}
})
const summary = {
metadata: metadata.length,
locataires: locataires.length,
operations: operations.length,
total: metadata.length + locataires.length + operations.length,
}
return {
metadata,
locataires,
operations,
changedMetadataPaths,
changedLocataireIndices,
changedOperationIndices,
summary,
}
}