Compare commits

..

9 Commits

Author SHA1 Message Date
41107c984c style: aligne le balayage de re-extraction sur le design system
All checks were successful
Build and Publish Docker Image / Build App Image (push) Successful in 1m16s
Build and Publish Docker Image / Build Summary (push) Successful in 3s
La page et le panneau de differences avaient ete ecrits avant que main ne pose
un design system unique : classes Tailwind brutes, et surtout un detail
depliable reste sur fond clair alors que son contenu venait de passer en sombre.

Reprend les classes du systeme (page, card, table, btn, badge, subcard,
empty-state, spinner) et ramene le detail au fond sombre. Les avertissements de
reecriture passent en amber-400, illisibles jusqu'ici sur fond sombre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 07:09:46 +02:00
92cf21309f feat: affiche la date de derniere extraction et revient aux documents
La liste des documents montrait la date d'import, figee au premier
enregistrement : c'est la date de la derniere extraction ayant produit les
donnees affichees qui renseigne, surtout apres un balayage de re-extraction. La
colonne `extracted_at` la porte, mise a jour a chaque enregistrement, et la date
d'import reste consultable en infobulle.

Le projet n'ayant pas d'outil de migration (`create_all` laisse intactes les
tables existantes), `init_db` rattrape les colonnes ajoutees apres coup : sans
cela une base deja installee cesserait de fonctionner. Les documents deja
presents recoivent leur date d'import, qui est bien celle de leur extraction.

Une fois la selection appliquee sans echec, le balayage renvoie vers la liste des
documents ; en cas d'echec on reste sur place, les lignes concernees portant leur
message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 07:02:28 +02:00
a1da4fc97f feat: annonce les reecritures que l'enregistrement imposera
Refuser une modification, c'est reenvoyer la valeur actuelle du document. Mais
l'enregistrement ramene les donnees a une forme canonique (aujourd'hui les
numeros de lot) : sur ces champs, le refus n'est pas conserve. L'interface le
laissait croire.

Plutot que de traiter le cas des lots en dur, le serveur devient seul juge de ce
qu'il stocke et l'annonce : `canonicalize_extraction` regroupe les reecritures
faites a la persistance, `save_document` et l'apercu passent par elle, et
/api/documents/{id}/re-extract renvoie `previous_canonical` a cote de
`previous_data`. Le front compare ces deux jeux et signale, sous chaque case
decochee, la valeur que l'enregistrement reecrira — sans connaitre aucune regle.
Une transformation ajoutee plus tard remontera donc toute seule dans l'interface.

Toutes les differences restent visibles et toutes les cases actives : voir ce
qui change et pouvoir se prononcer prime, quitte a etre prevenu que ce champ-la
reviendra reecrit.

Supprime au passage frontend/src/utils/lots.js, qui dupliquait la regle de
normalisation cote client.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 07:02:01 +02:00
43efea4945 feat: valide la re-extraction modification par modification
Le balayage n'imposait que le tout ou rien par document. Chaque modification a
desormais sa case : un champ de metadonnee, le nom ou le type d'un locataire,
une operation ajoutee ou supprimee. Les champs textuels retenus sont en outre
modifiables a la main, pour les cas ou ni l'ancienne ni la nouvelle valeur ne
convient. Tout reste coche par defaut : le cas courant tient toujours en un clic.

Les montants et lignes d'un locataire forment un seul bloc : les retenir
separement produirait un total ne correspondant plus a ses lignes, alors que ces
chiffres alimentent les revenus et les depenses.

Le document enregistre est construit par fusion (mergeExtraction) : on part des
donnees en base et on n'y applique que ce qui est retenu. Le diff porte donc
maintenant de quoi rejouer chaque changement (identifiant, chemin, valeurs
brutes) et l'index d'origine des operations supprimees, pour les remettre a leur
place si on refuse leur disparition. Les tags suivent la liste d'operations
finale, y compris ceux d'une operation restauree.

Une reecriture de forme d'un numero de lot ("0001" -> "01") est signalee comme
imposee et sa case verrouillee : le backend normalise tout enregistrement, une
case sans effet aurait laisse croire l'inverse.

Ajoute vitest : la fusion decide de ce qui est ecrit en base, elle est couverte
par 15 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 07:02:01 +02:00
5f3a26e7d7 feat: balayage de re-extraction de toute la base
Nouvelle page /re-extraction, accessible depuis la liste des documents : elle
rejoue l'extraction de chaque PDF stocke, document par document, avec une
progression et une estimation du temps restant (une extraction prend plusieurs
secondes) et un bouton d'arret. Rien n'est ecrit en base pendant le balayage.

Les documents dont l'extraction change sont listes avec le nombre de
differences par section, deja coches ; les inchanges sont replies et les
documents sans PDF ou en erreur isoles. Le detail depliable reutilise
l'affichage de diff de la re-extraction unitaire (extrait dans
ExtractionDiffDetails). Un seul bouton enregistre toute la selection.

