docs(sync) : add ModelType sync design spec

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-03-13 11:57:27 +01:00
parent 44cfa25eca
commit 46694d11d9

View 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