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>
298 lines
11 KiB
Vue
298 lines
11 KiB
Vue
<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>
|