Les tags de depenses sont reportes par signature d'operation et non par index :
une operation ajoutee ou retiree par le nouveau parser ne decale plus les
categories. Ce qui n'est pas reapparie recoit une prediction, modifiable dans le
detail avant enregistrement.

L'edition d'un document et l'application du balayage passent par
PUT /api/documents/{id}, qui vise le document par son ID.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 07:01:01 +02:00
6f2530014f fix: conserve l'identite d'un document re-extrait
Le remplacement par ID mettait a jour le document en le supprimant puis en le
recreant : la ligne changeait d'ID, cassant les liens qui la referencent (URL
d'edition, telechargement du PDF) juste apres un balayage de re-extraction. Le
document est desormais mis a jour sur place, seules les donnees derivees
(revenus, depenses) etant regenerees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 06:56:02 +02:00
9cf0a89aab feat: expose la re-extraction verifiable d'un document
POST /api/documents/{id}/re-extract retourne desormais, en plus des donnees
re-extraites, les donnees actuelles et les tags des depenses : un seul appel
suffit pour comparer avant/apres et reporter les tags. L'endpoint devient
synchrone pour que FastAPI l'execute dans un thread, l'extraction bloquant
plusieurs secondes par PDF.

PUT /api/documents/{id} enregistre la nouvelle extraction en visant le
document par son ID (save_document(replace_document_id=...)) : une extraction
qui corrige la reference ou la date met a jour le bon document au lieu d'en
creer un second, et refuse la collision avec un document voisin. Le PDF
stocke est conserve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 06:56:02 +02:00
50508c98a8 chore(ui): supprime PdfUploader, composant mort
Aucun import ne le referencait : le televersement se fait depuis la zone
de depot de l'accueil et le bouton de l'en-tete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 05:17:43 +02:00
7a87958af7 fix(ui): affiche les soldes crediteurs en vert
La comparaison portait sur « crediteur » sans accent alors que le parser
ecrit « créditeur » des qu'il a lu la mention dans le PDF : aucun solde
ne passait le test, tous s'affichaient en rouge.

Compare desormais sur une forme normalisee (sans accents ni casse), ce
qui couvre les deux formes presentes en base sans avoir a migrer les
documents deja extraits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 05:17:11 +02:00
23 changed files with 3296 additions and 331 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"chart.js": "^4.4.1",
@@ -20,6 +21,7 @@
"autoprefixer": "^10.4.18",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"vite": "^5.1.6"
"vite": "^5.1.6",
"vitest": "^4.1.10"
}
}

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

View File

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

View File

