feat: fix parsing

This commit is contained in:
2026-01-18 10:33:09 +01:00
parent 6bad5fbaf9
commit ee1db93298
4 changed files with 210 additions and 40 deletions

View File

@@ -128,9 +128,10 @@
>
<div class="space-y-2">
<OperationCard
v-for="(cat, idx) in data.data?.recapitulatif_operations"
v-for="(group, idx) in operationsGroupedByCategory"
:key="idx"
:category="cat"
:categorie="group.categorie"
:operations="group.operations"
/>
</div>
</DataSection>
@@ -140,7 +141,7 @@
</template>
<script setup>
import { ref, reactive } from 'vue'
import { ref, reactive, computed } from 'vue'
import DataSection from './DataSection.vue'
import DataCard from './DataCard.vue'
import DataRow from './DataRow.vue'
@@ -158,6 +159,27 @@ const props = defineProps({
}
})
// Grouper les operations par categorie pour l'affichage
const operationsGroupedByCategory = computed(() => {
const operations = props.data?.data?.recapitulatif_operations
if (!operations || !Array.isArray(operations)) return []
// Grouper par categorie en preservant l'ordre d'apparition
const groups = []
const categoryIndex = {}
for (const op of operations) {
const cat = op.categorie || 'AUTRES'
if (!(cat in categoryIndex)) {
categoryIndex[cat] = groups.length
groups.push({ categorie: cat, operations: [] })
}
groups[categoryIndex[cat]].operations.push(op)
}
return groups
})
const copyLabel = ref('Copier')
const expandedSections = reactive({
metadata: true,

View File

@@ -15,9 +15,9 @@
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<span class="font-medium text-gray-800 text-sm">{{ category.categorie }}</span>
<span class="font-medium text-gray-800 text-sm">{{ formatCategorie(categorie) }}</span>
<span class="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">
{{ category.operations?.length || 0 }} operations
{{ operations.length }} operation{{ operations.length > 1 ? 's' : '' }}
</span>
</div>
<div class="text-sm font-semibold text-gray-700">
@@ -29,18 +29,23 @@
<div v-show="expanded" class="border-t border-gray-100 bg-gray-50">
<div class="divide-y divide-gray-100">
<div
v-for="(op, idx) in category.operations"
v-for="(op, idx) in operations"
:key="idx"
class="px-3 py-2 bg-white text-xs"
>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<div v-if="op.sous_categorie" class="text-gray-400 text-xs mb-0.5">
{{ op.sous_categorie }}
</div>
<div v-if="op.fournisseur" class="font-medium text-gray-800 truncate">
{{ op.fournisseur }}
</div>
<div class="text-gray-500 truncate">{{ op.description || op.type_operation || '-' }}</div>
<div v-if="op.lot_concerne" class="text-gray-400 text-xs mt-0.5">
Lot: {{ op.lot_concerne }}
<div class="text-gray-500 truncate">{{ op.description || '-' }}</div>
<div v-if="op.lot_numero || op.lot_concerne" class="text-blue-500 text-xs mt-0.5">
<span v-if="op.lot_numero" class="font-medium">Lot {{ op.lot_numero }}</span>
<span v-if="op.lot_numero && op.lot_concerne"> - </span>
<span v-if="op.lot_concerne">{{ op.lot_concerne }}</span>
</div>
</div>
<div class="text-right flex-shrink-0">
@@ -48,10 +53,27 @@
<div v-if="op.montants?.tva" class="text-gray-400">
TVA: {{ formatCurrency(op.montants.tva) }}
</div>
<div v-if="op.montants?.locatif" class="text-green-600 text-xs">
Locatif: {{ formatCurrency(op.montants.locatif) }}
</div>
<div v-if="op.montants?.deductible" class="text-orange-600 text-xs">
Deductible: {{ formatCurrency(op.montants.deductible) }}
</div>
</div>
</div>
</div>
</div>
<!-- Totaux de la categorie -->
<div class="px-3 py-2 bg-gray-100 border-t border-gray-200 text-xs">
<div class="flex justify-between items-center">
<span class="font-medium text-gray-600">Total {{ formatCategorie(categorie) }}</span>
<div class="text-right">
<span class="font-semibold text-gray-800">{{ formatCurrency(totalDebit) }}</span>
<span v-if="totalTva > 0" class="text-gray-500 ml-2">(TVA: {{ formatCurrency(totalTva) }})</span>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -60,8 +82,12 @@
import { ref, computed } from 'vue'
const props = defineProps({
category: {
type: Object,
categorie: {
type: String,
required: true
},
operations: {
type: Array,
required: true
}
})
@@ -69,10 +95,22 @@ const props = defineProps({
const expanded = ref(false)
const totalDebit = computed(() => {
if (!props.category.operations) return 0
return props.category.operations.reduce((sum, op) => sum + (op.montants?.debit || 0), 0)
return props.operations.reduce((sum, op) => sum + (op.montants?.debit || 0), 0)
})
const totalTva = computed(() => {
return props.operations.reduce((sum, op) => sum + (op.montants?.tva || 0), 0)
})
function formatCategorie(cat) {
if (!cat) return '-'
// Convertir DEPENSES_LOCATIVES -> Depenses locatives
return cat
.replace(/_/g, ' ')
.toLowerCase()
.replace(/^\w/, c => c.toUpperCase())
}
function formatCurrency(value) {
if (value === null || value === undefined) return '-'
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value)

View File

@@ -6,6 +6,78 @@ from ..utils.dates import parse_french_date
from ..utils.amounts import extract_amounts_from_line
def _preprocess_locataires_text(text: str) -> str:
"""Prétraite le texte pour fusionner les sections sur plusieurs pages.
Supprime les éléments répétés sur chaque page pour permettre une extraction
continue des locataires dont les données s'étalent sur plusieurs pages.
Args:
text: Texte brut du PDF
Returns:
Texte nettoyé avec une seule section SITUATION DES LOCATAIRES
"""
lines = text.split("\n")
cleaned_lines = []
first_situation_found = False
in_situation_section = False
# Patterns à ignorer (en-têtes répétés sur chaque page)
skip_patterns = [
r"^\s*ROSIER-MODICA\s*$",
r"^\s*9 rue Juliette Récamier\s*$",
r"^\s*69455 Lyon Cedex 06\s*$",
r"^\s*S\.C\.I\.\s*PLESNA\s*$",
r"^\s*Immeuble\s*:\s*\d+\s*$",
r"^\s*\d+\s*RUE\s+", # Adresse immeuble
r"^\s*69\d{3}\s+LYON\s*$", # Code postal + ville
r"^\s*Lyon le \d{2}/\d{2}/\d{4}\s*$", # Date
r"^\s*Powered by ICS\s*$",
r"^\s*\d+\s*/\s*\d+\s*$", # Numéro de page (ex: 2/5)
r"^\s*Capital de", # Pied de page
r"^\s*Garantie de", # Pied de page
]
# Pattern pour l'en-tête de colonnes
header_pattern = r"^\s*Locataires\s+Période\s+Loyers"
for line in lines:
stripped = line.strip()
# Ignorer les lignes vides
if not stripped:
cleaned_lines.append(line)
continue
# Vérifier si c'est un pattern à ignorer
should_skip = False
for pattern in skip_patterns:
if re.match(pattern, stripped, re.IGNORECASE):
should_skip = True
break
if should_skip:
continue
# Gérer SITUATION DES LOCATAIRES
if "SITUATION DES LOCATAIRES" in stripped:
if not first_situation_found:
first_situation_found = True
in_situation_section = True
cleaned_lines.append(line)
# Ignorer les occurrences suivantes
continue
# Ignorer les en-têtes de colonnes répétés
if re.match(header_pattern, stripped):
continue
cleaned_lines.append(line)
return "\n".join(cleaned_lines)
def extract_situation_locataires(text: str) -> list[dict]:
"""Extrait la situation des locataires.
@@ -21,6 +93,9 @@ def extract_situation_locataires(text: str) -> list[dict]:
"""
situations: list[dict] = []
# Prétraiter le texte pour gérer les lots sur plusieurs pages
text = _preprocess_locataires_text(text)
# Trouver toutes les sections "SITUATION DES LOCATAIRES"
sections = text.split("SITUATION DES LOCATAIRES")

View File

@@ -5,6 +5,37 @@ import re
from ..utils.amounts import parse_amount
def _extract_lot_code_from_description(description: str) -> str | None:
"""Extrait le code lot depuis la description de l'opération.
Les codes lots suivent le format: {Lettre}{Numéro} où:
- La lettre identifie l'immeuble (M=Marietton, S=Servient, B=Bloch, etc.)
- Le numéro correspond au lot (ex: 06 -> lot 0006)
Exemples:
- "M06 - Commande moteur pompe" -> "0006"
- "S05 - Mise en service" -> "0005"
- "B01 - Plaques" -> "0001"
Args:
description: Description de l'opération
Returns:
Code lot au format 4 chiffres (ex: "0006") ou None si non trouvé
"""
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)
if match:
lot_num = match.group(1)
# Formater sur 4 chiffres (ex: "6" -> "0006", "12" -> "0012")
return lot_num.zfill(4)
return None
def extract_recapitulatif_operations(text: str) -> list[dict]:
"""Extrait le récapitulatif des opérations.
@@ -12,13 +43,17 @@ def extract_recapitulatif_operations(text: str) -> list[dict]:
text: Texte complet du PDF
Returns:
Liste des catégories d'opérations, chacune contenant:
- categorie: nom de la catégorie
- operations: liste des opérations avec type, fournisseur, description, montants
Liste plate des opérations, chacune contenant:
- categorie: catégorie normalisée (ex: DEPENSES_LOCATIVES)
- sous_categorie: type d'opération (ex: Contrat entreprise nettoyage)
- fournisseur: nom du fournisseur
- description: description de l'opération
- lot_concerne: lot concerné si applicable
- montants: dict avec debit, credit, tva, locatif, deductible
"""
categories: list[dict] = []
operations: list[dict] = []
# Catégories à identifier
# Catégories à identifier (libellé PDF -> format normalisé)
cat_keywords = {
"DEPENSES LOCATIVES": "DEPENSES_LOCATIVES",
"DEPENSES DEDUCTIBLES": "DEPENSES_DEDUCTIBLES",
@@ -36,10 +71,10 @@ def extract_recapitulatif_operations(text: str) -> list[dict]:
section = section.split("Solde créditeur en Euros")[0]
lines = section.split("\n")
current_cat: dict | None = None
current_cat_normalized: str | None = None
current_fournisseur: str | None = None
current_lot: str | None = None
current_type_op: str | None = None
current_sous_cat: str | None = None
for line in lines:
stripped = line.strip()
@@ -62,10 +97,10 @@ def extract_recapitulatif_operations(text: str) -> list[dict]:
continue
# Détecter une catégorie
found_cat = None
for kw, _cat_id in cat_keywords.items():
found_cat_normalized = None
for kw, cat_normalized in cat_keywords.items():
if kw in stripped:
found_cat = kw
found_cat_normalized = cat_normalized
# Extraire le lot si présent
lot_match = re.search(r"(?:LOT|/LOT)\s+(.+?)(?:\s{2,}|$)", stripped)
if lot_match:
@@ -74,19 +109,17 @@ def extract_recapitulatif_operations(text: str) -> list[dict]:
current_lot = None
break
if found_cat:
if current_cat and current_cat["operations"]:
categories.append(current_cat)
current_cat = {"categorie": found_cat, "operations": []}
if found_cat_normalized:
current_cat_normalized = found_cat_normalized
current_fournisseur = None
current_type_op = None
current_sous_cat = None
continue
if not current_cat:
if not current_cat_normalized:
continue
# Type d'opération (italique/description au début)
type_op_patterns = [
# Type d'opération / sous-catégorie (ex: "Contrat entreprise nettoyage")
sous_cat_patterns = [
"Nettoyage",
"Electricité",
"Contrat",
@@ -99,9 +132,9 @@ def extract_recapitulatif_operations(text: str) -> list[dict]:
"Lavage",
"Reglt",
]
for top in type_op_patterns:
if stripped.startswith(top):
current_type_op = stripped.split(" ")[0].strip()
for pattern in sous_cat_patterns:
if stripped.startswith(pattern):
current_sous_cat = stripped.split(" ")[0].strip()
break
# Fournisseur (NOM EN MAJUSCULES)
@@ -143,11 +176,16 @@ def extract_recapitulatif_operations(text: str) -> list[dict]:
description = description[len(current_fournisseur) :].strip()
if description and not description.startswith("Totaux"):
# Extraire le numéro de lot depuis la description (ex: M06 -> 0006)
lot_numero = _extract_lot_code_from_description(description)
operation = {
"type_operation": current_type_op or "",
"categorie": current_cat_normalized,
"sous_categorie": current_sous_cat or "",
"fournisseur": current_fournisseur,
"description": description,
"lot_concerne": current_lot,
"lot_numero": lot_numero,
"montants": {
"debit": amounts_float[0]
if len(amounts_float) > 0
@@ -162,9 +200,6 @@ def extract_recapitulatif_operations(text: str) -> list[dict]:
else 0.0,
},
}
current_cat["operations"].append(operation)
operations.append(operation)
if current_cat and current_cat["operations"]:
categories.append(current_cat)
return categories
return operations