From 46694d11d9a25a7040fc452bddfa735570ac053e Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 13 Mar 2026 11:57:27 +0100 Subject: [PATCH] docs(sync) : add ModelType sync design spec Co-Authored-By: Claude Opus 4.6 --- .../specs/2026-03-13-modeltype-sync-design.md | 442 ++++++++++++++++++ 1 file changed, 442 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-13-modeltype-sync-design.md diff --git a/docs/superpowers/specs/2026-03-13-modeltype-sync-design.md b/docs/superpowers/specs/2026-03-13-modeltype-sync-design.md new file mode 100644 index 0000000..2674522 --- /dev/null +++ b/docs/superpowers/specs/2026-03-13-modeltype-sync-design.md @@ -0,0 +1,442 @@ +# ModelType Sync — Design Spec + +## Objectif + +Quand un ModelType (catégorie) est modifié (structure, custom fields), propager automatiquement les changements à tous les items liés (Composants, Pièces, Produits). L'utilisateur voit un preview de l'impact et confirme avant que la sync ne s'exécute. + +## Décisions + +| Décision | Choix | +|----------|-------| +| Scope sync | Composants + Pièces + Produits | +| Sync destructive | Avec confirmation (modal frontend) | +| Custom fields — ajout | Créer `CustomFieldValue` vides | +| Custom fields — suppression | Supprimer avec confirmation | +| Custom fields — renommage | Propagation auto (label dans la définition) | +| Custom fields — changement de type | Clear les valeurs avec confirmation | +| 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`) | +| `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 | + +## Endpoints API + +### `POST /api/model_types/{id}/sync-preview` + +Calcule l'impact du diff entre le payload envoyé et l'état actuel des items liés. **Ne persiste rien.** + +**Sécurité :** `ROLE_GESTIONNAIRE` + +**Request body :** +```json +{ + "structure": { ... } +} +``` + +Le payload `structure` a le même format que celui envoyé au `PATCH /api/model_types/{id}`. + +**Response :** +```json +{ + "modelTypeId": "cl...", + "category": "COMPONENT", + "itemCount": 12, + "additions": { + "pieceSlots": 12, + "productSlots": 0, + "subcomponentSlots": 24, + "customFieldValues": 36 + }, + "deletions": { + "pieceSlots": 0, + "productSlots": 12, + "subcomponentSlots": 0, + "customFieldValues": 0 + }, + "modifications": { + "customFieldTypeChanges": 12 + } +} +``` + +Si `additions`, `deletions` et `modifications` sont tous à 0, le frontend skip la modal et sauvegarde directement. + +### `POST /api/model_types/{id}/sync` + +Exécute la propagation. Appelé **après** le `PATCH` du ModelType. + +**Sécurité :** `ROLE_GESTIONNAIRE` + +**Request body :** +```json +{ + "confirmDeletions": true, + "confirmTypeChanges": true +} +``` + +**Response :** `200` avec résumé de l'exécution. + +## Architecture Backend + +### Strategy Pattern + +``` +Service/ +├── ModelTypeSyncService.php # Orchestrateur +└── Sync/ + ├── SyncStrategyInterface.php # Interface + ├── ComposantSyncStrategy.php # Slots pièce/produit/sous-composant + custom fields + ├── PieceSyncStrategy.php # Slots produit + custom fields + └── ProductSyncStrategy.php # Custom fields uniquement +``` + +### Interface + +```php +interface SyncStrategyInterface +{ + public function supports(ModelType $modelType): bool; + public function preview(ModelType $modelType, array $newStructure): SyncPreviewResult; + public function execute(ModelType $modelType, SyncConfirmation $confirmation): SyncExecutionResult; +} +``` + +### Orchestrateur + +```php +class ModelTypeSyncService +{ + /** @param iterable $strategies */ + public function __construct(private iterable $strategies) {} + + public function preview(ModelType $modelType, array $newStructure): SyncPreviewResult + { + foreach ($this->strategies as $strategy) { + if ($strategy->supports($modelType)) { + return $strategy->preview($modelType, $newStructure); + } + } + throw new \LogicException('No strategy found for category: ' . $modelType->getCategory()->value); + } + + public function execute(ModelType $modelType, SyncConfirmation $confirmation): SyncExecutionResult + { + foreach ($this->strategies as $strategy) { + if ($strategy->supports($modelType)) { + return $strategy->execute($modelType, $confirmation); + } + } + throw new \LogicException('No strategy found for category: ' . $modelType->getCategory()->value); + } +} +``` + +Les strategies sont auto-injectées via `#[AutoconfigureTag('app.sync_strategy')]` et le tagged iterator de Symfony. + +### DTOs + +```php +class SyncPreviewResult +{ + public string $modelTypeId; + public string $category; + public int $itemCount; + public array $additions; // ['pieceSlots' => int, 'productSlots' => int, ...] + public array $deletions; + public array $modifications; +} + +class SyncConfirmation +{ + public bool $confirmDeletions = false; + public bool $confirmTypeChanges = false; +} + +class SyncExecutionResult +{ + public int $itemsUpdated; + public array $additions; + public array $deletions; + public array $modifications; +} +``` + +### Controller + +```php +#[Route('/api/model_types/{id}')] +class ModelTypeSyncController extends AbstractController +{ + #[Route('/sync-preview', methods: ['POST'])] + #[IsGranted('ROLE_GESTIONNAIRE')] + public function preview(ModelType $modelType, Request $request): JsonResponse + { + $structure = json_decode($request->getContent(), true)['structure'] ?? []; + $result = $this->syncService->preview($modelType, $structure); + return $this->json($result); + } + + #[Route('/sync', methods: ['POST'])] + #[IsGranted('ROLE_GESTIONNAIRE')] + public function sync(ModelType $modelType, Request $request): JsonResponse + { + $body = json_decode($request->getContent(), true); + $confirmation = new SyncConfirmation(); + $confirmation->confirmDeletions = $body['confirmDeletions'] ?? false; + $confirmation->confirmTypeChanges = $body['confirmTypeChanges'] ?? false; + $result = $this->syncService->execute($modelType, $confirmation); + return $this->json($result); + } +} +``` + +## 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`). + +### 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 | +| 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 | + +### Règles — Custom fields + +| 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 | + +Le matching des custom fields se fait par **index dans le tableau** (position stable). + +## Nouvelle Entité — `PieceProductSlot` + +### Table `piece_product_slots` + +| Colonne | Type | Contrainte | +|---------|------|------------| +| `id` | VARCHAR (CUID) | PK | +| `pieceid` | VARCHAR | FK → `pieces.id` CASCADE | +| `typeproductid` | VARCHAR | FK → `model_types.id` SET NULL, nullable | +| `selectedproductid` | VARCHAR | FK → `products.id` SET NULL, nullable | +| `familycode` | VARCHAR(255) | nullable | +| `position` | INT | NOT NULL | +| `createdat` | TIMESTAMP | NOT NULL | +| `updatedat` | TIMESTAMP | NOT NULL | + +### Entité PHP + +```php +#[ORM\Entity] +#[ORM\Table(name: 'piece_product_slots')] +#[ORM\HasLifecycleCallbacks] +class PieceProductSlot +{ + #[ORM\Id] + #[ORM\Column(type: 'string')] + private string $id; + + #[ORM\ManyToOne(targetEntity: Piece::class, inversedBy: 'productSlots')] + #[ORM\JoinColumn(name: 'pieceId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] + private Piece $piece; + + #[ORM\ManyToOne(targetEntity: ModelType::class)] + #[ORM\JoinColumn(name: 'typeProductId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] + private ?ModelType $typeProduct = null; + + #[ORM\ManyToOne(targetEntity: Product::class)] + #[ORM\JoinColumn(name: 'selectedProductId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] + private ?Product $selectedProduct = null; + + #[ORM\Column(type: 'string', length: 255, nullable: true)] + private ?string $familyCode = null; + + #[ORM\Column(type: 'integer')] + private int $position = 0; + + #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] + private DateTimeImmutable $createdAt; + + #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] + private DateTimeImmutable $updatedAt; +} +``` + +### Relation sur Piece + +```php +#[ORM\OneToMany(targetEntity: PieceProductSlot::class, mappedBy: 'piece', cascade: ['persist', 'remove'], orphanRemoval: true)] +#[ORM\OrderBy(['position' => 'ASC'])] +private Collection $productSlots; +``` + +### 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) + +## Versioning + +### Nouveau champ sur Composant, Piece, Product + +```php +#[ORM\Column(type: 'integer', options: ['default' => 1])] +#[Groups(['composant:read'])] // idem pour piece:read, product:read +private int $version = 1; +``` + +### Comportement + +- **Création** d'un item → `version = 1` +- **Sync** qui modifie les slots ou custom fields d'un item → `version += 1` +- Si la sync n'a aucun impact sur un item particulier (ses slots matchent déjà le skeleton), sa version ne change pas + +### 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; +``` + +## Frontend + +### Suppression du `restrictedMode` + +**Fichiers à supprimer :** +- `composables/useCategoryEditGuard.ts` +- `tests/composables/useCategoryEditGuard.test.ts` + +**Fichiers à modifier (retirer restrictedMode) :** +- `pages/component-category/[id]/edit.vue` +- `pages/piece-category/[id]/edit.vue` +- `pages/product-category/[id]/edit.vue` +- `components/StructureNodeEditor.vue` +- `components/PieceModelStructureEditor.vue` +- `components/ComponentModelStructureEditor.vue` +- `composables/useStructureNodeCrud.ts` +- `composables/useStructureNodeLogic.ts` +- `composables/usePieceStructureEditorLogic.ts` + +### Nouveau composant — `SyncConfirmationModal.vue` + +Modal DaisyUI qui reçoit un `SyncPreviewResult` et affiche : + +``` +Cette modification impacte X [composants|pièces|produits] : + +Ajouts : + • Y slots pièce à créer + • Z valeurs de champs personnalisés à initialiser + +Suppressions : + • W slots produit à supprimer (les sélections seront perdues) + +Modifications : + • V valeurs de champs à réinitialiser (changement de type) + +[Annuler] [Confirmer la synchronisation] +``` + +### Nouveau service — `modelTypes.ts` + +```typescript +export function syncPreview(id: string, structure: any) { + return requestFetch(`/api/model_types/${id}/sync-preview`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ structure }), + }) +} + +export function syncExecute(id: string, confirmation: { confirmDeletions: boolean, confirmTypeChanges: boolean }) { + return requestFetch(`/api/model_types/${id}/sync`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(confirmation), + }) +} +``` + +### Flow dans les pages d'édition de catégorie + +```typescript +const handleSubmit = async (payload) => { + // 1. Preview + const preview = await syncPreview(id, payload.structure) + + const hasImpact = preview.itemCount > 0 && ( + Object.values(preview.additions).some(v => v > 0) || + Object.values(preview.deletions).some(v => v > 0) || + Object.values(preview.modifications).some(v => v > 0) + ) + + // 2. Si impact, demander confirmation + if (hasImpact) { + showSyncModal(preview) // la modal appelle confirmSync() si confirmé + return + } + + // 3. Pas d'impact → save direct + await saveAndSync(payload) +} + +const confirmSync = async (payload, preview) => { + const needsDeleteConfirm = Object.values(preview.deletions).some(v => v > 0) + const needsTypeChangeConfirm = preview.modifications.customFieldTypeChanges > 0 + + await updateModelType(id, payload) + await syncExecute(id, { + confirmDeletions: needsDeleteConfirm, + confirmTypeChanges: needsTypeChangeConfirm, + }) + showSuccess('Catégorie mise à jour et synchronisée.') + router.push('/...') +} +``` + +## Non-régression + +### Machines + +- Le `MachineStructureController` lit les slots des composants. La sync modifie ces slots → les machines affichent automatiquement la dernière version au prochain chargement. +- Aucun changement dans le controller machine. + +### Quantités + +- La sync **ne touche jamais** aux slots qui matchent toujours un skeleton requirement. Les quantités (`ComposantPieceSlot.quantity`) et sélections existantes sont préservées. +- Les nouveaux slots ajoutés par la sync ont `quantity = 1` par défaut. +- Le `ComposantPieceSlotController` (PATCH quantity) reste inchangé. + +### `PieceProductSlot` — pas de quantité + +Cohérent avec `ComposantProductSlot` qui n'a pas de quantité non plus. + +## 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) +- `PieceProductSlotTest` — CRUD de la nouvelle entité +- Vérifier la non-régression : `MachineStructureControllerTest` existant doit passer sans modification + +### Frontend — Tests composables + +- Supprimer `tests/composables/useCategoryEditGuard.test.ts` +- Ajouter tests pour le flow sync dans les pages d'édition