Catégories orphelines : analyser et rattacher les catégories hors de la hiérarchie #393
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#393
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
Contexte
Lors d'un test, une catégorie a été « perdue » : elle n'apparaissait plus dans l'arbre de la page Catégories après une manipulation non identifiée. Analyse du 2026-10-06 (
/analyze, lu surmain8907eef) : la cause la plus probable est un cycle de parenté créé depuis le formulaire d'édition, et rien dans l'app ne permet de le voir ni de le réparer.Diagnostic vérifié dans le code
CategoryForm.tsx:44-62propose toutes les catégories de niveau 0 et 1, y compris la catégorie éditée (auto-boucle) et ses propres enfants (A -> B -> A).updateCategory(categoryService.ts:80-95) écritparent_idsans contrôle de cycle ni de profondeur (createCategorycontrôle la profondeur, pasupdateCategory: une catégorie de niveau 1 avec enfants rattachée sous une autre de niveau 1 donne 4 niveaux).buildTree,useCategories.ts:61-78), sélecteurs de catégorie des transactions, du filtre et de la ventilation (sortHierarchical,CategoryCombobox.tsx:61-110), budget (useBudget.ts:139-239,budgetService.ts:258-389), rapports hiérarchiques (buildCategoryTree,reportService.ts:580-672: montants absents).!c.parent_id, et le parent est absent de la liste filtréeis_active = 1).parent_idest en cause. Transactions, mots-clés etbudget_entriesexistent ; le tableau de bord (dashboardService.ts,LEFT JOINsans hiérarchie) les compte encore. Remettreparent_idà une valeur valide fait tout réapparaître.getCategoryDepth(categoryService.ts:30-44) remonte les parents dans unwhilesans garde ; avec un cycle actif,createCategorysous un membre du cycle ne se terminerait jamais (non atteignable par l'UI aujourd'hui, les membres d'un cycle n'étant jamais proposés comme parents).moveCategory(useCategories.ts:260-327) ne peut pas déposer dans son propre sous-arbre (findNodeéchoue,return).restoreCategories(dataExportService.ts:439) réinsère tels quels lesparent_idd'une archive, cycles compris (hors portée, voir plus bas) ;deactivateCategoryremonte les enfants d'un niveau (raccourcit un cycle existant, n'en crée pas) ; migration de catégories etreinitializeCategoriessans risque.Décisions (Max, 2026-10-06)
Définition retenue d'une catégorie orpheline : catégorie active (
is_active = 1) qui (a) appartient à un cycle ou en descend, (b) a un parent désactivé, ou (c) a un parent inexistant. Le cas (c) est probablement rendu impossible par les clés étrangères de sqlx (actives par défaut, non vérifié) mais reste détecté par sécurité.Travail à faire
categoryService.tsou module pur dédié, testable sans base) : à partir des lignesid, parent_id, is_active, retourner les orphelines avec leur motif (cycle,inactive_parent,missing_parent). Ne retourner que les racines de chaque groupe à rattacher (pour un cycle : le ou les membres dont le rattachement suffit à le casser), la descendance suivant d'elle-même.reattachCategory(id, parentId | null)qui passe par la même validation queupdateCategory(point suivant). Écriture souswithTransactionsi plusieurs lignes.updateCategory: refuser unparent_idégal à l'id, ou appartenant à la descendance de la catégorie (cycle), ou qui porterait la profondeur au-delà de 3 niveaux en comptant la descendance. Erreur nommée, traduite côté UI.getCategoryDepth: ensemble des ids visités, arrêt sur un cycle.CategoryForm.tsx) : exclure la catégorie éditée et toute sa descendance, et les parents qui dépasseraient la profondeur.logInfo) avec le nombre corrigé.useBudget.tsetbudgetService.ts.CHANGELOG.md+CHANGELOG.fr.md(Fixed + Added), section Catégories dedocs/guide-utilisateur.mdet clésdocs.*.Fichiers concernés
src/services/categoryService.ts— validationupdateCategory, gardegetCategoryDepth, diagnostic et rattachementsrc/components/categories/CategoryForm.tsx— filtrage des options de parentsrc/hooks/useCategories.ts— actions diagnostic/rattachement dans le reducersrc/pages/CategoriesPage.tsx(ou équivalent) + nouveau composantcomponents/categories/— outil de vérificationsrc/hooks/useBudget.ts,src/services/budgetService.ts— catégories à parent désactivésrc/i18n/locales/fr.json,en.json,CHANGELOG*.md,docs/guide-utilisateur.mdSurface de test
categoryService,useCategoriesniCategoryForm.CategoryCombobox.test.tscouvre les orphelins (:91) mais pas les cycles.useBudget.test.tsexiste.getCategoryDepthse termine sur un cycle ;renderToStaticMarkup, modèleLicenseCard.render.test.tsx) ;CategoryCombobox: comportement sur cycle documenté par un test.type:bug) — reproduire A -> B -> A parupdateCategory, constater le refus ; et sur des lignes déjà en cycle, constater que le diagnostic les trouve et que le rattachement les rend visibles dansbuildTree.Critères d'acceptation
updateCategoryrefuse un cycle et une profondeur supérieure à 3, avec un message traduit.getCategoryDepthse termine sur un cycle.Hors portée
restoreCategories,dataExportService.ts:439) : l'outil de la page Catégories répare après coup ; issue séparée si besoin.Complexité estimée
Medium.
Plan d'exécution — milestone lot-v018 (planifié le 2026-10-16)
Les sections ci-dessous viennent de la séance
/plan-rundu 2026-10-16 (revue/review-specSécurité, Architecture, Technique comprise). En cas de conflit avec le corps au-dessus, elles priment.Consignes communes
v0.17.2existe (git ls-remote --tags origin v0.17.2). Sinon, arrêt sans rien écrire.main065f580; #369, #419 et #421 les ont déplacés depuis. Repérer chaque site par son contenu (symbole, chaîne), jamais par son numéro.STATE.md: le report dansSTATE.mdse fait au/merge-stack.Depends on
Note : dépendance de code. La modale de rattachement utilise
CategoryCombobox, dont la liste doit sortir de la modale (portail de #429).Décisions prises en planification
CategoryForm.tsx) » est hors portée : #423 l'a faite (src/components/categories/parentOptions.ts, commentaire 23053).depthsOfetsubtreeHeightdeparentOptions.tsdanssrc/utils/categoryHierarchy.ts, avec un ensemble de visités sur toute remontée et toute descente.buildParentOptions, le diagnostic et la validation de service l'importent ; pas de troisième implémentation.parentOptions.test.tsreste vert.SELECT id, parent_id, is_active FROM categories. Les requêtes existantes filtrentis_active = 1, ce qui ferait lire un parent désactivé commemissing_parent. La validation de profondeur ne compte que les catégories actives, comme l'arbre.id.CategoryHierarchyErroraveccode: "self_parent" | "cycle" | "too_deep" | "parent_has_transactions", sur le modèle deTransactionLinkedToBalanceError.useCategoriesla reconnaît parinstanceofet dispatche la clé i18n du code, au lieu dee.message.updateCategoryetreattachCategoryrefusent un nouveau parent qui porte des transactions directes (code: "parent_has_transactions"). Sinon, dans la même transaction, le nouveau parent passe àis_inputable = 0, comme danscreateCategory. L'UI n'exclut pas ces parents des listes : l'erreur traduite s'affiche à l'enregistrement, dans le formulaire de catégorie comme dans la modale.parent_id,is_inputabledu parent) se font sous un seulwithTransaction, même pour une seule ligne.updateCategorySortOrdersécritparent_idsans validation de service ; ce chemin reste gardé par l'UI (moveCategoryrefuse son propre sous-arbre). L'écrire dans la section « Hors portée » de la PR et dansdocs/architecture.md.CategoryComboboxdu nouveau parent alimenté parbuildParentOptions, avec une option « Racine » enextraOptionsprésélectionnée, bouton « Rattacher »), et un bouton « Fermer ». Après un rattachement, la liste et l'arbre se rechargent, et une lignelogInfodonne le nombre corrigé.Review caveats
inactive_parentindiscernable : résolu par la requête dédiée.reattachCategorysur un membre d'un cycle à 2, puis à 3, se termine et casse le cycle.restoreCategoriespeut réinjecter des cycles depuis une archive : l'outil répare après coup ; pas delogWarnajouté ici.Spec source
spec-plan-lot-v018.md
maximus referenced this issue2026-10-12 20:37:16 +00:00
Note du
/analyzede #423 (2026-10-12) : la tâche « Sélecteur de parent (CategoryForm.tsx) : exclure la catégorie éditée et toute sa descendance, et les parents qui dépasseraient la profondeur » est reprise par #423 (décision D1 de Max), qui remplace ce sélecteur parCategoryCombobox. #393 garde la validation dansupdateCategory, la garde degetCategoryDepth, l'outil « Vérifier la hiérarchie » et le budget.