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": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chart.js": "^4.4.1",
|
"chart.js": "^4.4.1",
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
"autoprefixer": "^10.4.18",
|
"autoprefixer": "^10.4.18",
|
||||||
"postcss": "^8.4.35",
|
"postcss": "^8.4.35",
|
||||||
"tailwindcss": "^3.4.1",
|
"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>
|
</div>
|
||||||
|
|
||||||
<!-- Détail -->
|
<!-- 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">
|
<div v-if="open && diff.summary.total > 0" class="max-h-56 overflow-auto px-4 pb-3">
|
||||||
<!-- Métadonnées -->
|
<ExtractionDiffDetails :diff="diff" />
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import ExtractionDiffDetails from './ExtractionDiffDetails.vue'
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
diff: {
|
diff: {
|
||||||
@@ -117,16 +67,4 @@ defineProps({
|
|||||||
defineEmits(['revert', 'close'])
|
defineEmits(['revert', 'close'])
|
||||||
|
|
||||||
const open = ref(true)
|
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>
|
</script>
|
||||||
|
|||||||
@@ -11,6 +11,20 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 -->
|
<!-- Import button -->
|
||||||
<label class="btn btn-primary cursor-pointer">
|
<label class="btn btn-primary cursor-pointer">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -24,6 +38,7 @@
|
|||||||
@change="onFileSelect"
|
@change="onFileSelect"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
@@ -49,7 +64,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Document</th>
|
<th>Document</th>
|
||||||
<th>Immeuble</th>
|
<th>Immeuble</th>
|
||||||
<th>Date</th>
|
<th>Date / extraction</th>
|
||||||
<th class="text-right">Solde</th>
|
<th class="text-right">Solde</th>
|
||||||
<th class="text-center">Fichiers</th>
|
<th class="text-center">Fichiers</th>
|
||||||
<th class="text-right">Actions</th>
|
<th class="text-right">Actions</th>
|
||||||
@@ -85,7 +100,12 @@
|
|||||||
<!-- Date -->
|
<!-- Date -->
|
||||||
<td>
|
<td>
|
||||||
<div class="text-sm text-white">{{ formatDate(doc.date) }}</div>
|
<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>
|
</td>
|
||||||
|
|
||||||
<!-- Solde -->
|
<!-- Solde -->
|
||||||
@@ -93,7 +113,7 @@
|
|||||||
<span
|
<span
|
||||||
:class="[
|
:class="[
|
||||||
'text-sm font-medium',
|
'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) }}
|
{{ formatAmount(doc.solde_montant) }}
|
||||||
@@ -173,6 +193,7 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { pendingFile } from '../store'
|
import { pendingFile } from '../store'
|
||||||
|
import { isSoldeCrediteur } from '../utils/solde'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
|||||||
@@ -262,16 +262,17 @@ async function handleSave(depensesTags, shouldOverwrite) {
|
|||||||
saveError.value = null
|
saveError.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/save', {
|
// Mise à jour ciblée par ID : si la nouvelle extraction corrige la référence
|
||||||
method: 'POST',
|
// 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: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
source_file: extractedData.value.source_file,
|
source_file: extractedData.value.source_file,
|
||||||
data: extractedData.value.data,
|
data: extractedData.value.data,
|
||||||
depenses_tags: depensesTags,
|
depenses_tags: depensesTags
|
||||||
overwrite: true // Toujours true en mode édition
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,7 @@
|
|||||||
<span
|
<span
|
||||||
:class="[
|
:class="[
|
||||||
'text-sm font-medium',
|
'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) }}
|
{{ formatAmount(doc.solde_montant) }}
|
||||||
@@ -121,6 +121,7 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { pendingFile } from '../store'
|
import { pendingFile } from '../store'
|
||||||
|
import { isSoldeCrediteur } from '../utils/solde'
|
||||||
|
|
||||||
import QuickActions from '../components/dashboard/QuickActions.vue'
|
import QuickActions from '../components/dashboard/QuickActions.vue'
|
||||||
import FinancialSummary from '../components/dashboard/FinancialSummary.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 RevenusPage from './pages/RevenusPage.vue'
|
||||||
import DocumentsPage from './pages/DocumentsPage.vue'
|
import DocumentsPage from './pages/DocumentsPage.vue'
|
||||||
import EditDocumentPage from './pages/EditDocumentPage.vue'
|
import EditDocumentPage from './pages/EditDocumentPage.vue'
|
||||||
|
import ReExtractionPage from './pages/ReExtractionPage.vue'
|
||||||
import IAPage from './pages/IAPage.vue'
|
import IAPage from './pages/IAPage.vue'
|
||||||
import ConfigPage from './pages/ConfigPage.vue'
|
import ConfigPage from './pages/ConfigPage.vue'
|
||||||
|
|
||||||
@@ -48,6 +49,11 @@ const routes = [
|
|||||||
path: '/documents/:id/edit',
|
path: '/documents/:id/edit',
|
||||||
name: 'edit-document',
|
name: 'edit-document',
|
||||||
component: EditDocumentPage
|
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).
|
// Comparaison de deux jeux de données d'extraction (avant / après re-extraction).
|
||||||
//
|
//
|
||||||
// Chaque jeu a la forme { metadata, situation_locataires, recapitulatif_operations }.
|
// Chaque jeu a la forme { metadata, situation_locataires, recapitulatif_operations }.
|
||||||
// Retourne un résumé structuré des différences, destiné à la fois à l'affichage
|
// Retourne un résumé structuré des différences, destiné à trois usages :
|
||||||
// (panneau de diff) et à la mise en évidence inline (anneaux « modifié »).
|
// - 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
|
const EPS = 0.005
|
||||||
|
|
||||||
@@ -49,6 +56,27 @@ const METADATA_FIELDS = [
|
|||||||
['solde.date_arrete', 'Solde · date arrêté'],
|
['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) {
|
function ligneSig(l) {
|
||||||
if (!l) return '∅'
|
if (!l) return '∅'
|
||||||
const p = l.periode || {}
|
const p = l.periode || {}
|
||||||
@@ -77,30 +105,49 @@ function diffLignes(before, after) {
|
|||||||
return changes
|
return changes
|
||||||
}
|
}
|
||||||
|
|
||||||
function diffLocataire(before, after) {
|
// Changements d'un locataire modifié : un par champ identitaire, plus un bloc
|
||||||
const fields = []
|
// unique pour l'ensemble des montants et des lignes.
|
||||||
const scalar = [
|
function diffLocataire(index, before, after) {
|
||||||
['locataire.nom', 'Nom'],
|
const changes = []
|
||||||
['lot.numero', 'Lot'],
|
|
||||||
['lot.type', 'Type'],
|
for (const [path, label] of LOCATAIRE_IDENTITE) {
|
||||||
['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) {
|
|
||||||
const bv = get(before, path)
|
const bv = get(before, path)
|
||||||
const av = get(after, path)
|
const av = get(after, path)
|
||||||
if (!valEq(bv, av)) {
|
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) {
|
function locataireTitle(loc) {
|
||||||
@@ -110,7 +157,9 @@ function locataireTitle(loc) {
|
|||||||
return `${lot} — ${nom}`
|
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 '∅'
|
if (!op) return '∅'
|
||||||
const m = op.montants || {}
|
const m = op.montants || {}
|
||||||
return (
|
return (
|
||||||
@@ -132,7 +181,17 @@ export function computeExtractionDiff(before, after) {
|
|||||||
const bv = get(b.metadata, path)
|
const bv = get(b.metadata, path)
|
||||||
const av = get(a.metadata, path)
|
const av = get(a.metadata, path)
|
||||||
if (!valEq(bv, av)) {
|
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)
|
changedMetadataPaths.push(path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,14 +206,26 @@ export function computeExtractionDiff(before, after) {
|
|||||||
const ob = i < lb.length ? lb[i] : null
|
const ob = i < lb.length ? lb[i] : null
|
||||||
const oa = i < la.length ? la[i] : null
|
const oa = i < la.length ? la[i] : null
|
||||||
if (!ob && oa) {
|
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)
|
changedLocataireIndices.push(i)
|
||||||
} else if (ob && !oa) {
|
} 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 {
|
} else {
|
||||||
const fields = diffLocataire(ob, oa)
|
const changes = diffLocataire(i, ob, oa)
|
||||||
if (fields.length) {
|
if (changes.length) {
|
||||||
locataires.push({ index: i, kind: 'modified', title: locataireTitle(oa), fields })
|
locataires.push({ index: i, kind: 'modified', title: locataireTitle(oa), changes })
|
||||||
changedLocataireIndices.push(i)
|
changedLocataireIndices.push(i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,17 +253,32 @@ export function computeExtractionDiff(before, after) {
|
|||||||
oldCounts.set(s, remaining - 1) // appariée avec une ancienne identique
|
oldCounts.set(s, remaining - 1) // appariée avec une ancienne identique
|
||||||
} else {
|
} else {
|
||||||
changedOperationIndices.push(i)
|
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)
|
newCounts.set(s, (newCounts.get(s) || 0) + 1)
|
||||||
})
|
})
|
||||||
// Anciennes opérations absentes de la nouvelle extraction -> supprimées.
|
// 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()
|
const seen = new Map()
|
||||||
ob.forEach((op) => {
|
ob.forEach((op, i) => {
|
||||||
const s = opSig(op)
|
const s = opSig(op)
|
||||||
seen.set(s, (seen.get(s) || 0) + 1)
|
seen.set(s, (seen.get(s) || 0) + 1)
|
||||||
if ((newCounts.get(s) || 0) < seen.get(s)) {
|
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,
|
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.models import Depense, Document, Immeuble, Locataire, Lot, Revenu
|
||||||
from ...database.service import DuplicateDocumentError
|
from ...database.service import DuplicateDocumentError
|
||||||
from ...extractor import extract_compte_rendu
|
from ...extractor import extract_compte_rendu
|
||||||
|
from ...utils.canonical import canonical_copy
|
||||||
from ...utils.uploads import UploadTooLargeError, read_upload_limited
|
from ...utils.uploads import UploadTooLargeError, read_upload_limited
|
||||||
from ..schemas import DocumentSummary, SaveRequest, SaveResponse
|
from ..schemas import DocumentSummary, SaveRequest, SaveResponse
|
||||||
|
|
||||||
@@ -194,6 +195,7 @@ async def list_documents(
|
|||||||
solde_montant=doc.solde_montant,
|
solde_montant=doc.solde_montant,
|
||||||
solde_type=doc.solde_type,
|
solde_type=doc.solde_type,
|
||||||
created_at=doc.created_at.isoformat() if doc.created_at else None,
|
created_at=doc.created_at.isoformat() if doc.created_at else None,
|
||||||
|
extracted_at=doc.extracted_at.isoformat() if doc.extracted_at else None,
|
||||||
has_pdf=doc.pdf_path is not None,
|
has_pdf=doc.pdf_path is not None,
|
||||||
has_json=doc.json_path is not None,
|
has_json=doc.json_path is not None,
|
||||||
)
|
)
|
||||||
@@ -242,7 +244,11 @@ async def get_document(
|
|||||||
"siret": document.editeur_siret,
|
"siret": document.editeur_siret,
|
||||||
},
|
},
|
||||||
"json_data": json.loads(document.json_data) if document.json_data else None,
|
"json_data": json.loads(document.json_data) if document.json_data else None,
|
||||||
|
"depenses_tags": db_service.get_depenses_tags(document_id),
|
||||||
"created_at": document.created_at.isoformat() if document.created_at else None,
|
"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_pdf": document.pdf_path is not None,
|
||||||
"has_json": document.json_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")
|
@router.post("/documents/{document_id}/re-extract")
|
||||||
async def re_extract_document(
|
def re_extract_document(
|
||||||
document_id: int,
|
document_id: int,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Re-extrait les donnees depuis le PDF stocke.
|
"""Re-extrait les donnees depuis le PDF stocke.
|
||||||
|
|
||||||
Utile pour corriger l'extraction apres amelioration des parsers.
|
Utile pour corriger l'extraction apres amelioration des parsers, document par
|
||||||
Ne modifie pas automatiquement la base - retourne les nouvelles donnees
|
document ou lors d'un balayage de toute la base. Ne modifie pas la base :
|
||||||
pour validation par l'utilisateur.
|
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)
|
db_service = DatabaseService(session)
|
||||||
document = db_service.get_document_by_id(document_id)
|
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)}",
|
detail=f"Erreur lors de la re-extraction: {str(e)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
previous_data = json.loads(document.json_data) if document.json_data else None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"document_id": document_id,
|
"document_id": document_id,
|
||||||
"reference": document.reference,
|
"reference": document.reference,
|
||||||
"date": str(document.date),
|
"date": str(document.date),
|
||||||
|
"source_file": document.source_file,
|
||||||
"original_json_path": document.json_path,
|
"original_json_path": document.json_path,
|
||||||
|
"previous_data": previous_data,
|
||||||
|
"previous_canonical": canonical_copy(previous_data),
|
||||||
"re_extracted_data": new_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.",
|
"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_montant: float | None
|
||||||
solde_type: str | None
|
solde_type: str | None
|
||||||
created_at: str
|
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_pdf: bool = False
|
||||||
has_json: 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
|
# Build the engine (reuses the shared configuration) and create tables
|
||||||
engine = get_engine(db_path)
|
engine = get_engine(db_path)
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
_apply_schema_updates(engine)
|
||||||
|
|
||||||
# Seed predefined tags if the table is empty
|
# Seed predefined tags if the table is empty
|
||||||
_seed_tags_if_empty(engine)
|
_seed_tags_if_empty(engine)
|
||||||
@@ -94,6 +95,34 @@ def init_db(db_path: Path | None = None) -> Path:
|
|||||||
return db_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):
|
def _seed_tags_if_empty(engine):
|
||||||
"""Insert predefined tags if the tags table is empty."""
|
"""Insert predefined tags if the tags table is empty."""
|
||||||
from ..scripts.seed_tags import PREDEFINED_TAGS
|
from ..scripts.seed_tags import PREDEFINED_TAGS
|
||||||
|
|||||||
@@ -161,6 +161,10 @@ class Document(Base):
|
|||||||
solde_date_arrete = Column(Date, nullable=True)
|
solde_date_arrete = Column(Date, nullable=True)
|
||||||
|
|
||||||
created_at = Column(DateTime, default=_utcnow)
|
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)
|
# Chemins vers les fichiers stockés (relatifs à PLESNA_STORAGE_PATH)
|
||||||
pdf_path = Column(
|
pdf_path = Column(
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
"""Database service for saving and querying extracted data."""
|
"""Database service for saving and querying extracted data."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..utils.amounts import parse_amount
|
from ..utils.amounts import parse_amount
|
||||||
|
from ..utils.canonical import canonicalize_extraction
|
||||||
from ..utils.lots import (
|
from ..utils.lots import (
|
||||||
LOT_NUMERO_INCONNU,
|
LOT_NUMERO_INCONNU,
|
||||||
normalize_extraction_lots,
|
|
||||||
normalize_lot_numero,
|
normalize_lot_numero,
|
||||||
)
|
)
|
||||||
from . import storage
|
from . import storage
|
||||||
@@ -127,6 +127,7 @@ class DatabaseService:
|
|||||||
pdf_content: bytes = None,
|
pdf_content: bytes = None,
|
||||||
depenses_tags: list[dict] = None,
|
depenses_tags: list[dict] = None,
|
||||||
overwrite: bool = False,
|
overwrite: bool = False,
|
||||||
|
replace_document_id: int = None,
|
||||||
) -> Document:
|
) -> Document:
|
||||||
"""Save extracted JSON data to database.
|
"""Save extracted JSON data to database.
|
||||||
|
|
||||||
@@ -137,15 +138,24 @@ class DatabaseService:
|
|||||||
pdf_content: Binary content of the PDF file (optional, for storage)
|
pdf_content: Binary content of the PDF file (optional, for storage)
|
||||||
depenses_tags: List of tags for expenses
|
depenses_tags: List of tags for expenses
|
||||||
overwrite: If True, delete existing document and recreate it
|
overwrite: If True, delete existing document and recreate it
|
||||||
|
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:
|
Returns:
|
||||||
The created Document instance
|
The created Document instance
|
||||||
|
|
||||||
Raises:
|
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)
|
# Ramener l'extraction à sa forme canonique avant toute persistance
|
||||||
normalize_extraction_lots(data)
|
# (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", {})
|
metadata = data.get("metadata", {})
|
||||||
doc_info = metadata.get("document", {})
|
doc_info = metadata.get("document", {})
|
||||||
@@ -163,18 +173,38 @@ class DatabaseService:
|
|||||||
# Check for duplicates and preserve existing file paths if overwriting
|
# Check for duplicates and preserve existing file paths if overwriting
|
||||||
existing_pdf_path = None
|
existing_pdf_path = None
|
||||||
existing_json_path = None
|
existing_json_path = None
|
||||||
existing = self.check_duplicate(reference, doc_date)
|
reused: Document | None = None # document mis a jour sur place
|
||||||
if existing:
|
if replace_document_id is not None:
|
||||||
if overwrite:
|
existing = self.session.get(Document, replace_document_id)
|
||||||
# Preserve existing file paths for reuse
|
if existing is None:
|
||||||
existing_pdf_path = existing.pdf_path
|
raise ValueError(f"Document {replace_document_id} introuvable")
|
||||||
existing_json_path = existing.json_path
|
# La nouvelle extraction ne doit pas entrer en collision avec un
|
||||||
# Delete existing document (cascade will delete related data)
|
# autre document deja en base.
|
||||||
# But DON'T delete files - we'll reuse or update them
|
collision = self.check_duplicate(reference, doc_date)
|
||||||
self.session.delete(existing)
|
if collision is not None and collision.id != existing.id:
|
||||||
self.session.flush()
|
|
||||||
else:
|
|
||||||
raise DuplicateDocumentError(reference, doc_date)
|
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
|
# Get or create immeuble
|
||||||
immeuble = self.get_or_create_immeuble(
|
immeuble = self.get_or_create_immeuble(
|
||||||
@@ -204,23 +234,31 @@ class DatabaseService:
|
|||||||
pdf_path = existing_pdf_path
|
pdf_path = existing_pdf_path
|
||||||
json_path = existing_json_path
|
json_path = existing_json_path
|
||||||
|
|
||||||
# Create document
|
# Create document (ou mise a jour sur place lors d'un remplacement)
|
||||||
document = Document(
|
fields = {
|
||||||
reference=reference,
|
"reference": reference,
|
||||||
date=doc_date,
|
"date": doc_date,
|
||||||
type=doc_info.get("type"),
|
"type": doc_info.get("type"),
|
||||||
source_file=source_file,
|
"source_file": source_file,
|
||||||
immeuble_id=immeuble.id,
|
"immeuble_id": immeuble.id,
|
||||||
json_data=json.dumps(data, ensure_ascii=False, default=str),
|
"json_data": json.dumps(data, ensure_ascii=False, default=str),
|
||||||
editeur_nom=editeur_info.get("nom"),
|
"editeur_nom": editeur_info.get("nom"),
|
||||||
editeur_siret=editeur_info.get("siret"),
|
"editeur_siret": editeur_info.get("siret"),
|
||||||
solde_montant=self._normalize_amount(solde_info.get("montant")),
|
"solde_montant": self._normalize_amount(solde_info.get("montant")),
|
||||||
solde_type=solde_info.get("type"),
|
"solde_type": solde_info.get("type"),
|
||||||
solde_date_arrete=self._parse_date(solde_info.get("date_arrete")),
|
"solde_date_arrete": self._parse_date(solde_info.get("date_arrete")),
|
||||||
pdf_path=pdf_path,
|
"pdf_path": pdf_path,
|
||||||
json_path=json_path,
|
"json_path": json_path,
|
||||||
)
|
# Ces donnees viennent de l'extraction qu'on est en train d'enregistrer.
|
||||||
self.session.add(document)
|
"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()
|
self.session.flush()
|
||||||
|
|
||||||
# Save files to storage
|
# Save files to storage
|
||||||
@@ -341,6 +379,30 @@ class DatabaseService:
|
|||||||
"""Get a document by ID."""
|
"""Get a document by ID."""
|
||||||
return self.session.get(Document, doc_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(
|
def get_revenus_summary(
|
||||||
self, immeuble_id: int = None, year: int = None
|
self, immeuble_id: int = None, year: int = None
|
||||||
) -> list[dict]:
|
) -> 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