Les parseurs géométriques (par cellules de tableau) traitent les 5 PDF du corpus sans jamais déclencher le repli : les parseurs texte étaient 586 lignes de regex fragiles, non testées, à maintenir à chaque évolution du format de sortie. Les références golden sont inchangées après leur suppression — l'extraction produit exactement la même chose. Le parseur géométrique devient donc le seul chemin : ce qu'il ne lit pas est perdu. L'orchestrateur distingue maintenant les deux cas : - aucun lot lu -> ExtractionError (422 côté API). Un compte rendu sans lot n'existe pas : c'est le tableau qui n'a pas été reconnu, et mieux vaut échouer que d'enregistrer un document vide découvert bien plus tard, au moment de relire les chiffres ; - aucune opération -> accepté. Un mois sans dépense reste plausible. _extract_lot_code_from_description était la seule fonction encore utilisée : elle rejoint utils.lots sous le nom extract_lot_numero_from_description, à côté de normalize_lot_numero. extract_text_from_pdf, sans appelant, disparaît au passage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
243 lines
8.8 KiB
Python
243 lines
8.8 KiB
Python
"""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 ..utils.lots import extract_lot_numero_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_numero_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
|