# 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` 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

### `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.

**Erreurs :**
- `404` — ModelType introuvable
- `403` — droits insuffisants

### `POST /api/model_types/{id}/sync`

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`

**Request body :**
```json
{
  "confirmDeletions": true,
  "confirmTypeChanges": true
}
```

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

```
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;
}
```

**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
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);
    }
}
```

### 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 **`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 (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 |

### Règles — Custom fields

| Cas | Action |
|-----|--------|
| Nouveau custom field | **Créer** `CustomFieldValue` vides pour tous les items |
| 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 **`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 |
|---------|------|------------|
| `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;
```

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 `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

### 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 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

### 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`
- `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`

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`

Ajout de deux fonctions au service existant :

```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 (avant le save)
  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) {
    pendingPayload.value = payload
    syncPreviewData.value = preview
    showSyncModal.value = true
    return
  }

  // 3. Pas d'impact → save direct (PATCH + sync)
  await saveAndSync(payload, { confirmDeletions: false, confirmTypeChanges: false })
}

const onSyncConfirmed = async () => {
  const preview = syncPreviewData.value
  const needsDeleteConfirm = Object.values(preview.deletions).some(v => v > 0)
  const needsTypeChangeConfirm = preview.modifications.customFieldTypeChanges > 0

  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('/...')
}
```

## 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.

### 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 (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

- Supprimer `tests/composables/useCategoryEditGuard.test.ts`
- 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).
