fix(import): persist the import format and restore it faithfully #335
No reviewers
Labels
No labels
autopilot:pending-human
source:analyste
source:defenseur
source:human
source:medic
status:approved
status:blocked
status:in-progress
status:needs-clarification
status:needs-fix
status:ready
status:review
status:triage
type:bug
type:feature
type:infra
type:refactor
type:schema
type:security
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: maximus/Simpl-Resultat#335
Loading…
Reference in a new issue
No description provided.
Delete branch "issue-324-persist-import-format"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Resolves #324 — link 3 of the import-format stack, stacked on
issue-323-migration-v17(link 2). Review the diff against that branch, notmain.The bug
import_sourcescarried noamount_modeand nosign_conventionuntil v17, so restoring a configured source re-inferred the mode frommapping.debitAmount !== undefinedand wrotesignConvention: "negative_expense"outright (useImportWizard.ts:321-323). A credit-card statement configured for positive expenses came back on the default convention at its second import, andparseFilesInternal:514negated every amount — expenses landed as income, no error shown anywhere. The format is a read value now, not a guessed one.Two types and a codec, not one composed type
The
/review-specrevision on the issue is applied as written: a composedImportFormatis structurally impossible.ImportSource.has_headeris declared boolean,ImportConfigTemplate.has_headeris a number,SourceConfigis camelCase on a parsed mapping. So the guarantee does not come from a shared shape — it comes fromsrc/utils/importFormat.tsbeing the SINGLE conversion point betweenImportFormatRow(persisted, snake_case, mapping as JSON,has_headernormalized to 0/1) andImportFormat(domain), and from its completeness test.That test is enforced on two levels, both mutation-checked:
FORMAT_FIELD_PAIRSis typedRecord<keyof ImportFormat, keyof ImportFormatRow>— a field added to the format fails to build until it is listed.Deleting
sign_conventionfromformatToRow(the exact shape of the original bug) fails 14 tests. Re-introducing the hardcode in the restore fails the static guard.Validation rather than fallback
formatFromRowraises on a value it cannot map instead of defaulting. The v17CHECKadmitsabsolute_indicatorso the third amount mode ships without another migration, but the app cannot read one: falling through to thesinglebranch would read the wrong column for every row, and anything other thanpositive_expensewould silently meannegative_expense. This is the review's CWE-20 point, scoped to the read path this PR rewrites.The error carries an i18n key, and the wizard then opens on a fresh configuration — blocking the wizard would have made "reconfigure this source" an action the user could not take. Not a regression either: today's
JSON.parse(existing.column_mapping)already throws uncaught on corrupt JSON.Also here
checkDuplicatesInternaltoexecuteImport, so an import abandoned at the duplicate step leaves no configuration behind. A guard test holds that it is the only write point in the hook.<select>merely displays is deliberately NOT materialized — #325 turns an unmapped amount column into an explicit row error, and writing a 0 here would make it unreachable. The mode and the pruned mapping land in ONE state update:SourceConfigPanel's handlers each spread the sameconfigprop, so two consecutive calls would see the same stale value and the second would win.template_idis provenance only — recorded, restored, displayed, never re-read as format.selectedTemplateIdis no longer blanked on every source selection. An acceptance test rewrites a template end to end and asserts the linked source reads identically, plus a non-vacuity check that the template really did change.Verification
cargo checkclean. No migration, no Rust change.parseFilesInternaluntouched — link 1's static guard on its five pinned expressions still passes, and none of link 1's 8KNOWN DEFECTblocks moved.docs/entry: the plan centralises both in link 10 (#332) to keep this ten-link stack conflict-free.Handoff to #325 (link 4)
mapRowgoes insrc/utils/importFormat.ts, next to the codec. It should takeImportFormat—SourceConfig extends ImportFormatnow, so the wizard passes its config straight through. WhenmapRowlands, delete themapCorpusRowmirror incsvAutoDetect.test.tsand the guard at:246-267along with it, as link 1's comment instructs.Generated autonomously by /autopilot run of 2026-08-13
/pr-review— Verdict : APPROVELe correctif de cause racine tient : la convention de signe et le mode de montant sont désormais lus, plus jamais devinés, et les trois garde-fous annoncés (type, test unitaire, test d'intégration SQL) ont été vérifiés par mutation, pas crus sur parole. Aucun blocage ; quatre points de suivi, dont un qui contraint l'ordre de merge de la pile.
Vérifications faites (et non pas relues)
J'ai matérialisé l'arbre de la branche hors du dépôt (
git archive, aucun checkout) et exécuté les mutations :sign_conventiondeformatToRowtscTS2741 + 14 tests rouges — le chiffre exact du corps de la PRsign_conventionde la liste de colonnes SQL decreateSourcetscpasse (le SQL n'est pas typé) mais 6 tests deimport-format-roundtrip.test.tsrougessign_conventiondeupdateSourcesignConvention: "negative_expense"dans la restaurationLe deuxième cas est le plus important : c'est exactement le trou que le type ne peut pas voir, et le
FakeDbdeimport-format-roundtrip.test.tsle ferme parce qu'il interprète réellement la liste de colonnes de l'INSERT. La complétude n'est donc pas décorative.Autres points contrôlés :
for (const field of Object.keys(FORMAT_FIELD_PAIRS))est ce qui porte l'assertion — untoEqualglobal seul aurait laissé passer un champ tombé àundefined(toEqualignore les clésundefined). La boucle est présente des deux côtés.thrown'est pas re-transformé en fallback silencieux.formatFromRowlève,selectSourcedispatchSET_ERROR, et rien ne réarme l'erreur ensuite :loadHeadersWithConfigavale ses propres erreurs sans toucherstate.error, et niSET_STEPniSET_PARSED_PREVIEWne la remettent ànulldans le reducer. La bannière atteint donc bien l'écran de configuration, avec un message actionnable dans les deux langues.checkDuplicatesInternaln'utilisaitsourceIdnulle part sous le bloc supprimé, et dansexecuteImportl'écriture précède la bouclecreateImportedFile— la FKsource_idest servie.header_signatureetdescriptionne sont pas dans leSETdeupdateSource, donc préservés (#330 ne sera pas écrasé).has_header:ImportFormatRowInputélargit ànumber | booleanet!!row.has_headernormalise ; le test « reads the boolean an import_sources row is declared with » couvre l'écart déclaré/runtime des deux tables.DEFAULT 'negative_expense'de v17 restitue exactement la valeur que le code écrivait en dur, et le backfillLIKE '%debitAmount%'reproduit l'ancienne inférence. Une source déjà en base ne change pas de comportement au premier rechargement.setColumnsont des littéraux, aucun secret. La frontière de lecture est fail-closed.import.errors.*présentes en FR et EN, aucune clé dupliquée dans les deux fichiers (vérifié parobject_pairs_hook).tsc --noEmitpropre sur l'arbre de la branche, aucun.skip/.only. CI Forgejo verte (run 359).Les trois critères d'acceptation de #324 sont couverts par des tests qui échouent sans le correctif.
Suggestions non bloquantes
1.
clearMappingForModerend le?? 0atteignable en un aller-retour de radio — ne pas merger links 1-3 sans #325src/utils/importFormat.ts:174-192. Un basculementsingle→debit_credit→singlesupprime la cléamount: vérifié, le mapping{date:0, description:1, amount:4}ressort{date:0, description:1}. Le<select>affiche alorsmapping.amount ?? 0(« 0: Date »), etuseImportWizard.ts:536lit la colonne 0. OrparseFrenchAmountne rejette pas une date :"15/01/2026"→15,"2026-01-15"→2026. La ligne passe la validationisNaNet s'importe avec un montant faux, sans erreur.Avant cette PR la clé
amountsurvivait au basculement, donc c'est un chemin nouvellement atteignable vers la classe de bug que le chantier corrige. Le raisonnement de la PR est juste (matérialiser un0rendrait l'erreur de #325 inatteignable) et #325 est le maillon suivant,status:in-progress, avec « supprimer les fallbacks?? 0au profit d'une erreur de ligne explicite » dans ses tâches. C'est donc une contrainte d'ordre de merge, pas un défaut de conception : links 1-3 ne doivent pas atteindremainsans #325. L'étape d'aperçu limite la casse entre-temps (les montants aberrants sont visibles avant l'import).2. Bannière d'erreur périmée en changeant de source —
src/hooks/useImportWizard.ts:293selectSourcene remet jamaiserrorànullen entrée, etgoToStep("source-list")(ImportPage.tsx:125) ne le fait pas non plus. Séquence : source A illisible → « Reconfigurez la source avant d'importer » → retour → source B parfaitement valide → la bannière est toujours là et désigne maintenant la mauvaise source. Le défaut préexiste, mais cette PR fait deselectSourceun producteur d'erreurs, ce qui le rend visible. Une ligne :dispatch({ type: "SET_ERROR", payload: null })en tête deselectSource.3.
saveConfigAsTemplatene s'approprie pas le modèle créé —src/hooks/useImportWizard.ts:939Appliquer T1, éditer, puis « enregistrer comme nouveau modèle T2 » laisse
selectedTemplateIdsur T1 ;executeImportinscrit alors T1 en provenance d'une configuration qui vient de T2. Cosmétique tant quetemplate_idn'est jamais relu comme format — c'est bien l'invariant tenu ici — mais la provenance affichée est fausse.4. Message d'erreur anglais en dur —
src/hooks/useImportWizard.ts:928"Auto-detection failed. Please configure manually."traverse le nouveaut(state.error, { defaultValue: state.error })et s'affiche tel quel, non traduit. Préexistant, mais la PR touche la ligne d'affichage et vient de créer l'emplacement naturel (import.errors.*) pour le corriger.Mergée dans
mainen fast-forward avec le reste de la pile (tip37b832e).Forgejo ne détecte pas un merge local comme merged — la PR est donc fermée à la main, et l'issue liée s'est fermée automatiquement via son
Resolves #N.Tip cumulé validé avant push : 1181 vitest, 111 tests Rust, build tsc + vite. La CI ne tourne pas sur push
main, cette validation locale était donc le seul filet.Pull request closed