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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user