From 57615b3e9d76f115b80d27d604c1bdaea1896a15 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 13 Mar 2026 12:02:27 +0100 Subject: [PATCH] =?UTF-8?q?docs(sync)=20:=20address=20spec=20review=20feed?= =?UTF-8?q?back=20=E2=80=94=20atomicity,=20matching,=20M2M=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../specs/2026-03-13-modeltype-sync-design.md | 112 ++++++++++++++---- 1 file changed, 86 insertions(+), 26 deletions(-) diff --git a/docs/superpowers/specs/2026-03-13-modeltype-sync-design.md b/docs/superpowers/specs/2026-03-13-modeltype-sync-design.md index 2674522..023d4a6 100644 --- a/docs/superpowers/specs/2026-03-13-modeltype-sync-design.md +++ b/docs/superpowers/specs/2026-03-13-modeltype-sync-design.md @@ -17,10 +17,15 @@ Quand un ModelType (catégorie) est modifié (structure, custom fields), propage | Architecture backend | Strategy pattern (1 strategy par entity type) | | Déclenchement | En deux temps : preview séparé du sync | | Preview timing | AVANT le save (pas de rollback nécessaire) | -| Pièces — produits liés | Nouvelle table `PieceProductSlot` (comme `ComposantProductSlot`) | +| Pièces — produits liés | Nouvelle table `PieceProductSlot` remplace la M2M `piece_products` | | `restrictedMode` frontend | Supprimé complètement | | Versioning | `version` INT sur Composant, Pièce, Produit (incrémenté à chaque sync) | | Machines | Aucun changement — elles lisent les slots des composants, la sync met à jour ces slots | +| Matching slots | Par `typeXxxId` + `position` (pas de FK vers skeleton requirement) | +| Matching custom fields | Par `orderIndex` (propriété stable sur `CustomField`) | +| Atomicité PATCH + sync | Wrappé dans une transaction DB côté controller | +| Idempotence sync | `execute()` est idempotent — un double appel est un no-op | +| Audit | Les opérations de sync sont capturées par les subscribers `onFlush` existants | ## Endpoints API @@ -65,9 +70,13 @@ Le payload `structure` a le même format que celui envoyé au `PATCH /api/model_ Si `additions`, `deletions` et `modifications` sont tous à 0, le frontend skip la modal et sauvegarde directement. +**Erreurs :** +- `404` — ModelType introuvable +- `403` — droits insuffisants + ### `POST /api/model_types/{id}/sync` -Exécute la propagation. Appelé **après** le `PATCH` du ModelType. +Exécute la propagation. Appelé **après** le `PATCH` du ModelType, dans la même requête frontend (PATCH + sync enchaînés). **Sécurité :** `ROLE_GESTIONNAIRE` @@ -79,8 +88,14 @@ Exécute la propagation. Appelé **après** le `PATCH` du ModelType. } ``` +Si des suppressions sont nécessaires mais `confirmDeletions` est `false`, le sync **skip les suppressions** (applique uniquement les ajouts). Idem pour `confirmTypeChanges` et les clear de valeurs. Cela permet un sync partiel (ajouts only) sans confirmation. + **Response :** `200` avec résumé de l'exécution. +**Erreurs :** +- `404` — ModelType introuvable +- `403` — droits insuffisants + ## Architecture Backend ### Strategy Pattern @@ -106,6 +121,8 @@ interface SyncStrategyInterface } ``` +**Note sur `execute()` :** Cette méthode est appelée **après** le PATCH du ModelType, donc les skeleton requirements sont déjà mis à jour en base. La strategy compare les skeleton requirements actuels (fraîchement mis à jour) avec les slots existants des items liés. Pas besoin de recevoir `$newStructure` — les relations ORM reflètent déjà le nouvel état. + ### Orchestrateur ```php @@ -195,18 +212,30 @@ class ModelTypeSyncController extends AbstractController } ``` +### Atomicité PATCH + Sync + +Le frontend enchaîne `PATCH` puis `POST /sync` en deux requêtes HTTP. Le `POST /sync` wrappe toute l'opération dans une transaction DB (`$em->wrapInTransaction()`). Si le sync échoue, les modifications de slots sont rollback. Le PATCH du ModelType (skeleton requirements) reste committée — c'est acceptable car un re-sync est toujours possible. + +En cas d'échec réseau entre le PATCH et le sync, le ModelType est à jour mais les items ne sont pas synchronisés. Le prochain save de la catégorie reproposera le sync-preview, qui détectera les différences. + ## Logique de Diff / Sync ### Matching des slots -Pour chaque item lié, on compare ses slots actuels avec les skeleton requirements du ModelType. Le matching se fait par **position** + **type référencé** (`typePieceId`, `typeProductId`, `typeComposantId`). +Pour chaque item lié, on compare ses slots actuels avec les skeleton requirements du ModelType. Le matching se fait par **`typeXxxId`** (le type référencé : `typePieceId`, `typeProductId`, `typeComposantId`) + **`position`**. + +Il n'y a **pas de FK directe** entre un slot et un skeleton requirement. Le lien est implicite via le type + position. + +**Pour le preview :** la strategy parse le `$newStructure` (payload JSON) et le compare aux slots actuels sans toucher à la DB. + +**Pour l'execute :** la strategy lit les skeleton requirements actuels (déjà mis à jour par le PATCH) et les compare aux slots actuels. ### Règles — Slots (pièce, produit, sous-composant) | Cas | Action | |-----|--------| | Skeleton requirement existe, pas de slot correspondant | **Ajouter** slot vide (type + position, `quantity = 1` pour pièces, pas de sélection) | -| Slot existe, plus de skeleton requirement | **Supprimer** le slot (avec confirmation) — sélection perdue | +| Slot existe, plus de skeleton requirement | **Supprimer** le slot (si `confirmDeletions`) — sélection perdue | | Les deux existent, position différente | **Mettre à jour** la position du slot | | Slot existe et matche | **Ne rien toucher** — sélection et quantité préservées | @@ -215,14 +244,20 @@ Pour chaque item lié, on compare ses slots actuels avec les skeleton requiremen | Cas | Action | |-----|--------| | Nouveau custom field | **Créer** `CustomFieldValue` vides pour tous les items | -| Custom field supprimé | **Supprimer** les `CustomFieldValue` (avec confirmation) | -| Renommé (même index, nom différent) | **Propagation auto** — label dans la définition, valeurs intactes | -| Type changé | **Clear** les valeurs (avec confirmation) — `CustomFieldValue` conservée, `value` vidée | +| Custom field supprimé | **Supprimer** les `CustomFieldValue` (si `confirmDeletions`) | +| Renommé (même `orderIndex`, nom différent) | **Propagation auto** — label dans la définition, valeurs intactes | +| Type changé (même `orderIndex`, type différent) | **Clear** les valeurs (si `confirmTypeChanges`) — `CustomFieldValue` conservée, `value` vidée | -Le matching des custom fields se fait par **index dans le tableau** (position stable). +Le matching des custom fields se fait par **`orderIndex`** (propriété stable sur l'entité `CustomField`), pas par index de tableau. Cela évite les faux positifs lors de réordonnancement. ## Nouvelle Entité — `PieceProductSlot` +### Contexte — Remplacement de la M2M `piece_products` + +Actuellement, les produits liés aux pièces passent par une relation M2M (`piece_products`, colonnes `a`/`b`). Cette table n'a pas de notion de `position`, `typeProductId`, ou `familyCode`. + +`PieceProductSlot` **remplace** cette M2M pour uniformiser l'architecture avec les slots des Composants. La M2M existante sera conservée temporairement puis supprimée dans une migration future. + ### Table `piece_product_slots` | Colonne | Type | Contrainte | @@ -282,11 +317,14 @@ class PieceProductSlot private Collection $productSlots; ``` +La relation M2M `$products` existante sur `Piece` sera marquée deprecated puis supprimée dans une migration future. + ### Migration 1. Créer la table `piece_product_slots` -2. Migrer les données existantes de `_pieceproducts` (M2M) → chaque entrée devient un `PieceProductSlot` avec `selectedProductId` renseigné, `position` auto-incrémentée -3. Conserver `_pieceproducts` temporairement (suppression dans une migration future) +2. Migrer les données existantes de `piece_products` (M2M) → chaque entrée devient un `PieceProductSlot` avec `selectedProductId` renseigné, `typeProductId` déduit du produit sélectionné (`product.typeProduct`), `position` auto-incrémentée +3. Conserver `piece_products` temporairement (suppression dans une migration future) +4. Mettre à jour le virtual getter `getStructure()` de Piece pour lire les `productSlots` ## Versioning @@ -307,9 +345,9 @@ private int $version = 1; ### Migration ```sql -ALTER TABLE composants ADD COLUMN version INT NOT NULL DEFAULT 1; -ALTER TABLE pieces ADD COLUMN version INT NOT NULL DEFAULT 1; -ALTER TABLE products ADD COLUMN version INT NOT NULL DEFAULT 1; +ALTER TABLE composants ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1; +ALTER TABLE pieces ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1; +ALTER TABLE products ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1; ``` ## Frontend @@ -327,9 +365,12 @@ ALTER TABLE products ADD COLUMN version INT NOT NULL DEFAULT 1; - `components/StructureNodeEditor.vue` - `components/PieceModelStructureEditor.vue` - `components/ComponentModelStructureEditor.vue` +- `components/model-types/ModelTypeForm.vue` - `composables/useStructureNodeCrud.ts` - `composables/useStructureNodeLogic.ts` - `composables/usePieceStructureEditorLogic.ts` +- `tests/components/ModelTypeForm.test.ts` (si existant) +- `tests/components/PieceModelStructureEditor.test.ts` ### Nouveau composant — `SyncConfirmationModal.vue` @@ -353,6 +394,8 @@ Modifications : ### Nouveau service — `modelTypes.ts` +Ajout de deux fonctions au service existant : + ```typescript export function syncPreview(id: string, structure: any) { return requestFetch(`/api/model_types/${id}/sync-preview`, { @@ -375,7 +418,7 @@ export function syncExecute(id: string, confirmation: { confirmDeletions: boolea ```typescript const handleSubmit = async (payload) => { - // 1. Preview + // 1. Preview (avant le save) const preview = await syncPreview(id, payload.structure) const hasImpact = preview.itemCount > 0 && ( @@ -386,23 +429,30 @@ const handleSubmit = async (payload) => { // 2. Si impact, demander confirmation if (hasImpact) { - showSyncModal(preview) // la modal appelle confirmSync() si confirmé + pendingPayload.value = payload + syncPreviewData.value = preview + showSyncModal.value = true return } - // 3. Pas d'impact → save direct - await saveAndSync(payload) + // 3. Pas d'impact → save direct (PATCH + sync) + await saveAndSync(payload, { confirmDeletions: false, confirmTypeChanges: false }) } -const confirmSync = async (payload, preview) => { +const onSyncConfirmed = async () => { + const preview = syncPreviewData.value const needsDeleteConfirm = Object.values(preview.deletions).some(v => v > 0) const needsTypeChangeConfirm = preview.modifications.customFieldTypeChanges > 0 - await updateModelType(id, payload) - await syncExecute(id, { + await saveAndSync(pendingPayload.value, { confirmDeletions: needsDeleteConfirm, confirmTypeChanges: needsTypeChangeConfirm, }) +} + +const saveAndSync = async (payload, confirmation) => { + await updateModelType(id, payload) + await syncExecute(id, confirmation) showSuccess('Catégorie mise à jour et synchronisée.') router.push('/...') } @@ -425,18 +475,28 @@ const confirmSync = async (payload, preview) => { Cohérent avec `ComposantProductSlot` qui n'a pas de quantité non plus. +### Relation M2M `piece_products` + +La M2M existante reste en base pendant la période de transition. Le code frontend/backend qui la lit devra être migré vers les `productSlots`. La M2M sera supprimée dans une migration future une fois que tout le code utilise les slots. + ## Tests ### Backend — PHPUnit -- `ModelTypeSyncControllerTest` — tests des endpoints preview et sync -- `ComposantSyncStrategyTest` — logique de diff pour composants -- `PieceSyncStrategyTest` — logique de diff pour pièces -- `ProductSyncStrategyTest` — logique de diff pour produits (custom fields) +- `ModelTypeSyncControllerTest` — tests des endpoints preview et sync (y compris erreurs 403/404, confirmations partielles) +- `ComposantSyncStrategyTest` — logique de diff pour composants (ajout, suppression, position update, no-op) +- `PieceSyncStrategyTest` — logique de diff pour pièces (ajout/suppression de product slots) +- `ProductSyncStrategyTest` — logique de diff pour produits (custom fields only) - `PieceProductSlotTest` — CRUD de la nouvelle entité +- Idempotence : vérifier qu'un double appel à `sync` est un no-op - Vérifier la non-régression : `MachineStructureControllerTest` existant doit passer sans modification -### Frontend — Tests composables +### Frontend — Tests - Supprimer `tests/composables/useCategoryEditGuard.test.ts` -- Ajouter tests pour le flow sync dans les pages d'édition +- Mettre à jour `tests/components/PieceModelStructureEditor.test.ts` (retirer restrictedMode) +- Ajouter tests pour le flow sync dans les pages d'édition (preview → modal → confirm → save) + +## Performance + +Pour le volume attendu (dizaines d'items par catégorie, pas de milliers), la sync en PHP avec l'ORM Doctrine est suffisante. Si le volume augmente significativement, les opérations de création/suppression de slots pourront être converties en batch SQL (INSERT ... SELECT, DELETE ... WHERE) sans changer l'architecture (la strategy encapsule la logique).