docs(sync) : address spec review feedback — atomicity, matching, M2M migration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-03-13 12:02:27 +01:00
parent 46694d11d9
commit 57615b3e9d

View File

@@ -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).