feat: fix parsing
This commit is contained in:
@@ -128,9 +128,10 @@
|
|||||||
>
|
>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<OperationCard
|
<OperationCard
|
||||||
v-for="(cat, idx) in data.data?.recapitulatif_operations"
|
v-for="(group, idx) in operationsGroupedByCategory"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
:category="cat"
|
:categorie="group.categorie"
|
||||||
|
:operations="group.operations"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DataSection>
|
</DataSection>
|
||||||
@@ -140,7 +141,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive } from 'vue'
|
import { ref, reactive, computed } from 'vue'
|
||||||
import DataSection from './DataSection.vue'
|
import DataSection from './DataSection.vue'
|
||||||
import DataCard from './DataCard.vue'
|
import DataCard from './DataCard.vue'
|
||||||
import DataRow from './DataRow.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 copyLabel = ref('Copier')
|
||||||
const expandedSections = reactive({
|
const expandedSections = reactive({
|
||||||
metadata: true,
|
metadata: true,
|
||||||
|
|||||||
@@ -15,9 +15,9 @@
|
|||||||
>
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm font-semibold text-gray-700">
|
<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 v-show="expanded" class="border-t border-gray-100 bg-gray-50">
|
||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100">
|
||||||
<div
|
<div
|
||||||
v-for="(op, idx) in category.operations"
|
v-for="(op, idx) in operations"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
class="px-3 py-2 bg-white text-xs"
|
class="px-3 py-2 bg-white text-xs"
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<div class="flex-1 min-w-0">
|
<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">
|
<div v-if="op.fournisseur" class="font-medium text-gray-800 truncate">
|
||||||
{{ op.fournisseur }}
|
{{ op.fournisseur }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-500 truncate">{{ op.description || op.type_operation || '-' }}</div>
|
<div class="text-gray-500 truncate">{{ op.description || '-' }}</div>
|
||||||
<div v-if="op.lot_concerne" class="text-gray-400 text-xs mt-0.5">
|
<div v-if="op.lot_numero || op.lot_concerne" class="text-blue-500 text-xs mt-0.5">
|
||||||
Lot: {{ op.lot_concerne }}
|
<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>
|
</div>
|
||||||
<div class="text-right flex-shrink-0">
|
<div class="text-right flex-shrink-0">
|
||||||
@@ -48,7 +53,24 @@
|
|||||||
<div v-if="op.montants?.tva" class="text-gray-400">
|
<div v-if="op.montants?.tva" class="text-gray-400">
|
||||||
TVA: {{ formatCurrency(op.montants.tva) }}
|
TVA: {{ formatCurrency(op.montants.tva) }}
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="op.montants?.locatif" class="text-green-600 text-xs">
|
||||||
|
Locatif: {{ formatCurrency(op.montants.locatif) }}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
@@ -60,8 +82,12 @@
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
category: {
|
categorie: {
|
||||||
type: Object,
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
operations: {
|
||||||
|
type: Array,
|
||||||
required: true
|
required: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -69,10 +95,22 @@ const props = defineProps({
|
|||||||
const expanded = ref(false)
|
const expanded = ref(false)
|
||||||
|
|
||||||
const totalDebit = computed(() => {
|
const totalDebit = computed(() => {
|
||||||
if (!props.category.operations) return 0
|
return props.operations.reduce((sum, op) => sum + (op.montants?.debit || 0), 0)
|
||||||
return props.category.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) {
|
function formatCurrency(value) {
|
||||||
if (value === null || value === undefined) return '-'
|
if (value === null || value === undefined) return '-'
|
||||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value)
|
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
|
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]:
|
def extract_situation_locataires(text: str) -> list[dict]:
|
||||||
"""Extrait la situation des locataires.
|
"""Extrait la situation des locataires.
|
||||||
|
|
||||||
@@ -21,6 +93,9 @@ def extract_situation_locataires(text: str) -> list[dict]:
|
|||||||
"""
|
"""
|
||||||
situations: 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"
|
# Trouver toutes les sections "SITUATION DES LOCATAIRES"
|
||||||
sections = text.split("SITUATION DES LOCATAIRES")
|
sections = text.split("SITUATION DES LOCATAIRES")
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,37 @@ import re
|
|||||||
from ..utils.amounts import parse_amount
|
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]:
|
def extract_recapitulatif_operations(text: str) -> list[dict]:
|
||||||
"""Extrait le récapitulatif des opérations.
|
"""Extrait le récapitulatif des opérations.
|
||||||
|
|
||||||
@@ -12,13 +43,17 @@ def extract_recapitulatif_operations(text: str) -> list[dict]:
|
|||||||
text: Texte complet du PDF
|
text: Texte complet du PDF
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Liste des catégories d'opérations, chacune contenant:
|
Liste plate des opérations, chacune contenant:
|
||||||
- categorie: nom de la catégorie
|
- categorie: catégorie normalisée (ex: DEPENSES_LOCATIVES)
|
||||||
- operations: liste des opérations avec type, fournisseur, description, montants
|
- 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 = {
|
cat_keywords = {
|
||||||
"DEPENSES LOCATIVES": "DEPENSES_LOCATIVES",
|
"DEPENSES LOCATIVES": "DEPENSES_LOCATIVES",
|
||||||
"DEPENSES DEDUCTIBLES": "DEPENSES_DEDUCTIBLES",
|
"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]
|
section = section.split("Solde créditeur en Euros")[0]
|
||||||
|
|
||||||
lines = section.split("\n")
|
lines = section.split("\n")
|
||||||
current_cat: dict | None = None
|
current_cat_normalized: str | None = None
|
||||||
current_fournisseur: str | None = None
|
current_fournisseur: str | None = None
|
||||||
current_lot: str | None = None
|
current_lot: str | None = None
|
||||||
current_type_op: str | None = None
|
current_sous_cat: str | None = None
|
||||||
|
|
||||||
for line in lines:
|
for line in lines:
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
@@ -62,10 +97,10 @@ def extract_recapitulatif_operations(text: str) -> list[dict]:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Détecter une catégorie
|
# Détecter une catégorie
|
||||||
found_cat = None
|
found_cat_normalized = None
|
||||||
for kw, _cat_id in cat_keywords.items():
|
for kw, cat_normalized in cat_keywords.items():
|
||||||
if kw in stripped:
|
if kw in stripped:
|
||||||
found_cat = kw
|
found_cat_normalized = cat_normalized
|
||||||
# Extraire le lot si présent
|
# Extraire le lot si présent
|
||||||
lot_match = re.search(r"(?:LOT|/LOT)\s+(.+?)(?:\s{2,}|$)", stripped)
|
lot_match = re.search(r"(?:LOT|/LOT)\s+(.+?)(?:\s{2,}|$)", stripped)
|
||||||
if lot_match:
|
if lot_match:
|
||||||
@@ -74,19 +109,17 @@ def extract_recapitulatif_operations(text: str) -> list[dict]:
|
|||||||
current_lot = None
|
current_lot = None
|
||||||
break
|
break
|
||||||
|
|
||||||
if found_cat:
|
if found_cat_normalized:
|
||||||
if current_cat and current_cat["operations"]:
|
current_cat_normalized = found_cat_normalized
|
||||||
categories.append(current_cat)
|
|
||||||
current_cat = {"categorie": found_cat, "operations": []}
|
|
||||||
current_fournisseur = None
|
current_fournisseur = None
|
||||||
current_type_op = None
|
current_sous_cat = None
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not current_cat:
|
if not current_cat_normalized:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Type d'opération (italique/description au début)
|
# Type d'opération / sous-catégorie (ex: "Contrat entreprise nettoyage")
|
||||||
type_op_patterns = [
|
sous_cat_patterns = [
|
||||||
"Nettoyage",
|
"Nettoyage",
|
||||||
"Electricité",
|
"Electricité",
|
||||||
"Contrat",
|
"Contrat",
|
||||||
@@ -99,9 +132,9 @@ def extract_recapitulatif_operations(text: str) -> list[dict]:
|
|||||||
"Lavage",
|
"Lavage",
|
||||||
"Reglt",
|
"Reglt",
|
||||||
]
|
]
|
||||||
for top in type_op_patterns:
|
for pattern in sous_cat_patterns:
|
||||||
if stripped.startswith(top):
|
if stripped.startswith(pattern):
|
||||||
current_type_op = stripped.split(" ")[0].strip()
|
current_sous_cat = stripped.split(" ")[0].strip()
|
||||||
break
|
break
|
||||||
|
|
||||||
# Fournisseur (NOM EN MAJUSCULES)
|
# Fournisseur (NOM EN MAJUSCULES)
|
||||||
@@ -143,11 +176,16 @@ def extract_recapitulatif_operations(text: str) -> list[dict]:
|
|||||||
description = description[len(current_fournisseur) :].strip()
|
description = description[len(current_fournisseur) :].strip()
|
||||||
|
|
||||||
if description and not description.startswith("Totaux"):
|
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 = {
|
operation = {
|
||||||
"type_operation": current_type_op or "",
|
"categorie": current_cat_normalized,
|
||||||
|
"sous_categorie": current_sous_cat or "",
|
||||||
"fournisseur": current_fournisseur,
|
"fournisseur": current_fournisseur,
|
||||||
"description": description,
|
"description": description,
|
||||||
"lot_concerne": current_lot,
|
"lot_concerne": current_lot,
|
||||||
|
"lot_numero": lot_numero,
|
||||||
"montants": {
|
"montants": {
|
||||||
"debit": amounts_float[0]
|
"debit": amounts_float[0]
|
||||||
if len(amounts_float) > 0
|
if len(amounts_float) > 0
|
||||||
@@ -162,9 +200,6 @@ def extract_recapitulatif_operations(text: str) -> list[dict]:
|
|||||||
else 0.0,
|
else 0.0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
current_cat["operations"].append(operation)
|
operations.append(operation)
|
||||||
|
|
||||||
if current_cat and current_cat["operations"]:
|
return operations
|
||||||
categories.append(current_cat)
|
|
||||||
|
|
||||||
return categories
|
|
||||||
|
|||||||
Reference in New Issue
Block a user