feat: reimport pdf

This commit is contained in:
2026-01-22 21:23:46 +01:00
parent 981fabcc27
commit 4f0ca67dc4
13 changed files with 1068 additions and 14 deletions

3
data/.gitignore vendored
View File

@@ -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

View File

@@ -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"

View File

@@ -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)
} }

View File

@@ -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

View 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>

View 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>

View File

@@ -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
} }
] ]

View File

@@ -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.",
}

View File

@@ -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
# ============================================================ # ============================================================

View File

@@ -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",
] ]

View File

@@ -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"),

View File

@@ -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)

View 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