@@ -47,65 +47,15 @@
</div>
<!-- Détail -->
<div v-if="open && diff.summary.total > 0" class="max-h-56 overflow-auto px-4 pb-3 space-y-3 text-xs">
<!-- Métadonnées -->
<div v-if="diff.metadata.length">
<div class="font-semibold text-amber-300 mb-1">Métadonnées</div>
<div class="space-y-0.5">
<div v-for="(c, i) in diff.metadata" :key="'m' + i" class="flex items-baseline gap-2">
<span class="text-gray-500 w-40 flex-shrink-0">{{ c.label }}</span>
<span class="text-red-400 line-through">{{ c.before }}</span>
<span class="text-gray-500"></span>
<span class="text-green-400 font-medium">{{ c.after }}</span>
</div>
</div>
</div>
<!-- Locataires -->
<div v-if="diff.locataires.length">
<div class="font-semibold text-amber-300 mb-1">Locataires</div>
<div class="space-y-1.5">
<div v-for="(loc, i) in diff.locataires" :key="'l' + i" class="bg-gray-900/70 border border-gray-700 rounded p-1.5">
<div class="flex items-center gap-2 mb-0.5">
<span :class="kindClass(loc.kind)" class="text-[10px] uppercase font-semibold px-1 rounded">
{{ kindLabel(loc.kind) }}
</span>
<span class="font-medium text-gray-200">{{ loc.title }}</span>
</div>
<div v-for="(f, j) in loc.fields" :key="j" class="flex items-baseline gap-2 pl-2">
<span class="text-gray-500 w-24 flex-shrink-0">{{ f.label }}</span>
<span class="text-red-400 line-through break-all">{{ f.before }}</span>
<span class="text-gray-500"></span>
<span class="text-green-400 font-medium break-all">{{ f.after }}</span>
</div>
</div>
</div>
</div>
<!-- Opérations -->
<div v-if="diff.operations.length">
<div class="font-semibold text-amber-300 mb-1">Opérations</div>
<div class="space-y-1.5">
<div v-for="(op, i) in diff.operations" :key="'o' + i" class="bg-gray-900/70 border border-gray-700 rounded p-1.5">
<div class="flex items-center gap-2 mb-0.5">
<span :class="kindClass(op.kind)" class="text-[10px] uppercase font-semibold px-1 rounded">
{{ kindLabel(op.kind) }}
</span>
<span v-if="op.index >= 0" class="text-gray-500">opération #{{ op.index + 1 }}</span>
</div>
<div class="pl-2">
<div class="text-red-400 line-through break-all">{{ op.before }}</div>
<div class="text-green-400 font-medium break-all">{{ op.after }}</div>
</div>
</div>
</div>
</div>
<div v-if="open && diff.summary.total > 0" class="max-h-56 overflow-auto px-4 pb-3">
<ExtractionDiffDetails :diff="diff" />
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import ExtractionDiffDetails from './ExtractionDiffDetails.vue'
defineProps({
diff: {
@@ -117,16 +67,4 @@ defineProps({
defineEmits(['revert', 'close'])
const open = ref(true)
function kindLabel(kind) {
if (kind === 'added') return 'ajouté'
if (kind === 'removed') return 'supprimé'
return 'modifié'
}
function kindClass(kind) {
if (kind === 'added') return 'bg-green-500/20 text-green-300'
if (kind === 'removed') return 'bg-red-500/20 text-red-300'
return 'bg-amber-500/20 text-amber-300'
}
</script>

View File

@@ -10,6 +10,20 @@
{{ documents.length }} document{{ documents.length > 1 ? 's' : '' }} en base
</p>
</div>
<div class="flex items-center gap-3">
<!-- Re-extraction de toute la base -->
<router-link
to="/re-extraction"
class="btn btn-secondary"
title="Rejouer l'extraction de tous les PDF stockes"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Re-extraire la base
</router-link>
<!-- Import button -->
<label class="btn btn-primary cursor-pointer">
@@ -24,6 +38,7 @@
@change="onFileSelect"
/>
</label>
</div>
</div>
<!-- Loading state -->
@@ -49,7 +64,7 @@
<tr>
<th>Document</th>
<th>Immeuble</th>
<th>Date</th>
<th>Date / extraction</th>
<th class="text-right">Solde</th>
<th class="text-center">Fichiers</th>
<th class="text-right">Actions</th>
@@ -85,15 +100,20 @@
<!-- Date -->
<td>
<div class="text-sm text-white">{{ formatDate(doc.date) }}</div>
<div class="text-xs text-gray-500">Importe le {{ formatDateTime(doc.created_at) }}</div>
<div
class="text-xs text-gray-500"
:title="`Importé le ${formatDateTime(doc.created_at)}`"
>
Extrait le {{ formatDateTime(doc.extracted_at || doc.created_at) }}
</div>
</td>
<!-- Solde -->
<td class="text-right">
<span
<span
:class="[
'text-sm font-medium',
doc.solde_type === 'crediteur' ? 'text-green-400' : 'text-red-400'
isSoldeCrediteur(doc.solde_type) ? 'text-green-400' : 'text-red-400'
]"
>
{{ formatAmount(doc.solde_montant) }}
@@ -173,6 +193,7 @@
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { pendingFile } from '../store'
import { isSoldeCrediteur } from '../utils/solde'
const router = useRouter()

View File

@@ -262,16 +262,17 @@ async function handleSave(depensesTags, shouldOverwrite) {
saveError.value = null
try {
const response = await fetch('/api/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
// Mise à jour ciblée par ID : si la nouvelle extraction corrige la référence
// ou la date, c'est bien ce document qui est mis à jour, pas un doublon.
const response = await fetch(`/api/documents/${documentId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
source_file: extractedData.value.source_file,
data: extractedData.value.data,
depenses_tags: depensesTags,
overwrite: true // Toujours true en mode édition
depenses_tags: depensesTags
})
})

View File

@@ -97,10 +97,10 @@
</div>
</div>
<div class="text-right ml-4">
<span
<span
:class="[
'text-sm font-medium',
doc.solde_type === 'crediteur' ? 'text-green-400' : 'text-red-400'
isSoldeCrediteur(doc.solde_type) ? 'text-green-400' : 'text-red-400'
]"
>
{{ formatAmount(doc.solde_montant) }}
@@ -121,6 +121,7 @@
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { pendingFile } from '../store'
import { isSoldeCrediteur } from '../utils/solde'
import QuickActions from '../components/dashboard/QuickActions.vue'
import FinancialSummary from '../components/dashboard/FinancialSummary.vue'

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

View File

@@ -5,6 +5,7 @@ import AnalyticsPage from './pages/AnalyticsPage.vue'
import RevenusPage from './pages/RevenusPage.vue'
import DocumentsPage from './pages/DocumentsPage.vue'
import EditDocumentPage from './pages/EditDocumentPage.vue'
import ReExtractionPage from './pages/ReExtractionPage.vue'
import IAPage from './pages/IAPage.vue'
import ConfigPage from './pages/ConfigPage.vue'
@@ -48,6 +49,11 @@ const routes = [
path: '/documents/:id/edit',
name: 'edit-document',
component: EditDocumentPage
},
{
path: '/re-extraction',
name: 're-extraction',
component: ReExtractionPage
}
]

View File

@@ -1,8 +1,15 @@
// Comparaison de deux jeux de données d'extraction (avant / après re-extraction).
//
// Chaque jeu a la forme { metadata, situation_locataires, recapitulatif_operations }.
// Retourne un résumé structuré des différences, destiné à la fois à l'affichage
// (panneau de diff) et à la mise en évidence inline (anneaux « modifié »).
// Retourne un résumé structuré des différences, destiné à trois usages :
// - l'affichage (panneau de diff) via `label` / `before` / `after`, déjà formatés ;
// - la mise en évidence inline (anneaux « modifié ») via les listes `changed*` ;
// - l'application sélective (cf. mergeExtraction.js) via `id`, `path` et les
// valeurs brutes `beforeValue` / `afterValue`.
//
// Maille des changements sélectionnables : un champ pour ce qui est textuel
// (nom, numéro de lot, métadonnées), un bloc pour les montants et lignes d'un
// locataire — les retenir séparément produirait des totaux qui ne somment plus.
const EPS = 0.005
@@ -49,6 +56,27 @@ const METADATA_FIELDS = [
['solde.date_arrete', 'Solde · date arrêté'],
]
// Champs identitaires d'un locataire : du texte, sans lien arithmétique avec le
// reste. Chacun se retient ou se refuse indépendamment.
const LOCATAIRE_IDENTITE = [
['locataire.nom', 'Nom'],
['lot.numero', 'Lot'],
['lot.type', 'Type'],
]
// Champs chiffrés d'un locataire : liés entre eux (total = somme des postes,
// impayés = total réglés), donc regroupés en un seul changement.
const LOCATAIRE_MONTANTS = [
['totaux.loyers', 'Total loyers'],
['totaux.taxes', 'Total taxes'],
['totaux.provisions', 'Total provisions'],
['totaux.divers', 'Total divers'],
['totaux.solde_anterieur', 'Solde antérieur'],
['totaux.total', 'Total'],
['totaux.regles', 'Réglés'],
['totaux.impayes', 'Impayés'],
]
function ligneSig(l) {
if (!l) return '∅'
const p = l.periode || {}
@@ -77,30 +105,49 @@ function diffLignes(before, after) {
return changes
}
function diffLocataire(before, after) {
const fields = []
const scalar = [
['locataire.nom', 'Nom'],
['lot.numero', 'Lot'],
['lot.type', 'Type'],
['totaux.loyers', 'Total loyers'],
['totaux.taxes', 'Total taxes'],
['totaux.provisions', 'Total provisions'],
['totaux.divers', 'Total divers'],
['totaux.solde_anterieur', 'Solde antérieur'],
['totaux.total', 'Total'],
['totaux.regles', 'Réglés'],
['totaux.impayes', 'Impayés'],
]
for (const [path, label] of scalar) {
// Changements d'un locataire modifié : un par champ identitaire, plus un bloc
// unique pour l'ensemble des montants et des lignes.
function diffLocataire(index, before, after) {
const changes = []
for (const [path, label] of LOCATAIRE_IDENTITE) {
const bv = get(before, path)
const av = get(after, path)
if (!valEq(bv, av)) {
fields.push({ label, before: fmtValue(bv), after: fmtValue(av) })
changes.push({
id: `loc:${index}:${path}`,
kind: 'champ',
path,
label,
before: fmtValue(bv),
after: fmtValue(av),
beforeValue: bv,
afterValue: av,
editable: true,
})
}
}
fields.push(...diffLignes(before?.lignes, after?.lignes))
return fields
const montants = []
for (const [path, label] of LOCATAIRE_MONTANTS) {
const bv = get(before, path)
const av = get(after, path)
if (!valEq(bv, av)) {
montants.push({ label, before: fmtValue(bv), after: fmtValue(av) })
}
}
montants.push(...diffLignes(before?.lignes, after?.lignes))
if (montants.length) {
changes.push({
id: `loc:${index}:montants`,
kind: 'montants',
label: 'Montants et lignes',
fields: montants,
})
}
return changes
}
function locataireTitle(loc) {
@@ -110,7 +157,9 @@ function locataireTitle(loc) {
return `${lot}${nom}`
}
function opSig(op) {
// Signature d'une opération : sert au diff (appariement par contenu) comme au
// report des tags d'une extraction à l'autre.
export function opSig(op) {
if (!op) return '∅'
const m = op.montants || {}
return (
@@ -132,7 +181,17 @@ export function computeExtractionDiff(before, after) {
const bv = get(b.metadata, path)
const av = get(a.metadata, path)
if (!valEq(bv, av)) {
metadata.push({ label, before: fmtValue(bv), after: fmtValue(av) })
metadata.push({
id: `meta:${path}`,
kind: 'champ',
path,
label,
before: fmtValue(bv),
after: fmtValue(av),
beforeValue: bv,
afterValue: av,
editable: true,
})
changedMetadataPaths.push(path)
}
}
@@ -147,14 +206,26 @@ export function computeExtractionDiff(before, after) {
const ob = i < lb.length ? lb[i] : null
const oa = i < la.length ? la[i] : null
if (!ob && oa) {
locataires.push({ index: i, kind: 'added', title: locataireTitle(oa), fields: [] })
locataires.push({
id: `loc:${i}:entier`,
index: i,
kind: 'added',
title: locataireTitle(oa),
changes: [],
})
changedLocataireIndices.push(i)
} else if (ob && !oa) {
locataires.push({ index: i, kind: 'removed', title: locataireTitle(ob), fields: [] })
locataires.push({
id: `loc:${i}:entier`,
index: i,
kind: 'removed',
title: locataireTitle(ob),
changes: [],
})
} else {
const fields = diffLocataire(ob, oa)
if (fields.length) {
locataires.push({ index: i, kind: 'modified', title: locataireTitle(oa), fields })
const changes = diffLocataire(i, ob, oa)
if (changes.length) {
locataires.push({ index: i, kind: 'modified', title: locataireTitle(oa), changes })
changedLocataireIndices.push(i)
}
}
@@ -182,17 +253,32 @@ export function computeExtractionDiff(before, after) {
oldCounts.set(s, remaining - 1) // appariée avec une ancienne identique
} else {
changedOperationIndices.push(i)
operations.push({ index: i, kind: 'added', before: '∅', after: fmtValue(s) })
operations.push({
id: `op:added:${i}`,
index: i,
oldIndex: -1,
kind: 'added',
before: '∅',
after: fmtValue(s),
})
}
newCounts.set(s, (newCounts.get(s) || 0) + 1)
})
// Anciennes opérations absentes de la nouvelle extraction -> supprimées.
// `oldIndex` permet de les réinsérer à leur place si la suppression est refusée.
const seen = new Map()
ob.forEach((op) => {
ob.forEach((op, i) => {
const s = opSig(op)
seen.set(s, (seen.get(s) || 0) + 1)
if ((newCounts.get(s) || 0) < seen.get(s)) {
operations.push({ index: -1, kind: 'removed', before: fmtValue(s), after: '∅' })
operations.push({
id: `op:removed:${i}`,
index: -1,
oldIndex: i,
kind: 'removed',
before: fmtValue(s),
after: '∅',
})
}
})
@@ -213,3 +299,79 @@ export function computeExtractionDiff(before, after) {
summary,
}
}
/**
* Repère les changements que le serveur imposera même si on les refuse.
*
* Refuser un changement, c'est réenvoyer la valeur actuelle du document. Or
* l'enregistrement réécrit certaines valeurs sous une forme canonique : si la
* valeur actuelle n'est pas déjà canonique, la refuser ne changera rien.
*
* On le déduit en comparant les données actuelles à leur forme canonique, toutes
* deux fournies par le serveur : aucune règle de réécriture n'est connue ici, et
* une transformation ajoutée plus tard remonte sans modification de ce code.
*
* @returns {Object} identifiant de changement → valeur que le serveur écrira
*/
export function computeServerOverrides(diff, previous, canonical) {
const overrides = {}
if (!previous || !canonical) return overrides
const differs = (a, b) => JSON.stringify(a ?? null) !== JSON.stringify(b ?? null)
for (const change of diff.metadata) {
const actuel = get(previous.metadata, change.path)
const impose = get(canonical.metadata, change.path)
if (differs(actuel, impose)) overrides[change.id] = impose
}
const prevLoc = previous.situation_locataires || []
const canonLoc = canonical.situation_locataires || []
for (const loc of diff.locataires) {
const actuel = prevLoc[loc.index]
const impose = canonLoc[loc.index]
if (actuel === undefined) continue // locataire ajouté : rien à conserver
if (loc.kind !== 'modified') {
// Refuser, c'est conserver le locataire entier tel qu'il est en base.
if (differs(actuel, impose)) overrides[loc.id] = impose
continue
}
for (const change of loc.changes) {
if (change.kind === 'montants') {
if (differs(actuel?.totaux, impose?.totaux) || differs(actuel?.lignes, impose?.lignes)) {
overrides[change.id] = { totaux: impose?.totaux, lignes: impose?.lignes }
}
} else {
const a = get(actuel, change.path)
const b = get(impose, change.path)
if (differs(a, b)) overrides[change.id] = b
}
}
}
const prevOps = previous.recapitulatif_operations || []
const canonOps = canonical.recapitulatif_operations || []
for (const op of diff.operations) {
if (op.kind !== 'removed') continue // un ajout refusé n'est pas enregistré
if (differs(prevOps[op.oldIndex], canonOps[op.oldIndex])) {
overrides[op.id] = canonOps[op.oldIndex]
}
}
return overrides
}
// Identifiants de tous les changements sélectionnables, dans l'ordre d'affichage.
// Un locataire ajouté ou supprimé compte pour un ; un locataire modifié expose
// une entrée par champ identitaire, plus une pour ses montants.
export function listChangeIds(diff) {
const ids = []
for (const m of diff.metadata) ids.push(m.id)
for (const loc of diff.locataires) {
if (loc.kind === 'modified') ids.push(...loc.changes.map((c) => c.id))
else ids.push(loc.id)
}
for (const op of diff.operations) ids.push(op.id)
return ids
}

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

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

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

View 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 lindex dorigine 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 dorigine', () => {
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('najoute 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 lenregistrement', () => {
// 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 laffichage', () => {
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)
})
})

View File

@@ -13,6 +13,7 @@ from ...database import DatabaseService, get_session, storage
from ...database.models import Depense, Document, Immeuble, Locataire, Lot, Revenu
from ...database.service import DuplicateDocumentError
from ...extractor import extract_compte_rendu
from ...utils.canonical import canonical_copy
from ...utils.uploads import UploadTooLargeError, read_upload_limited
from ..schemas import DocumentSummary, SaveRequest, SaveResponse
@@ -194,6 +195,7 @@ async def list_documents(
solde_montant=doc.solde_montant,
solde_type=doc.solde_type,
created_at=doc.created_at.isoformat() if doc.created_at else None,
extracted_at=doc.extracted_at.isoformat() if doc.extracted_at else None,
has_pdf=doc.pdf_path is not None,
has_json=doc.json_path is not None,
)
@@ -242,7 +244,11 @@ async def get_document(
"siret": document.editeur_siret,
},
"json_data": json.loads(document.json_data) if document.json_data else None,
"depenses_tags": db_service.get_depenses_tags(document_id),
"created_at": document.created_at.isoformat() if document.created_at else None,
"extracted_at": document.extracted_at.isoformat()
if document.extracted_at
else None,
"has_pdf": document.pdf_path is not None,
"has_json": document.json_path is not None,
}
@@ -358,18 +364,84 @@ async def download_document_json(
)
@router.put("/documents/{document_id}", response_model=SaveResponse)
async def update_document(
document_id: int,
request: SaveRequest,
session: Session = Depends(get_session),
) -> SaveResponse:
"""Remplace les donnees d'un document existant, identifie par son ID.
A la difference de `POST /api/save` avec `overwrite`, qui retrouve le
document par (reference, date), le document vise est ici designe par son ID :
une nouvelle extraction qui corrige la reference ou la date met a jour le bon
document au lieu d'en creer un second. Le PDF stocke est conserve.
- **data**: Nouvelles donnees extraites
- **depenses_tags**: Tags a appliquer aux depenses (par index d'operation)
- **source_file**: Nom du fichier source (optionnel, conserve si absent)
"""
db_service = DatabaseService(session)
existing = db_service.get_document_by_id(document_id)
if not existing:
raise HTTPException(status_code=404, detail="Document non trouve")
try:
document = db_service.save_document(
data=request.data,
source_file=request.source_file or existing.source_file,
depenses_tags=request.depenses_tags,
replace_document_id=document_id,
)
except DuplicateDocumentError as e:
return SaveResponse(
success=False,
message=(
f"Un autre document porte deja reference={e.reference}, date={e.date}"
),
reference=e.reference,
date=str(e.date),
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception:
logger.exception("Erreur lors de la mise a jour du document %s", document_id)
raise HTTPException(
status_code=500, detail="Erreur lors de la mise a jour du document."
)
return SaveResponse(
success=True,
message="Document mis a jour avec succes",
document_id=document.id,
reference=document.reference,
date=str(document.date),
)
@router.post("/documents/{document_id}/re-extract")
async def re_extract_document(
def re_extract_document(
document_id: int,
session: Session = Depends(get_session),
) -> dict:
"""Re-extrait les donnees depuis le PDF stocke.
Utile pour corriger l'extraction apres amelioration des parsers.
Ne modifie pas automatiquement la base - retourne les nouvelles donnees
pour validation par l'utilisateur.
Utile pour corriger l'extraction apres amelioration des parsers, document par
document ou lors d'un balayage de toute la base. Ne modifie pas la base :
retourne cote a cote les donnees actuelles et les donnees re-extraites, pour
que l'appelant compare et decide s'il enregistre (PUT /api/documents/{id}).
Retourne les donnees re-extraites du PDF.
`depenses_tags` porte les tags actuels du document, pour pouvoir etre
reportes sur la nouvelle extraction plutot que perdus au reenregistrement.
`previous_canonical` donne les donnees actuelles telles qu'elles seraient
reecrites a l'enregistrement (cf. utils.canonical). L'appelant compare avec
`previous_data` pour savoir quels champs le serveur imposera de toute facon,
et le signaler avant que l'utilisateur ne se prononce — sans avoir a
connaitre les regles de reecriture.
Endpoint synchrone (`def`) : l'extraction est bloquante et prend plusieurs
secondes, FastAPI l'execute donc dans un thread pour ne pas figer le serveur.
"""
db_service = DatabaseService(session)
document = db_service.get_document_by_id(document_id)
@@ -401,11 +473,17 @@ async def re_extract_document(
detail=f"Erreur lors de la re-extraction: {str(e)}",
)
previous_data = json.loads(document.json_data) if document.json_data else None
return {
"document_id": document_id,
"reference": document.reference,
"date": str(document.date),
"source_file": document.source_file,
"original_json_path": document.json_path,
"previous_data": previous_data,
"previous_canonical": canonical_copy(previous_data),
"re_extracted_data": new_data,
"depenses_tags": db_service.get_depenses_tags(document_id),
"message": "Donnees re-extraites. Utilisez PUT /api/documents/{id} pour mettre a jour.",
}

View File

@@ -53,6 +53,9 @@ class DocumentSummary(BaseModel):
solde_montant: float | None
solde_type: str | None
created_at: str
#: Derniere extraction ayant produit les donnees (absente sur une base
#: anterieure a ce champ : l'appelant retombe alors sur `created_at`).
extracted_at: str | None = None
has_pdf: bool = False
has_json: bool = False

View File

@@ -87,6 +87,7 @@ def init_db(db_path: Path | None = None) -> Path:
# Build the engine (reuses the shared configuration) and create tables
engine = get_engine(db_path)
Base.metadata.create_all(bind=engine)
_apply_schema_updates(engine)
# Seed predefined tags if the table is empty
_seed_tags_if_empty(engine)
@@ -94,6 +95,34 @@ def init_db(db_path: Path | None = None) -> Path:
return db_path
#: Colonnes ajoutees apres coup, par table : nom -> (definition SQL, valeur de
#: rattrapage pour les lignes existantes). `create_all` ne modifie pas une table
#: deja presente, et le projet n'utilise pas d'outil de migration : sans ce
#: rattrapage, une base installee cesserait de fonctionner apres mise a jour.
_ADDED_COLUMNS = {
"documents": {
# Les documents deja en base ont ete extraits lors de leur import.
"extracted_at": ("DATETIME", "created_at"),
},
}
def _apply_schema_updates(engine):
"""Ajoute a une base existante les colonnes apparues depuis sa creation."""
with engine.begin() as conn:
for table, columns in _ADDED_COLUMNS.items():
existing = {
row[1] for row in conn.execute(text(f"PRAGMA table_info({table})"))
}
if not existing: # table absente : create_all vient de la creer
continue
for name, (definition, backfill) in columns.items():
if name in existing:
continue
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {name} {definition}"))
conn.execute(text(f"UPDATE {table} SET {name} = {backfill}"))
def _seed_tags_if_empty(engine):
"""Insert predefined tags if the tags table is empty."""
from ..scripts.seed_tags import PREDEFINED_TAGS

View File

@@ -161,6 +161,10 @@ class Document(Base):
solde_date_arrete = Column(Date, nullable=True)
created_at = Column(DateTime, default=_utcnow)
#: Date de la derniere extraction ayant produit les donnees stockees. Une
#: re-extraction validee la met a jour, contrairement a `created_at` qui
#: reste la date du premier import.
extracted_at = Column(DateTime, default=_utcnow)
# Chemins vers les fichiers stockés (relatifs à PLESNA_STORAGE_PATH)
pdf_path = Column(

View File

@@ -1,16 +1,16 @@
"""Database service for saving and querying extracted data."""
import json
from datetime import date, datetime
from datetime import date, datetime, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..utils.amounts import parse_amount
from ..utils.canonical import canonicalize_extraction
from ..utils.lots import (
LOT_NUMERO_INCONNU,
normalize_extraction_lots,
normalize_lot_numero,
)
from . import storage
@@ -127,6 +127,7 @@ class DatabaseService:
pdf_content: bytes = None,
depenses_tags: list[dict] = None,
overwrite: bool = False,
replace_document_id: int = None,
) -> Document:
"""Save extracted JSON data to database.
@@ -137,15 +138,24 @@ class DatabaseService:
pdf_content: Binary content of the PDF file (optional, for storage)
depenses_tags: List of tags for expenses
overwrite: If True, delete existing document and recreate it
replace_document_id: ID du document a remplacer. Le document vise est
identifie par son ID et non par (reference, date) : une nouvelle
extraction qui corrige la reference ou la date met alors a jour le
bon document au lieu d'en creer un second.
Returns:
The created Document instance
Raises:
DuplicateDocumentError: If document already exists and overwrite=False
DuplicateDocumentError: If document already exists and overwrite=False,
ou si un *autre* document porte deja la (reference, date) de
`data` lors d'un remplacement par ID
ValueError: Si `replace_document_id` ne designe aucun document
"""
# Uniformiser les numéros de lot avant toute persistance (JSON + tables)
normalize_extraction_lots(data)
# Ramener l'extraction à sa forme canonique avant toute persistance
# (JSON + tables). Point de passage unique : l'aperçu de re-extraction
# interroge la même fonction pour annoncer ces réécritures.
canonicalize_extraction(data)
metadata = data.get("metadata", {})
doc_info = metadata.get("document", {})
@@ -163,18 +173,38 @@ class DatabaseService:
# Check for duplicates and preserve existing file paths if overwriting
existing_pdf_path = None
existing_json_path = None
existing = self.check_duplicate(reference, doc_date)
if existing:
if overwrite:
# Preserve existing file paths for reuse
existing_pdf_path = existing.pdf_path
existing_json_path = existing.json_path
# Delete existing document (cascade will delete related data)
# But DON'T delete files - we'll reuse or update them
self.session.delete(existing)
self.session.flush()
else:
reused: Document | None = None # document mis a jour sur place
if replace_document_id is not None:
existing = self.session.get(Document, replace_document_id)
if existing is None:
raise ValueError(f"Document {replace_document_id} introuvable")
# La nouvelle extraction ne doit pas entrer en collision avec un
# autre document deja en base.
collision = self.check_duplicate(reference, doc_date)
if collision is not None and collision.id != existing.id:
raise DuplicateDocumentError(reference, doc_date)
existing_pdf_path = existing.pdf_path
existing_json_path = existing.json_path
# Les donnees derivees sont regenerees, mais la ligne document est
# conservee : son ID survit a la re-extraction, donc les liens qui
# la referencent (URL d'edition, PDF) restent valides.
existing.revenus.clear()
existing.depenses.clear()
self.session.flush()
reused = existing
else:
existing = self.check_duplicate(reference, doc_date)
if existing:
if overwrite:
# Preserve existing file paths for reuse
existing_pdf_path = existing.pdf_path
existing_json_path = existing.json_path
# Delete existing document (cascade will delete related data)
# But DON'T delete files - we'll reuse or update them
self.session.delete(existing)
self.session.flush()
else:
raise DuplicateDocumentError(reference, doc_date)
# Get or create immeuble
immeuble = self.get_or_create_immeuble(
@@ -204,23 +234,31 @@ class DatabaseService:
pdf_path = existing_pdf_path
json_path = existing_json_path
# Create document
document = Document(
reference=reference,
date=doc_date,
type=doc_info.get("type"),
source_file=source_file,
immeuble_id=immeuble.id,
json_data=json.dumps(data, ensure_ascii=False, default=str),
editeur_nom=editeur_info.get("nom"),
editeur_siret=editeur_info.get("siret"),
solde_montant=self._normalize_amount(solde_info.get("montant")),
solde_type=solde_info.get("type"),
solde_date_arrete=self._parse_date(solde_info.get("date_arrete")),
pdf_path=pdf_path,
json_path=json_path,
)
self.session.add(document)
# Create document (ou mise a jour sur place lors d'un remplacement)
fields = {
"reference": reference,
"date": doc_date,
"type": doc_info.get("type"),
"source_file": source_file,
"immeuble_id": immeuble.id,
"json_data": json.dumps(data, ensure_ascii=False, default=str),
"editeur_nom": editeur_info.get("nom"),
"editeur_siret": editeur_info.get("siret"),
"solde_montant": self._normalize_amount(solde_info.get("montant")),
"solde_type": solde_info.get("type"),
"solde_date_arrete": self._parse_date(solde_info.get("date_arrete")),
"pdf_path": pdf_path,
"json_path": json_path,
# Ces donnees viennent de l'extraction qu'on est en train d'enregistrer.
"extracted_at": datetime.now(timezone.utc),
}
if reused is not None:
document = reused
for key, value in fields.items():
setattr(document, key, value)
else:
document = Document(**fields)
self.session.add(document)
self.session.flush()
# Save files to storage
@@ -341,6 +379,30 @@ class DatabaseService:
"""Get a document by ID."""
return self.session.get(Document, doc_id)
def get_depenses_tags(self, doc_id: int) -> list[dict]:
"""Retourne les tags actuels des dépenses d'un document.
Les dépenses sont ordonnées par id, ce qui reproduit l'ordre de
`recapitulatif_operations` au moment de l'enregistrement (cf.
`save_document`). L'index retourné correspond donc à celui de
l'opération dans le JSON, format attendu par `depenses_tags`.
Seules les dépenses effectivement taguées sont retournées.
"""
stmt = (
select(Depense, Tag.nom)
.outerjoin(Tag, Depense.tag_id == Tag.id)
.where(Depense.document_id == doc_id)
.order_by(Depense.id)
)
rows = self.session.execute(stmt).all()
return [
{"index": idx, "tag_id": depense.tag_id, "tag_nom": tag_nom}
for idx, (depense, tag_nom) in enumerate(rows)
if depense.tag_id is not None
]
def get_revenus_summary(
self, immeuble_id: int = None, year: int = None
) -> list[dict]:

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

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