Files
pdf_oralia_vibe/frontend/src/pages/EditDocumentPage.vue
Bertrand Benjamin c79ecc45ff 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>
2026-07-19 21:29:04 +02:00

298 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="flex-1 flex flex-col overflow-hidden">
<!-- Header avec breadcrumb et actions -->
<div class="flex-shrink-0 px-6 py-4 bg-gray-800 border-b border-gray-700">
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2 text-sm text-gray-400 mb-1">
<router-link to="/documents" class="hover:text-white transition-colors">Documents</router-link>
<span></span>
<span class="text-white">{{ documentData?.reference || 'Chargement...' }}</span>
</div>
<h1 class="text-xl font-semibold text-white">
Édition du document {{ documentData?.reference }}
</h1>
<p v-if="documentData" class="text-sm text-gray-400 mt-1">
{{ documentData.immeuble?.adresse || '-' }} - {{ formatDate(documentData.date) }}
</p>
</div>
<div class="flex items-center gap-3">
<button
@click="cancel"
:disabled="isSaving"
class="px-4 py-2 text-sm text-gray-400 hover:text-white transition-colors disabled:opacity-50"
>
Annuler
</button>
<button
v-if="!showTagging && extractedData"
@click="reExtract"
:disabled="isSaving || isReExtracting || !documentData?.has_pdf"
:title="documentData?.has_pdf ? 'Relancer l\'extraction depuis le PDF stocké' : 'PDF non disponible pour ce document'"
class="px-4 py-2 text-sm bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
<svg
class="w-4 h-4"
:class="{ 'animate-spin': isReExtracting }"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{{ isReExtracting ? 'Extraction…' : "Relancer l'extraction" }}
</button>
<button
v-if="!showTagging && extractedData"
@click="goToTagging"
:disabled="isSaving"
class="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
Continuer vers le tagging
</button>
</div>
</div>
</div>
<!-- Loading state -->
<div v-if="isLoading" class="flex-1 flex items-center justify-center bg-gray-800">
<div class="text-center">
<div class="inline-block animate-spin rounded-full h-12 w-12 border-4 border-gray-600 border-t-blue-400 mb-4"></div>
<p class="text-gray-400">Chargement du document...</p>
</div>
</div>
<!-- Corps : Split view PDF + Données -->
<div v-else class="flex-1 flex overflow-hidden">
<!-- Gauche : PDF Preview -->
<div class="w-1/2 border-r border-gray-700 flex flex-col">
<div class="flex-shrink-0 px-4 py-2 bg-gray-700/50 border-b border-gray-700">
<span class="text-sm text-gray-300">Aperçu PDF</span>
</div>
<div class="flex-1 overflow-hidden">
<PdfPreview
v-if="documentData?.has_pdf"
:url="`/api/documents/${documentId}/pdf`"
:file-name="documentData?.source_file || 'document.pdf'"
class="h-full"
/>
<div v-else class="h-full flex items-center justify-center bg-gray-800 text-gray-500">
<div class="text-center">
<svg class="mx-auto h-16 w-16 text-gray-600 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p class="text-lg font-medium">PDF non disponible</p>
<p class="text-sm text-gray-600 mt-1">Ce document a été importé sans stockage du PDF</p>
</div>
</div>
</div>
</div>
<!-- Droite : Édition ou Tagging -->
<div class="w-1/2 flex flex-col bg-gray-50">
<!-- Bandeau erreur de re-extraction -->
<div v-if="reExtractError" class="flex-shrink-0 px-4 py-2 bg-red-500/10 border-b border-red-500/30 text-sm text-red-600">
{{ reExtractError }}
</div>
<!-- Panneau des différences après re-extraction -->
<ReExtractionDiff
v-if="diff && !showTagging"
:diff="diff"
@revert="revertReExtract"
@close="diff = null"
/>
<!-- Vue 1 : JsonViewer (mode édition) -->
<JsonViewer
v-if="!showTagging && extractedData"
:data="extractedData"
@update:data="extractedData = $event"
:is-loading="false"
:highlight="highlightInfo"
:diff="diff"
class="flex-1"
/>
<!-- Vue 2 : TaggingStep -->
<TaggingStep
v-if="showTagging && extractedData"
:depenses="extractedData.data.recapitulatif_operations"
:is-duplicate="true"
@save="handleSave"
@cancel="showTagging = false"
class="flex-1"
/>
<!-- Error message -->
<div v-if="saveError" class="flex-shrink-0 px-4 py-3 bg-red-500/10 border-t border-red-500/30">
<div class="flex items-center gap-2 text-sm text-red-400">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ saveError }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import PdfPreview from '../components/PdfPreview.vue'
import JsonViewer from '../components/JsonViewer.vue'
import TaggingStep from '../components/TaggingStep.vue'
import ReExtractionDiff from '../components/ReExtractionDiff.vue'
import { computeExtractionDiff } from '../utils/diffExtraction.js'
const router = useRouter()
const route = useRoute()
const documentId = route.params.id
const documentData = ref(null)
const extractedData = ref(null)
const isLoading = ref(true)
const showTagging = ref(false)
const isSaving = ref(false)
const saveError = ref(null)
const isReExtracting = ref(false)
const reExtractError = ref(null)
const diff = ref(null)
const previousData = ref(null)
const highlightInfo = computed(() => {
const q = route.query
if (q.highlight === 'locataire') return { type: 'locataire', lot_numero: q.lot_numero, locataire_nom: q.locataire_nom }
if (q.highlight === 'operation') return { type: 'operation', fournisseur: q.fournisseur, description: q.description }
return null
})
onMounted(async () => {
await loadDocument()
})
async function loadDocument() {
isLoading.value = true
try {
const response = await fetch(`/api/documents/${documentId}`)
if (!response.ok) {
throw new Error('Document non trouvé')
}
documentData.value = await response.json()
// Formatter les données pour JsonViewer (même structure que ExtractPage)
extractedData.value = {
source_file: documentData.value.source_file,
data: documentData.value.json_data
}
} catch (err) {
console.error('Error loading document:', err)
alert('Erreur lors du chargement du document: ' + err.message)
router.push('/documents')
} finally {
isLoading.value = false
}
}
function formatDate(dateStr) {
if (!dateStr) return '-'
const [year, month, day] = dateStr.split('-')
return `${day}/${month}/${year}`
}
function cancel() {
const hasChanges = extractedData.value !== null
if (hasChanges) {
const confirmed = confirm('Abandonner les modifications ?')
if (!confirmed) return
}
router.push('/documents')
}
function goToTagging() {
showTagging.value = true
saveError.value = null
}
async function reExtract() {
isReExtracting.value = true
reExtractError.value = null
try {
const response = await fetch(`/api/documents/${documentId}/re-extract`, {
method: 'POST',
})
const result = await response.json()
if (!response.ok) {
throw new Error(result.detail || 'Échec de la re-extraction')
}
// Sauvegarder l'état actuel pour pouvoir revenir en arrière.
previousData.value = JSON.parse(JSON.stringify(extractedData.value.data))
const newData = result.re_extracted_data
diff.value = computeExtractionDiff(previousData.value, newData)
// Remplacer les données de travail par la nouvelle extraction.
extractedData.value = { ...extractedData.value, data: newData }
} catch (err) {
console.error('Re-extraction error:', err)
reExtractError.value = err.message || 'Une erreur est survenue lors de la re-extraction'
} finally {
isReExtracting.value = false
}
}
function revertReExtract() {
if (!previousData.value) return
extractedData.value = { ...extractedData.value, data: previousData.value }
previousData.value = null
diff.value = null
}
async function handleSave(depensesTags, shouldOverwrite) {
isSaving.value = true
saveError.value = null
try {
const response = await fetch('/api/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
source_file: extractedData.value.source_file,
data: extractedData.value.data,
depenses_tags: depensesTags,
overwrite: true // Toujours true en mode édition
})
})
const result = await response.json()
if (!response.ok) {
throw new Error(result.detail || 'Erreur lors de la sauvegarde')
}
if (result.success) {
// Succès - redirection vers la liste des documents
router.push('/documents')
} else {
throw new Error(result.message || 'Échec de la sauvegarde')
}
} catch (err) {
console.error('Save error:', err)
saveError.value = err.message || 'Une erreur est survenue lors de la sauvegarde'
// Scroll to top to show error
window.scrollTo({ top: 0, behavior: 'smooth' })
} finally {
isSaving.value = false
}
}
</script>