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:
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user