# Entity Versioning — Design Spec **Date :** 2026-03-25 **Entites concernees :** Machine, Composant, Piece, Produit **Approche :** Extension du systeme AuditLog existant --- ## Objectif Permettre de consulter l'historique des versions numerotees (v1, v2, v3...) des entites principales et de restaurer n'importe quelle version anterieure, afin de ne jamais perdre de donnees. --- ## Regles metier ### Creation de version - Chaque `create` ou `update` sur une entite incremente automatiquement le compteur `version` de l'entite - Le numero de version est enregistre dans l'AuditLog correspondant (nouvelle colonne `version`) ### Restauration - La restauration cree une **nouvelle version** (v+1) — on ne supprime jamais d'historique - Le service `EntityVersionService::restore()` cree **manuellement** un AuditLog avec `action = "restore"` et le diff contient `restoredFromVersion: N` - Important : le flush du restore declenche les AuditSubscribers, qui produiraient un `update` duplique. Pour eviter cela, l'entite porte un flag transitoire `$skipAudit = true` que les subscribers verifient ### Controle de squelette (Composant, Piece, Produit uniquement) - Avant restauration, on compare le ModelType actuel avec celui du snapshot - **Meme squelette (ModelType)** : restore complet — champs de base + slots + custom fields - **Squelette different** : restore partiel — uniquement les champs de base (nom, description, reference, constructeurs, prix) ### Controle d'integrite - Avant restauration, on verifie que toutes les entites liees dans le snapshot existent encore en base : - **Composant** : pieces selectionnees dans les slots, produits, sous-composants, constructeurs - **Piece** : produits selectionnes dans les slots, constructeurs - **Produit** : constructeurs - **Machine** : site, liens composants/pieces/produits (MachineComponentLink, MachinePieceLink, MachineProductLink) - Les entites manquantes generent des **warnings** affiches a l'utilisateur - Les slots avec des entites supprimees sont restaures **vides** (sans selection) - Pour les custom field values : restauration par `fieldId` + entite parente (pas par ID de la CustomFieldValue elle-meme, car un sync ModelType peut recreer les CFV avec des IDs differents) - Les controles d'integrite utilisent des requetes batch (`findBy(['id' => $ids])`) plutot que des requetes individuelles par slot ### Machines - Pas de controle de squelette (pas de ModelType) : restauration toujours complete - Controle d'integrite sur le site et les liens machine - Machine n'a pas de champ `description` (contrairement aux autres entites) ### Permissions - Consulter les versions : `ROLE_VIEWER` - Restaurer une version : `ROLE_GESTIONNAIRE` et au-dessus --- ## Modifications backend ### 1. Colonne `version` sur AuditLog ```sql ALTER TABLE audit_logs ADD COLUMN version INT DEFAULT NULL; ``` Nullable car les AuditLogs existants n'ont pas de version. ### 2. Colonne `version` sur Machine ```sql ALTER TABLE machine ADD COLUMN version INT NOT NULL DEFAULT 1; ``` Les entites Composant, Piece, Produit ont deja cette colonne. ### 3. Enrichissement des snapshots Les Audit Subscribers doivent inclure dans le `snapshot` : **Composant :** ```json { "id": "cl...", "name": "...", "reference": "...", "description": "...", "prix": 100.00, "typeComposant": { "id": "cl...", "name": "...", "code": "..." }, "product": { "id": "cl...", "name": "..." }, "constructeurIds": [{ "id": "cl...", "name": "..." }], "customFieldValues": [{ "id": "cl...", "fieldName": "...", "value": "..." }], "pieceSlots": [ { "id": "cl...", "typePieceId": "cl...", "selectedPieceId": "cl...", "quantity": 1, "position": 0 } ], "subcomponentSlots": [ { "id": "cl...", "alias": "...", "familyCode": "...", "typeComposantId": "cl...", "selectedComposantId": "cl...", "position": 0 } ], "productSlots": [ { "id": "cl...", "typeProductId": "cl...", "selectedProductId": "cl...", "familyCode": "...", "position": 0 } ], "version": 3 } ``` **Piece :** ```json { "id": "cl...", "name": "...", "reference": "...", "description": "...", "prix": 50.00, "typePiece": { "id": "cl...", "name": "...", "code": "..." }, "product": { "id": "cl...", "name": "..." }, "constructeurIds": [{ "id": "cl...", "name": "..." }], "customFieldValues": [{ "id": "cl...", "fieldName": "...", "value": "..." }], "productSlots": [ { "id": "cl...", "typeProductId": "cl...", "selectedProductId": "cl...", "familyCode": "...", "position": 0 } ], "version": 2 } ``` **Produit :** ```json { "id": "cl...", "name": "...", "reference": "...", "supplierPrice": 25.00, "typeProduct": { "id": "cl...", "name": "...", "code": "..." }, "constructeurIds": [{ "id": "cl...", "name": "..." }], "customFieldValues": [{ "id": "cl...", "fieldName": "...", "value": "..." }], "version": 1 } ``` **Machine :** ```json { "id": "cl...", "name": "...", "reference": "...", "prix": 1500.00, "site": { "id": "cl...", "name": "..." }, "constructeurIds": [{ "id": "cl...", "name": "..." }], "customFieldValues": [{ "id": "cl...", "fieldName": "...", "value": "..." }], "version": 4 } ``` ### 4. Incrementation automatique de la version Dans chaque Audit Subscriber, a chaque `create`/`update` : 1. Appeler `$entity->incrementVersion()` 2. Ecrire `$auditLog->setVersion($entity->getVersion())` Pour Machine, ajouter la methode `incrementVersion()` et la propriete `version` a l'entite. ### 5. Nouveaux endpoints — `EntityVersionController` | Methode | Route | Description | Role | |---------|-------|-------------|------| | GET | `/api/{entity}/{id}/versions` | Liste des versions | ROLE_VIEWER | | GET | `/api/{entity}/{id}/versions/{version}/preview` | Preview + controles avant restore | ROLE_GESTIONNAIRE | | POST | `/api/{entity}/{id}/versions/{version}/restore` | Execute la restauration | ROLE_GESTIONNAIRE | `{entity}` = `machines`, `composants`, `pieces`, `products` **GET versions — Response :** ```json { "items": [ { "version": 3, "action": "update", "createdAt": "2026-03-25T14:30:00+00:00", "actor": { "id": "cl...", "label": "Jean Dupont" }, "diff": { "name": { "from": "Ancien", "to": "Nouveau" } } } ], "total": 3 } ``` **GET preview — Response :** ```json { "version": 2, "restoreMode": "full", "diff": { "name": { "current": "Nouveau", "restored": "Ancien" }, "reference": { "current": "REF-002", "restored": "REF-001" } }, "warnings": [ { "field": "pieceSlots[0].selectedPieceId", "message": "La piece 'Roulement XY' (cl...) n'existe plus. Le slot sera restaure vide.", "missingEntityId": "cl...", "missingEntityName": "Roulement XY" } ], "snapshot": { } } ``` `restoreMode` : `"full"` (meme squelette) ou `"partial"` (squelette different, champs de base uniquement). **POST restore — Response :** ```json { "success": true, "newVersion": 6, "restoredFromVersion": 2, "restoreMode": "full", "warnings": [] } ``` ### 6. Service `EntityVersionService` Service centralise pour la logique de versioning : - `getVersions(string $entityType, string $entityId): array` — liste des versions depuis AuditLog - `getRestorePreview(string $entityType, string $entityId, int $version): array` — controles + diff - `restore(string $entityType, string $entityId, int $version): array` — execution du restore Methodes internes : - `checkSkeletonCompatibility(object $entity, array $snapshot): string` — retourne `"full"` ou `"partial"` - `checkIntegrity(string $entityType, array $snapshot): array` — retourne les warnings - `applyRestore(object $entity, array $snapshot, string $mode): void` — applique les changements --- ## Modifications frontend ### 1. Composant `EntityVersionList.vue` Composant reutilisable affiche dans un onglet "Versions" sur les pages de detail. **Props :** - `entityType: 'machines' | 'composants' | 'pieces' | 'products'` - `entityId: string` **Affichage :** - Tableau : version, date, auteur, action, diff resume - Badge "Actuelle" sur la version la plus recente - Bouton "Restaurer" sur chaque ligne (sauf version actuelle), visible uniquement pour ROLE_GESTIONNAIRE+ ### 2. Composant `VersionRestoreModal.vue` Modal de confirmation avec preview. **Props :** - `entityType`, `entityId`, `version` (cible) - `previewData` (resultat du GET preview) **Affichage :** - Indicateur de mode : "Restauration complete" ou "Restauration partielle" - Diff visuel : champs qui changent (valeur actuelle -> valeur restauree) - Warnings en alerte orange pour les entites manquantes - Boutons "Confirmer la restauration" / "Annuler" ### 3. Composable `useEntityVersions.ts` ```typescript interface Deps { entityType: MaybeRef entityId: MaybeRef } export function useEntityVersions(deps: Deps) { // fetchVersions() — GET /api/{entity}/{id}/versions // fetchPreview(version) — GET /api/{entity}/{id}/versions/{version}/preview // restore(version) — POST /api/{entity}/{id}/versions/{version}/restore } ``` ### 4. Integration dans les pages de detail Ajouter un onglet "Versions" dans les pages : - `pages/machines/[id].vue` - `pages/composants/[id].vue` - `pages/pieces/[id].vue` - `pages/products/[id].vue` L'onglet affiche `EntityVersionList` qui gere l'ouverture de `VersionRestoreModal`. --- ## Migration Une seule migration PostgreSQL : ```sql -- Colonne version sur audit_logs ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS version INT DEFAULT NULL; -- Colonne version sur machine ALTER TABLE machine ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1; -- Index pour requetes par version CREATE INDEX IF NOT EXISTS idx_audit_entity_version ON audit_logs (entity_type, entity_id, version); ``` --- ## Ce qui change (breaking) - **Piece snapshot** : le champ legacy `productIds` (ancien JSON) est remplace par `productSlots` (tables normalisees). Les anciens AuditLogs conservent `productIds` dans leur snapshot mais les nouveaux ne l'auront plus. Le restore utilise `productSlots` exclusivement. ## Ce qui ne change PAS - L'onglet/page d'historique existant (`EntityHistoryController`) reste inchange - Les AuditLogs existants (sans version) continuent de fonctionner - Le mecanisme d'audit automatique via les Subscribers reste identique, juste enrichi - Les documents ne sont pas versionnes (hors scope)