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:
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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: () => []
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
132
frontend/src/components/ReExtractionDiff.vue
Normal file
132
frontend/src/components/ReExtractionDiff.vue
Normal 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>
|
||||
Reference in New Issue
Block a user