docs(sync) : add ModelType sync design spec
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
442
docs/superpowers/specs/2026-03-13-modeltype-sync-design.md
Normal file
442
docs/superpowers/specs/2026-03-13-modeltype-sync-design.md
Normal file
@@ -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<SyncStrategyInterface> $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
|
||||||
Reference in New Issue
Block a user