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>
|
||||
@@ -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
|
||||
|
||||
215
frontend/src/utils/diffExtraction.js
Normal file
215
frontend/src/utils/diffExtraction.js
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
"""Orchestrateur principal pour l'extraction des comptes rendus de gérance."""
|
||||
|
||||
import logging
|
||||
|
||||
from .parsers.locataires import extract_situation_locataires
|
||||
from .parsers.locataires_table import extract_situation_locataires_from_pdf
|
||||
from .parsers.metadata import extract_metadata
|
||||
from .parsers.operations import extract_recapitulatif_operations
|
||||
from .parsers.operations_table import extract_recapitulatif_operations_from_pdf
|
||||
from .parsers.pdf import read_pdf
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def extract_compte_rendu(pdf_path: str) -> dict:
|
||||
"""Extrait toutes les informations d'un PDF de compte rendu de gérance.
|
||||
@@ -26,8 +32,28 @@ def extract_compte_rendu(pdf_path: str) -> dict:
|
||||
"""
|
||||
content = read_pdf(pdf_path)
|
||||
|
||||
# Extraction des locataires par cellules de tableau (géométrique) : robuste aux
|
||||
# colonnes vides et aux lignes mal alignées. Repli sur l'ancien parseur texte si
|
||||
# le tableau n'a pas de filets détectables ou en cas d'erreur inattendue.
|
||||
try:
|
||||
situation = extract_situation_locataires_from_pdf(pdf_path)
|
||||
except Exception:
|
||||
logger.exception("Extraction locataires par cellules échouée, repli sur le parseur texte")
|
||||
situation = []
|
||||
if not situation:
|
||||
situation = extract_situation_locataires(content.text)
|
||||
|
||||
# Opérations par cellules de tableau, même repli sur le parseur texte.
|
||||
try:
|
||||
operations = extract_recapitulatif_operations_from_pdf(pdf_path)
|
||||
except Exception:
|
||||
logger.exception("Extraction opérations par cellules échouée, repli sur le parseur texte")
|
||||
operations = []
|
||||
if not operations:
|
||||
operations = extract_recapitulatif_operations(content.text)
|
||||
|
||||
return {
|
||||
"metadata": extract_metadata(content.text, content.words),
|
||||
"situation_locataires": extract_situation_locataires(content.text),
|
||||
"recapitulatif_operations": extract_recapitulatif_operations(content.text),
|
||||
"situation_locataires": situation,
|
||||
"recapitulatif_operations": operations,
|
||||
}
|
||||
|
||||
322
src/plesna_gerance/parsers/locataires_table.py
Normal file
322
src/plesna_gerance/parsers/locataires_table.py
Normal file
@@ -0,0 +1,322 @@
|
||||
"""Extraction de la situation des locataires par cellules de tableau (géométrique).
|
||||
|
||||
Contrairement à :func:`plesna_gerance.parsers.locataires.extract_situation_locataires`
|
||||
(qui parse le texte mis en page avec des regex de position), cette implémentation
|
||||
reconstruit chaque **ligne visuelle** à partir des coordonnées des mots (regroupement
|
||||
par ``y``) et affecte chaque valeur à **sa colonne** via les filets du tableau
|
||||
(bandes ``x`` déduites de la ligne d'en-tête).
|
||||
|
||||
Avantages sur l'approche texte + regex :
|
||||
|
||||
- robuste aux **cellules vides** (pas de décalage d'indices : un montant est lu
|
||||
dans la bande de sa colonne, pas au n-ième rang de la ligne) ;
|
||||
- robuste aux **lignes mal alignées** (les PDF Oralia n'ont pas de baseline nette :
|
||||
le regroupement par ``y`` tolérant recompose la ligne comme le ferait l'œil) ;
|
||||
- ignore d'office le **texte hors des filets** (logo/adresse/en-tête réimprimé en
|
||||
haut de page), qui polluait la détection du nom de locataire.
|
||||
|
||||
La sortie est **identique** en structure à ``extract_situation_locataires`` pour
|
||||
rester interchangeable (mêmes clés ``lot`` / ``locataire`` / ``lignes`` / ``totaux``).
|
||||
"""
|
||||
|
||||
import re
|
||||
from unicodedata import normalize as _normalize
|
||||
|
||||
import pdfplumber
|
||||
|
||||
from ..utils.amounts import extract_amounts_from_line
|
||||
from ..utils.dates import parse_french_date
|
||||
|
||||
# Tolérance verticale (points PDF) pour regrouper les mots d'une même ligne visuelle.
|
||||
_Y_TOL = 3.0
|
||||
|
||||
_LOT_RE = re.compile(
|
||||
r"^Lot\s+(\d{4})\s+"
|
||||
r"(Loc\.\s*Commercial|Appartement\s+T\d|Studio|Garage|Cave|Parking)"
|
||||
)
|
||||
_PERIODE_RE = re.compile(r"Du\s+\d{2}\.\d{2}\.\d{2}\s+Au\s+(\d{2}\.\d{2}\.\d{2})")
|
||||
|
||||
# En-tête de colonne (normalisé sans accents) -> clé canonique.
|
||||
_HEADER_MAP = {
|
||||
"locataires": "loc",
|
||||
"periode": "periode",
|
||||
"loyers": "loyers",
|
||||
"taxes": "taxes",
|
||||
"provisions": "provisions",
|
||||
"divers": "divlbl", # libellé divers
|
||||
"total": "total",
|
||||
"regles": "regles",
|
||||
"impayes": "impayes",
|
||||
}
|
||||
|
||||
|
||||
def _strip_accents(text: str) -> str:
|
||||
return "".join(c for c in _normalize("NFD", text) if ord(c) < 128).lower()
|
||||
|
||||
|
||||
def _num(cell: str) -> float:
|
||||
"""Dernier montant d'une cellule (les dates DD.MM.YY sont exclues)."""
|
||||
amounts = extract_amounts_from_line(cell or "")
|
||||
return amounts[-1] if amounts else 0.0
|
||||
|
||||
|
||||
def _column_keys(header_cells, page) -> list[str | None]:
|
||||
"""Associe chaque colonne du tableau à une clé canonique via son en-tête.
|
||||
|
||||
La colonne sans en-tête qui suit « Divers » porte le **montant** divers
|
||||
(le libellé étant dans la colonne « Divers »).
|
||||
"""
|
||||
keys: list[str | None] = []
|
||||
prev: str | None = None
|
||||
for cell in header_cells:
|
||||
label = ""
|
||||
if cell is not None:
|
||||
label = _strip_accents((page.crop(cell).extract_text() or "").strip())
|
||||
key = _HEADER_MAP.get(label)
|
||||
if key is None:
|
||||
key = "divamt" if prev == "divlbl" else None
|
||||
keys.append(key)
|
||||
if key is not None:
|
||||
prev = key
|
||||
return keys
|
||||
|
||||
|
||||
def _rows_from_page(page) -> list[dict]:
|
||||
"""Retourne les lignes visuelles du tableau « situation locataires » d'une page.
|
||||
|
||||
Chaque ligne est un ``dict`` {clé_colonne: texte}. Retourne ``[]`` si la page
|
||||
ne contient pas ce tableau.
|
||||
"""
|
||||
# Repérer le tableau « situation locataires » (en-tête commençant par « Locataires »).
|
||||
table = None
|
||||
for candidate in page.find_tables():
|
||||
header = candidate.rows[0].cells
|
||||
if header and header[0] is not None:
|
||||
first = _strip_accents(page.crop(header[0]).extract_text() or "")
|
||||
if "locataires" in first:
|
||||
table = candidate
|
||||
break
|
||||
if table is None:
|
||||
return []
|
||||
|
||||
header = table.rows[0].cells
|
||||
keys = _column_keys(header, page)
|
||||
bands = [(c[0], c[2]) if c is not None else None for c in header]
|
||||
|
||||
def column_of(x_center: float) -> int | None:
|
||||
for i, band in enumerate(bands):
|
||||
if band and band[0] - 1 <= x_center <= band[1] + 1:
|
||||
return i
|
||||
return None
|
||||
|
||||
words = page.crop(table.bbox).extract_words()
|
||||
words.sort(key=lambda w: (round((w["top"] + w["bottom"]) / 2, 1), w["x0"]))
|
||||
|
||||
# Regrouper les mots en lignes visuelles (clustering vertical tolérant).
|
||||
clusters: list[list] = []
|
||||
for word in words:
|
||||
y_center = (word["top"] + word["bottom"]) / 2
|
||||
if clusters and abs(y_center - clusters[-1][0]) <= _Y_TOL:
|
||||
clusters[-1][1].append(word)
|
||||
else:
|
||||
clusters.append([y_center, [word]])
|
||||
|
||||
rows: list[dict] = []
|
||||
for _y, line_words in clusters:
|
||||
cells: dict[str, list[str]] = {}
|
||||
for word in sorted(line_words, key=lambda w: w["x0"]):
|
||||
idx = column_of((word["x0"] + word["x1"]) / 2)
|
||||
key = keys[idx] if idx is not None else None
|
||||
if key is not None:
|
||||
cells.setdefault(key, []).append(word["text"])
|
||||
rows.append({k: " ".join(v) for k, v in cells.items()})
|
||||
return rows
|
||||
|
||||
|
||||
def _new_lot(numero: str, lot_type: str) -> dict:
|
||||
return {
|
||||
"lot": {"numero": numero, "type": lot_type},
|
||||
"locataire": {"nom": ""},
|
||||
"lignes": [],
|
||||
"totaux": {
|
||||
"solde_anterieur": 0.0,
|
||||
"loyers": 0.0,
|
||||
"taxes": 0.0,
|
||||
"provisions": 0.0,
|
||||
"divers": 0.0,
|
||||
"total": 0.0,
|
||||
"regles": 0.0,
|
||||
"impayes": 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _periode(row: dict) -> dict:
|
||||
if not _PERIODE_RE.search(row.get("periode", "")):
|
||||
return {"debut": None, "fin": None}
|
||||
dates = re.findall(r"\d{2}\.\d{2}\.\d{2}", row.get("periode", ""))
|
||||
return {
|
||||
"debut": parse_french_date(dates[0]) if dates else None,
|
||||
"fin": parse_french_date(dates[1]) if len(dates) > 1 else None,
|
||||
}
|
||||
|
||||
|
||||
def _append_periode_line(lot: dict, row: dict) -> None:
|
||||
"""Ajoute une ligne loyer ou divers à partir d'une ligne de période."""
|
||||
loyers = _num(row.get("loyers", ""))
|
||||
taxes = _num(row.get("taxes", ""))
|
||||
provisions = _num(row.get("provisions", ""))
|
||||
divers_montant = _num(row.get("divamt", ""))
|
||||
divers_libelle = row.get("divlbl") or None
|
||||
total = _num(row.get("total", ""))
|
||||
regles = _num(row.get("regles", ""))
|
||||
impayes = _num(row.get("impayes", ""))
|
||||
|
||||
# Ligne purement « divers » : montant divers présent, colonnes loyer vides.
|
||||
is_divers = (
|
||||
divers_montant != 0.0 and loyers == 0.0 and taxes == 0.0 and provisions == 0.0
|
||||
)
|
||||
|
||||
if is_divers:
|
||||
lot["lignes"].append(
|
||||
{
|
||||
"type": "divers",
|
||||
"periode": _periode(row),
|
||||
"loyers": 0.0,
|
||||
"taxes": 0.0,
|
||||
"provisions": 0.0,
|
||||
"divers": {"montant": divers_montant, "libelle": divers_libelle},
|
||||
"total": total,
|
||||
"regles": regles,
|
||||
"impayes": impayes,
|
||||
}
|
||||
)
|
||||
else:
|
||||
lot["lignes"].append(
|
||||
{
|
||||
"type": "loyer",
|
||||
"periode": _periode(row),
|
||||
"loyers": loyers,
|
||||
"taxes": taxes,
|
||||
"provisions": provisions,
|
||||
"divers": (
|
||||
{"montant": divers_montant, "libelle": divers_libelle}
|
||||
if divers_montant
|
||||
else {"montant": 0.0, "libelle": None}
|
||||
),
|
||||
"total": total,
|
||||
"regles": regles,
|
||||
"impayes": impayes,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _fill_totaux(lot: dict, row: dict) -> None:
|
||||
totaux = lot["totaux"]
|
||||
# Un solde antérieur reporté apparaît dans la colonne « période » de la ligne Totaux.
|
||||
solde_totaux = _num(row.get("periode", ""))
|
||||
if solde_totaux and not totaux["solde_anterieur"]:
|
||||
totaux["solde_anterieur"] = solde_totaux
|
||||
totaux["loyers"] = _num(row.get("loyers", ""))
|
||||
totaux["taxes"] = _num(row.get("taxes", ""))
|
||||
totaux["provisions"] = _num(row.get("provisions", ""))
|
||||
totaux["divers"] = _num(row.get("divamt", ""))
|
||||
totaux["total"] = _num(row.get("total", ""))
|
||||
totaux["regles"] = _num(row.get("regles", ""))
|
||||
totaux["impayes"] = _num(row.get("impayes", ""))
|
||||
|
||||
|
||||
def extract_situation_locataires_from_pdf(pdf_path: str) -> list[dict]:
|
||||
"""Extrait la situation des locataires par cellules de tableau.
|
||||
|
||||
Args:
|
||||
pdf_path: Chemin vers le PDF de compte rendu de gérance.
|
||||
|
||||
Returns:
|
||||
Liste des situations par lot (même structure que
|
||||
:func:`plesna_gerance.parsers.locataires.extract_situation_locataires`).
|
||||
"""
|
||||
situations: list[dict] = []
|
||||
current: dict | None = None
|
||||
|
||||
with pdfplumber.open(pdf_path) as pdf:
|
||||
for page in pdf.pages:
|
||||
if "SITUATION DES LOCATAIRES" not in (page.extract_text() or ""):
|
||||
continue
|
||||
|
||||
for row in _rows_from_page(page):
|
||||
loc = (row.get("loc") or "").strip()
|
||||
periode = (row.get("periode") or "").strip()
|
||||
|
||||
# En-tête de colonnes réimprimé.
|
||||
if _strip_accents(loc) == "locataires":
|
||||
continue
|
||||
|
||||
# Nouveau lot.
|
||||
lot_match = _LOT_RE.match(loc)
|
||||
if lot_match:
|
||||
if current:
|
||||
situations.append(current)
|
||||
current = _new_lot(lot_match.group(1), lot_match.group(2))
|
||||
# La ligne d'en-tête de lot peut porter un premier loyer.
|
||||
if _PERIODE_RE.search(periode):
|
||||
_append_periode_line(current, row)
|
||||
continue
|
||||
|
||||
if current is None:
|
||||
continue
|
||||
|
||||
# Ligne Totaux du lot.
|
||||
if loc.startswith("Totaux"):
|
||||
_fill_totaux(current, row)
|
||||
continue
|
||||
|
||||
# Solde Antérieur (libellé + montant dans la colonne période).
|
||||
if periode.startswith("Solde Antérieur"):
|
||||
montant = _num(periode)
|
||||
current["lignes"].append(
|
||||
{
|
||||
"type": "solde_anterieur",
|
||||
"periode": {"debut": None, "fin": None},
|
||||
"loyers": montant,
|
||||
"taxes": 0.0,
|
||||
"provisions": 0.0,
|
||||
"divers": {"montant": 0.0, "libelle": None},
|
||||
"total": _num(row.get("total", "")) or montant,
|
||||
"regles": _num(row.get("regles", "")),
|
||||
"impayes": _num(row.get("impayes", "")),
|
||||
}
|
||||
)
|
||||
current["totaux"]["solde_anterieur"] = montant
|
||||
continue
|
||||
|
||||
# Rappel de loyer.
|
||||
if loc.startswith("Rappel"):
|
||||
current["lignes"].append(
|
||||
{
|
||||
"type": "rappel_loyer",
|
||||
"periode": _periode(row),
|
||||
"loyers": _num(row.get("loyers", "")),
|
||||
"taxes": 0.0,
|
||||
"provisions": 0.0,
|
||||
"divers": {"montant": 0.0, "libelle": None},
|
||||
"total": _num(row.get("total", "")),
|
||||
"regles": _num(row.get("regles", "")),
|
||||
"impayes": _num(row.get("impayes", "")),
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# Ligne de période (loyer ou divers).
|
||||
if _PERIODE_RE.search(periode):
|
||||
_append_periode_line(current, row)
|
||||
continue
|
||||
|
||||
# Nom du locataire (cellule « Locataires » seule, nom pas encore trouvé).
|
||||
if loc and not current["locataire"]["nom"]:
|
||||
current["locataire"]["nom"] = loc
|
||||
|
||||
if current:
|
||||
situations.append(current)
|
||||
|
||||
return situations
|
||||
@@ -26,8 +26,10 @@ def _extract_lot_code_from_description(description: str) -> str | None:
|
||||
if not description:
|
||||
return None
|
||||
|
||||
# Pattern: lettre majuscule + 1-2 chiffres, suivi de " - " ou fin de mot
|
||||
match = re.search(r"\b[A-Z](\d{1,2})\s*-", description)
|
||||
# Pattern: lettre majuscule + (espace optionnelle) + 1-2 chiffres, terminé par
|
||||
# un espace, un tiret ou la fin. Gère "S10 - ...", "S 17 - ..." (espace dans le
|
||||
# code) et "S01 SOLDE ..." (code lot non suivi d'un tiret).
|
||||
match = re.search(r"\b[A-Z]\s*(\d{1,2})(?=[\s-]|$)", description)
|
||||
if match:
|
||||
lot_num = match.group(1)
|
||||
# Formater sur 4 chiffres (ex: "6" -> "0006", "12" -> "0012")
|
||||
|
||||
242
src/plesna_gerance/parsers/operations_table.py
Normal file
242
src/plesna_gerance/parsers/operations_table.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""Extraction du récapitulatif des opérations par cellules de tableau (géométrique).
|
||||
|
||||
Même principe que :mod:`plesna_gerance.parsers.locataires_table` : on reconstruit
|
||||
chaque **ligne visuelle** (regroupement des mots par ``y``) et on affecte chaque
|
||||
valeur à **sa colonne** via les filets du tableau (bandes ``x``).
|
||||
|
||||
Gains sur l'ancien parseur texte (:mod:`plesna_gerance.parsers.operations`) :
|
||||
|
||||
- le **fournisseur** (colonne de gauche, en MAJUSCULES) est proprement séparé de la
|
||||
**description** (colonne du milieu) — plus de report erroné (ex. TOTALENERGIES
|
||||
étiqueté DIDIER NETTOYAGE) ;
|
||||
- chaque **montant** tombe dans sa colonne (Débit / Crédit / TVA / Locatif /
|
||||
Déductible), sans décalage dû aux cellules vides.
|
||||
|
||||
La sortie est identique en structure à ``extract_recapitulatif_operations``.
|
||||
"""
|
||||
|
||||
from unicodedata import normalize as _normalize
|
||||
|
||||
import pdfplumber
|
||||
|
||||
from ..utils.amounts import extract_amounts_from_line
|
||||
from .operations import _extract_lot_code_from_description
|
||||
|
||||
_Y_TOL = 3.0
|
||||
|
||||
# Libellé PDF (début de cellule) -> catégorie normalisée.
|
||||
_CAT_KEYWORDS = {
|
||||
"DEPENSES LOCATIVES": "DEPENSES_LOCATIVES",
|
||||
"DEPENSES DEDUCTIBLES": "DEPENSES_DEDUCTIBLES",
|
||||
"DEPENSES NON RECUPERABLES": "DEPENSES_NON_RECUPERABLES",
|
||||
"DEPENSES RECUPERABLES PAR LOT": "DEPENSES_RECUPERABLES",
|
||||
"HONORAIRES DE GESTION": "HONORAIRES_DE_GESTION",
|
||||
"DIVERS": "DIVERS",
|
||||
}
|
||||
|
||||
_AMOUNT_KEYS = ("debit", "credit", "tva", "locatif", "deductible")
|
||||
|
||||
|
||||
def _strip_accents(text: str) -> str:
|
||||
return "".join(c for c in _normalize("NFD", text) if ord(c) < 128).lower()
|
||||
|
||||
|
||||
def _num(cell: str) -> float | None:
|
||||
"""Montant d'une cellule, ou ``None`` si la cellule ne contient pas de montant."""
|
||||
amounts = extract_amounts_from_line(cell or "")
|
||||
return amounts[-1] if amounts else None
|
||||
|
||||
|
||||
def _match_category(text: str) -> str | None:
|
||||
up = (text or "").upper().strip()
|
||||
for keyword, normalized in _CAT_KEYWORDS.items():
|
||||
if up.startswith(keyword):
|
||||
return normalized
|
||||
return None
|
||||
|
||||
|
||||
def _is_fournisseur(text: str) -> bool:
|
||||
"""Une cellule de gauche est un fournisseur si elle est en MAJUSCULES.
|
||||
|
||||
Distingue « BOUVARD ENTREPRISE » (fournisseur) de « Travaux divers »
|
||||
(sous-catégorie en casse mixte).
|
||||
"""
|
||||
letters = [c for c in text if c.isalpha()]
|
||||
return bool(letters) and all(c.isupper() for c in letters) and not _match_category(text)
|
||||
|
||||
|
||||
def _looks_like_continuation(text: str) -> bool:
|
||||
"""Fragment de description débordé sur la ligne suivante (ex. « Y », « IN »)."""
|
||||
return len(text) <= 4 and " " not in text and text.isalpha()
|
||||
|
||||
|
||||
def _column_keys(header_cells, page) -> list[str | None]:
|
||||
keys: list[str | None] = []
|
||||
for cell in header_cells:
|
||||
label = ""
|
||||
if cell is not None:
|
||||
label = _strip_accents((page.crop(cell).extract_text() or "").strip())
|
||||
if "debit" in label:
|
||||
key = "debit"
|
||||
elif "credit" in label:
|
||||
key = "credit"
|
||||
elif "t.v.a" in label or "tva" in label:
|
||||
key = "tva"
|
||||
elif "locatif" in label:
|
||||
key = "locatif"
|
||||
elif "deductible" in label:
|
||||
key = "deductible"
|
||||
elif "recapitulatif" in label:
|
||||
key = "desc"
|
||||
elif label == "":
|
||||
key = "left"
|
||||
else:
|
||||
key = None
|
||||
keys.append(key)
|
||||
return keys
|
||||
|
||||
|
||||
def _rows_from_page(page) -> list[dict]:
|
||||
"""Lignes visuelles du tableau « récapitulatif des opérations » d'une page."""
|
||||
table = None
|
||||
for candidate in page.find_tables():
|
||||
header = " ".join(
|
||||
(page.crop(c).extract_text() or "") if c is not None else ""
|
||||
for c in candidate.rows[0].cells
|
||||
)
|
||||
if "Locatif" in header and "ductible" in header:
|
||||
table = candidate
|
||||
break
|
||||
if table is None:
|
||||
return []
|
||||
|
||||
header = table.rows[0].cells
|
||||
keys = _column_keys(header, page)
|
||||
bands = [(c[0], c[2]) if c is not None else None for c in header]
|
||||
|
||||
def column_of(x_center: float) -> int | None:
|
||||
for i, band in enumerate(bands):
|
||||
if band and band[0] - 1 <= x_center <= band[1] + 1:
|
||||
return i
|
||||
return None
|
||||
|
||||
words = page.crop(table.bbox).extract_words()
|
||||
words.sort(key=lambda w: (round((w["top"] + w["bottom"]) / 2, 1), w["x0"]))
|
||||
|
||||
clusters: list[list] = []
|
||||
for word in words:
|
||||
y_center = (word["top"] + word["bottom"]) / 2
|
||||
if clusters and abs(y_center - clusters[-1][0]) <= _Y_TOL:
|
||||
clusters[-1][1].append(word)
|
||||
else:
|
||||
clusters.append([y_center, [word]])
|
||||
|
||||
rows: list[dict] = []
|
||||
for _y, line_words in clusters:
|
||||
cells: dict[str, list[str]] = {}
|
||||
for word in sorted(line_words, key=lambda w: w["x0"]):
|
||||
idx = column_of((word["x0"] + word["x1"]) / 2)
|
||||
key = keys[idx] if idx is not None else None
|
||||
if key is not None:
|
||||
cells.setdefault(key, []).append(word["text"])
|
||||
rows.append({k: " ".join(v) for k, v in cells.items()})
|
||||
return rows
|
||||
|
||||
|
||||
def extract_recapitulatif_operations_from_pdf(pdf_path: str) -> list[dict]:
|
||||
"""Extrait le récapitulatif des opérations par cellules de tableau.
|
||||
|
||||
Returns:
|
||||
Liste plate des opérations (même structure que
|
||||
:func:`plesna_gerance.parsers.operations.extract_recapitulatif_operations`).
|
||||
"""
|
||||
operations: list[dict] = []
|
||||
current_cat: str | None = None
|
||||
current_fournisseur: str | None = None
|
||||
current_sous_cat: str | None = None
|
||||
block_id = 0
|
||||
|
||||
with pdfplumber.open(pdf_path) as pdf:
|
||||
for page in pdf.pages:
|
||||
if "RECAPITULATIF DES OPERATIONS" not in (page.extract_text() or ""):
|
||||
continue
|
||||
|
||||
for row in _rows_from_page(page):
|
||||
left = (row.get("left") or "").strip()
|
||||
desc = (row.get("desc") or "").strip()
|
||||
montants = {k: _num(row.get(k, "")) for k in _AMOUNT_KEYS}
|
||||
has_amount = any(v is not None for v in montants.values())
|
||||
|
||||
low = _strip_accents(desc)
|
||||
|
||||
# En-tête, totaux, solde : ignorés.
|
||||
if low.startswith("recapitulatif"):
|
||||
continue
|
||||
if (
|
||||
low.startswith("totaux")
|
||||
or low.startswith("total des reglements")
|
||||
or "solde crediteur" in low
|
||||
):
|
||||
continue
|
||||
|
||||
# En-tête de catégorie (sans montant).
|
||||
cat = _match_category(left) or _match_category(desc)
|
||||
if cat and not has_amount:
|
||||
current_cat = cat
|
||||
current_fournisseur = None
|
||||
current_sous_cat = None
|
||||
block_id += 1
|
||||
continue
|
||||
|
||||
# Colonne de gauche : fournisseur (MAJUSCULES) ou sous-catégorie.
|
||||
if left:
|
||||
if _is_fournisseur(left):
|
||||
current_fournisseur = left
|
||||
else:
|
||||
current_sous_cat = left
|
||||
|
||||
# Ligne sans montant : sous-catégorie (col1) ou continuation de description.
|
||||
if not has_amount:
|
||||
if desc:
|
||||
if (
|
||||
_looks_like_continuation(desc)
|
||||
and operations
|
||||
and operations[-1]["_block"] == block_id
|
||||
):
|
||||
operations[-1]["description"] = (
|
||||
operations[-1]["description"] + desc
|
||||
).strip()
|
||||
else:
|
||||
current_sous_cat = desc
|
||||
continue
|
||||
|
||||
# Ligne avec montant : une opération.
|
||||
operations.append(
|
||||
{
|
||||
"categorie": current_cat,
|
||||
"sous_categorie": current_sous_cat or "",
|
||||
"fournisseur": current_fournisseur,
|
||||
"description": desc,
|
||||
"lot_concerne": None,
|
||||
"lot_numero": _extract_lot_code_from_description(desc),
|
||||
"montants": {k: (montants[k] or 0.0) for k in _AMOUNT_KEYS},
|
||||
"_block": block_id,
|
||||
}
|
||||
)
|
||||
|
||||
# Report du fournisseur unique d'un bloc sur les opérations qui en manquent
|
||||
# (cas des honoraires : le nom du gestionnaire n'apparaît qu'une fois, au milieu).
|
||||
by_block: dict[int, list[dict]] = {}
|
||||
for op in operations:
|
||||
by_block.setdefault(op["_block"], []).append(op)
|
||||
for block_ops in by_block.values():
|
||||
first = next((o["fournisseur"] for o in block_ops if o["fournisseur"]), None)
|
||||
if first:
|
||||
for op in block_ops:
|
||||
if not op["fournisseur"]:
|
||||
op["fournisseur"] = first
|
||||
|
||||
for op in operations:
|
||||
op.pop("_block", None)
|
||||
|
||||
return operations
|
||||
Reference in New Issue
Block a user