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>
This commit is contained in:
@@ -4,7 +4,8 @@
|
||||
<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" class="flex items-baseline gap-2">
|
||||
<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"
|
||||
@@ -28,6 +29,8 @@
|
||||
@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-orange-700">⚠ {{ overrideLabel(c.id) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -50,19 +53,19 @@
|
||||
</span>
|
||||
<span class="font-medium text-gray-200">{{ loc.title }}</span>
|
||||
</div>
|
||||
<p v-if="loc.id && overrideLabel(loc.id)" class="pl-5 text-orange-700 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="flex items-baseline gap-2 pl-2">
|
||||
<component :is="selectable && !c.forced ? 'label' : 'span'" class="flex items-baseline gap-2 flex-1 min-w-0" :class="selectable && !c.forced && 'cursor-pointer'">
|
||||
<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)"
|
||||
:disabled="c.forced"
|
||||
:title="c.hint"
|
||||
@change="toggle(c.id, $event.target.checked)"
|
||||
class="w-3.5 h-3.5 rounded flex-shrink-0 self-center disabled:opacity-50"
|
||||
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>
|
||||
@@ -79,7 +82,8 @@
|
||||
@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"
|
||||
/>
|
||||
<span v-if="selectable && c.forced" class="text-gray-400 italic flex-shrink-0">{{ c.hint }}</span>
|
||||
</div>
|
||||
<p v-if="overrideLabel(c.id)" class="pl-6 text-orange-700">⚠ {{ overrideLabel(c.id) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Montants et lignes : un seul bloc, pour que les sommes restent justes -->
|
||||
@@ -103,6 +107,7 @@
|
||||
<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-orange-700">⚠ {{ overrideLabel(c.id) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,6 +136,7 @@
|
||||
<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-orange-700">⚠ {{ overrideLabel(op.id) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,6 +146,8 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { fmtValue } from '../utils/diffExtraction.js'
|
||||
|
||||
const props = defineProps({
|
||||
diff: {
|
||||
type: Object,
|
||||
@@ -151,12 +159,27 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
// Changements que l'enregistrement réécrira quoi qu'on décide, par
|
||||
// identifiant → valeur imposée (cf. computeServerOverrides).
|
||||
serverOverrides: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:selection'])
|
||||
|
||||
const selectable = computed(() => props.selection !== null)
|
||||
|
||||
// Texte d'alerte quand refuser un changement ne servirait à rien.
|
||||
function overrideLabel(id) {
|
||||
if (!selectable.value || accepted(id)) return null
|
||||
if (!(id in props.serverOverrides)) return null
|
||||
const value = props.serverOverrides[id]
|
||||
const shown = value != null && typeof value === 'object' ? '' : ` « ${fmtValue(value)} »`
|
||||
return `L’enregistrement réécrira cette valeur${shown}`
|
||||
}
|
||||
|
||||
function accepted(id) {
|
||||
if (!selectable.value) return true
|
||||
const state = props.selection[id]
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
<ExtractionDiffDetails
|
||||
:diff="item.diff"
|
||||
:selection="item.selection"
|
||||
:server-overrides="item.serverOverrides"
|
||||
@update:selection="updateSelection(item, $event)"
|
||||
/>
|
||||
|
||||
@@ -321,7 +322,11 @@
|
||||
import { computed, onMounted, onBeforeUnmount, reactive, ref } from 'vue'
|
||||
import ExtractionDiffDetails from '../components/ExtractionDiffDetails.vue'
|
||||
import TagAutocomplete from '../components/TagAutocomplete.vue'
|
||||
import { computeExtractionDiff, listChangeIds } from '../utils/diffExtraction.js'
|
||||
import {
|
||||
computeExtractionDiff,
|
||||
computeServerOverrides,
|
||||
listChangeIds,
|
||||
} from '../utils/diffExtraction.js'
|
||||
import { countAccepted, mergeExtraction } from '../utils/mergeExtraction.js'
|
||||
import { fillPredictions, remapTags, toDepensesTags } from '../utils/tagRemap.js'
|
||||
|
||||
@@ -432,6 +437,8 @@ function resetItems() {
|
||||
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: {},
|
||||
@@ -492,6 +499,11 @@ async function scanOne(item) {
|
||||
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'
|
||||
|
||||
@@ -11,8 +11,6 @@
|
||||
// (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.
|
||||
|
||||
import { sameLot } from './lots.js'
|
||||
|
||||
const EPS = 0.005
|
||||
|
||||
function numEq(a, b) {
|
||||
@@ -116,10 +114,6 @@ function diffLocataire(index, before, after) {
|
||||
const bv = get(before, path)
|
||||
const av = get(after, path)
|
||||
if (!valEq(bv, av)) {
|
||||
// Une réécriture de forme du numéro de lot ("0001" → "01") est imposée par
|
||||
// le backend, qui normalise tout enregistrement : inutile de proposer de
|
||||
// la refuser, la valeur reviendrait normalisée.
|
||||
const forced = path === 'lot.numero' && sameLot(bv, av)
|
||||
changes.push({
|
||||
id: `loc:${index}:${path}`,
|
||||
kind: 'champ',
|
||||
@@ -129,9 +123,7 @@ function diffLocataire(index, before, after) {
|
||||
after: fmtValue(av),
|
||||
beforeValue: bv,
|
||||
afterValue: av,
|
||||
editable: !forced,
|
||||
forced,
|
||||
hint: forced ? 'Numéro normalisé automatiquement à l’enregistrement' : undefined,
|
||||
editable: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -308,6 +300,68 @@ export function computeExtractionDiff(before, after) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
// Miroir de la normalisation des numéros de lot appliquée par le backend
|
||||
// (src/plesna_gerance/utils/lots.py) : tout enregistrement y passe, quelle que
|
||||
// soit la valeur envoyée. Le front en a besoin pour ne pas proposer de refuser
|
||||
// un changement que le serveur réappliquera de toute façon.
|
||||
|
||||
const LOT_NUMERO_WIDTH = 2
|
||||
const LOT_NUMERO_INCONNU = '00'
|
||||
|
||||
export function normalizeLotNumero(value) {
|
||||
if (value == null) return null
|
||||
const digits = String(value).replace(/\D/g, '')
|
||||
if (!digits) return null
|
||||
const significant = digits.replace(/^0+/, '')
|
||||
if (!significant) return LOT_NUMERO_INCONNU
|
||||
return significant.padStart(LOT_NUMERO_WIDTH, '0')
|
||||
}
|
||||
|
||||
// Vrai quand deux écritures désignent le même lot ("0001" et "01").
|
||||
export function sameLot(a, b) {
|
||||
return normalizeLotNumero(a) === normalizeLotNumero(b)
|
||||
}
|
||||
@@ -8,6 +8,12 @@
|
||||
// `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))
|
||||
@@ -18,13 +24,6 @@ function isAccepted(selection, id) {
|
||||
return state ? state.accepted !== false : true
|
||||
}
|
||||
|
||||
// Un changement imposé (normalisation appliquée par le backend) est appliqué
|
||||
// quoi qu'il arrive : le refuser produirait un écart entre ce qu'affiche
|
||||
// l'interface et ce qui finit en base.
|
||||
function isApplied(selection, change) {
|
||||
return change.forced === true || isAccepted(selection, change.id)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -58,7 +57,7 @@ export function mergeExtraction(previous, next, diff, selection = {}) {
|
||||
// --- Métadonnées : un champ retenu écrase le champ correspondant.
|
||||
if (!merged.metadata) merged.metadata = {}
|
||||
for (const change of diff.metadata) {
|
||||
if (isApplied(selection, change)) {
|
||||
if (isAccepted(selection, change.id)) {
|
||||
setPath(merged.metadata, change.path, chosenValue(selection, change))
|
||||
}
|
||||
}
|
||||
@@ -90,7 +89,7 @@ export function mergeExtraction(previous, next, diff, selection = {}) {
|
||||
|
||||
const kept = clone(previousLoc[i])
|
||||
for (const change of entry.changes) {
|
||||
if (!isApplied(selection, change)) continue
|
||||
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)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
// 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, listChangeIds } from '../src/utils/diffExtraction.js'
|
||||
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.
|
||||
@@ -13,13 +17,13 @@ const previous = {
|
||||
},
|
||||
situation_locataires: [
|
||||
{
|
||||
lot: { numero: '0007', type: 'Appartement' },
|
||||
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: '0008', type: 'Appartement' },
|
||||
lot: { numero: '08', type: 'Appartement' },
|
||||
locataire: { nom: 'BESSON Léa' },
|
||||
totaux: { total: 800, regles: 800, impayes: 0 },
|
||||
lignes: [{ type: 'loyer', loyers: 800, total: 800 }],
|
||||
@@ -31,9 +35,11 @@ const previous = {
|
||||
],
|
||||
}
|
||||
|
||||
// Nouvelle extraction : corrige les numéros de lot, mais agrège un libellé dans
|
||||
// le nom du locataire (régression du parser), change des montants, ajoute une
|
||||
// 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' },
|
||||
@@ -42,7 +48,7 @@ const next = {
|
||||
},
|
||||
situation_locataires: [
|
||||
{
|
||||
lot: { numero: '07', type: 'Appartement' },
|
||||
lot: { numero: '12', type: 'Appartement' },
|
||||
locataire: { nom: 'LATAPY NINA Complément loyer' },
|
||||
totaux: { total: 1250, regles: 1200, impayes: 50 },
|
||||
lignes: [
|
||||
@@ -80,33 +86,6 @@ describe('découpage du diff', () => {
|
||||
expect(montants.fields.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('marque comme imposée la seule réécriture de forme d’un numéro de lot', () => {
|
||||
// Le backend normalise tout numéro de lot à l'enregistrement : refuser
|
||||
// "0007" → "07" n'aurait aucun effet, la case ne doit pas le laisser croire.
|
||||
const lot = diff.locataires[0].changes.find((c) => c.path === 'lot.numero')
|
||||
expect(lot.forced).toBe(true)
|
||||
expect(lot.editable).toBe(false)
|
||||
|
||||
const merged = merge({ [lot.id]: { accepted: false } })
|
||||
expect(merged.situation_locataires[0].lot.numero).toBe('07')
|
||||
})
|
||||
|
||||
it('laisse refusable un vrai changement de lot', () => {
|
||||
const autreLot = {
|
||||
...next,
|
||||
situation_locataires: [
|
||||
{ ...next.situation_locataires[0], lot: { numero: '12', type: 'Appartement' } },
|
||||
next.situation_locataires[1],
|
||||
],
|
||||
}
|
||||
const d = computeExtractionDiff(previous, autreLot)
|
||||
const lot = d.locataires[0].changes.find((c) => c.path === 'lot.numero')
|
||||
|
||||
expect(lot.forced).toBe(false) // "0007" et "12" ne sont pas le même lot
|
||||
const merged = mergeExtraction(previous, autreLot, d, { [lot.id]: { accepted: false } })
|
||||
expect(merged.situation_locataires[0].lot.numero).toBe('0007')
|
||||
})
|
||||
|
||||
it('retient l’index d’origine des opérations supprimées', () => {
|
||||
const removed = diff.operations.find((o) => o.kind === 'removed')
|
||||
expect(removed.oldIndex).toBe(1) // TOTAL / elec
|
||||
@@ -125,7 +104,7 @@ describe('fusion sélective', () => {
|
||||
const latapy = merged.situation_locataires[0]
|
||||
|
||||
expect(latapy.locataire.nom).toBe('LATAPY NINA')
|
||||
expect(latapy.lot.numero).toBe('07')
|
||||
expect(latapy.lot.numero).toBe('12')
|
||||
expect(latapy.totaux.total).toBe(1250) // montants acceptés
|
||||
})
|
||||
|
||||
@@ -136,7 +115,7 @@ describe('fusion sélective', () => {
|
||||
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('07')
|
||||
expect(latapy.lot.numero).toBe('12')
|
||||
expect(latapy.locataire.nom).toBe('LATAPY NINA Complément loyer')
|
||||
})
|
||||
|
||||
@@ -214,6 +193,36 @@ describe('locataires ajoutés ou supprimés', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('réécritures imposées par l’enregistrement', () => {
|
||||
// Le serveur fournit les données actuelles et leur forme canonique ; le front
|
||||
// en déduit ce qu'un refus ne pourrait pas conserver, sans connaître la règle.
|
||||
const enBase = JSON.parse(JSON.stringify(previous))
|
||||
enBase.situation_locataires[0].lot.numero = '0007' // écriture héritée
|
||||
const canonique = JSON.parse(JSON.stringify(enBase))
|
||||
canonique.situation_locataires[0].lot.numero = '07' // ce que le serveur écrirait
|
||||
|
||||
const d = computeExtractionDiff(enBase, next)
|
||||
|
||||
it('signale le champ que le serveur réécrira', () => {
|
||||
const overrides = computeServerOverrides(d, enBase, canonique)
|
||||
const lot = d.locataires[0].changes.find((c) => c.path === 'lot.numero')
|
||||
|
||||
expect(overrides[lot.id]).toBe('07')
|
||||
})
|
||||
|
||||
it('ne signale rien pour les champs que le serveur laisse tels quels', () => {
|
||||
const overrides = computeServerOverrides(d, enBase, canonique)
|
||||
const nom = d.locataires[0].changes.find((c) => c.path === 'locataire.nom')
|
||||
|
||||
expect(nom.id in overrides).toBe(false)
|
||||
expect(Object.keys(overrides)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('ne signale rien quand le serveur ne réécrit rien', () => {
|
||||
expect(computeServerOverrides(diff, previous, previous)).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('comptage pour l’affichage', () => {
|
||||
it('compte les changements retenus', () => {
|
||||
const ids = listChangeIds(diff)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -429,6 +430,12 @@ def re_extract_document(
|
||||
`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.
|
||||
"""
|
||||
@@ -462,13 +469,16 @@ 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": json.loads(document.json_data) if document.json_data else None,
|
||||
"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.",
|
||||
|
||||
@@ -8,9 +8,9 @@ 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
|
||||
@@ -152,8 +152,10 @@ class DatabaseService:
|
||||
`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", {})
|
||||
|
||||
39
src/plesna_gerance/utils/canonical.py
Normal file
39
src/plesna_gerance/utils/canonical.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Forme canonique d'une extraction, telle qu'elle sera persistee.
|
||||
|
||||
L'enregistrement ne stocke pas toujours les donnees telles qu'on les lui donne :
|
||||
certaines valeurs sont ramenees a une ecriture canonique (aujourd'hui les
|
||||
numeros de lot). L'interface de re-extraction doit pouvoir annoncer ces
|
||||
reecritures *avant* d'enregistrer, sans reimplementer les regles : elle demande
|
||||
au serveur la forme canonique et compare.
|
||||
|
||||
Toute transformation appliquee a la persistance doit donc etre ajoutee ici, et
|
||||
nulle part ailleurs : `save_document` et l'apercu passent par cette fonction.
|
||||
"""
|
||||
|
||||
import copy
|
||||
from typing import Any
|
||||
|
||||
from .lots import normalize_extraction_lots
|
||||
|
||||
|
||||
def canonicalize_extraction(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Applique, sur place, les reecritures faites a l'enregistrement.
|
||||
|
||||
Args:
|
||||
data: Extraction (metadata, situation_locataires, recapitulatif_operations)
|
||||
|
||||
Returns:
|
||||
Le meme dict, sous sa forme canonique
|
||||
"""
|
||||
normalize_extraction_lots(data)
|
||||
return data
|
||||
|
||||
|
||||
def canonical_copy(data: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
"""Comme `canonicalize_extraction`, mais sans toucher a l'original.
|
||||
|
||||
Sert a l'apercu : on montre ce qui serait ecrit sans rien modifier.
|
||||
"""
|
||||
if data is None:
|
||||
return None
|
||||
return canonicalize_extraction(copy.deepcopy(data))
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests du remplacement d'un document par son ID (re-extraction validee)."""
|
||||
|
||||
import copy
|
||||
import json
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
@@ -106,6 +107,60 @@ def test_replace_document_inconnu_leve_value_error(db_session, sample_data):
|
||||
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
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user