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) | | Architecture backend | Strategy pattern (1 strategy par entity type) |
| Déclenchement | En deux temps : preview séparé du sync | | Déclenchement | En deux temps : preview séparé du sync |
| Preview timing | AVANT le save (pas de rollback nécessaire) | | 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 | | `restrictedMode` frontend | Supprimé complètement |
| Versioning | `version` INT sur Composant, Pièce, Produit (incrémenté à chaque sync) | | 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 | | 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 ## 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. 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` ### `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` **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. **Response :** `200` avec résumé de l'exécution.
**Erreurs :**
- `404` — ModelType introuvable
- `403` — droits insuffisants
## Architecture Backend ## Architecture Backend
### Strategy Pattern ### 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 ### Orchestrateur
```php ```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 ## Logique de Diff / Sync
### Matching des slots ### 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) ### Règles — Slots (pièce, produit, sous-composant)
| Cas | Action | | Cas | Action |
|-----|--------| |-----|--------|
| Skeleton requirement existe, pas de slot correspondant | **Ajouter** slot vide (type + position, `quantity = 1` pour pièces, pas de sélection) | | 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 | | 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 | | 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 | | Cas | Action |
|-----|--------| |-----|--------|
| Nouveau custom field | **Créer** `CustomFieldValue` vides pour tous les items | | Nouveau custom field | **Créer** `CustomFieldValue` vides pour tous les items |
| Custom field supprimé | **Supprimer** les `CustomFieldValue` (avec confirmation) | | Custom field supprimé | **Supprimer** les `CustomFieldValue` (si `confirmDeletions`) |
| Renommé (même index, nom différent) | **Propagation auto** — label dans la définition, valeurs intactes | | Renommé (même `orderIndex`, 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 | | 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` ## 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` ### Table `piece_product_slots`
| Colonne | Type | Contrainte | | Colonne | Type | Contrainte |
@@ -282,11 +317,14 @@ class PieceProductSlot
private Collection $productSlots; private Collection $productSlots;
``` ```
La relation M2M `$products` existante sur `Piece` sera marquée deprecated puis supprimée dans une migration future.
### Migration ### Migration
1. Créer la table `piece_product_slots` 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 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 `_pieceproducts` temporairement (suppression dans une migration future) 3. Conserver `piece_products` temporairement (suppression dans une migration future)
4. Mettre à jour le virtual getter `getStructure()` de Piece pour lire les `productSlots`
## Versioning ## Versioning
@@ -307,9 +345,9 @@ private int $version = 1;
### Migration ### Migration
```sql ```sql
ALTER TABLE composants 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 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 version INT NOT NULL DEFAULT 1; ALTER TABLE products ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1;
``` ```
## Frontend ## Frontend
@@ -327,9 +365,12 @@ ALTER TABLE products ADD COLUMN version INT NOT NULL DEFAULT 1;
- `components/StructureNodeEditor.vue` - `components/StructureNodeEditor.vue`
- `components/PieceModelStructureEditor.vue` - `components/PieceModelStructureEditor.vue`
- `components/ComponentModelStructureEditor.vue` - `components/ComponentModelStructureEditor.vue`
- `components/model-types/ModelTypeForm.vue`
- `composables/useStructureNodeCrud.ts` - `composables/useStructureNodeCrud.ts`
- `composables/useStructureNodeLogic.ts` - `composables/useStructureNodeLogic.ts`
- `composables/usePieceStructureEditorLogic.ts` - `composables/usePieceStructureEditorLogic.ts`
- `tests/components/ModelTypeForm.test.ts` (si existant)
- `tests/components/PieceModelStructureEditor.test.ts`
### Nouveau composant — `SyncConfirmationModal.vue` ### Nouveau composant — `SyncConfirmationModal.vue`
@@ -353,6 +394,8 @@ Modifications :
### Nouveau service — `modelTypes.ts` ### Nouveau service — `modelTypes.ts`
Ajout de deux fonctions au service existant :
```typescript ```typescript
export function syncPreview(id: string, structure: any) { export function syncPreview(id: string, structure: any) {
return requestFetch(`/api/model_types/${id}/sync-preview`, { return requestFetch(`/api/model_types/${id}/sync-preview`, {
@@ -375,7 +418,7 @@ export function syncExecute(id: string, confirmation: { confirmDeletions: boolea
```typescript ```typescript
const handleSubmit = async (payload) => { const handleSubmit = async (payload) => {
// 1. Preview // 1. Preview (avant le save)
const preview = await syncPreview(id, payload.structure) const preview = await syncPreview(id, payload.structure)
const hasImpact = preview.itemCount > 0 && ( const hasImpact = preview.itemCount > 0 && (
@@ -386,23 +429,30 @@ const handleSubmit = async (payload) => {
// 2. Si impact, demander confirmation // 2. Si impact, demander confirmation
if (hasImpact) { if (hasImpact) {
showSyncModal(preview) // la modal appelle confirmSync() si confirmé pendingPayload.value = payload
syncPreviewData.value = preview
showSyncModal.value = true
return return
} }
// 3. Pas d'impact → save direct // 3. Pas d'impact → save direct (PATCH + sync)
await saveAndSync(payload) 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 needsDeleteConfirm = Object.values(preview.deletions).some(v => v > 0)
const needsTypeChangeConfirm = preview.modifications.customFieldTypeChanges > 0 const needsTypeChangeConfirm = preview.modifications.customFieldTypeChanges > 0
await updateModelType(id, payload) await saveAndSync(pendingPayload.value, {
await syncExecute(id, {
confirmDeletions: needsDeleteConfirm, confirmDeletions: needsDeleteConfirm,
confirmTypeChanges: needsTypeChangeConfirm, confirmTypeChanges: needsTypeChangeConfirm,
}) })
}
const saveAndSync = async (payload, confirmation) => {
await updateModelType(id, payload)
await syncExecute(id, confirmation)
showSuccess('Catégorie mise à jour et synchronisée.') showSuccess('Catégorie mise à jour et synchronisée.')
router.push('/...') router.push('/...')
} }
@@ -425,18 +475,28 @@ const confirmSync = async (payload, preview) => {
Cohérent avec `ComposantProductSlot` qui n'a pas de quantité non plus. 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 ## Tests
### Backend — PHPUnit ### Backend — PHPUnit
- `ModelTypeSyncControllerTest` — tests des endpoints preview et sync - `ModelTypeSyncControllerTest` — tests des endpoints preview et sync (y compris erreurs 403/404, confirmations partielles)
- `ComposantSyncStrategyTest` — logique de diff pour composants - `ComposantSyncStrategyTest` — logique de diff pour composants (ajout, suppression, position update, no-op)
- `PieceSyncStrategyTest` — logique de diff pour pièces - `PieceSyncStrategyTest` — logique de diff pour pièces (ajout/suppression de product slots)
- `ProductSyncStrategyTest` — logique de diff pour produits (custom fields) - `ProductSyncStrategyTest` — logique de diff pour produits (custom fields only)
- `PieceProductSlotTest` — CRUD de la nouvelle entité - `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 - Vérifier la non-régression : `MachineStructureControllerTest` existant doit passer sans modification
### Frontend — Tests composables ### Frontend — Tests
- Supprimer `tests/composables/useCategoryEditGuard.test.ts` - 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).