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)
|
||||
|
||||
Reference in New Issue
Block a user