Compare commits
9 Commits
94b885aedb
...
41107c984c
| Author | SHA1 | Date | |
|---|---|---|---|
| 41107c984c | |||
| 92cf21309f | |||
| a1da4fc97f | |||
| 43efea4945 | |||
| 5f3a26e7d7 | |||
| 6f2530014f | |||
| 9cf0a89aab | |||
| 50508c98a8 | |||
| 7a87958af7 |
1171
frontend/package-lock.json
generated
1171
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.4.1",
|
||||
@@ -20,6 +21,7 @@
|
||||
"autoprefixer": "^10.4.18",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"vite": "^5.1.6"
|
||||
"vite": "^5.1.6",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
227
frontend/src/components/ExtractionDiffDetails.vue
Normal file
227
frontend/src/components/ExtractionDiffDetails.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div class="space-y-3 text-xs">
|
||||
<!-- Métadonnées -->
|
||||
<div v-if="diff.metadata.length">
|
||||
<div class="font-semibold text-amber-300 mb-1">Métadonnées</div>
|
||||
<div class="space-y-0.5">
|
||||
<div v-for="c in diff.metadata" :key="c.id">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<component :is="selectable ? 'label' : 'span'" class="flex items-baseline gap-2 flex-1 min-w-0" :class="selectable && 'cursor-pointer'">
|
||||
<input
|
||||
v-if="selectable"
|
||||
type="checkbox"
|
||||
:checked="accepted(c.id)"
|
||||
@change="toggle(c.id, $event.target.checked)"
|
||||
class="w-3.5 h-3.5 rounded flex-shrink-0 self-center"
|
||||
/>
|
||||
<span class="text-gray-500 w-40 flex-shrink-0">{{ c.label }}</span>
|
||||
<span class="text-red-400" :class="accepted(c.id) && 'line-through'">{{ c.before }}</span>
|
||||
<span class="text-gray-500">→</span>
|
||||
<span v-if="!selectable || !c.editable" class="text-green-400 font-medium" :class="!accepted(c.id) && 'line-through opacity-50'">
|
||||
{{ c.after }}
|
||||
</span>
|
||||
</component>
|
||||
<input
|
||||
v-if="selectable && c.editable"
|
||||
type="text"
|
||||
:value="displayValue(c)"
|
||||
:disabled="!accepted(c.id)"
|
||||
@input="edit(c, $event.target.value)"
|
||||
class="flex-1 min-w-0 px-1.5 py-0.5 rounded border border-gray-600 bg-gray-900 text-green-400 font-medium disabled:opacity-40 disabled:line-through focus:ring-1 focus:ring-amber-500 focus:border-amber-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="overrideLabel(c.id)" class="pl-6 text-amber-400">⚠ {{ overrideLabel(c.id) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Locataires -->
|
||||
<div v-if="diff.locataires.length">
|
||||
<div class="font-semibold text-amber-300 mb-1">Locataires</div>
|
||||
<div class="space-y-1.5">
|
||||
<div v-for="(loc, i) in diff.locataires" :key="loc.id || 'l' + i" class="bg-gray-900/70 border border-gray-700 rounded p-1.5">
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
<input
|
||||
v-if="selectable && loc.kind !== 'modified'"
|
||||
type="checkbox"
|
||||
:checked="accepted(loc.id)"
|
||||
@change="toggle(loc.id, $event.target.checked)"
|
||||
class="w-3.5 h-3.5 rounded flex-shrink-0"
|
||||
/>
|
||||
<span :class="kindClass(loc.kind)" class="text-[10px] uppercase font-semibold px-1 rounded">
|
||||
{{ kindLabel(loc.kind) }}
|
||||
</span>
|
||||
<span class="font-medium text-gray-200">{{ loc.title }}</span>
|
||||
</div>
|
||||
<p v-if="loc.id && overrideLabel(loc.id)" class="pl-5 text-amber-400 mb-0.5">⚠ {{ overrideLabel(loc.id) }}</p>
|
||||
|
||||
<div v-for="c in loc.changes" :key="c.id">
|
||||
<!-- Champ identitaire : cochable et modifiable individuellement -->
|
||||
<div v-if="c.kind === 'champ'" class="pl-2">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<component :is="selectable ? 'label' : 'span'" class="flex items-baseline gap-2 flex-1 min-w-0" :class="selectable && 'cursor-pointer'">
|
||||
<input
|
||||
v-if="selectable"
|
||||
type="checkbox"
|
||||
:checked="accepted(c.id)"
|
||||
@change="toggle(c.id, $event.target.checked)"
|
||||
class="w-3.5 h-3.5 rounded flex-shrink-0 self-center"
|
||||
/>
|
||||
<span class="text-gray-500 w-24 flex-shrink-0">{{ c.label }}</span>
|
||||
<span class="text-red-400 break-all" :class="accepted(c.id) && 'line-through'">{{ c.before }}</span>
|
||||
<span class="text-gray-500">→</span>
|
||||
<span v-if="!selectable || !c.editable" class="text-green-400 font-medium break-all" :class="!accepted(c.id) && 'line-through opacity-50'">
|
||||
{{ c.after }}
|
||||
</span>
|
||||
</component>
|
||||
<input
|
||||
v-if="selectable && c.editable"
|
||||
type="text"
|
||||
:value="displayValue(c)"
|
||||
:disabled="!accepted(c.id)"
|
||||
@input="edit(c, $event.target.value)"
|
||||
class="flex-1 min-w-0 px-1.5 py-0.5 rounded border border-gray-600 bg-gray-900 text-green-400 font-medium disabled:opacity-40 disabled:line-through focus:ring-1 focus:ring-amber-500 focus:border-amber-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="overrideLabel(c.id)" class="pl-6 text-amber-400">⚠ {{ overrideLabel(c.id) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Montants et lignes : un seul bloc, pour que les sommes restent justes -->
|
||||
<div v-else class="pl-2">
|
||||
<component :is="selectable ? 'label' : 'div'" class="flex items-center gap-2" :class="selectable && 'cursor-pointer'">
|
||||
<input
|
||||
v-if="selectable"
|
||||
type="checkbox"
|
||||
:checked="accepted(c.id)"
|
||||
@change="toggle(c.id, $event.target.checked)"
|
||||
class="w-3.5 h-3.5 rounded flex-shrink-0"
|
||||
/>
|
||||
<span class="text-gray-300 font-medium">{{ c.label }}</span>
|
||||
<span class="text-gray-400">({{ c.fields.length }} changement{{ c.fields.length > 1 ? 's' : '' }})</span>
|
||||
</component>
|
||||
<div :class="['pl-5 space-y-0.5', selectable && !accepted(c.id) && 'opacity-40']">
|
||||
<div v-for="(f, j) in c.fields" :key="j" class="flex items-baseline gap-2">
|
||||
<span class="text-gray-500 w-24 flex-shrink-0">{{ f.label }}</span>
|
||||
<span class="text-red-400 break-all" :class="accepted(c.id) && 'line-through'">{{ f.before }}</span>
|
||||
<span class="text-gray-500">→</span>
|
||||
<span class="text-green-400 font-medium break-all" :class="!accepted(c.id) && 'line-through'">{{ f.after }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="overrideLabel(c.id)" class="pl-5 text-amber-400">⚠ {{ overrideLabel(c.id) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Opérations -->
|
||||
<div v-if="diff.operations.length">
|
||||
<div class="font-semibold text-amber-300 mb-1">Opérations</div>
|
||||
<div class="space-y-1.5">
|
||||
<div v-for="(op, i) in diff.operations" :key="op.id || 'o' + i" class="bg-gray-900/70 border border-gray-700 rounded p-1.5">
|
||||
<component :is="selectable ? 'label' : 'div'" class="flex items-center gap-2 mb-0.5" :class="selectable && 'cursor-pointer'">
|
||||
<input
|
||||
v-if="selectable"
|
||||
type="checkbox"
|
||||
:checked="accepted(op.id)"
|
||||
@change="toggle(op.id, $event.target.checked)"
|
||||
class="w-3.5 h-3.5 rounded flex-shrink-0"
|
||||
/>
|
||||
<span :class="kindClass(op.kind)" class="text-[10px] uppercase font-semibold px-1 rounded">
|
||||
{{ kindLabel(op.kind) }}
|
||||
</span>
|
||||
<span v-if="op.index >= 0" class="text-gray-500">opération #{{ op.index + 1 }}</span>
|
||||
</component>
|
||||
<div :class="['pl-2', selectable && !accepted(op.id) && 'opacity-40']">
|
||||
<div class="text-red-400 break-all" :class="accepted(op.id) && 'line-through'">{{ op.before }}</div>
|
||||
<div class="text-green-400 font-medium break-all" :class="!accepted(op.id) && 'line-through'">{{ op.after }}</div>
|
||||
</div>
|
||||
<p v-if="overrideLabel(op.id)" class="pl-2 text-amber-400">⚠ {{ overrideLabel(op.id) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { fmtValue } from '../utils/diffExtraction.js'
|
||||
|
||||
const props = defineProps({
|
||||
diff: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
// Absente : affichage seul. Fournie : chaque changement devient cochable, et
|
||||
// les champs textuels retenus deviennent modifiables.
|
||||
selection: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
// Changements que l'enregistrement réécrira quoi qu'on décide, par
|
||||
// identifiant → valeur imposée (cf. computeServerOverrides).
|
||||
serverOverrides: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:selection'])
|
||||
|
||||
const selectable = computed(() => props.selection !== null)
|
||||
|
||||
// Texte d'alerte quand refuser un changement ne servirait à rien.
|
||||
function overrideLabel(id) {
|
||||
if (!selectable.value || accepted(id)) return null
|
||||
if (!(id in props.serverOverrides)) return null
|
||||
const value = props.serverOverrides[id]
|
||||
const shown = value != null && typeof value === 'object' ? '' : ` « ${fmtValue(value)} »`
|
||||
return `L’enregistrement réécrira cette valeur${shown}`
|
||||
}
|
||||
|
||||
function accepted(id) {
|
||||
if (!selectable.value) return true
|
||||
const state = props.selection[id]
|
||||
return state ? state.accepted !== false : true
|
||||
}
|
||||
|
||||
function stateOf(id) {
|
||||
return props.selection[id] || { accepted: true }
|
||||
}
|
||||
|
||||
function toggle(id, value) {
|
||||
emit('update:selection', { ...props.selection, [id]: { ...stateOf(id), accepted: value } })
|
||||
}
|
||||
|
||||
// Valeur affichée dans le champ de saisie : celle déjà retenue, sinon celle
|
||||
// proposée par la nouvelle extraction.
|
||||
function displayValue(change) {
|
||||
const state = props.selection?.[change.id]
|
||||
const value = state && state.value !== undefined ? state.value : change.afterValue
|
||||
return value == null ? '' : String(value)
|
||||
}
|
||||
|
||||
function edit(change, raw) {
|
||||
// Un champ numérique en base doit le rester (montant du solde, par exemple).
|
||||
const value = typeof change.afterValue === 'number' && raw.trim() !== '' && !Number.isNaN(Number(raw))
|
||||
? Number(raw)
|
||||
: raw
|
||||
emit('update:selection', {
|
||||
...props.selection,
|
||||
[change.id]: { ...stateOf(change.id), accepted: true, value },
|
||||
})
|
||||
}
|
||||
|
||||
function kindLabel(kind) {
|
||||
if (kind === 'added') return 'ajouté'
|
||||
if (kind === 'removed') return 'supprimé'
|
||||
return 'modifié'
|
||||
}
|
||||
|
||||
function kindClass(kind) {
|
||||
if (kind === 'added') return 'bg-green-500/20 text-green-300'
|
||||
if (kind === 'removed') return 'bg-red-500/20 text-red-300'
|
||||
return 'bg-amber-500/20 text-amber-300'
|
||||
}
|
||||
</script>
|
||||
@@ -1,168 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="border-2 border-dashed rounded-lg p-8 text-center transition-colors"
|
||||
:class="{
|
||||
'border-blue-500 bg-blue-500/10': isDragging,
|
||||
'border-gray-700 hover:border-gray-600': !isDragging && !file,
|
||||
'border-green-500 bg-green-500/10': file && !isLoading
|
||||
}"
|
||||
@dragenter.prevent="onDragEnter"
|
||||
@dragleave.prevent="onDragLeave"
|
||||
@dragover.prevent
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<!-- Loading state -->
|
||||
<div v-if="isLoading" class="flex flex-col items-center gap-4">
|
||||
<svg class="animate-spin h-12 w-12 text-blue-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p class="text-gray-400">Extraction en cours...</p>
|
||||
</div>
|
||||
|
||||
<!-- File selected state -->
|
||||
<div v-else-if="file" class="flex flex-col items-center gap-4">
|
||||
<svg class="h-12 w-12 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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-200 font-medium">{{ file.name }}</p>
|
||||
<p class="text-sm text-gray-500">{{ formatFileSize(file.size) }}</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
@click="extractPdf"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
Extraire les donnees
|
||||
</button>
|
||||
<button
|
||||
@click="clearFile"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Default upload state -->
|
||||
<div v-else class="flex flex-col items-center gap-4">
|
||||
<svg class="h-12 w-12 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-gray-400 mb-1">Glissez un PDF ici ou</p>
|
||||
<label class="cursor-pointer text-blue-400 hover:text-blue-300 font-medium">
|
||||
cliquez pour selectionner
|
||||
<input
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
class="hidden"
|
||||
@change="onFileSelect"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">Fichiers PDF uniquement</p>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
<div v-if="error" class="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||
<p class="text-red-400 text-sm">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const emit = defineEmits(['file-selected', 'extraction-complete', 'extraction-error'])
|
||||
|
||||
const file = ref(null)
|
||||
const isDragging = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
let dragCounter = 0
|
||||
|
||||
function onDragEnter() {
|
||||
dragCounter++
|
||||
isDragging.value = true
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
dragCounter--
|
||||
if (dragCounter === 0) {
|
||||
isDragging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onDrop(e) {
|
||||
dragCounter = 0
|
||||
isDragging.value = false
|
||||
|
||||
const droppedFile = e.dataTransfer.files[0]
|
||||
if (droppedFile) {
|
||||
handleFile(droppedFile)
|
||||
}
|
||||
}
|
||||
|
||||
function onFileSelect(e) {
|
||||
const selectedFile = e.target.files[0]
|
||||
if (selectedFile) {
|
||||
handleFile(selectedFile)
|
||||
}
|
||||
}
|
||||
|
||||
function handleFile(f) {
|
||||
error.value = null
|
||||
|
||||
// Validate file type
|
||||
if (!f.name.toLowerCase().endsWith('.pdf')) {
|
||||
error.value = 'Veuillez selectionner un fichier PDF'
|
||||
return
|
||||
}
|
||||
|
||||
file.value = f
|
||||
emit('file-selected', f)
|
||||
}
|
||||
|
||||
function clearFile() {
|
||||
file.value = null
|
||||
error.value = null
|
||||
emit('file-selected', null)
|
||||
}
|
||||
|
||||
async function extractPdf() {
|
||||
if (!file.value) return
|
||||
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file.value)
|
||||
|
||||
const response = await fetch('/api/extract', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.detail || 'Erreur lors de l\'extraction')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
emit('extraction-complete', data)
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
emit('extraction-error', err.message)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
</script>
|
||||
@@ -47,65 +47,15 @@
|
||||
</div>
|
||||
|
||||
<!-- Détail -->
|
||||
<div v-if="open && diff.summary.total > 0" class="max-h-56 overflow-auto px-4 pb-3 space-y-3 text-xs">
|
||||
<!-- Métadonnées -->
|
||||
<div v-if="diff.metadata.length">
|
||||
<div class="font-semibold text-amber-300 mb-1">Métadonnées</div>
|
||||
<div class="space-y-0.5">
|
||||
<div v-for="(c, i) in diff.metadata" :key="'m' + i" class="flex items-baseline gap-2">
|
||||
<span class="text-gray-500 w-40 flex-shrink-0">{{ c.label }}</span>
|
||||
<span class="text-red-400 line-through">{{ c.before }}</span>
|
||||
<span class="text-gray-500">→</span>
|
||||
<span class="text-green-400 font-medium">{{ c.after }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Locataires -->
|
||||
<div v-if="diff.locataires.length">
|
||||
<div class="font-semibold text-amber-300 mb-1">Locataires</div>
|
||||
<div class="space-y-1.5">
|
||||
<div v-for="(loc, i) in diff.locataires" :key="'l' + i" class="bg-gray-900/70 border border-gray-700 rounded p-1.5">
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
<span :class="kindClass(loc.kind)" class="text-[10px] uppercase font-semibold px-1 rounded">
|
||||
{{ kindLabel(loc.kind) }}
|
||||
</span>
|
||||
<span class="font-medium text-gray-200">{{ loc.title }}</span>
|
||||
</div>
|
||||
<div v-for="(f, j) in loc.fields" :key="j" class="flex items-baseline gap-2 pl-2">
|
||||
<span class="text-gray-500 w-24 flex-shrink-0">{{ f.label }}</span>
|
||||
<span class="text-red-400 line-through break-all">{{ f.before }}</span>
|
||||
<span class="text-gray-500">→</span>
|
||||
<span class="text-green-400 font-medium break-all">{{ f.after }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Opérations -->
|
||||
<div v-if="diff.operations.length">
|
||||
<div class="font-semibold text-amber-300 mb-1">Opérations</div>
|
||||
<div class="space-y-1.5">
|
||||
<div v-for="(op, i) in diff.operations" :key="'o' + i" class="bg-gray-900/70 border border-gray-700 rounded p-1.5">
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
<span :class="kindClass(op.kind)" class="text-[10px] uppercase font-semibold px-1 rounded">
|
||||
{{ kindLabel(op.kind) }}
|
||||
</span>
|
||||
<span v-if="op.index >= 0" class="text-gray-500">opération #{{ op.index + 1 }}</span>
|
||||
</div>
|
||||
<div class="pl-2">
|
||||
<div class="text-red-400 line-through break-all">{{ op.before }}</div>
|
||||
<div class="text-green-400 font-medium break-all">{{ op.after }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="open && diff.summary.total > 0" class="max-h-56 overflow-auto px-4 pb-3">
|
||||
<ExtractionDiffDetails :diff="diff" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import ExtractionDiffDetails from './ExtractionDiffDetails.vue'
|
||||
|
||||
defineProps({
|
||||
diff: {
|
||||
@@ -117,16 +67,4 @@ defineProps({
|
||||
defineEmits(['revert', 'close'])
|
||||
|
||||
const open = ref(true)
|
||||
|
||||
function kindLabel(kind) {
|
||||
if (kind === 'added') return 'ajouté'
|
||||
if (kind === 'removed') return 'supprimé'
|
||||
return 'modifié'
|
||||
}
|
||||
|
||||
function kindClass(kind) {
|
||||
if (kind === 'added') return 'bg-green-500/20 text-green-300'
|
||||
if (kind === 'removed') return 'bg-red-500/20 text-red-300'
|
||||
return 'bg-amber-500/20 text-amber-300'
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -11,6 +11,20 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Re-extraction de toute la base -->
|
||||
<router-link
|
||||
to="/re-extraction"
|
||||
class="btn btn-secondary"
|
||||
title="Rejouer l'extraction de tous les PDF stockes"
|
||||
>
|
||||
<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 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Re-extraire la base
|
||||
</router-link>
|
||||
|
||||
<!-- Import button -->
|
||||
<label class="btn btn-primary cursor-pointer">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -24,6 +38,7 @@
|
||||
@change="onFileSelect"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
@@ -49,7 +64,7 @@
|
||||
<tr>
|
||||
<th>Document</th>
|
||||
<th>Immeuble</th>
|
||||
<th>Date</th>
|
||||
<th>Date / extraction</th>
|
||||
<th class="text-right">Solde</th>
|
||||
<th class="text-center">Fichiers</th>
|
||||
<th class="text-right">Actions</th>
|
||||
@@ -85,7 +100,12 @@
|
||||
<!-- Date -->
|
||||
<td>
|
||||
<div class="text-sm text-white">{{ formatDate(doc.date) }}</div>
|
||||
<div class="text-xs text-gray-500">Importe le {{ formatDateTime(doc.created_at) }}</div>
|
||||
<div
|
||||
class="text-xs text-gray-500"
|
||||
:title="`Importé le ${formatDateTime(doc.created_at)}`"
|
||||
>
|
||||
Extrait le {{ formatDateTime(doc.extracted_at || doc.created_at) }}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Solde -->
|
||||
@@ -93,7 +113,7 @@
|
||||
<span
|
||||
:class="[
|
||||
'text-sm font-medium',
|
||||
doc.solde_type === 'crediteur' ? 'text-green-400' : 'text-red-400'
|
||||
isSoldeCrediteur(doc.solde_type) ? 'text-green-400' : 'text-red-400'
|
||||
]"
|
||||
>
|
||||
{{ formatAmount(doc.solde_montant) }}
|
||||
@@ -173,6 +193,7 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { pendingFile } from '../store'
|
||||
import { isSoldeCrediteur } from '../utils/solde'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
|
||||
@@ -262,16 +262,17 @@ async function handleSave(depensesTags, shouldOverwrite) {
|
||||
saveError.value = null
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/save', {
|
||||
method: 'POST',
|
||||
// Mise à jour ciblée par ID : si la nouvelle extraction corrige la référence
|
||||
// ou la date, c'est bien ce document qui est mis à jour, pas un doublon.
|
||||
const response = await fetch(`/api/documents/${documentId}`, {
|
||||
method: 'PUT',
|
||||
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
|
||||
depenses_tags: depensesTags
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
<span
|
||||
:class="[
|
||||
'text-sm font-medium',
|
||||
doc.solde_type === 'crediteur' ? 'text-green-400' : 'text-red-400'
|
||||
isSoldeCrediteur(doc.solde_type) ? 'text-green-400' : 'text-red-400'
|
||||
]"
|
||||
>
|
||||
{{ formatAmount(doc.solde_montant) }}
|
||||
@@ -121,6 +121,7 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { pendingFile } from '../store'
|
||||
import { isSoldeCrediteur } from '../utils/solde'
|
||||
|
||||
import QuickActions from '../components/dashboard/QuickActions.vue'
|
||||
import FinancialSummary from '../components/dashboard/FinancialSummary.vue'
|
||||
|
||||
680
frontend/src/pages/ReExtractionPage.vue
Normal file
680
frontend/src/pages/ReExtractionPage.vue
Normal file
@@ -0,0 +1,680 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-content pb-28">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Ré-extraction de la base</h1>
|
||||
<p class="page-subtitle max-w-2xl">
|
||||
Rejoue l'extraction de chaque PDF stocké après une amélioration des parsers.
|
||||
Rien n'est écrit en base tant que vous n'avez pas validé : les documents dont
|
||||
l'extraction change sont listés ci-dessous, à appliquer en une fois.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 flex-shrink-0">
|
||||
<button
|
||||
v-if="!isScanning"
|
||||
@click="startScan"
|
||||
:disabled="isLoading || isApplying || documents.length === 0"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<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 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{{ hasScanned ? 'Relancer le balayage' : 'Lancer le balayage' }}
|
||||
</button>
|
||||
<button v-else @click="stopScan" class="btn btn-secondary">
|
||||
Arrêter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progression du balayage -->
|
||||
<div v-if="isScanning || hasScanned" class="card card-body space-y-3">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-white">
|
||||
<template v-if="isScanning">
|
||||
Balayage en cours — {{ scanned }} / {{ documents.length }}
|
||||
</template>
|
||||
<template v-else>
|
||||
Balayage terminé — {{ scanned }} / {{ documents.length }} document{{ documents.length > 1 ? 's' : '' }}
|
||||
</template>
|
||||
</span>
|
||||
<span v-if="isScanning" class="text-gray-400">
|
||||
{{ remainingLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="h-2 bg-gray-700 rounded overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-blue-500 transition-all duration-300"
|
||||
:style="{ width: progressPercent + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-4 text-xs">
|
||||
<span class="text-amber-400">{{ counts.modifie }} à réenregistrer</span>
|
||||
<span class="text-gray-400">{{ counts.identique }} inchangé{{ counts.identique > 1 ? 's' : '' }}</span>
|
||||
<span v-if="counts.applique" class="text-green-400">{{ counts.applique }} appliqué{{ counts.applique > 1 ? 's' : '' }}</span>
|
||||
<span v-if="counts.sans_pdf" class="text-gray-500">{{ counts.sans_pdf }} sans PDF</span>
|
||||
<span v-if="counts.erreur || counts.echec" class="text-red-400">
|
||||
{{ counts.erreur + counts.echec }} en erreur
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chargement initial -->
|
||||
<div v-if="isLoading" class="card empty-state">
|
||||
<div class="spinner h-8 w-8"></div>
|
||||
<p class="mt-4">Chargement des documents...</p>
|
||||
</div>
|
||||
|
||||
<!-- Invitation initiale -->
|
||||
<div v-else-if="!hasScanned && !isScanning" class="card empty-state">
|
||||
<p class="text-gray-300">
|
||||
{{ documents.length }} document{{ documents.length > 1 ? 's' : '' }} en base,
|
||||
dont {{ documents.filter((d) => d.has_pdf).length }} avec PDF stocké.
|
||||
</p>
|
||||
<p class="text-gray-500 text-sm mt-2">
|
||||
Comptez environ {{ SECONDS_PER_DOC }} s par document, soit
|
||||
{{ formatDuration(documents.filter((d) => d.has_pdf).length * SECONDS_PER_DOC) }} au total.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Documents modifiés -->
|
||||
<div v-if="changedItems.length" class="card">
|
||||
<div class="card-header bg-gray-800">
|
||||
<label class="flex items-center gap-2 text-sm text-white cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="allSelected"
|
||||
:indeterminate.prop="someSelected && !allSelected"
|
||||
@change="toggleAll"
|
||||
class="w-4 h-4 rounded"
|
||||
/>
|
||||
Extraction modifiée ({{ changedItems.length }})
|
||||
</label>
|
||||
<span class="text-xs text-gray-400">{{ selectedItems.length }} sélectionné{{ selectedItems.length > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-gray-800">
|
||||
<div v-for="item in changedItems" :key="item.id">
|
||||
<!-- Ligne -->
|
||||
<div class="flex items-center gap-3 px-4 py-3 hover:bg-gray-800/50 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="item.selected"
|
||||
:disabled="isApplying || item.counts.accepted === 0"
|
||||
:title="item.counts.accepted === 0 ? 'Aucune modification retenue pour ce document' : ''"
|
||||
class="w-4 h-4 rounded flex-shrink-0 disabled:opacity-40"
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-white font-mono">{{ item.reference }}</span>
|
||||
<span class="text-xs text-gray-500">{{ formatDate(item.date) }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 truncate">{{ item.immeuble_adresse || '-' }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Résumé des différences -->
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||||
<span v-if="item.diff.summary.metadata" class="badge badge-neutral">
|
||||
{{ item.diff.summary.metadata }} métadonnée{{ item.diff.summary.metadata > 1 ? 's' : '' }}
|
||||
</span>
|
||||
<span v-if="item.diff.summary.locataires" class="badge badge-info">
|
||||
{{ item.diff.summary.locataires }} locataire{{ item.diff.summary.locataires > 1 ? 's' : '' }}
|
||||
</span>
|
||||
<span v-if="item.diff.summary.operations" class="badge badge-warning">
|
||||
{{ item.diff.summary.operations }} opération{{ item.diff.summary.operations > 1 ? 's' : '' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="item.counts.accepted < item.counts.total"
|
||||
class="badge badge-warning"
|
||||
title="Une partie des modifications a été écartée"
|
||||
>
|
||||
{{ item.counts.accepted }}/{{ item.counts.total }} retenues
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- État des tags -->
|
||||
<div class="w-44 flex-shrink-0 text-xs text-right">
|
||||
<span class="text-gray-400">{{ item.tagStats.reported }} tag{{ item.tagStats.reported > 1 ? 's' : '' }} reporté{{ item.tagStats.reported > 1 ? 's' : '' }}</span>
|
||||
<div v-if="item.tagStats.predicted" class="text-blue-300">{{ item.tagStats.predicted }} prédit{{ item.tagStats.predicted > 1 ? 's' : '' }}</div>
|
||||
<div v-if="item.tagStats.untagged" class="text-amber-300">{{ item.tagStats.untagged }} sans tag</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<span v-if="item.status === 'applique'" class="badge badge-success">
|
||||
Appliqué
|
||||
</span>
|
||||
<span v-else-if="item.status === 'echec'" class="badge badge-danger" :title="item.error">
|
||||
Échec
|
||||
</span>
|
||||
<button @click="toggleDetail(item.id)" class="btn btn-sm btn-secondary">
|
||||
{{ openDetails[item.id] ? 'Masquer' : 'Détail' }}
|
||||
</button>
|
||||
<a
|
||||
:href="`/api/documents/${item.id}/pdf`"
|
||||
target="_blank"
|
||||
class="btn btn-sm btn-ghost"
|
||||
title="Ouvrir le PDF"
|
||||
>
|
||||
PDF
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Détail : différences (cochables) + tags à confirmer -->
|
||||
<div v-if="openDetails[item.id]" class="bg-gray-950 border-t border-gray-700 px-4 py-3 space-y-4">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-gray-400">
|
||||
{{ item.counts.accepted }} modification{{ item.counts.accepted > 1 ? 's' : '' }}
|
||||
retenue{{ item.counts.accepted > 1 ? 's' : '' }} sur {{ item.counts.total }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="setAllChanges(item, true)" class="btn btn-sm btn-ghost">
|
||||
Tout retenir
|
||||
</button>
|
||||
<button @click="setAllChanges(item, false)" class="btn btn-sm btn-ghost">
|
||||
Tout écarter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ExtractionDiffDetails
|
||||
:diff="item.diff"
|
||||
:selection="item.selection"
|
||||
:server-overrides="item.serverOverrides"
|
||||
@update:selection="updateSelection(item, $event)"
|
||||
/>
|
||||
|
||||
<div v-if="item.tagsToReview.length">
|
||||
<div class="font-semibold text-amber-300 text-xs mb-2">
|
||||
Tags à confirmer ({{ item.tagStats.reported }} reporté{{ item.tagStats.reported > 1 ? 's' : '' }} automatiquement)
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="opIndex in item.tagsToReview"
|
||||
:key="opIndex"
|
||||
class="flex items-center gap-3 subcard p-2"
|
||||
>
|
||||
<div class="flex-1 min-w-0 text-xs">
|
||||
<div class="font-medium text-gray-200 truncate">
|
||||
{{ item.newData.recapitulatif_operations[opIndex].fournisseur || 'Fournisseur inconnu' }}
|
||||
</div>
|
||||
<div class="text-gray-500 truncate">
|
||||
{{ item.newData.recapitulatif_operations[opIndex].sous_categorie
|
||||
|| item.newData.recapitulatif_operations[opIndex].description || '-' }}
|
||||
· {{ formatAmount(item.newData.recapitulatif_operations[opIndex].montants?.debit) }}
|
||||
</div>
|
||||
<div v-if="item.tags[opIndex]?.origin === 'predit'" class="text-blue-400 mt-0.5">
|
||||
Prédit ({{ item.tags[opIndex].confidence }}%) — {{ item.tags[opIndex].reason }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-56 flex-shrink-0">
|
||||
<TagAutocomplete
|
||||
:model-value="item.tags[opIndex]?.tag_id ?? null"
|
||||
:options="availableTags"
|
||||
:has-prediction="item.tags[opIndex]?.origin === 'predit'"
|
||||
:is-empty="!item.tags[opIndex]?.tag_id"
|
||||
@update:modelValue="setTag(item, opIndex, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-xs text-gray-400">
|
||||
Les {{ item.tagStats.reported }} tag{{ item.tagStats.reported > 1 ? 's' : '' }} du document
|
||||
{{ item.tagStats.reported > 1 ? 'sont reportés' : 'est reporté' }} sur la nouvelle extraction.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Documents en erreur -->
|
||||
<div v-if="problemItems.length" class="card">
|
||||
<div class="card-header bg-gray-800">
|
||||
<span class="card-title">Non ré-extraits ({{ problemItems.length }})</span>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-800">
|
||||
<div v-for="item in problemItems" :key="item.id" class="flex items-center gap-3 px-4 py-2.5">
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="text-sm font-mono text-white">{{ item.reference }}</span>
|
||||
<span class="text-xs text-gray-500 ml-2">{{ formatDate(item.date) }}</span>
|
||||
</div>
|
||||
<span class="text-xs" :class="item.status === 'sans_pdf' ? 'text-gray-500' : 'text-red-400'">
|
||||
{{ item.status === 'sans_pdf' ? 'PDF non stocké' : item.error }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Documents inchangés -->
|
||||
<div v-if="identicalItems.length" class="card">
|
||||
<button
|
||||
@click="showIdentical = !showIdentical"
|
||||
class="card-header w-full bg-gray-800 text-sm text-gray-300 hover:text-white transition-colors"
|
||||
>
|
||||
<span>{{ identicalItems.length }} document{{ identicalItems.length > 1 ? 's' : '' }} inchangé{{ identicalItems.length > 1 ? 's' : '' }}</span>
|
||||
<span class="text-xs">{{ showIdentical ? 'Masquer' : 'Voir' }}</span>
|
||||
</button>
|
||||
<div v-if="showIdentical" class="divide-y divide-gray-800">
|
||||
<div v-for="item in identicalItems" :key="item.id" class="flex items-center gap-3 px-4 py-2 text-xs">
|
||||
<span class="font-mono text-gray-300">{{ item.reference }}</span>
|
||||
<span class="text-gray-500">{{ formatDate(item.date) }}</span>
|
||||
<span class="text-gray-500 truncate">{{ item.immeuble_adresse || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Barre d'application -->
|
||||
<div
|
||||
v-if="changedItems.length"
|
||||
class="fixed bottom-0 left-0 right-0 bg-gray-900 border-t border-gray-700 px-6 py-3"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="text-sm" :class="applyError && !isApplying ? 'text-red-400' : 'text-gray-400'">
|
||||
<template v-if="isApplying">
|
||||
Enregistrement {{ applyDone }} / {{ applyTotal }}...
|
||||
</template>
|
||||
<template v-else-if="applyError">
|
||||
{{ applyError }}
|
||||
</template>
|
||||
<template v-else>
|
||||
Seules les modifications retenues seront enregistrées ({{ acceptedInSelection }} sur
|
||||
{{ totalInSelection }} pour la sélection).
|
||||
</template>
|
||||
</div>
|
||||
<button
|
||||
@click="applySelection"
|
||||
:disabled="isApplying || isScanning || selectedItems.length === 0"
|
||||
class="btn btn-primary font-medium"
|
||||
>
|
||||
Appliquer la sélection ({{ selectedItems.length }})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onBeforeUnmount, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ExtractionDiffDetails from '../components/ExtractionDiffDetails.vue'
|
||||
import TagAutocomplete from '../components/TagAutocomplete.vue'
|
||||
import {
|
||||
computeExtractionDiff,
|
||||
computeServerOverrides,
|
||||
listChangeIds,
|
||||
} from '../utils/diffExtraction.js'
|
||||
import { countAccepted, mergeExtraction } from '../utils/mergeExtraction.js'
|
||||
import { fillPredictions, remapTags, toDepensesTags } from '../utils/tagRemap.js'
|
||||
|
||||
// Ordre de grandeur mesuré d'une extraction, avant toute mesure réelle.
|
||||
const SECONDS_PER_DOC = 7
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const documents = ref([])
|
||||
const items = ref([])
|
||||
const availableTags = ref([])
|
||||
const openDetails = reactive({})
|
||||
const showIdentical = ref(false)
|
||||
|
||||
const isLoading = ref(true)
|
||||
const isScanning = ref(false)
|
||||
const hasScanned = ref(false)
|
||||
const scanned = ref(0)
|
||||
const durations = ref([])
|
||||
|
||||
const isApplying = ref(false)
|
||||
const applyDone = ref(0)
|
||||
const applyTotal = ref(0)
|
||||
const applyError = ref(null)
|
||||
|
||||
let abortScan = false
|
||||
let controller = null
|
||||
|
||||
const changedItems = computed(() =>
|
||||
items.value.filter((i) => ['modifie', 'applique', 'echec'].includes(i.status))
|
||||
)
|
||||
const identicalItems = computed(() => items.value.filter((i) => i.status === 'identique'))
|
||||
const problemItems = computed(() =>
|
||||
items.value.filter((i) => ['sans_pdf', 'erreur'].includes(i.status))
|
||||
)
|
||||
const selectedItems = computed(() => changedItems.value.filter((i) => i.selected))
|
||||
const acceptedInSelection = computed(() =>
|
||||
selectedItems.value.reduce((sum, i) => sum + i.counts.accepted, 0)
|
||||
)
|
||||
const totalInSelection = computed(() =>
|
||||
selectedItems.value.reduce((sum, i) => sum + i.counts.total, 0)
|
||||
)
|
||||
const allSelected = computed(
|
||||
() => changedItems.value.length > 0 && selectedItems.value.length === changedItems.value.length
|
||||
)
|
||||
const someSelected = computed(() => selectedItems.value.length > 0)
|
||||
|
||||
const counts = computed(() => {
|
||||
const c = { modifie: 0, identique: 0, sans_pdf: 0, erreur: 0, applique: 0, echec: 0 }
|
||||
for (const i of items.value) if (i.status in c) c[i.status]++
|
||||
return c
|
||||
})
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
if (!documents.value.length) return 0
|
||||
return Math.round((scanned.value / documents.value.length) * 100)
|
||||
})
|
||||
|
||||
const remainingLabel = computed(() => {
|
||||
const left = documents.value.length - scanned.value
|
||||
if (left <= 0) return ''
|
||||
const avg = durations.value.length
|
||||
? durations.value.reduce((s, d) => s + d, 0) / durations.value.length
|
||||
: SECONDS_PER_DOC
|
||||
return `≈ ${formatDuration(left * avg)} restant`
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadDocuments(), loadTags()])
|
||||
isLoading.value = false
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
abortScan = true
|
||||
controller?.abort()
|
||||
})
|
||||
|
||||
async function loadDocuments() {
|
||||
try {
|
||||
const response = await fetch('/api/documents?limit=1000')
|
||||
if (!response.ok) throw new Error('Chargement des documents impossible')
|
||||
documents.value = await response.json()
|
||||
resetItems()
|
||||
} catch (err) {
|
||||
console.error('Erreur de chargement des documents:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTags() {
|
||||
try {
|
||||
const response = await fetch('/api/tags')
|
||||
if (response.ok) availableTags.value = await response.json()
|
||||
} catch (err) {
|
||||
console.error('Erreur de chargement des tags:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function resetItems() {
|
||||
items.value = documents.value.map((doc) => ({
|
||||
id: doc.id,
|
||||
reference: doc.reference,
|
||||
date: doc.date,
|
||||
source_file: doc.source_file,
|
||||
immeuble_adresse: doc.immeuble_adresse,
|
||||
has_pdf: doc.has_pdf,
|
||||
status: 'attente',
|
||||
diff: null,
|
||||
previousData: null,
|
||||
previousTags: [],
|
||||
newData: null,
|
||||
// État des cases du détail, par identifiant de changement.
|
||||
selection: {},
|
||||
// Changements que l'enregistrement réécrira, quel que soit le choix.
|
||||
serverOverrides: {},
|
||||
changeIds: [],
|
||||
counts: { accepted: 0, total: 0 },
|
||||
tags: {},
|
||||
tagStats: { reported: 0, predicted: 0, untagged: 0 },
|
||||
tagsToReview: [],
|
||||
selected: false,
|
||||
error: null,
|
||||
}))
|
||||
for (const key of Object.keys(openDetails)) delete openDetails[key]
|
||||
}
|
||||
|
||||
async function startScan() {
|
||||
resetItems()
|
||||
scanned.value = 0
|
||||
durations.value = []
|
||||
applyError.value = null
|
||||
abortScan = false
|
||||
isScanning.value = true
|
||||
hasScanned.value = true
|
||||
|
||||
for (const item of items.value) {
|
||||
if (abortScan) break
|
||||
await scanOne(item)
|
||||
scanned.value++
|
||||
}
|
||||
|
||||
isScanning.value = false
|
||||
}
|
||||
|
||||
async function scanOne(item) {
|
||||
if (!item.has_pdf) {
|
||||
item.status = 'sans_pdf'
|
||||
return
|
||||
}
|
||||
|
||||
const startedAt = Date.now()
|
||||
controller = new AbortController()
|
||||
try {
|
||||
const response = await fetch(`/api/documents/${item.id}/re-extract`, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
})
|
||||
const result = await response.json()
|
||||
if (!response.ok) throw new Error(result.detail || 'Échec de la ré-extraction')
|
||||
|
||||
const diff = computeExtractionDiff(result.previous_data, result.re_extracted_data)
|
||||
item.previousData = result.previous_data
|
||||
item.newData = result.re_extracted_data
|
||||
item.diff = diff
|
||||
|
||||
if (diff.summary.total === 0) {
|
||||
item.status = 'identique'
|
||||
return
|
||||
}
|
||||
|
||||
// Toutes les modifications sont retenues par défaut : le cas courant reste
|
||||
// à un clic, décocher relève de l'exception.
|
||||
item.changeIds = listChangeIds(diff)
|
||||
item.selection = {}
|
||||
item.counts = countAccepted(item.changeIds, {})
|
||||
item.serverOverrides = computeServerOverrides(
|
||||
diff,
|
||||
result.previous_data,
|
||||
result.previous_canonical
|
||||
)
|
||||
|
||||
await prepareTags(item, result)
|
||||
item.status = 'modifie'
|
||||
item.selected = true
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') return
|
||||
console.error(`Ré-extraction du document ${item.id}:`, err)
|
||||
item.status = 'erreur'
|
||||
item.error = err.message || 'Erreur inconnue'
|
||||
} finally {
|
||||
durations.value.push((Date.now() - startedAt) / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
// Reporte les tags actuels sur la nouvelle extraction, puis complète par
|
||||
// prédiction ce qui n'a pas pu être réapparié.
|
||||
async function prepareTags(item, result) {
|
||||
const previousOps = result.previous_data?.recapitulatif_operations || []
|
||||
const newOps = result.re_extracted_data?.recapitulatif_operations || []
|
||||
const { tags, reported, missing } = remapTags(previousOps, result.depenses_tags, newOps)
|
||||
|
||||
const predicted = await fillPredictions(tags, newOps, missing)
|
||||
|
||||
item.previousTags = result.depenses_tags || []
|
||||
item.tags = tags
|
||||
item.tagStats = {
|
||||
reported,
|
||||
predicted,
|
||||
untagged: missing.length - predicted,
|
||||
}
|
||||
// Seules les opérations non reportées à l'identique demandent une confirmation.
|
||||
item.tagsToReview = missing
|
||||
}
|
||||
|
||||
// Les tags ont été choisis sur la liste d'opérations ré-extraite ; écarter un
|
||||
// ajout ou refuser une suppression change cette liste. On les reporte donc une
|
||||
// dernière fois, par signature, sur les opérations effectivement enregistrées.
|
||||
function finalTags(item, mergedData) {
|
||||
const newOps = item.newData.recapitulatif_operations || []
|
||||
const previousOps = item.previousData?.recapitulatif_operations || []
|
||||
const finalOps = mergedData.recapitulatif_operations || []
|
||||
|
||||
const chosen = Object.entries(item.tags).map(([index, t]) => ({
|
||||
index: Number(index),
|
||||
tag_id: t.tag_id,
|
||||
tag_nom: t.tag_nom,
|
||||
}))
|
||||
const { tags, missing } = remapTags(newOps, chosen, finalOps)
|
||||
|
||||
// Une opération dont on a refusé la suppression revient de l'ancienne
|
||||
// extraction : son tag aussi, il n'est pas dans les choix ci-dessus.
|
||||
if (missing.length) {
|
||||
const { tags: restored } = remapTags(previousOps, item.previousTags, finalOps)
|
||||
for (const i of missing) if (restored[i]) tags[i] = restored[i]
|
||||
}
|
||||
|
||||
return toDepensesTags(tags)
|
||||
}
|
||||
|
||||
function setTag(item, opIndex, tagId) {
|
||||
if (tagId == null) {
|
||||
delete item.tags[opIndex]
|
||||
} else {
|
||||
const tag = availableTags.value.find((t) => t.id === tagId)
|
||||
item.tags[opIndex] = { tag_id: tagId, tag_nom: tag?.nom || null, origin: 'manuel' }
|
||||
}
|
||||
item.tags = { ...item.tags }
|
||||
refreshTagStats(item)
|
||||
}
|
||||
|
||||
function refreshTagStats(item) {
|
||||
let predicted = 0
|
||||
for (const opIndex of item.tagsToReview) {
|
||||
if (item.tags[opIndex]?.tag_id != null) predicted++
|
||||
}
|
||||
item.tagStats = {
|
||||
...item.tagStats,
|
||||
predicted,
|
||||
untagged: item.tagsToReview.length - predicted,
|
||||
}
|
||||
}
|
||||
|
||||
function updateSelection(item, selection) {
|
||||
item.selection = selection
|
||||
item.counts = countAccepted(item.changeIds, selection)
|
||||
// Plus rien à retenir : réenregistrer le document n'aurait aucun effet.
|
||||
if (item.counts.accepted === 0) item.selected = false
|
||||
}
|
||||
|
||||
function setAllChanges(item, accepted) {
|
||||
const selection = {}
|
||||
for (const id of item.changeIds) {
|
||||
// Une valeur saisie à la main est conservée si le changement reste retenu.
|
||||
selection[id] = { ...(item.selection[id] || {}), accepted }
|
||||
}
|
||||
updateSelection(item, selection)
|
||||
}
|
||||
|
||||
function stopScan() {
|
||||
abortScan = true
|
||||
controller?.abort()
|
||||
isScanning.value = false
|
||||
}
|
||||
|
||||
function toggleAll(event) {
|
||||
const value = event.target.checked
|
||||
for (const item of changedItems.value) item.selected = value
|
||||
}
|
||||
|
||||
function toggleDetail(id) {
|
||||
openDetails[id] = !openDetails[id]
|
||||
}
|
||||
|
||||
async function applySelection() {
|
||||
const targets = selectedItems.value
|
||||
if (!targets.length) return
|
||||
|
||||
const confirmed = confirm(
|
||||
`Réenregistrer ${targets.length} document${targets.length > 1 ? 's' : ''} avec la nouvelle extraction ?\n\n` +
|
||||
'Les données actuelles de ces documents seront remplacées.'
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
isApplying.value = true
|
||||
applyError.value = null
|
||||
applyDone.value = 0
|
||||
applyTotal.value = targets.length
|
||||
|
||||
let failures = 0
|
||||
for (const item of targets) {
|
||||
try {
|
||||
const data = mergeExtraction(item.previousData, item.newData, item.diff, item.selection)
|
||||
const response = await fetch(`/api/documents/${item.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
source_file: item.source_file,
|
||||
data,
|
||||
depenses_tags: finalTags(item, data),
|
||||
}),
|
||||
})
|
||||
const result = await response.json()
|
||||
if (!response.ok) throw new Error(result.detail || 'Échec de l\'enregistrement')
|
||||
if (!result.success) throw new Error(result.message || 'Échec de l\'enregistrement')
|
||||
|
||||
item.status = 'applique'
|
||||
item.selected = false
|
||||
} catch (err) {
|
||||
console.error(`Enregistrement du document ${item.id}:`, err)
|
||||
item.status = 'echec'
|
||||
item.error = err.message || 'Erreur inconnue'
|
||||
failures++
|
||||
} finally {
|
||||
applyDone.value++
|
||||
}
|
||||
}
|
||||
|
||||
isApplying.value = false
|
||||
if (failures) {
|
||||
// On reste sur la page : les lignes en échec portent leur message.
|
||||
applyError.value = `${failures} document${failures > 1 ? 's' : ''} n'${failures > 1 ? 'ont' : 'a'} pas pu être enregistré${failures > 1 ? 's' : ''}`
|
||||
return
|
||||
}
|
||||
router.push('/documents')
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
const [year, month, day] = dateStr.split('-')
|
||||
return `${day}/${month}/${year}`
|
||||
}
|
||||
|
||||
function formatAmount(amount) {
|
||||
if (amount == null) return '-'
|
||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(amount)
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const total = Math.max(0, Math.round(seconds))
|
||||
if (total < 60) return `${total} s`
|
||||
const minutes = Math.round(total / 60)
|
||||
return `${minutes} min`
|
||||
}
|
||||
</script>
|
||||
@@ -5,6 +5,7 @@ import AnalyticsPage from './pages/AnalyticsPage.vue'
|
||||
import RevenusPage from './pages/RevenusPage.vue'
|
||||
import DocumentsPage from './pages/DocumentsPage.vue'
|
||||
import EditDocumentPage from './pages/EditDocumentPage.vue'
|
||||
import ReExtractionPage from './pages/ReExtractionPage.vue'
|
||||
import IAPage from './pages/IAPage.vue'
|
||||
import ConfigPage from './pages/ConfigPage.vue'
|
||||
|
||||
@@ -48,6 +49,11 @@ const routes = [
|
||||
path: '/documents/:id/edit',
|
||||
name: 'edit-document',
|
||||
component: EditDocumentPage
|
||||
},
|
||||
{
|
||||
path: '/re-extraction',
|
||||
name: 're-extraction',
|
||||
component: ReExtractionPage
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
// Comparaison de deux jeux de données d'extraction (avant / après re-extraction).
|
||||
//
|
||||
// Chaque jeu a la forme { metadata, situation_locataires, recapitulatif_operations }.
|
||||
// Retourne un résumé structuré des différences, destiné à la fois à l'affichage
|
||||
// (panneau de diff) et à la mise en évidence inline (anneaux « modifié »).
|
||||
// Retourne un résumé structuré des différences, destiné à trois usages :
|
||||
// - l'affichage (panneau de diff) via `label` / `before` / `after`, déjà formatés ;
|
||||
// - la mise en évidence inline (anneaux « modifié ») via les listes `changed*` ;
|
||||
// - l'application sélective (cf. mergeExtraction.js) via `id`, `path` et les
|
||||
// valeurs brutes `beforeValue` / `afterValue`.
|
||||
//
|
||||
// Maille des changements sélectionnables : un champ pour ce qui est textuel
|
||||
// (nom, numéro de lot, métadonnées), un bloc pour les montants et lignes d'un
|
||||
// locataire — les retenir séparément produirait des totaux qui ne somment plus.
|
||||
|
||||
const EPS = 0.005
|
||||
|
||||
@@ -49,6 +56,27 @@ const METADATA_FIELDS = [
|
||||
['solde.date_arrete', 'Solde · date arrêté'],
|
||||
]
|
||||
|
||||
// Champs identitaires d'un locataire : du texte, sans lien arithmétique avec le
|
||||
// reste. Chacun se retient ou se refuse indépendamment.
|
||||
const LOCATAIRE_IDENTITE = [
|
||||
['locataire.nom', 'Nom'],
|
||||
['lot.numero', 'Lot'],
|
||||
['lot.type', 'Type'],
|
||||
]
|
||||
|
||||
// Champs chiffrés d'un locataire : liés entre eux (total = somme des postes,
|
||||
// impayés = total − réglés), donc regroupés en un seul changement.
|
||||
const LOCATAIRE_MONTANTS = [
|
||||
['totaux.loyers', 'Total loyers'],
|
||||
['totaux.taxes', 'Total taxes'],
|
||||
['totaux.provisions', 'Total provisions'],
|
||||
['totaux.divers', 'Total divers'],
|
||||
['totaux.solde_anterieur', 'Solde antérieur'],
|
||||
['totaux.total', 'Total'],
|
||||
['totaux.regles', 'Réglés'],
|
||||
['totaux.impayes', 'Impayés'],
|
||||
]
|
||||
|
||||
function ligneSig(l) {
|
||||
if (!l) return '∅'
|
||||
const p = l.periode || {}
|
||||
@@ -77,30 +105,49 @@ function diffLignes(before, after) {
|
||||
return changes
|
||||
}
|
||||
|
||||
function diffLocataire(before, after) {
|
||||
const fields = []
|
||||
const scalar = [
|
||||
['locataire.nom', 'Nom'],
|
||||
['lot.numero', 'Lot'],
|
||||
['lot.type', 'Type'],
|
||||
['totaux.loyers', 'Total loyers'],
|
||||
['totaux.taxes', 'Total taxes'],
|
||||
['totaux.provisions', 'Total provisions'],
|
||||
['totaux.divers', 'Total divers'],
|
||||
['totaux.solde_anterieur', 'Solde antérieur'],
|
||||
['totaux.total', 'Total'],
|
||||
['totaux.regles', 'Réglés'],
|
||||
['totaux.impayes', 'Impayés'],
|
||||
]
|
||||
for (const [path, label] of scalar) {
|
||||
// Changements d'un locataire modifié : un par champ identitaire, plus un bloc
|
||||
// unique pour l'ensemble des montants et des lignes.
|
||||
function diffLocataire(index, before, after) {
|
||||
const changes = []
|
||||
|
||||
for (const [path, label] of LOCATAIRE_IDENTITE) {
|
||||
const bv = get(before, path)
|
||||
const av = get(after, path)
|
||||
if (!valEq(bv, av)) {
|
||||
fields.push({ label, before: fmtValue(bv), after: fmtValue(av) })
|
||||
changes.push({
|
||||
id: `loc:${index}:${path}`,
|
||||
kind: 'champ',
|
||||
path,
|
||||
label,
|
||||
before: fmtValue(bv),
|
||||
after: fmtValue(av),
|
||||
beforeValue: bv,
|
||||
afterValue: av,
|
||||
editable: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
fields.push(...diffLignes(before?.lignes, after?.lignes))
|
||||
return fields
|
||||
|
||||
const montants = []
|
||||
for (const [path, label] of LOCATAIRE_MONTANTS) {
|
||||
const bv = get(before, path)
|
||||
const av = get(after, path)
|
||||
if (!valEq(bv, av)) {
|
||||
montants.push({ label, before: fmtValue(bv), after: fmtValue(av) })
|
||||
}
|
||||
}
|
||||
montants.push(...diffLignes(before?.lignes, after?.lignes))
|
||||
|
||||
if (montants.length) {
|
||||
changes.push({
|
||||
id: `loc:${index}:montants`,
|
||||
kind: 'montants',
|
||||
label: 'Montants et lignes',
|
||||
fields: montants,
|
||||
})
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
function locataireTitle(loc) {
|
||||
@@ -110,7 +157,9 @@ function locataireTitle(loc) {
|
||||
return `${lot} — ${nom}`
|
||||
}
|
||||
|
||||
function opSig(op) {
|
||||
// Signature d'une opération : sert au diff (appariement par contenu) comme au
|
||||
// report des tags d'une extraction à l'autre.
|
||||
export function opSig(op) {
|
||||
if (!op) return '∅'
|
||||
const m = op.montants || {}
|
||||
return (
|
||||
@@ -132,7 +181,17 @@ export function computeExtractionDiff(before, after) {
|
||||
const bv = get(b.metadata, path)
|
||||
const av = get(a.metadata, path)
|
||||
if (!valEq(bv, av)) {
|
||||
metadata.push({ label, before: fmtValue(bv), after: fmtValue(av) })
|
||||
metadata.push({
|
||||
id: `meta:${path}`,
|
||||
kind: 'champ',
|
||||
path,
|
||||
label,
|
||||
before: fmtValue(bv),
|
||||
after: fmtValue(av),
|
||||
beforeValue: bv,
|
||||
afterValue: av,
|
||||
editable: true,
|
||||
})
|
||||
changedMetadataPaths.push(path)
|
||||
}
|
||||
}
|
||||
@@ -147,14 +206,26 @@ export function computeExtractionDiff(before, after) {
|
||||
const ob = i < lb.length ? lb[i] : null
|
||||
const oa = i < la.length ? la[i] : null
|
||||
if (!ob && oa) {
|
||||
locataires.push({ index: i, kind: 'added', title: locataireTitle(oa), fields: [] })
|
||||
locataires.push({
|
||||
id: `loc:${i}:entier`,
|
||||
index: i,
|
||||
kind: 'added',
|
||||
title: locataireTitle(oa),
|
||||
changes: [],
|
||||
})
|
||||
changedLocataireIndices.push(i)
|
||||
} else if (ob && !oa) {
|
||||
locataires.push({ index: i, kind: 'removed', title: locataireTitle(ob), fields: [] })
|
||||
locataires.push({
|
||||
id: `loc:${i}:entier`,
|
||||
index: i,
|
||||
kind: 'removed',
|
||||
title: locataireTitle(ob),
|
||||
changes: [],
|
||||
})
|
||||
} else {
|
||||
const fields = diffLocataire(ob, oa)
|
||||
if (fields.length) {
|
||||
locataires.push({ index: i, kind: 'modified', title: locataireTitle(oa), fields })
|
||||
const changes = diffLocataire(i, ob, oa)
|
||||
if (changes.length) {
|
||||
locataires.push({ index: i, kind: 'modified', title: locataireTitle(oa), changes })
|
||||
changedLocataireIndices.push(i)
|
||||
}
|
||||
}
|
||||
@@ -182,17 +253,32 @@ export function computeExtractionDiff(before, after) {
|
||||
oldCounts.set(s, remaining - 1) // appariée avec une ancienne identique
|
||||
} else {
|
||||
changedOperationIndices.push(i)
|
||||
operations.push({ index: i, kind: 'added', before: '∅', after: fmtValue(s) })
|
||||
operations.push({
|
||||
id: `op:added:${i}`,
|
||||
index: i,
|
||||
oldIndex: -1,
|
||||
kind: 'added',
|
||||
before: '∅',
|
||||
after: fmtValue(s),
|
||||
})
|
||||
}
|
||||
newCounts.set(s, (newCounts.get(s) || 0) + 1)
|
||||
})
|
||||
// Anciennes opérations absentes de la nouvelle extraction -> supprimées.
|
||||
// `oldIndex` permet de les réinsérer à leur place si la suppression est refusée.
|
||||
const seen = new Map()
|
||||
ob.forEach((op) => {
|
||||
ob.forEach((op, i) => {
|
||||
const s = opSig(op)
|
||||
seen.set(s, (seen.get(s) || 0) + 1)
|
||||
if ((newCounts.get(s) || 0) < seen.get(s)) {
|
||||
operations.push({ index: -1, kind: 'removed', before: fmtValue(s), after: '∅' })
|
||||
operations.push({
|
||||
id: `op:removed:${i}`,
|
||||
index: -1,
|
||||
oldIndex: i,
|
||||
kind: 'removed',
|
||||
before: fmtValue(s),
|
||||
after: '∅',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -213,3 +299,79 @@ export function computeExtractionDiff(before, after) {
|
||||
summary,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Repère les changements que le serveur imposera même si on les refuse.
|
||||
*
|
||||
* Refuser un changement, c'est réenvoyer la valeur actuelle du document. Or
|
||||
* l'enregistrement réécrit certaines valeurs sous une forme canonique : si la
|
||||
* valeur actuelle n'est pas déjà canonique, la refuser ne changera rien.
|
||||
*
|
||||
* On le déduit en comparant les données actuelles à leur forme canonique, toutes
|
||||
* deux fournies par le serveur : aucune règle de réécriture n'est connue ici, et
|
||||
* une transformation ajoutée plus tard remonte sans modification de ce code.
|
||||
*
|
||||
* @returns {Object} identifiant de changement → valeur que le serveur écrira
|
||||
*/
|
||||
export function computeServerOverrides(diff, previous, canonical) {
|
||||
const overrides = {}
|
||||
if (!previous || !canonical) return overrides
|
||||
|
||||
const differs = (a, b) => JSON.stringify(a ?? null) !== JSON.stringify(b ?? null)
|
||||
|
||||
for (const change of diff.metadata) {
|
||||
const actuel = get(previous.metadata, change.path)
|
||||
const impose = get(canonical.metadata, change.path)
|
||||
if (differs(actuel, impose)) overrides[change.id] = impose
|
||||
}
|
||||
|
||||
const prevLoc = previous.situation_locataires || []
|
||||
const canonLoc = canonical.situation_locataires || []
|
||||
for (const loc of diff.locataires) {
|
||||
const actuel = prevLoc[loc.index]
|
||||
const impose = canonLoc[loc.index]
|
||||
if (actuel === undefined) continue // locataire ajouté : rien à conserver
|
||||
|
||||
if (loc.kind !== 'modified') {
|
||||
// Refuser, c'est conserver le locataire entier tel qu'il est en base.
|
||||
if (differs(actuel, impose)) overrides[loc.id] = impose
|
||||
continue
|
||||
}
|
||||
for (const change of loc.changes) {
|
||||
if (change.kind === 'montants') {
|
||||
if (differs(actuel?.totaux, impose?.totaux) || differs(actuel?.lignes, impose?.lignes)) {
|
||||
overrides[change.id] = { totaux: impose?.totaux, lignes: impose?.lignes }
|
||||
}
|
||||
} else {
|
||||
const a = get(actuel, change.path)
|
||||
const b = get(impose, change.path)
|
||||
if (differs(a, b)) overrides[change.id] = b
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const prevOps = previous.recapitulatif_operations || []
|
||||
const canonOps = canonical.recapitulatif_operations || []
|
||||
for (const op of diff.operations) {
|
||||
if (op.kind !== 'removed') continue // un ajout refusé n'est pas enregistré
|
||||
if (differs(prevOps[op.oldIndex], canonOps[op.oldIndex])) {
|
||||
overrides[op.id] = canonOps[op.oldIndex]
|
||||
}
|
||||
}
|
||||
|
||||
return overrides
|
||||
}
|
||||
|
||||
// Identifiants de tous les changements sélectionnables, dans l'ordre d'affichage.
|
||||
// Un locataire ajouté ou supprimé compte pour un ; un locataire modifié expose
|
||||
// une entrée par champ identitaire, plus une pour ses montants.
|
||||
export function listChangeIds(diff) {
|
||||
const ids = []
|
||||
for (const m of diff.metadata) ids.push(m.id)
|
||||
for (const loc of diff.locataires) {
|
||||
if (loc.kind === 'modified') ids.push(...loc.changes.map((c) => c.id))
|
||||
else ids.push(loc.id)
|
||||
}
|
||||
for (const op of diff.operations) ids.push(op.id)
|
||||
return ids
|
||||
}
|
||||
|
||||
133
frontend/src/utils/mergeExtraction.js
Normal file
133
frontend/src/utils/mergeExtraction.js
Normal file
@@ -0,0 +1,133 @@
|
||||
// Fusion sélective de deux extractions.
|
||||
//
|
||||
// Le balayage de ré-extraction n'impose pas de tout prendre ou tout laisser :
|
||||
// l'utilisateur retient les changements un par un. Le document enregistré est
|
||||
// donc construit ici, en partant des données actuelles et en n'y appliquant que
|
||||
// les changements retenus — éventuellement avec une valeur saisie à la main.
|
||||
//
|
||||
// `selection` associe l'identifiant d'un changement (cf. diffExtraction.js) à
|
||||
// { accepted, value } ; une entrée absente vaut « retenu », pour que le cas
|
||||
// courant (tout accepter) ne demande aucune initialisation.
|
||||
//
|
||||
// Limite connue : le backend normalise les numéros de lot de tout enregistrement
|
||||
// (save_document → normalize_extraction_lots). Refuser une réécriture de forme
|
||||
// « 0001 » → « 01 » produit bien « 0001 » ici, mais la base portera « 01 ».
|
||||
// Choix assumé : mieux vaut voir toutes les différences et pouvoir se prononcer,
|
||||
// même si ce champ-là revient normalisé.
|
||||
|
||||
function clone(value) {
|
||||
return value == null ? value : JSON.parse(JSON.stringify(value))
|
||||
}
|
||||
|
||||
function isAccepted(selection, id) {
|
||||
const state = selection?.[id]
|
||||
return state ? state.accepted !== false : true
|
||||
}
|
||||
|
||||
// Valeur à écrire pour un changement retenu : celle saisie à la main si elle
|
||||
// existe, sinon celle proposée par la nouvelle extraction.
|
||||
function chosenValue(selection, change) {
|
||||
const state = selection?.[change.id]
|
||||
if (state && state.value !== undefined) return state.value
|
||||
return change.afterValue
|
||||
}
|
||||
|
||||
function setPath(obj, path, value) {
|
||||
const keys = path.split('.')
|
||||
let target = obj
|
||||
for (const key of keys.slice(0, -1)) {
|
||||
if (target[key] == null || typeof target[key] !== 'object') target[key] = {}
|
||||
target = target[key]
|
||||
}
|
||||
target[keys.at(-1)] = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit les données à enregistrer.
|
||||
*
|
||||
* @param {Object} previous données actuellement en base
|
||||
* @param {Object} next données ré-extraites
|
||||
* @param {Object} diff sortie de computeExtractionDiff(previous, next)
|
||||
* @param {Object} selection état des cases, par identifiant de changement
|
||||
*/
|
||||
export function mergeExtraction(previous, next, diff, selection = {}) {
|
||||
const merged = clone(previous) || {}
|
||||
const nextData = next || {}
|
||||
|
||||
// --- Métadonnées : un champ retenu écrase le champ correspondant.
|
||||
if (!merged.metadata) merged.metadata = {}
|
||||
for (const change of diff.metadata) {
|
||||
if (isAccepted(selection, change.id)) {
|
||||
setPath(merged.metadata, change.path, chosenValue(selection, change))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Locataires : alignés par index, comme dans le diff.
|
||||
const previousLoc = previous?.situation_locataires || []
|
||||
const nextLoc = nextData.situation_locataires || []
|
||||
const byIndex = new Map(diff.locataires.map((loc) => [loc.index, loc]))
|
||||
const locataires = []
|
||||
for (let i = 0; i < Math.max(previousLoc.length, nextLoc.length); i++) {
|
||||
const entry = byIndex.get(i)
|
||||
|
||||
// Locataire inchangé : l'ancienne et la nouvelle version sont équivalentes.
|
||||
if (!entry) {
|
||||
const kept = i < previousLoc.length ? previousLoc[i] : nextLoc[i]
|
||||
if (kept) locataires.push(clone(kept))
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.kind === 'added') {
|
||||
if (isAccepted(selection, entry.id)) locataires.push(clone(nextLoc[i]))
|
||||
continue
|
||||
}
|
||||
if (entry.kind === 'removed') {
|
||||
// Refuser la suppression, c'est conserver le locataire actuel.
|
||||
if (!isAccepted(selection, entry.id)) locataires.push(clone(previousLoc[i]))
|
||||
continue
|
||||
}
|
||||
|
||||
const kept = clone(previousLoc[i])
|
||||
for (const change of entry.changes) {
|
||||
if (!isAccepted(selection, change.id)) continue
|
||||
if (change.kind === 'montants') {
|
||||
// Les chiffres d'un locataire viennent en bloc du même parser.
|
||||
kept.totaux = clone(nextLoc[i]?.totaux)
|
||||
kept.lignes = clone(nextLoc[i]?.lignes)
|
||||
} else {
|
||||
setPath(kept, change.path, chosenValue(selection, change))
|
||||
}
|
||||
}
|
||||
locataires.push(kept)
|
||||
}
|
||||
merged.situation_locataires = locataires
|
||||
|
||||
// --- Opérations : on part de la nouvelle liste, dont on retire les ajouts
|
||||
// refusés, puis on y réinsère les suppressions refusées à leur place d'origine.
|
||||
const previousOps = previous?.recapitulatif_operations || []
|
||||
const refusedAdded = new Set(
|
||||
diff.operations
|
||||
.filter((op) => op.kind === 'added' && !isAccepted(selection, op.id))
|
||||
.map((op) => op.index)
|
||||
)
|
||||
const operations = []
|
||||
;(nextData.recapitulatif_operations || []).forEach((op, i) => {
|
||||
if (!refusedAdded.has(i)) operations.push(clone(op))
|
||||
})
|
||||
const restored = diff.operations
|
||||
.filter((op) => op.kind === 'removed' && !isAccepted(selection, op.id))
|
||||
.sort((x, y) => x.oldIndex - y.oldIndex)
|
||||
for (const op of restored) {
|
||||
operations.splice(Math.min(op.oldIndex, operations.length), 0, clone(previousOps[op.oldIndex]))
|
||||
}
|
||||
merged.recapitulatif_operations = operations
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
// Nombre de changements retenus sur le total, pour l'affichage.
|
||||
export function countAccepted(changeIds, selection = {}) {
|
||||
let accepted = 0
|
||||
for (const id of changeIds) if (isAccepted(selection, id)) accepted++
|
||||
return { accepted, total: changeIds.length }
|
||||
}
|
||||
14
frontend/src/utils/solde.js
Normal file
14
frontend/src/utils/solde.js
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Le type de solde arrive sous deux formes selon le document : « créditeur »
|
||||
* accentué quand le parser l'a lu dans le PDF, « crediteur » sans accent quand
|
||||
* il retombe sur sa valeur par défaut (voir parsers/metadata.py). On compare
|
||||
* donc sur une forme normalisée, sans accents ni casse.
|
||||
*/
|
||||
export function isSoldeCrediteur(soldeType) {
|
||||
if (!soldeType) return false
|
||||
const normalise = soldeType
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '') // retire les diacritiques combinants
|
||||
.toLowerCase()
|
||||
return normalise === 'crediteur'
|
||||
}
|
||||
95
frontend/src/utils/tagRemap.js
Normal file
95
frontend/src/utils/tagRemap.js
Normal file
@@ -0,0 +1,95 @@
|
||||
// Report des tags de dépenses d'une extraction sur la suivante.
|
||||
//
|
||||
// Les tags sont stockés par *index* d'opération. Une ré-extraction peut ajouter,
|
||||
// retirer ou réordonner des opérations : reporter les tags par index décalerait
|
||||
// alors silencieusement les catégories. On les réapparie donc par signature
|
||||
// d'opération (fournisseur, description, montants), et ce qui ne se réapparie
|
||||
// pas est signalé plutôt que deviné.
|
||||
|
||||
import { opSig } from './diffExtraction.js'
|
||||
|
||||
/**
|
||||
* Réapparie les tags de l'ancienne extraction sur la nouvelle.
|
||||
*
|
||||
* @param {Array} previousOps opérations de l'extraction en base
|
||||
* @param {Array} tags tags actuels, [{ index, tag_id, tag_nom }]
|
||||
* @param {Array} newOps opérations de la nouvelle extraction
|
||||
* @returns {{ tags: Object, reported: number, missing: number[] }}
|
||||
* `tags` associe l'index d'une nouvelle opération à { tag_id, tag_nom, origin },
|
||||
* `missing` liste les index des nouvelles opérations restées sans tag.
|
||||
*/
|
||||
export function remapTags(previousOps, tags, newOps) {
|
||||
const previous = previousOps || []
|
||||
const next = newOps || []
|
||||
|
||||
// Signature -> file des tags disponibles (plusieurs opérations peuvent
|
||||
// partager la même signature, on les consomme dans l'ordre).
|
||||
const available = new Map()
|
||||
for (const t of tags || []) {
|
||||
const op = previous[t.index]
|
||||
if (!op) continue
|
||||
const sig = opSig(op)
|
||||
if (!available.has(sig)) available.set(sig, [])
|
||||
available.get(sig).push(t)
|
||||
}
|
||||
|
||||
const remapped = {}
|
||||
const missing = []
|
||||
next.forEach((op, i) => {
|
||||
const queue = available.get(opSig(op))
|
||||
const t = queue && queue.length ? queue.shift() : null
|
||||
if (t) {
|
||||
remapped[i] = { tag_id: t.tag_id, tag_nom: t.tag_nom, origin: 'reporte' }
|
||||
} else {
|
||||
missing.push(i)
|
||||
}
|
||||
})
|
||||
|
||||
return { tags: remapped, reported: Object.keys(remapped).length, missing }
|
||||
}
|
||||
|
||||
/**
|
||||
* Complète les opérations sans tag par une prédiction du backend.
|
||||
*
|
||||
* Modifie `remapped` en place et retourne le nombre de tags prédits. Une panne
|
||||
* de prédiction n'est pas bloquante : les opérations restent simplement à taguer.
|
||||
*/
|
||||
export async function fillPredictions(remapped, newOps, missing) {
|
||||
if (!missing.length) return 0
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/predict-tags', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ depenses: missing.map((i) => newOps[i]) }),
|
||||
})
|
||||
if (!response.ok) return 0
|
||||
|
||||
const { predictions } = await response.json()
|
||||
let predicted = 0
|
||||
for (const p of predictions || []) {
|
||||
if (p.tag_id == null) continue
|
||||
const opIndex = missing[p.index]
|
||||
if (opIndex == null) continue
|
||||
remapped[opIndex] = {
|
||||
tag_id: p.tag_id,
|
||||
tag_nom: p.tag_name || null,
|
||||
origin: 'predit',
|
||||
confidence: p.confidence,
|
||||
reason: p.reason,
|
||||
}
|
||||
predicted++
|
||||
}
|
||||
return predicted
|
||||
} catch (err) {
|
||||
console.error('Prédiction de tags indisponible:', err)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// Format attendu par l'API de sauvegarde : [{ index, tag_id }].
|
||||
export function toDepensesTags(remapped) {
|
||||
return Object.entries(remapped)
|
||||
.filter(([, t]) => t && t.tag_id != null)
|
||||
.map(([index, t]) => ({ index: Number(index), tag_id: t.tag_id }))
|
||||
}
|
||||
232
frontend/tests/mergeExtraction.test.js
Normal file
232
frontend/tests/mergeExtraction.test.js
Normal file
@@ -0,0 +1,232 @@
|
||||
// La fusion décide de ce qui est écrit en base : elle est testée pièce par pièce.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
computeExtractionDiff,
|
||||
computeServerOverrides,
|
||||
listChangeIds,
|
||||
} from '../src/utils/diffExtraction.js'
|
||||
import { countAccepted, mergeExtraction } from '../src/utils/mergeExtraction.js'
|
||||
|
||||
// Données en base, inspirées d'un compte rendu réel.
|
||||
const previous = {
|
||||
metadata: {
|
||||
document: { reference: '33680000', date: '2026-06-22' },
|
||||
immeuble: { code: '33689020', adresse: '4 RUE SERVIENT' },
|
||||
solde: { montant: 11214.51, type: 'crediteur' },
|
||||
},
|
||||
situation_locataires: [
|
||||
{
|
||||
lot: { numero: '07', type: 'Appartement' },
|
||||
locataire: { nom: 'LATAPY NINA' },
|
||||
totaux: { total: 1200, regles: 1200, impayes: 0 },
|
||||
lignes: [{ type: 'loyer', loyers: 500, total: 500 }],
|
||||
},
|
||||
{
|
||||
lot: { numero: '08', type: 'Appartement' },
|
||||
locataire: { nom: 'BESSON Léa' },
|
||||
totaux: { total: 800, regles: 800, impayes: 0 },
|
||||
lignes: [{ type: 'loyer', loyers: 800, total: 800 }],
|
||||
},
|
||||
],
|
||||
recapitulatif_operations: [
|
||||
{ categorie: 'DL', fournisseur: 'PPR', description: 'entretien', montants: { debit: 80 } },
|
||||
{ categorie: 'DL', fournisseur: 'TOTAL', description: 'elec', montants: { debit: 120 } },
|
||||
],
|
||||
}
|
||||
|
||||
// Nouvelle extraction : rattache le premier locataire à un autre lot, agrège un
|
||||
// libellé dans son nom (régression du parser), change ses montants, ajoute une
|
||||
// opération et en perd une autre.
|
||||
// Les numéros de lot arrivent des deux côtés sous leur forme normalisée : le
|
||||
// backend les normalise avant de renvoyer les données à comparer.
|
||||
const next = {
|
||||
metadata: {
|
||||
document: { reference: '33680000', date: '2026-06-22' },
|
||||
immeuble: { code: '33689020', adresse: '4 R. SERVIENT' },
|
||||
solde: { montant: 11214.51, type: 'crediteur' },
|
||||
},
|
||||
situation_locataires: [
|
||||
{
|
||||
lot: { numero: '12', type: 'Appartement' },
|
||||
locataire: { nom: 'LATAPY NINA Complément loyer' },
|
||||
totaux: { total: 1250, regles: 1200, impayes: 50 },
|
||||
lignes: [
|
||||
{ type: 'loyer', loyers: 500, total: 500 },
|
||||
{ type: 'complement', loyers: 50, total: 50 },
|
||||
],
|
||||
},
|
||||
{
|
||||
lot: { numero: '08', type: 'Appartement' },
|
||||
locataire: { nom: 'BESSON Léa' },
|
||||
totaux: { total: 800, regles: 800, impayes: 0 },
|
||||
lignes: [{ type: 'loyer', loyers: 800, total: 800 }],
|
||||
},
|
||||
],
|
||||
recapitulatif_operations: [
|
||||
{ categorie: 'DL', fournisseur: 'PPR', description: 'entretien', montants: { debit: 80 } },
|
||||
{ categorie: 'DL', fournisseur: 'NOUVEAU', description: 'ajout', montants: { debit: 42 } },
|
||||
],
|
||||
}
|
||||
|
||||
const diff = computeExtractionDiff(previous, next)
|
||||
const merge = (selection) => mergeExtraction(previous, next, diff, selection)
|
||||
|
||||
describe('découpage du diff', () => {
|
||||
it('sépare les champs identitaires du bloc de montants', () => {
|
||||
const latapy = diff.locataires.find((l) => l.index === 0)
|
||||
expect(latapy.changes.map((c) => c.id)).toEqual([
|
||||
'loc:0:locataire.nom',
|
||||
'loc:0:lot.numero',
|
||||
'loc:0:montants',
|
||||
])
|
||||
// Le bloc de montants porte le détail, mais reste un seul changement.
|
||||
const montants = latapy.changes.at(-1)
|
||||
expect(montants.kind).toBe('montants')
|
||||
expect(montants.fields.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('retient l’index d’origine des opérations supprimées', () => {
|
||||
const removed = diff.operations.find((o) => o.kind === 'removed')
|
||||
expect(removed.oldIndex).toBe(1) // TOTAL / elec
|
||||
})
|
||||
})
|
||||
|
||||
describe('fusion sélective', () => {
|
||||
it('sans sélection, reproduit la nouvelle extraction', () => {
|
||||
expect(merge({})).toEqual(next)
|
||||
})
|
||||
|
||||
it('refuse un nom tout en gardant la correction du numéro de lot', () => {
|
||||
// Le cas qui motive la maille fine : la régression sur le nom est écartée,
|
||||
// la correction du lot est conservée, sur le même locataire.
|
||||
const merged = merge({ 'loc:0:locataire.nom': { accepted: false } })
|
||||
const latapy = merged.situation_locataires[0]
|
||||
|
||||
expect(latapy.locataire.nom).toBe('LATAPY NINA')
|
||||
expect(latapy.lot.numero).toBe('12')
|
||||
expect(latapy.totaux.total).toBe(1250) // montants acceptés
|
||||
})
|
||||
|
||||
it('garde les anciens montants et lignes quand le bloc est refusé', () => {
|
||||
const merged = merge({ 'loc:0:montants': { accepted: false } })
|
||||
const latapy = merged.situation_locataires[0]
|
||||
|
||||
expect(latapy.totaux).toEqual(previous.situation_locataires[0].totaux)
|
||||
expect(latapy.lignes).toEqual(previous.situation_locataires[0].lignes)
|
||||
// Le refus des chiffres n'annule pas les champs identitaires acceptés.
|
||||
expect(latapy.lot.numero).toBe('12')
|
||||
expect(latapy.locataire.nom).toBe('LATAPY NINA Complément loyer')
|
||||
})
|
||||
|
||||
it('écrit la valeur saisie à la main plutôt que celle proposée', () => {
|
||||
const merged = merge({
|
||||
'loc:0:locataire.nom': { accepted: true, value: 'LATAPY Nina' },
|
||||
'meta:immeuble.adresse': { accepted: true, value: '4 rue Servient' },
|
||||
})
|
||||
|
||||
expect(merged.situation_locataires[0].locataire.nom).toBe('LATAPY Nina')
|
||||
expect(merged.metadata.immeuble.adresse).toBe('4 rue Servient')
|
||||
})
|
||||
|
||||
it('laisse une métadonnée refusée à sa valeur en base', () => {
|
||||
const merged = merge({ 'meta:immeuble.adresse': { accepted: false } })
|
||||
expect(merged.metadata.immeuble.adresse).toBe('4 RUE SERVIENT')
|
||||
})
|
||||
|
||||
it('écarte une opération ajoutée dont on ne veut pas', () => {
|
||||
const added = diff.operations.find((o) => o.kind === 'added')
|
||||
const merged = merge({ [added.id]: { accepted: false } })
|
||||
|
||||
expect(merged.recapitulatif_operations.map((o) => o.fournisseur)).toEqual(['PPR'])
|
||||
})
|
||||
|
||||
it('réinsère à sa place une opération dont on refuse la suppression', () => {
|
||||
const removed = diff.operations.find((o) => o.kind === 'removed')
|
||||
const merged = merge({ [removed.id]: { accepted: false } })
|
||||
|
||||
expect(merged.recapitulatif_operations.map((o) => o.fournisseur)).toEqual([
|
||||
'PPR',
|
||||
'TOTAL', // restaurée à son index d'origine
|
||||
'NOUVEAU',
|
||||
])
|
||||
})
|
||||
|
||||
it('ne modifie pas les données d’origine', () => {
|
||||
const avant = JSON.stringify(previous)
|
||||
merge({ 'loc:0:montants': { accepted: false } })
|
||||
expect(JSON.stringify(previous)).toBe(avant)
|
||||
})
|
||||
})
|
||||
|
||||
describe('locataires ajoutés ou supprimés', () => {
|
||||
const avecAjout = {
|
||||
...next,
|
||||
situation_locataires: [
|
||||
...next.situation_locataires,
|
||||
{ lot: { numero: '09' }, locataire: { nom: 'NOUVEAU LOCATAIRE' }, totaux: {}, lignes: [] },
|
||||
],
|
||||
}
|
||||
const diffAjout = computeExtractionDiff(previous, avecAjout)
|
||||
|
||||
it('n’ajoute pas un locataire refusé', () => {
|
||||
const merged = mergeExtraction(previous, avecAjout, diffAjout, {
|
||||
'loc:2:entier': { accepted: false },
|
||||
})
|
||||
expect(merged.situation_locataires).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('conserve un locataire dont on refuse la disparition', () => {
|
||||
const sansBesson = {
|
||||
...next,
|
||||
situation_locataires: next.situation_locataires.slice(0, 1),
|
||||
}
|
||||
const diffSuppr = computeExtractionDiff(previous, sansBesson)
|
||||
const merged = mergeExtraction(previous, sansBesson, diffSuppr, {
|
||||
'loc:1:entier': { accepted: false },
|
||||
})
|
||||
|
||||
expect(merged.situation_locataires.map((l) => l.locataire.nom)).toEqual([
|
||||
'LATAPY NINA Complément loyer',
|
||||
'BESSON Léa',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('réécritures imposées par l’enregistrement', () => {
|
||||
// Le serveur fournit les données actuelles et leur forme canonique ; le front
|
||||
// en déduit ce qu'un refus ne pourrait pas conserver, sans connaître la règle.
|
||||
const enBase = JSON.parse(JSON.stringify(previous))
|
||||
enBase.situation_locataires[0].lot.numero = '0007' // écriture héritée
|
||||
const canonique = JSON.parse(JSON.stringify(enBase))
|
||||
canonique.situation_locataires[0].lot.numero = '07' // ce que le serveur écrirait
|
||||
|
||||
const d = computeExtractionDiff(enBase, next)
|
||||
|
||||
it('signale le champ que le serveur réécrira', () => {
|
||||
const overrides = computeServerOverrides(d, enBase, canonique)
|
||||
const lot = d.locataires[0].changes.find((c) => c.path === 'lot.numero')
|
||||
|
||||
expect(overrides[lot.id]).toBe('07')
|
||||
})
|
||||
|
||||
it('ne signale rien pour les champs que le serveur laisse tels quels', () => {
|
||||
const overrides = computeServerOverrides(d, enBase, canonique)
|
||||
const nom = d.locataires[0].changes.find((c) => c.path === 'locataire.nom')
|
||||
|
||||
expect(nom.id in overrides).toBe(false)
|
||||
expect(Object.keys(overrides)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('ne signale rien quand le serveur ne réécrit rien', () => {
|
||||
expect(computeServerOverrides(diff, previous, previous)).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('comptage pour l’affichage', () => {
|
||||
it('compte les changements retenus', () => {
|
||||
const ids = listChangeIds(diff)
|
||||
expect(countAccepted(ids, {})).toEqual({ accepted: ids.length, total: ids.length })
|
||||
expect(countAccepted(ids, { [ids[0]]: { accepted: false } }).accepted).toBe(ids.length - 1)
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,7 @@ from ...database import DatabaseService, get_session, storage
|
||||
from ...database.models import Depense, Document, Immeuble, Locataire, Lot, Revenu
|
||||
from ...database.service import DuplicateDocumentError
|
||||
from ...extractor import extract_compte_rendu
|
||||
from ...utils.canonical import canonical_copy
|
||||
from ...utils.uploads import UploadTooLargeError, read_upload_limited
|
||||
from ..schemas import DocumentSummary, SaveRequest, SaveResponse
|
||||
|
||||
@@ -194,6 +195,7 @@ async def list_documents(
|
||||
solde_montant=doc.solde_montant,
|
||||
solde_type=doc.solde_type,
|
||||
created_at=doc.created_at.isoformat() if doc.created_at else None,
|
||||
extracted_at=doc.extracted_at.isoformat() if doc.extracted_at else None,
|
||||
has_pdf=doc.pdf_path is not None,
|
||||
has_json=doc.json_path is not None,
|
||||
)
|
||||
@@ -242,7 +244,11 @@ async def get_document(
|
||||
"siret": document.editeur_siret,
|
||||
},
|
||||
"json_data": json.loads(document.json_data) if document.json_data else None,
|
||||
"depenses_tags": db_service.get_depenses_tags(document_id),
|
||||
"created_at": document.created_at.isoformat() if document.created_at else None,
|
||||
"extracted_at": document.extracted_at.isoformat()
|
||||
if document.extracted_at
|
||||
else None,
|
||||
"has_pdf": document.pdf_path is not None,
|
||||
"has_json": document.json_path is not None,
|
||||
}
|
||||
@@ -358,18 +364,84 @@ async def download_document_json(
|
||||
)
|
||||
|
||||
|
||||
@router.put("/documents/{document_id}", response_model=SaveResponse)
|
||||
async def update_document(
|
||||
document_id: int,
|
||||
request: SaveRequest,
|
||||
session: Session = Depends(get_session),
|
||||
) -> SaveResponse:
|
||||
"""Remplace les donnees d'un document existant, identifie par son ID.
|
||||
|
||||
A la difference de `POST /api/save` avec `overwrite`, qui retrouve le
|
||||
document par (reference, date), le document vise est ici designe par son ID :
|
||||
une nouvelle extraction qui corrige la reference ou la date met a jour le bon
|
||||
document au lieu d'en creer un second. Le PDF stocke est conserve.
|
||||
|
||||
- **data**: Nouvelles donnees extraites
|
||||
- **depenses_tags**: Tags a appliquer aux depenses (par index d'operation)
|
||||
- **source_file**: Nom du fichier source (optionnel, conserve si absent)
|
||||
"""
|
||||
db_service = DatabaseService(session)
|
||||
existing = db_service.get_document_by_id(document_id)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Document non trouve")
|
||||
|
||||
try:
|
||||
document = db_service.save_document(
|
||||
data=request.data,
|
||||
source_file=request.source_file or existing.source_file,
|
||||
depenses_tags=request.depenses_tags,
|
||||
replace_document_id=document_id,
|
||||
)
|
||||
except DuplicateDocumentError as e:
|
||||
return SaveResponse(
|
||||
success=False,
|
||||
message=(
|
||||
f"Un autre document porte deja 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:
|
||||
logger.exception("Erreur lors de la mise a jour du document %s", document_id)
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Erreur lors de la mise a jour du document."
|
||||
)
|
||||
|
||||
return SaveResponse(
|
||||
success=True,
|
||||
message="Document mis a jour avec succes",
|
||||
document_id=document.id,
|
||||
reference=document.reference,
|
||||
date=str(document.date),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/documents/{document_id}/re-extract")
|
||||
async def re_extract_document(
|
||||
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.
|
||||
Utile pour corriger l'extraction apres amelioration des parsers, document par
|
||||
document ou lors d'un balayage de toute la base. Ne modifie pas la base :
|
||||
retourne cote a cote les donnees actuelles et les donnees re-extraites, pour
|
||||
que l'appelant compare et decide s'il enregistre (PUT /api/documents/{id}).
|
||||
|
||||
Retourne les donnees re-extraites du PDF.
|
||||
`depenses_tags` porte les tags actuels du document, pour pouvoir etre
|
||||
reportes sur la nouvelle extraction plutot que perdus au reenregistrement.
|
||||
|
||||
`previous_canonical` donne les donnees actuelles telles qu'elles seraient
|
||||
reecrites a l'enregistrement (cf. utils.canonical). L'appelant compare avec
|
||||
`previous_data` pour savoir quels champs le serveur imposera de toute facon,
|
||||
et le signaler avant que l'utilisateur ne se prononce — sans avoir a
|
||||
connaitre les regles de reecriture.
|
||||
|
||||
Endpoint synchrone (`def`) : l'extraction est bloquante et prend plusieurs
|
||||
secondes, FastAPI l'execute donc dans un thread pour ne pas figer le serveur.
|
||||
"""
|
||||
db_service = DatabaseService(session)
|
||||
document = db_service.get_document_by_id(document_id)
|
||||
@@ -401,11 +473,17 @@ async def re_extract_document(
|
||||
detail=f"Erreur lors de la re-extraction: {str(e)}",
|
||||
)
|
||||
|
||||
previous_data = json.loads(document.json_data) if document.json_data else None
|
||||
|
||||
return {
|
||||
"document_id": document_id,
|
||||
"reference": document.reference,
|
||||
"date": str(document.date),
|
||||
"source_file": document.source_file,
|
||||
"original_json_path": document.json_path,
|
||||
"previous_data": previous_data,
|
||||
"previous_canonical": canonical_copy(previous_data),
|
||||
"re_extracted_data": new_data,
|
||||
"depenses_tags": db_service.get_depenses_tags(document_id),
|
||||
"message": "Donnees re-extraites. Utilisez PUT /api/documents/{id} pour mettre a jour.",
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@ class DocumentSummary(BaseModel):
|
||||
solde_montant: float | None
|
||||
solde_type: str | None
|
||||
created_at: str
|
||||
#: Derniere extraction ayant produit les donnees (absente sur une base
|
||||
#: anterieure a ce champ : l'appelant retombe alors sur `created_at`).
|
||||
extracted_at: str | None = None
|
||||
has_pdf: bool = False
|
||||
has_json: bool = False
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ def init_db(db_path: Path | None = None) -> Path:
|
||||
# Build the engine (reuses the shared configuration) and create tables
|
||||
engine = get_engine(db_path)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_apply_schema_updates(engine)
|
||||
|
||||
# Seed predefined tags if the table is empty
|
||||
_seed_tags_if_empty(engine)
|
||||
@@ -94,6 +95,34 @@ def init_db(db_path: Path | None = None) -> Path:
|
||||
return db_path
|
||||
|
||||
|
||||
#: Colonnes ajoutees apres coup, par table : nom -> (definition SQL, valeur de
|
||||
#: rattrapage pour les lignes existantes). `create_all` ne modifie pas une table
|
||||
#: deja presente, et le projet n'utilise pas d'outil de migration : sans ce
|
||||
#: rattrapage, une base installee cesserait de fonctionner apres mise a jour.
|
||||
_ADDED_COLUMNS = {
|
||||
"documents": {
|
||||
# Les documents deja en base ont ete extraits lors de leur import.
|
||||
"extracted_at": ("DATETIME", "created_at"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _apply_schema_updates(engine):
|
||||
"""Ajoute a une base existante les colonnes apparues depuis sa creation."""
|
||||
with engine.begin() as conn:
|
||||
for table, columns in _ADDED_COLUMNS.items():
|
||||
existing = {
|
||||
row[1] for row in conn.execute(text(f"PRAGMA table_info({table})"))
|
||||
}
|
||||
if not existing: # table absente : create_all vient de la creer
|
||||
continue
|
||||
for name, (definition, backfill) in columns.items():
|
||||
if name in existing:
|
||||
continue
|
||||
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {name} {definition}"))
|
||||
conn.execute(text(f"UPDATE {table} SET {name} = {backfill}"))
|
||||
|
||||
|
||||
def _seed_tags_if_empty(engine):
|
||||
"""Insert predefined tags if the tags table is empty."""
|
||||
from ..scripts.seed_tags import PREDEFINED_TAGS
|
||||
|
||||
@@ -161,6 +161,10 @@ class Document(Base):
|
||||
solde_date_arrete = Column(Date, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
#: Date de la derniere extraction ayant produit les donnees stockees. Une
|
||||
#: re-extraction validee la met a jour, contrairement a `created_at` qui
|
||||
#: reste la date du premier import.
|
||||
extracted_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
# Chemins vers les fichiers stockés (relatifs à PLESNA_STORAGE_PATH)
|
||||
pdf_path = Column(
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"""Database service for saving and querying extracted data."""
|
||||
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..utils.amounts import parse_amount
|
||||
from ..utils.canonical import canonicalize_extraction
|
||||
from ..utils.lots import (
|
||||
LOT_NUMERO_INCONNU,
|
||||
normalize_extraction_lots,
|
||||
normalize_lot_numero,
|
||||
)
|
||||
from . import storage
|
||||
@@ -127,6 +127,7 @@ class DatabaseService:
|
||||
pdf_content: bytes = None,
|
||||
depenses_tags: list[dict] = None,
|
||||
overwrite: bool = False,
|
||||
replace_document_id: int = None,
|
||||
) -> Document:
|
||||
"""Save extracted JSON data to database.
|
||||
|
||||
@@ -137,15 +138,24 @@ class DatabaseService:
|
||||
pdf_content: Binary content of the PDF file (optional, for storage)
|
||||
depenses_tags: List of tags for expenses
|
||||
overwrite: If True, delete existing document and recreate it
|
||||
replace_document_id: ID du document a remplacer. Le document vise est
|
||||
identifie par son ID et non par (reference, date) : une nouvelle
|
||||
extraction qui corrige la reference ou la date met alors a jour le
|
||||
bon document au lieu d'en creer un second.
|
||||
|
||||
Returns:
|
||||
The created Document instance
|
||||
|
||||
Raises:
|
||||
DuplicateDocumentError: If document already exists and overwrite=False
|
||||
DuplicateDocumentError: If document already exists and overwrite=False,
|
||||
ou si un *autre* document porte deja la (reference, date) de
|
||||
`data` lors d'un remplacement par ID
|
||||
ValueError: Si `replace_document_id` ne designe aucun document
|
||||
"""
|
||||
# Uniformiser les numéros de lot avant toute persistance (JSON + tables)
|
||||
normalize_extraction_lots(data)
|
||||
# Ramener l'extraction à sa forme canonique avant toute persistance
|
||||
# (JSON + tables). Point de passage unique : l'aperçu de re-extraction
|
||||
# interroge la même fonction pour annoncer ces réécritures.
|
||||
canonicalize_extraction(data)
|
||||
|
||||
metadata = data.get("metadata", {})
|
||||
doc_info = metadata.get("document", {})
|
||||
@@ -163,18 +173,38 @@ class DatabaseService:
|
||||
# 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)
|
||||
if existing:
|
||||
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)
|
||||
# But DON'T delete files - we'll reuse or update them
|
||||
self.session.delete(existing)
|
||||
self.session.flush()
|
||||
else:
|
||||
reused: Document | None = None # document mis a jour sur place
|
||||
if replace_document_id is not None:
|
||||
existing = self.session.get(Document, replace_document_id)
|
||||
if existing is None:
|
||||
raise ValueError(f"Document {replace_document_id} introuvable")
|
||||
# La nouvelle extraction ne doit pas entrer en collision avec un
|
||||
# autre document deja en base.
|
||||
collision = self.check_duplicate(reference, doc_date)
|
||||
if collision is not None and collision.id != existing.id:
|
||||
raise DuplicateDocumentError(reference, doc_date)
|
||||
existing_pdf_path = existing.pdf_path
|
||||
existing_json_path = existing.json_path
|
||||
# Les donnees derivees sont regenerees, mais la ligne document est
|
||||
# conservee : son ID survit a la re-extraction, donc les liens qui
|
||||
# la referencent (URL d'edition, PDF) restent valides.
|
||||
existing.revenus.clear()
|
||||
existing.depenses.clear()
|
||||
self.session.flush()
|
||||
reused = existing
|
||||
else:
|
||||
existing = self.check_duplicate(reference, doc_date)
|
||||
if existing:
|
||||
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)
|
||||
# But DON'T delete files - we'll reuse or update them
|
||||
self.session.delete(existing)
|
||||
self.session.flush()
|
||||
else:
|
||||
raise DuplicateDocumentError(reference, doc_date)
|
||||
|
||||
# Get or create immeuble
|
||||
immeuble = self.get_or_create_immeuble(
|
||||
@@ -204,23 +234,31 @@ class DatabaseService:
|
||||
pdf_path = existing_pdf_path
|
||||
json_path = existing_json_path
|
||||
|
||||
# Create document
|
||||
document = Document(
|
||||
reference=reference,
|
||||
date=doc_date,
|
||||
type=doc_info.get("type"),
|
||||
source_file=source_file,
|
||||
immeuble_id=immeuble.id,
|
||||
json_data=json.dumps(data, ensure_ascii=False, default=str),
|
||||
editeur_nom=editeur_info.get("nom"),
|
||||
editeur_siret=editeur_info.get("siret"),
|
||||
solde_montant=self._normalize_amount(solde_info.get("montant")),
|
||||
solde_type=solde_info.get("type"),
|
||||
solde_date_arrete=self._parse_date(solde_info.get("date_arrete")),
|
||||
pdf_path=pdf_path,
|
||||
json_path=json_path,
|
||||
)
|
||||
self.session.add(document)
|
||||
# Create document (ou mise a jour sur place lors d'un remplacement)
|
||||
fields = {
|
||||
"reference": reference,
|
||||
"date": doc_date,
|
||||
"type": doc_info.get("type"),
|
||||
"source_file": source_file,
|
||||
"immeuble_id": immeuble.id,
|
||||
"json_data": json.dumps(data, ensure_ascii=False, default=str),
|
||||
"editeur_nom": editeur_info.get("nom"),
|
||||
"editeur_siret": editeur_info.get("siret"),
|
||||
"solde_montant": self._normalize_amount(solde_info.get("montant")),
|
||||
"solde_type": solde_info.get("type"),
|
||||
"solde_date_arrete": self._parse_date(solde_info.get("date_arrete")),
|
||||
"pdf_path": pdf_path,
|
||||
"json_path": json_path,
|
||||
# Ces donnees viennent de l'extraction qu'on est en train d'enregistrer.
|
||||
"extracted_at": datetime.now(timezone.utc),
|
||||
}
|
||||
if reused is not None:
|
||||
document = reused
|
||||
for key, value in fields.items():
|
||||
setattr(document, key, value)
|
||||
else:
|
||||
document = Document(**fields)
|
||||
self.session.add(document)
|
||||
self.session.flush()
|
||||
|
||||
# Save files to storage
|
||||
@@ -341,6 +379,30 @@ class DatabaseService:
|
||||
"""Get a document by ID."""
|
||||
return self.session.get(Document, doc_id)
|
||||
|
||||
def get_depenses_tags(self, doc_id: int) -> list[dict]:
|
||||
"""Retourne les tags actuels des dépenses d'un document.
|
||||
|
||||
Les dépenses sont ordonnées par id, ce qui reproduit l'ordre de
|
||||
`recapitulatif_operations` au moment de l'enregistrement (cf.
|
||||
`save_document`). L'index retourné correspond donc à celui de
|
||||
l'opération dans le JSON, format attendu par `depenses_tags`.
|
||||
|
||||
Seules les dépenses effectivement taguées sont retournées.
|
||||
"""
|
||||
stmt = (
|
||||
select(Depense, Tag.nom)
|
||||
.outerjoin(Tag, Depense.tag_id == Tag.id)
|
||||
.where(Depense.document_id == doc_id)
|
||||
.order_by(Depense.id)
|
||||
)
|
||||
rows = self.session.execute(stmt).all()
|
||||
|
||||
return [
|
||||
{"index": idx, "tag_id": depense.tag_id, "tag_nom": tag_nom}
|
||||
for idx, (depense, tag_nom) in enumerate(rows)
|
||||
if depense.tag_id is not None
|
||||
]
|
||||
|
||||
def get_revenus_summary(
|
||||
self, immeuble_id: int = None, year: int = None
|
||||
) -> list[dict]:
|
||||
|
||||
39
src/plesna_gerance/utils/canonical.py
Normal file
39
src/plesna_gerance/utils/canonical.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Forme canonique d'une extraction, telle qu'elle sera persistee.
|
||||
|
||||
L'enregistrement ne stocke pas toujours les donnees telles qu'on les lui donne :
|
||||
certaines valeurs sont ramenees a une ecriture canonique (aujourd'hui les
|
||||
numeros de lot). L'interface de re-extraction doit pouvoir annoncer ces
|
||||
reecritures *avant* d'enregistrer, sans reimplementer les regles : elle demande
|
||||
au serveur la forme canonique et compare.
|
||||
|
||||
Toute transformation appliquee a la persistance doit donc etre ajoutee ici, et
|
||||
nulle part ailleurs : `save_document` et l'apercu passent par cette fonction.
|
||||
"""
|
||||
|
||||
import copy
|
||||
from typing import Any
|
||||
|
||||
from .lots import normalize_extraction_lots
|
||||
|
||||
|
||||
def canonicalize_extraction(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Applique, sur place, les reecritures faites a l'enregistrement.
|
||||
|
||||
Args:
|
||||
data: Extraction (metadata, situation_locataires, recapitulatif_operations)
|
||||
|
||||
Returns:
|
||||
Le meme dict, sous sa forme canonique
|
||||
"""
|
||||
normalize_extraction_lots(data)
|
||||
return data
|
||||
|
||||
|
||||
def canonical_copy(data: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
"""Comme `canonicalize_extraction`, mais sans toucher a l'original.
|
||||
|
||||
Sert a l'apercu : on montre ce qui serait ecrit sans rien modifier.
|
||||
"""
|
||||
if data is None:
|
||||
return None
|
||||
return canonicalize_extraction(copy.deepcopy(data))
|
||||
192
tests/test_reextraction.py
Normal file
192
tests/test_reextraction.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""Tests du remplacement d'un document par son ID (re-extraction validee)."""
|
||||
|
||||
import copy
|
||||
import json
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from plesna_gerance.database import storage
|
||||
from plesna_gerance.database.models import Depense, Document, Revenu, Tag
|
||||
from plesna_gerance.database.service import DatabaseService, DuplicateDocumentError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pdf_bytes():
|
||||
"""Contenu binaire arbitraire : le stockage ne relit jamais le PDF ici."""
|
||||
return b"%PDF-1.4 fake"
|
||||
|
||||
|
||||
def test_replace_document_met_a_jour_meme_si_la_reference_change(
|
||||
db_session, sample_data
|
||||
):
|
||||
"""Une re-extraction qui corrige la reference met a jour le meme document.
|
||||
|
||||
C'est tout l'interet du remplacement par ID : avec un enregistrement par
|
||||
(reference, date), ce cas creerait un second document.
|
||||
"""
|
||||
service = DatabaseService(db_session)
|
||||
original = service.save_document(data=sample_data)
|
||||
original_id = original.id
|
||||
|
||||
new_data = copy.deepcopy(sample_data)
|
||||
new_data["metadata"]["document"]["reference"] = "REF001-CORRIGEE"
|
||||
|
||||
updated = service.save_document(data=new_data, replace_document_id=original_id)
|
||||
|
||||
assert db_session.query(Document).count() == 1
|
||||
assert updated.reference == "REF001-CORRIGEE"
|
||||
assert updated.date == date(2024, 1, 15)
|
||||
# L'ID survit au remplacement : les liens vers le document restent valides.
|
||||
assert updated.id == original_id
|
||||
|
||||
|
||||
def test_replace_document_regenere_les_donnees_derivees(db_session, sample_data):
|
||||
"""Revenus et depenses sont remplaces, pas cumules."""
|
||||
service = DatabaseService(db_session)
|
||||
original = service.save_document(data=sample_data)
|
||||
|
||||
new_data = copy.deepcopy(sample_data)
|
||||
new_data["recapitulatif_operations"][0]["montants"]["debit"] = 90.0
|
||||
new_data["situation_locataires"][0]["lignes"][0]["loyers"] = 600.0
|
||||
service.save_document(data=new_data, replace_document_id=original.id)
|
||||
|
||||
depenses = db_session.query(Depense).filter_by(document_id=original.id).all()
|
||||
revenus = db_session.query(Revenu).filter_by(document_id=original.id).all()
|
||||
assert len(depenses) == 1 and depenses[0].debit == 90.0
|
||||
assert len(revenus) == 1 and revenus[0].loyers == 600.0
|
||||
|
||||
|
||||
def test_replace_document_conserve_le_pdf_et_reecrit_le_json(
|
||||
db_session, sample_data, pdf_bytes
|
||||
):
|
||||
service = DatabaseService(db_session)
|
||||
original = service.save_document(
|
||||
data=sample_data, source_file="cr.pdf", pdf_content=pdf_bytes
|
||||
)
|
||||
pdf_path, json_path = original.pdf_path, original.json_path
|
||||
assert pdf_path and json_path
|
||||
|
||||
new_data = copy.deepcopy(sample_data)
|
||||
new_data["situation_locataires"][0]["locataire"]["nom"] = "MARTIN"
|
||||
|
||||
# Re-enregistrement sans PDF : celui deja stocke doit etre conserve.
|
||||
updated = service.save_document(
|
||||
data=new_data, source_file="cr.pdf", replace_document_id=original.id
|
||||
)
|
||||
|
||||
assert updated.pdf_path == pdf_path
|
||||
assert updated.json_path == json_path
|
||||
assert storage.read_pdf(pdf_path) == pdf_bytes
|
||||
stored = storage.read_json(json_path)
|
||||
assert stored["situation_locataires"][0]["locataire"]["nom"] == "MARTIN"
|
||||
|
||||
|
||||
def test_replace_document_refuse_une_collision_avec_un_autre_document(
|
||||
db_session, sample_data
|
||||
):
|
||||
"""La nouvelle extraction ne doit pas ecraser un document voisin."""
|
||||
service = DatabaseService(db_session)
|
||||
premier = service.save_document(data=sample_data)
|
||||
|
||||
autre_data = copy.deepcopy(sample_data)
|
||||
autre_data["metadata"]["document"]["date"] = "2024-02-15"
|
||||
service.save_document(data=autre_data)
|
||||
|
||||
# Le premier document re-extrait porterait la (reference, date) du second.
|
||||
collision_data = copy.deepcopy(autre_data)
|
||||
with pytest.raises(DuplicateDocumentError):
|
||||
service.save_document(data=collision_data, replace_document_id=premier.id)
|
||||
|
||||
assert db_session.query(Document).count() == 2
|
||||
|
||||
|
||||
def test_replace_document_inconnu_leve_value_error(db_session, sample_data):
|
||||
service = DatabaseService(db_session)
|
||||
with pytest.raises(ValueError):
|
||||
service.save_document(data=sample_data, replace_document_id=4242)
|
||||
|
||||
|
||||
def test_un_numero_de_lot_refuse_est_quand_meme_reecrit(db_session, sample_data):
|
||||
"""L'enregistrement ramene les donnees a leur forme canonique.
|
||||
|
||||
Refuser une reecriture de numero de lot ("0001" -> "01") ne la conserve donc
|
||||
pas : c'est exactement ce que `previous_canonical` sert a annoncer avant
|
||||
l'enregistrement.
|
||||
"""
|
||||
service = DatabaseService(db_session)
|
||||
original = service.save_document(data=copy.deepcopy(sample_data))
|
||||
|
||||
refus = copy.deepcopy(sample_data)
|
||||
refus["situation_locataires"][0]["lot"]["numero"] = "0001"
|
||||
updated = service.save_document(data=refus, replace_document_id=original.id)
|
||||
|
||||
stored = json.loads(updated.json_data)
|
||||
assert stored["situation_locataires"][0]["lot"]["numero"] == "01"
|
||||
|
||||
|
||||
def test_re_extract_annonce_les_reecritures_de_l_enregistrement(
|
||||
db_session, sample_data, pdf_bytes, monkeypatch
|
||||
):
|
||||
"""`previous_canonical` montre ce que l'enregistrement imposerait.
|
||||
|
||||
L'interface s'en sert pour prevenir, avant que l'utilisateur ne se prononce,
|
||||
qu'un refus sur ce champ ne serait pas conserve — sans connaitre la regle.
|
||||
"""
|
||||
from plesna_gerance.api.routes import documents as documents_routes
|
||||
|
||||
service = DatabaseService(db_session)
|
||||
document = service.save_document(
|
||||
data=copy.deepcopy(sample_data), source_file="cr.pdf", pdf_content=pdf_bytes
|
||||
)
|
||||
|
||||
# JSON herite, anterieur a la normalisation des lots.
|
||||
ancien = copy.deepcopy(sample_data)
|
||||
ancien["situation_locataires"][0]["lot"]["numero"] = "0001"
|
||||
document.json_data = json.dumps(ancien, ensure_ascii=False)
|
||||
db_session.commit()
|
||||
|
||||
monkeypatch.setattr(
|
||||
documents_routes,
|
||||
"extract_compte_rendu",
|
||||
lambda path: copy.deepcopy(sample_data),
|
||||
)
|
||||
result = documents_routes.re_extract_document(document.id, db_session)
|
||||
|
||||
# Les donnees actuelles sont renvoyees telles quelles...
|
||||
assert result["previous_data"]["situation_locataires"][0]["lot"]["numero"] == "0001"
|
||||
# ...et leur forme canonique montre la reecriture a venir.
|
||||
assert (
|
||||
result["previous_canonical"]["situation_locataires"][0]["lot"]["numero"] == "01"
|
||||
)
|
||||
|
||||
|
||||
def test_get_depenses_tags_puis_report_sur_la_nouvelle_extraction(
|
||||
db_session, sample_data
|
||||
):
|
||||
"""Les tags actuels sont indexes comme les operations, et reportables."""
|
||||
service = DatabaseService(db_session)
|
||||
tag = Tag(nom="ENTRETIEN")
|
||||
db_session.add(tag)
|
||||
db_session.flush()
|
||||
|
||||
original = service.save_document(
|
||||
data=sample_data, depenses_tags=[{"index": 0, "tag_id": tag.id}]
|
||||
)
|
||||
|
||||
tags = service.get_depenses_tags(original.id)
|
||||
assert tags == [{"index": 0, "tag_id": tag.id, "tag_nom": "ENTRETIEN"}]
|
||||
|
||||
# Report sur une nouvelle extraction du meme document.
|
||||
new_data = copy.deepcopy(sample_data)
|
||||
new_data["recapitulatif_operations"][0]["montants"]["debit"] = 75.0
|
||||
updated = service.save_document(
|
||||
data=new_data,
|
||||
depenses_tags=[{"index": t["index"], "tag_id": t["tag_id"]} for t in tags],
|
||||
replace_document_id=original.id,
|
||||
)
|
||||
|
||||
depenses = db_session.query(Depense).filter_by(document_id=updated.id).all()
|
||||
assert len(depenses) == 1
|
||||
assert depenses[0].debit == 75.0
|
||||
assert depenses[0].tag_id == tag.id
|
||||
73
tests/test_schema_updates.py
Normal file
73
tests/test_schema_updates.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Rattrapage de schema sur une base creee avant l'ajout d'une colonne.
|
||||
|
||||
Le projet n'a pas d'outil de migration : `create_all` laisse intactes les tables
|
||||
deja presentes. Sans le rattrapage d'`init_db`, une base installee cesserait de
|
||||
fonctionner des qu'une colonne est ajoutee au modele.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from plesna_gerance.database import connection
|
||||
|
||||
|
||||
def test_ajoute_extracted_at_a_une_base_existante(tmp_path, monkeypatch):
|
||||
db_path = tmp_path / "ancienne.sqlite"
|
||||
monkeypatch.setenv("PLESNA_DB_PATH", str(db_path))
|
||||
monkeypatch.setenv("PLESNA_STORAGE_PATH", str(tmp_path / "documents"))
|
||||
|
||||
# Base telle qu'elle existait avant le champ, avec un document dedans.
|
||||
connection.reset_connection()
|
||||
connection.init_db(db_path)
|
||||
engine = connection.get_engine(db_path)
|
||||
importe_le = datetime(2026, 3, 1, 10, 0, tzinfo=timezone.utc)
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("ALTER TABLE documents DROP COLUMN extracted_at"))
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO immeubles (code) VALUES ('IMM1');"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO documents (reference, date, immeuble_id, json_data,"
|
||||
" created_at) VALUES ('REF', '2026-03-01', 1, '{}', :created)"
|
||||
),
|
||||
{"created": importe_le},
|
||||
)
|
||||
connection.reset_connection()
|
||||
|
||||
connection.init_db(db_path)
|
||||
|
||||
with connection.get_engine(db_path).begin() as conn:
|
||||
colonnes = {
|
||||
row[1] for row in conn.execute(text("PRAGMA table_info(documents)"))
|
||||
}
|
||||
assert "extracted_at" in colonnes
|
||||
|
||||
# Un document deja en base a ete extrait lors de son import.
|
||||
extracted_at, created_at = conn.execute(
|
||||
text("SELECT extracted_at, created_at FROM documents")
|
||||
).one()
|
||||
assert extracted_at == created_at
|
||||
|
||||
connection.reset_connection()
|
||||
|
||||
|
||||
def test_rattrapage_idempotent(tmp_path, monkeypatch):
|
||||
"""Relancer init_db sur une base a jour ne doit rien casser."""
|
||||
db_path = tmp_path / "a_jour.sqlite"
|
||||
monkeypatch.setenv("PLESNA_DB_PATH", str(db_path))
|
||||
monkeypatch.setenv("PLESNA_STORAGE_PATH", str(tmp_path / "documents"))
|
||||
|
||||
connection.reset_connection()
|
||||
connection.init_db(db_path)
|
||||
connection.reset_connection()
|
||||
connection.init_db(db_path)
|
||||
|
||||
with connection.get_engine(db_path).begin() as conn:
|
||||
colonnes = [row[1] for row in conn.execute(text("PRAGMA table_info(documents)"))]
|
||||
assert colonnes.count("extracted_at") == 1
|
||||
|
||||
connection.reset_connection()
|
||||
Reference in New Issue
Block a user