feat: reimport pdf
This commit is contained in:
3
data/.gitignore
vendored
3
data/.gitignore
vendored
@@ -3,5 +3,8 @@
|
|||||||
*.sqlite-journal
|
*.sqlite-journal
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
|
# Ignore stored documents (PDFs and JSONs)
|
||||||
|
documents/
|
||||||
|
|
||||||
# Keep this .gitignore
|
# Keep this .gitignore
|
||||||
!.gitignore
|
!.gitignore
|
||||||
|
|||||||
@@ -18,6 +18,14 @@
|
|||||||
Accueil
|
Accueil
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
|
<router-link
|
||||||
|
to="/documents"
|
||||||
|
class="px-3 py-1.5 text-sm rounded transition-colors"
|
||||||
|
:class="$route.path === '/documents' ? 'bg-gray-700 text-white' : 'text-gray-400 hover:text-white'"
|
||||||
|
>
|
||||||
|
Documents
|
||||||
|
</router-link>
|
||||||
|
|
||||||
<router-link
|
<router-link
|
||||||
to="/revenus"
|
to="/revenus"
|
||||||
class="px-3 py-1.5 text-sm rounded transition-colors"
|
class="px-3 py-1.5 text-sm rounded transition-colors"
|
||||||
|
|||||||
@@ -327,13 +327,32 @@ function updateOperationsGroup(categorie, updatedGroupOperations) {
|
|||||||
if (!updated.data) updated.data = {}
|
if (!updated.data) updated.data = {}
|
||||||
if (!updated.data.recapitulatif_operations) updated.data.recapitulatif_operations = []
|
if (!updated.data.recapitulatif_operations) updated.data.recapitulatif_operations = []
|
||||||
|
|
||||||
// Reconstruire la liste complete des operations
|
// Reconstruire la liste en preservant l'ordre original
|
||||||
// 1. Filtrer toutes les operations de cette categorie
|
const newOperations = []
|
||||||
const otherOperations = updated.data.recapitulatif_operations.filter(op => op.categorie !== categorie)
|
let updatedIndex = 0
|
||||||
|
|
||||||
// 2. Combiner avec les operations mises a jour
|
// Parcourir les operations originales et remplacer celles de la categorie modifiee
|
||||||
updated.data.recapitulatif_operations = [...otherOperations, ...updatedGroupOperations]
|
for (const op of updated.data.recapitulatif_operations) {
|
||||||
|
if (op.categorie === categorie) {
|
||||||
|
// Remplacer par les operations mises a jour (dans l'ordre)
|
||||||
|
if (updatedIndex < updatedGroupOperations.length) {
|
||||||
|
newOperations.push(updatedGroupOperations[updatedIndex])
|
||||||
|
updatedIndex++
|
||||||
|
}
|
||||||
|
// Si on a supprime des operations, on ne les ajoute pas
|
||||||
|
} else {
|
||||||
|
// Garder les operations des autres categories telles quelles
|
||||||
|
newOperations.push(op)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajouter les nouvelles operations si on en a ajoute
|
||||||
|
while (updatedIndex < updatedGroupOperations.length) {
|
||||||
|
newOperations.push(updatedGroupOperations[updatedIndex])
|
||||||
|
updatedIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
updated.data.recapitulatif_operations = newOperations
|
||||||
emit('update:data', updated)
|
emit('update:data', updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,10 @@ const props = defineProps({
|
|||||||
type: File,
|
type: File,
|
||||||
default: null
|
default: null
|
||||||
},
|
},
|
||||||
|
url: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
fileName: {
|
fileName: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'document.pdf'
|
default: 'document.pdf'
|
||||||
@@ -131,14 +135,26 @@ const baseScale = ref(1)
|
|||||||
let pdfDoc = null
|
let pdfDoc = null
|
||||||
let renderTask = null
|
let renderTask = null
|
||||||
|
|
||||||
async function loadPdf(file) {
|
async function loadPdf(source) {
|
||||||
if (!file) return
|
if (!source) return
|
||||||
|
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const arrayBuffer = await file.arrayBuffer()
|
let arrayBuffer
|
||||||
|
|
||||||
|
// Support URL
|
||||||
|
if (typeof source === 'string') {
|
||||||
|
const response = await fetch(source)
|
||||||
|
if (!response.ok) throw new Error('Erreur lors du chargement du PDF')
|
||||||
|
arrayBuffer = await response.arrayBuffer()
|
||||||
|
}
|
||||||
|
// Support File
|
||||||
|
else {
|
||||||
|
arrayBuffer = await source.arrayBuffer()
|
||||||
|
}
|
||||||
|
|
||||||
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer })
|
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer })
|
||||||
pdfDoc = await loadingTask.promise
|
pdfDoc = await loadingTask.promise
|
||||||
|
|
||||||
@@ -251,9 +267,10 @@ function onWheel(e) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => props.file, async (newFile) => {
|
watch([() => props.file, () => props.url], async ([newFile, newUrl]) => {
|
||||||
if (newFile) {
|
const source = newUrl || newFile
|
||||||
await loadPdf(newFile)
|
if (source) {
|
||||||
|
await loadPdf(source)
|
||||||
} else {
|
} else {
|
||||||
pdfDoc = null
|
pdfDoc = null
|
||||||
totalPages.value = 0
|
totalPages.value = 0
|
||||||
|
|||||||
234
frontend/src/pages/DocumentsPage.vue
Normal file
234
frontend/src/pages/DocumentsPage.vue
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex-1 overflow-auto p-6 bg-gray-800">
|
||||||
|
<div class="max-w-6xl mx-auto space-y-6">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-semibold text-white">Documents importes</h1>
|
||||||
|
<p class="text-sm text-gray-400 mt-1">
|
||||||
|
{{ documents.length }} document{{ documents.length > 1 ? 's' : '' }} en base
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Import button -->
|
||||||
|
<label class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors cursor-pointer flex items-center gap-2">
|
||||||
|
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||||
|
</svg>
|
||||||
|
Importer un PDF
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf,.pdf"
|
||||||
|
class="hidden"
|
||||||
|
@change="onFileSelect"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="isLoading" class="bg-gray-900 rounded-lg p-12 text-center">
|
||||||
|
<div class="inline-block animate-spin rounded-full h-8 w-8 border-2 border-gray-600 border-t-blue-400"></div>
|
||||||
|
<p class="text-gray-400 mt-4">Chargement des documents...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-else-if="documents.length === 0" class="bg-gray-900 rounded-lg p-12 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-gray-400 text-lg">Aucun document importe</p>
|
||||||
|
<p class="text-gray-500 text-sm mt-2">Importez votre premier PDF pour commencer</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Documents table -->
|
||||||
|
<div v-else class="bg-gray-900 rounded-lg overflow-hidden border border-gray-700">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-800 border-b border-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Document</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Immeuble</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Date</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider">Solde</th>
|
||||||
|
<th class="px-4 py-3 text-center text-xs font-medium text-gray-400 uppercase tracking-wider">Fichiers</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-800">
|
||||||
|
<tr
|
||||||
|
v-for="doc in documents"
|
||||||
|
:key="doc.id"
|
||||||
|
class="hover:bg-gray-800/50 transition-colors"
|
||||||
|
>
|
||||||
|
<!-- Document info -->
|
||||||
|
<td class="px-4 py-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex-shrink-0 w-10 h-10 bg-red-500/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-red-400" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"/>
|
||||||
|
<path d="M14 2v6h6M16 13H8M16 17H8M10 9H8"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-white font-mono">{{ doc.reference }}</div>
|
||||||
|
<div class="text-xs text-gray-500">{{ doc.source_file || 'Fichier non stocke' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Immeuble -->
|
||||||
|
<td class="px-4 py-4">
|
||||||
|
<div class="text-sm text-white">{{ doc.immeuble_adresse || '-' }}</div>
|
||||||
|
<div class="text-xs text-gray-500 font-mono">{{ doc.immeuble_code }}</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Date -->
|
||||||
|
<td class="px-4 py-4">
|
||||||
|
<div class="text-sm text-white">{{ formatDate(doc.date) }}</div>
|
||||||
|
<div class="text-xs text-gray-500">Importe le {{ formatDateTime(doc.created_at) }}</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Solde -->
|
||||||
|
<td class="px-4 py-4 text-right">
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'text-sm font-medium',
|
||||||
|
doc.solde_type === 'crediteur' ? 'text-green-400' : 'text-red-400'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ formatAmount(doc.solde_montant) }}
|
||||||
|
</span>
|
||||||
|
<div class="text-xs text-gray-500">{{ doc.solde_type }}</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Files status -->
|
||||||
|
<td class="px-4 py-4">
|
||||||
|
<div class="flex items-center justify-center gap-2">
|
||||||
|
<!-- PDF download -->
|
||||||
|
<a
|
||||||
|
v-if="doc.has_pdf"
|
||||||
|
:href="`/api/documents/${doc.id}/pdf`"
|
||||||
|
target="_blank"
|
||||||
|
class="p-1.5 rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors"
|
||||||
|
title="Telecharger le PDF"
|
||||||
|
>
|
||||||
|
<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="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
|
||||||
|
</a>
|
||||||
|
<span v-else class="p-1.5 rounded bg-gray-700 text-gray-500" title="PDF non stocke">
|
||||||
|
<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="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- JSON download -->
|
||||||
|
<a
|
||||||
|
v-if="doc.has_json"
|
||||||
|
:href="`/api/documents/${doc.id}/json`"
|
||||||
|
target="_blank"
|
||||||
|
class="p-1.5 rounded bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30 transition-colors"
|
||||||
|
title="Telecharger le JSON"
|
||||||
|
>
|
||||||
|
<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="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<span v-else class="p-1.5 rounded bg-gray-700 text-gray-500" title="JSON non stocke">
|
||||||
|
<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="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<td class="px-4 py-4 text-right">
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<!-- Edit & reimport button -->
|
||||||
|
<button
|
||||||
|
@click="router.push(`/documents/${doc.id}/edit`)"
|
||||||
|
class="px-3 py-1.5 text-xs rounded bg-blue-600/20 text-blue-400 hover:bg-blue-600 hover:text-white transition-colors flex items-center gap-1"
|
||||||
|
title="Editer et reimporter"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 1 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
|
</svg>
|
||||||
|
Editer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { pendingFile } from '../store'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// State
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const documents = ref([])
|
||||||
|
|
||||||
|
// Formatting helpers
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
if (!dateStr) return '-'
|
||||||
|
const [year, month, day] = dateStr.split('-')
|
||||||
|
return `${day}/${month}/${year}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(isoStr) {
|
||||||
|
if (!isoStr) return '-'
|
||||||
|
const date = new Date(isoStr)
|
||||||
|
return date.toLocaleDateString('fr-FR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAmount(amount) {
|
||||||
|
if (amount == null) return '-'
|
||||||
|
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load documents
|
||||||
|
async function loadDocuments() {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/documents?limit=100')
|
||||||
|
if (response.ok) {
|
||||||
|
documents.value = await response.json()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load documents:', err)
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// File selection for new import
|
||||||
|
function onFileSelect(e) {
|
||||||
|
const file = e.target.files[0]
|
||||||
|
if (file && file.name.toLowerCase().endsWith('.pdf')) {
|
||||||
|
pendingFile.value = file
|
||||||
|
router.push('/extract')
|
||||||
|
}
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadDocuments()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
217
frontend/src/pages/EditDocumentPage.vue
Normal file
217
frontend/src/pages/EditDocumentPage.vue
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
<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="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">
|
||||||
|
<!-- Vue 1 : JsonViewer (mode édition) -->
|
||||||
|
<JsonViewer
|
||||||
|
v-if="!showTagging && extractedData"
|
||||||
|
:data="extractedData"
|
||||||
|
@update:data="extractedData = $event"
|
||||||
|
:is-loading="false"
|
||||||
|
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, 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'
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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 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>
|
||||||
@@ -3,6 +3,8 @@ import HomePage from './pages/HomePage.vue'
|
|||||||
import ExtractPage from './pages/ExtractPage.vue'
|
import ExtractPage from './pages/ExtractPage.vue'
|
||||||
import AnalyticsPage from './pages/AnalyticsPage.vue'
|
import AnalyticsPage from './pages/AnalyticsPage.vue'
|
||||||
import RevenusPage from './pages/RevenusPage.vue'
|
import RevenusPage from './pages/RevenusPage.vue'
|
||||||
|
import DocumentsPage from './pages/DocumentsPage.vue'
|
||||||
|
import EditDocumentPage from './pages/EditDocumentPage.vue'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
@@ -24,6 +26,16 @@ const routes = [
|
|||||||
path: '/revenus',
|
path: '/revenus',
|
||||||
name: 'revenus',
|
name: 'revenus',
|
||||||
component: RevenusPage
|
component: RevenusPage
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/documents',
|
||||||
|
name: 'documents',
|
||||||
|
component: DocumentsPage
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/documents/:id/edit',
|
||||||
|
name: 'edit-document',
|
||||||
|
component: EditDocumentPage
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,15 @@
|
|||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, File, UploadFile, Form
|
||||||
|
from fastapi.responses import Response, JSONResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from ...database import get_session, DatabaseService
|
from ...database import get_session, DatabaseService, storage
|
||||||
from ...database.models import Document, Immeuble, Lot, Locataire, Revenu, Depense
|
from ...database.models import Document, Immeuble, Lot, Locataire, Revenu, Depense
|
||||||
from ...database.service import DuplicateDocumentError
|
from ...database.service import DuplicateDocumentError
|
||||||
|
from ...extractor import extract_compte_rendu
|
||||||
from ..schemas import SaveRequest, SaveResponse, DocumentSummary
|
from ..schemas import SaveRequest, SaveResponse, DocumentSummary
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["documents"])
|
router = APIRouter(prefix="/api", tags=["documents"])
|
||||||
@@ -64,6 +66,80 @@ async def save_document(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/save-with-pdf", response_model=SaveResponse)
|
||||||
|
async def save_document_with_pdf(
|
||||||
|
pdf_file: UploadFile = File(..., description="Fichier PDF original"),
|
||||||
|
data: str = Form(..., description="Donnees JSON extraites"),
|
||||||
|
depenses_tags: str | None = Form(None, description="Tags JSON pour les depenses"),
|
||||||
|
overwrite: bool = Form(False, description="Ecraser si existant"),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> SaveResponse:
|
||||||
|
"""Sauvegarde les donnees extraites avec le fichier PDF original.
|
||||||
|
|
||||||
|
Cette version stocke le PDF et le JSON sur le disque pour tracabilite.
|
||||||
|
|
||||||
|
- **pdf_file**: Fichier PDF original (multipart)
|
||||||
|
- **data**: Donnees JSON extraites (string JSON)
|
||||||
|
- **depenses_tags**: Tags JSON pour les depenses (optionnel)
|
||||||
|
- **overwrite**: Si True, ecrase le document existant
|
||||||
|
"""
|
||||||
|
# Parse JSON data
|
||||||
|
try:
|
||||||
|
parsed_data = json.loads(data)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"JSON invalide pour data: {e}")
|
||||||
|
|
||||||
|
# Parse depenses_tags if provided
|
||||||
|
parsed_tags = None
|
||||||
|
if depenses_tags:
|
||||||
|
try:
|
||||||
|
parsed_tags = json.loads(depenses_tags)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail=f"JSON invalide pour depenses_tags: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Read PDF content
|
||||||
|
try:
|
||||||
|
pdf_content = await pdf_file.read()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Erreur lecture du PDF: {str(e)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
db_service = DatabaseService(session)
|
||||||
|
document = db_service.save_document(
|
||||||
|
data=parsed_data,
|
||||||
|
source_file=pdf_file.filename,
|
||||||
|
pdf_content=pdf_content,
|
||||||
|
depenses_tags=parsed_tags,
|
||||||
|
overwrite=overwrite,
|
||||||
|
)
|
||||||
|
|
||||||
|
return SaveResponse(
|
||||||
|
success=True,
|
||||||
|
message="Document sauvegarde avec succes (PDF et JSON stockes)",
|
||||||
|
document_id=document.id,
|
||||||
|
reference=document.reference,
|
||||||
|
date=str(document.date),
|
||||||
|
)
|
||||||
|
|
||||||
|
except DuplicateDocumentError as e:
|
||||||
|
return SaveResponse(
|
||||||
|
success=False,
|
||||||
|
message=f"Document deja existant: reference={e.reference}, date={e.date}",
|
||||||
|
reference=e.reference,
|
||||||
|
date=str(e.date),
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Erreur lors de la sauvegarde: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats")
|
@router.get("/stats")
|
||||||
async def get_stats(
|
async def get_stats(
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
@@ -110,6 +186,8 @@ async def list_documents(
|
|||||||
solde_montant=doc.solde_montant,
|
solde_montant=doc.solde_montant,
|
||||||
solde_type=doc.solde_type,
|
solde_type=doc.solde_type,
|
||||||
created_at=doc.created_at.isoformat() if doc.created_at else None,
|
created_at=doc.created_at.isoformat() if doc.created_at else None,
|
||||||
|
has_pdf=doc.pdf_path is not None,
|
||||||
|
has_json=doc.json_path is not None,
|
||||||
)
|
)
|
||||||
for doc in documents
|
for doc in documents
|
||||||
]
|
]
|
||||||
@@ -157,6 +235,8 @@ async def get_document(
|
|||||||
},
|
},
|
||||||
"json_data": json.loads(document.json_data) if document.json_data else None,
|
"json_data": json.loads(document.json_data) if document.json_data else None,
|
||||||
"created_at": document.created_at.isoformat() if document.created_at else None,
|
"created_at": document.created_at.isoformat() if document.created_at else None,
|
||||||
|
"has_pdf": document.pdf_path is not None,
|
||||||
|
"has_json": document.json_path is not None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -189,3 +269,135 @@ async def check_duplicate(
|
|||||||
"reference": reference,
|
"reference": reference,
|
||||||
"date": date,
|
"date": date,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/documents/{document_id}/pdf")
|
||||||
|
async def download_document_pdf(
|
||||||
|
document_id: int,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> Response:
|
||||||
|
"""Telecharge le fichier PDF original d'un document.
|
||||||
|
|
||||||
|
Retourne le fichier PDF si disponible, sinon erreur 404.
|
||||||
|
"""
|
||||||
|
db_service = DatabaseService(session)
|
||||||
|
document = db_service.get_document_by_id(document_id)
|
||||||
|
|
||||||
|
if not document:
|
||||||
|
raise HTTPException(status_code=404, detail="Document non trouve")
|
||||||
|
|
||||||
|
if not document.pdf_path:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Fichier PDF non disponible pour ce document",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
pdf_content = storage.read_pdf(document.pdf_path)
|
||||||
|
filename = document.source_file or f"{document.reference}.pdf"
|
||||||
|
return Response(
|
||||||
|
content=pdf_content,
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": f'inline; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Fichier PDF introuvable sur le disque",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/documents/{document_id}/json")
|
||||||
|
async def download_document_json(
|
||||||
|
document_id: int,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> Response:
|
||||||
|
"""Telecharge le fichier JSON extrait d'un document.
|
||||||
|
|
||||||
|
Retourne le fichier JSON si disponible sur disque,
|
||||||
|
sinon retourne le json_data de la base de donnees.
|
||||||
|
"""
|
||||||
|
db_service = DatabaseService(session)
|
||||||
|
document = db_service.get_document_by_id(document_id)
|
||||||
|
|
||||||
|
if not document:
|
||||||
|
raise HTTPException(status_code=404, detail="Document non trouve")
|
||||||
|
|
||||||
|
filename = f"{document.reference}_{document.date}.json"
|
||||||
|
|
||||||
|
# Try to read from storage first
|
||||||
|
if document.json_path:
|
||||||
|
try:
|
||||||
|
json_data = storage.read_json(document.json_path)
|
||||||
|
return JSONResponse(
|
||||||
|
content=json_data,
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass # Fall back to database
|
||||||
|
|
||||||
|
# Fall back to json_data in database
|
||||||
|
if document.json_data:
|
||||||
|
json_data = json.loads(document.json_data)
|
||||||
|
return JSONResponse(
|
||||||
|
content=json_data,
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Donnees JSON non disponibles pour ce document",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/documents/{document_id}/re-extract")
|
||||||
|
async def re_extract_document(
|
||||||
|
document_id: int,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Re-extrait les donnees depuis le PDF stocke.
|
||||||
|
|
||||||
|
Utile pour corriger l'extraction apres amelioration des parsers.
|
||||||
|
Ne modifie pas automatiquement la base - retourne les nouvelles donnees
|
||||||
|
pour validation par l'utilisateur.
|
||||||
|
|
||||||
|
Retourne les donnees re-extraites du PDF.
|
||||||
|
"""
|
||||||
|
db_service = DatabaseService(session)
|
||||||
|
document = db_service.get_document_by_id(document_id)
|
||||||
|
|
||||||
|
if not document:
|
||||||
|
raise HTTPException(status_code=404, detail="Document non trouve")
|
||||||
|
|
||||||
|
if not document.pdf_path:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Fichier PDF non disponible pour ce document - re-extraction impossible",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get absolute path
|
||||||
|
pdf_full_path = storage.get_absolute_path(document.pdf_path)
|
||||||
|
|
||||||
|
if not pdf_full_path.exists():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Fichier PDF introuvable sur le disque",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Re-extract
|
||||||
|
try:
|
||||||
|
new_data = extract_compte_rendu(str(pdf_full_path))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"Erreur lors de la re-extraction: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"document_id": document_id,
|
||||||
|
"reference": document.reference,
|
||||||
|
"date": str(document.date),
|
||||||
|
"original_json_path": document.json_path,
|
||||||
|
"re_extracted_data": new_data,
|
||||||
|
"message": "Donnees re-extraites. Utilisez PUT /api/documents/{id} pour mettre a jour.",
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ class DocumentSummary(BaseModel):
|
|||||||
solde_montant: float | None
|
solde_montant: float | None
|
||||||
solde_type: str | None
|
solde_type: str | None
|
||||||
created_at: str
|
created_at: str
|
||||||
|
has_pdf: bool = False
|
||||||
|
has_json: bool = False
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from .connection import get_engine, get_session, get_session_factory, init_db
|
from .connection import get_engine, get_session, get_session_factory, init_db
|
||||||
from .models import Base, Document, Immeuble, Lot, Locataire, Revenu, Depense
|
from .models import Base, Document, Immeuble, Lot, Locataire, Revenu, Depense
|
||||||
from .service import DatabaseService, DuplicateDocumentError
|
from .service import DatabaseService, DuplicateDocumentError
|
||||||
|
from . import storage
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"get_engine",
|
"get_engine",
|
||||||
@@ -18,4 +19,5 @@ __all__ = [
|
|||||||
"Depense",
|
"Depense",
|
||||||
"DatabaseService",
|
"DatabaseService",
|
||||||
"DuplicateDocumentError",
|
"DuplicateDocumentError",
|
||||||
|
"storage",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -144,6 +144,14 @@ class Document(Base):
|
|||||||
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Chemins vers les fichiers stockés (relatifs à PLESNA_STORAGE_PATH)
|
||||||
|
pdf_path = Column(
|
||||||
|
String(500), nullable=True
|
||||||
|
) # ex: "2024/S_REF-01234_2024-01-15.pdf"
|
||||||
|
json_path = Column(
|
||||||
|
String(500), nullable=True
|
||||||
|
) # ex: "2024/S_REF-01234_2024-01-15.json"
|
||||||
|
|
||||||
# Clé unique: référence + date (évite les doublons)
|
# Clé unique: référence + date (évite les doublons)
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("reference", "date", name="uq_document_reference_date"),
|
UniqueConstraint("reference", "date", name="uq_document_reference_date"),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
|
|||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
from .models import Document, Immeuble, Lot, Locataire, Revenu, Depense, Tag
|
from .models import Document, Immeuble, Lot, Locataire, Revenu, Depense, Tag
|
||||||
|
from . import storage
|
||||||
|
|
||||||
|
|
||||||
class DuplicateDocumentError(Exception):
|
class DuplicateDocumentError(Exception):
|
||||||
@@ -98,6 +99,7 @@ class DatabaseService:
|
|||||||
self,
|
self,
|
||||||
data: dict[str, Any],
|
data: dict[str, Any],
|
||||||
source_file: str = None,
|
source_file: str = None,
|
||||||
|
pdf_content: bytes = None,
|
||||||
depenses_tags: list[dict] = None,
|
depenses_tags: list[dict] = None,
|
||||||
overwrite: bool = False,
|
overwrite: bool = False,
|
||||||
) -> Document:
|
) -> Document:
|
||||||
@@ -107,6 +109,7 @@ class DatabaseService:
|
|||||||
data: The 'data' portion of the extracted JSON (contains metadata,
|
data: The 'data' portion of the extracted JSON (contains metadata,
|
||||||
situation_locataires, recapitulatif_operations)
|
situation_locataires, recapitulatif_operations)
|
||||||
source_file: Original PDF filename
|
source_file: Original PDF filename
|
||||||
|
pdf_content: Binary content of the PDF file (optional, for storage)
|
||||||
depenses_tags: List of tags for expenses
|
depenses_tags: List of tags for expenses
|
||||||
overwrite: If True, delete existing document and recreate it
|
overwrite: If True, delete existing document and recreate it
|
||||||
|
|
||||||
@@ -129,11 +132,17 @@ class DatabaseService:
|
|||||||
if not reference or not doc_date:
|
if not reference or not doc_date:
|
||||||
raise ValueError("Document must have reference and date")
|
raise ValueError("Document must have reference and date")
|
||||||
|
|
||||||
# Check for duplicates
|
# Check for duplicates and preserve existing file paths if overwriting
|
||||||
|
existing_pdf_path = None
|
||||||
|
existing_json_path = None
|
||||||
existing = self.check_duplicate(reference, doc_date)
|
existing = self.check_duplicate(reference, doc_date)
|
||||||
if existing:
|
if existing:
|
||||||
if overwrite:
|
if overwrite:
|
||||||
|
# Preserve existing file paths for reuse
|
||||||
|
existing_pdf_path = existing.pdf_path
|
||||||
|
existing_json_path = existing.json_path
|
||||||
# Delete existing document (cascade will delete related data)
|
# Delete existing document (cascade will delete related data)
|
||||||
|
# But DON'T delete files - we'll reuse or update them
|
||||||
self.session.delete(existing)
|
self.session.delete(existing)
|
||||||
self.session.flush()
|
self.session.flush()
|
||||||
else:
|
else:
|
||||||
@@ -147,6 +156,26 @@ class DatabaseService:
|
|||||||
code_postal=immeuble_info.get("code_postal"),
|
code_postal=immeuble_info.get("code_postal"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Compute storage paths for PDF and JSON
|
||||||
|
pdf_path = None
|
||||||
|
json_path = None
|
||||||
|
if pdf_content is not None:
|
||||||
|
# New PDF provided - compute new paths
|
||||||
|
pdf_path, json_path = storage.compute_document_paths(
|
||||||
|
reference=reference,
|
||||||
|
doc_date=doc_date,
|
||||||
|
immeuble_adresse=immeuble.adresse,
|
||||||
|
)
|
||||||
|
# Delete old files if paths are different
|
||||||
|
if existing_pdf_path and existing_pdf_path != pdf_path:
|
||||||
|
storage.delete_document_files(existing_pdf_path, None)
|
||||||
|
if existing_json_path and existing_json_path != json_path:
|
||||||
|
storage.delete_document_files(None, existing_json_path)
|
||||||
|
elif existing_json_path:
|
||||||
|
# No new PDF but we have existing paths - preserve them
|
||||||
|
pdf_path = existing_pdf_path
|
||||||
|
json_path = existing_json_path
|
||||||
|
|
||||||
# Create document
|
# Create document
|
||||||
document = Document(
|
document = Document(
|
||||||
reference=reference,
|
reference=reference,
|
||||||
@@ -160,10 +189,21 @@ class DatabaseService:
|
|||||||
solde_montant=solde_info.get("montant"),
|
solde_montant=solde_info.get("montant"),
|
||||||
solde_type=solde_info.get("type"),
|
solde_type=solde_info.get("type"),
|
||||||
solde_date_arrete=self._parse_date(solde_info.get("date_arrete")),
|
solde_date_arrete=self._parse_date(solde_info.get("date_arrete")),
|
||||||
|
pdf_path=pdf_path,
|
||||||
|
json_path=json_path,
|
||||||
)
|
)
|
||||||
self.session.add(document)
|
self.session.add(document)
|
||||||
self.session.flush()
|
self.session.flush()
|
||||||
|
|
||||||
|
# Save files to storage
|
||||||
|
if pdf_content is not None and pdf_path and json_path:
|
||||||
|
# New PDF provided - save both files
|
||||||
|
storage.save_pdf(pdf_content, pdf_path)
|
||||||
|
storage.save_json(data, json_path)
|
||||||
|
elif json_path:
|
||||||
|
# No new PDF but we have a json_path - update the JSON file
|
||||||
|
storage.save_json(data, json_path)
|
||||||
|
|
||||||
# Process situation_locataires (revenus)
|
# Process situation_locataires (revenus)
|
||||||
for situation in data.get("situation_locataires", []):
|
for situation in data.get("situation_locataires", []):
|
||||||
self._save_situation_locataire(document, immeuble, situation)
|
self._save_situation_locataire(document, immeuble, situation)
|
||||||
|
|||||||
280
src/plesna_gerance/database/storage.py
Normal file
280
src/plesna_gerance/database/storage.py
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
"""Service de stockage des fichiers PDF et JSON."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _get_project_root() -> Path:
|
||||||
|
"""Find project root by looking for pyproject.toml."""
|
||||||
|
current = Path(__file__).resolve()
|
||||||
|
for parent in current.parents:
|
||||||
|
if (parent / "pyproject.toml").exists():
|
||||||
|
return parent
|
||||||
|
return Path.home() / ".plesna_gerance"
|
||||||
|
|
||||||
|
|
||||||
|
def get_storage_root() -> Path:
|
||||||
|
"""Get storage root from environment or default.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the documents storage directory.
|
||||||
|
"""
|
||||||
|
env_path = os.environ.get("PLESNA_STORAGE_PATH")
|
||||||
|
if env_path:
|
||||||
|
return Path(env_path)
|
||||||
|
return _get_project_root() / "data" / "documents"
|
||||||
|
|
||||||
|
|
||||||
|
def extract_street_letter(adresse: str | None) -> str:
|
||||||
|
"""Extract the first letter of the main street name.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
"4 RUE SERVIENT" -> "S"
|
||||||
|
"33 RUE MARC BLOCH" -> "M"
|
||||||
|
"1 RUE MARIETTON" -> "M"
|
||||||
|
"12 AVENUE JEAN JAURES" -> "J"
|
||||||
|
None -> "X"
|
||||||
|
|
||||||
|
Args:
|
||||||
|
adresse: The address string (e.g., "4 RUE SERVIENT")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Single uppercase letter representing the street, or "X" if unknown.
|
||||||
|
"""
|
||||||
|
if not adresse:
|
||||||
|
return "X"
|
||||||
|
|
||||||
|
# Normalize: uppercase, remove extra spaces
|
||||||
|
adresse = adresse.upper().strip()
|
||||||
|
|
||||||
|
# Remove common street type prefixes to get to the street name
|
||||||
|
# Pattern: [number] [street type] [street name]
|
||||||
|
street_types = [
|
||||||
|
r"^\d+\s+", # Leading number
|
||||||
|
r"^RUE\s+",
|
||||||
|
r"^AVENUE\s+",
|
||||||
|
r"^BOULEVARD\s+",
|
||||||
|
r"^PLACE\s+",
|
||||||
|
r"^ALLEE\s+",
|
||||||
|
r"^IMPASSE\s+",
|
||||||
|
r"^CHEMIN\s+",
|
||||||
|
r"^COURS\s+",
|
||||||
|
r"^PASSAGE\s+",
|
||||||
|
r"^QUAI\s+",
|
||||||
|
]
|
||||||
|
|
||||||
|
remaining = adresse
|
||||||
|
for pattern in street_types:
|
||||||
|
remaining = re.sub(pattern, "", remaining, flags=re.IGNORECASE)
|
||||||
|
|
||||||
|
# Get first letter of what remains
|
||||||
|
remaining = remaining.strip()
|
||||||
|
if remaining and remaining[0].isalpha():
|
||||||
|
return remaining[0].upper()
|
||||||
|
|
||||||
|
return "X"
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_filename(name: str) -> str:
|
||||||
|
"""Sanitize a string to be used as filename.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: The string to sanitize.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sanitized string safe for filenames.
|
||||||
|
"""
|
||||||
|
# Replace problematic characters
|
||||||
|
sanitized = re.sub(r'[<>:"/\\|?*]', "_", name)
|
||||||
|
# Remove leading/trailing spaces and dots
|
||||||
|
sanitized = sanitized.strip(". ")
|
||||||
|
return sanitized
|
||||||
|
|
||||||
|
|
||||||
|
def compute_document_paths(
|
||||||
|
reference: str,
|
||||||
|
doc_date: date,
|
||||||
|
immeuble_adresse: str | None = None,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""Compute the relative paths for PDF and JSON storage.
|
||||||
|
|
||||||
|
Naming convention: {YYYY}/{L}_{reference}_{YYYY-MM-DD}.{ext}
|
||||||
|
Where L is the first letter of the street name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
reference: Document reference (e.g., "01234")
|
||||||
|
doc_date: Document date
|
||||||
|
immeuble_adresse: Address to extract street letter from
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (pdf_relative_path, json_relative_path)
|
||||||
|
"""
|
||||||
|
year = str(doc_date.year)
|
||||||
|
letter = extract_street_letter(immeuble_adresse)
|
||||||
|
date_str = doc_date.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
# Sanitize reference for filename
|
||||||
|
safe_ref = sanitize_filename(reference)
|
||||||
|
|
||||||
|
# Build filename: L_reference_date
|
||||||
|
base_name = f"{letter}_{safe_ref}_{date_str}"
|
||||||
|
|
||||||
|
pdf_path = f"{year}/{base_name}.pdf"
|
||||||
|
json_path = f"{year}/{base_name}.json"
|
||||||
|
|
||||||
|
return pdf_path, json_path
|
||||||
|
|
||||||
|
|
||||||
|
def save_pdf(
|
||||||
|
pdf_content: bytes,
|
||||||
|
relative_path: str,
|
||||||
|
storage_root: Path | None = None,
|
||||||
|
) -> Path:
|
||||||
|
"""Save PDF content to storage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pdf_content: Binary content of the PDF file.
|
||||||
|
relative_path: Relative path (from compute_document_paths).
|
||||||
|
storage_root: Optional custom storage root.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Absolute path to the saved file.
|
||||||
|
"""
|
||||||
|
if storage_root is None:
|
||||||
|
storage_root = get_storage_root()
|
||||||
|
|
||||||
|
full_path = storage_root / relative_path
|
||||||
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
full_path.write_bytes(pdf_content)
|
||||||
|
return full_path
|
||||||
|
|
||||||
|
|
||||||
|
def save_json(
|
||||||
|
data: dict[str, Any],
|
||||||
|
relative_path: str,
|
||||||
|
storage_root: Path | None = None,
|
||||||
|
) -> Path:
|
||||||
|
"""Save JSON data to storage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Dictionary to serialize as JSON.
|
||||||
|
relative_path: Relative path (from compute_document_paths).
|
||||||
|
storage_root: Optional custom storage root.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Absolute path to the saved file.
|
||||||
|
"""
|
||||||
|
if storage_root is None:
|
||||||
|
storage_root = get_storage_root()
|
||||||
|
|
||||||
|
full_path = storage_root / relative_path
|
||||||
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with open(full_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2, default=str)
|
||||||
|
|
||||||
|
return full_path
|
||||||
|
|
||||||
|
|
||||||
|
def get_absolute_path(relative_path: str, storage_root: Path | None = None) -> Path:
|
||||||
|
"""Get absolute path from relative storage path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
relative_path: Relative path stored in database.
|
||||||
|
storage_root: Optional custom storage root.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Absolute path to the file.
|
||||||
|
"""
|
||||||
|
if storage_root is None:
|
||||||
|
storage_root = get_storage_root()
|
||||||
|
return storage_root / relative_path
|
||||||
|
|
||||||
|
|
||||||
|
def file_exists(relative_path: str, storage_root: Path | None = None) -> bool:
|
||||||
|
"""Check if a file exists in storage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
relative_path: Relative path stored in database.
|
||||||
|
storage_root: Optional custom storage root.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if file exists.
|
||||||
|
"""
|
||||||
|
return get_absolute_path(relative_path, storage_root).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def read_pdf(relative_path: str, storage_root: Path | None = None) -> bytes:
|
||||||
|
"""Read PDF content from storage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
relative_path: Relative path stored in database.
|
||||||
|
storage_root: Optional custom storage root.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Binary content of the PDF.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: If file doesn't exist.
|
||||||
|
"""
|
||||||
|
full_path = get_absolute_path(relative_path, storage_root)
|
||||||
|
return full_path.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def read_json(relative_path: str, storage_root: Path | None = None) -> dict[str, Any]:
|
||||||
|
"""Read JSON data from storage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
relative_path: Relative path stored in database.
|
||||||
|
storage_root: Optional custom storage root.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed JSON data.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: If file doesn't exist.
|
||||||
|
"""
|
||||||
|
full_path = get_absolute_path(relative_path, storage_root)
|
||||||
|
with open(full_path, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_document_files(
|
||||||
|
pdf_path: str | None,
|
||||||
|
json_path: str | None,
|
||||||
|
storage_root: Path | None = None,
|
||||||
|
) -> tuple[bool, bool]:
|
||||||
|
"""Delete document files from storage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pdf_path: Relative path to PDF (can be None).
|
||||||
|
json_path: Relative path to JSON (can be None).
|
||||||
|
storage_root: Optional custom storage root.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (pdf_deleted, json_deleted) booleans.
|
||||||
|
"""
|
||||||
|
if storage_root is None:
|
||||||
|
storage_root = get_storage_root()
|
||||||
|
|
||||||
|
pdf_deleted = False
|
||||||
|
json_deleted = False
|
||||||
|
|
||||||
|
if pdf_path:
|
||||||
|
pdf_full = storage_root / pdf_path
|
||||||
|
if pdf_full.exists():
|
||||||
|
pdf_full.unlink()
|
||||||
|
pdf_deleted = True
|
||||||
|
|
||||||
|
if json_path:
|
||||||
|
json_full = storage_root / json_path
|
||||||
|
if json_full.exists():
|
||||||
|
json_full.unlink()
|
||||||
|
json_deleted = True
|
||||||
|
|
||||||
|
return pdf_deleted, json_deleted
|
||||||
Reference in New Issue
Block a user