feat: fix parsing
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user