Compare commits

..

22 Commits

Author SHA1 Message Date
Matthieu
39e503ae18 chore(release) : bump version to 1.5.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 16:50:59 +01:00
Matthieu
70ed354c42 Merge branch 'fix/filtres-listes' into develop 2026-02-11 16:50:48 +01:00
Matthieu
ba98ae37f4 feat(entity) : auto-capitalize first letter of names on Composant and ModelType
Update setName() to use mb_strtoupper on the first character so that
category and component names always start with an uppercase letter.
Also update frontend submodule with URL state preservation and back
button improvements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 16:48:46 +01:00
Matthieu
906d39793f fix(filters) : repair broken filters on catalog and document pages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:33:20 +01:00
Matthieu
f970c1928d fix(api) : cap pagination to 200 items/page to prevent OOM in production
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 11:11:09 +01:00
Matthieu
2a1d966b87 chore(frontend) : update submodule — smart cache on composables
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 09:18:50 +01:00
Matthieu
a393b62e9f chore(frontend) : update submodule — Malio brand colors
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 09:06:30 +01:00
Matthieu
1247f72af6 chore(frontend) : update submodule — activity log + clickable catalog types
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 08:54:26 +01:00
Matthieu
6735bf252c feat(activity-log) : add paginated activity log endpoint and store constructeur names in audit
- New GET /api/activity-logs endpoint with pagination and filters
  (entityType, action) for the global activity log page
- Add findAllPaginated() to AuditLogRepository
- normalizeCollection() now stores {id, name} objects instead of
  bare IDs so constructeur changes display readable names

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 08:54:19 +01:00
Matthieu
508066d39f fix(frontend) : update submodule with custom field display fix
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 16:48:12 +01:00
Matthieu
70956c204e fix(audit) : inject Security for actor resolution + track custom field changes
- Inject Security service into all 3 audit subscribers to resolve
  actor profile from authenticated user (fixes "Par Inconnu" issue)
- Add CustomFieldValue tracking: insertions, updates, and deletions
  on custom field values now produce audit log entries on the parent
  entity (composant, piece, product) with field name prefix
  "customField:{name}"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 16:48:07 +01:00
Matthieu
16a7eac0c6 chore(release) : v1.4.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 15:59:55 +01:00
Matthieu
37ac08b182 chore(frontend) : update submodule — edit pages optimization
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 15:58:50 +01:00
Matthieu
5ef80b362e perf(api) : add serialization groups to CustomFieldValue and CustomField
Expose customField definitions (id, name, type, required, options, orderIndex)
inline in entity responses, eliminating separate API calls for custom field data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 15:58:43 +01:00
Matthieu
78f19daf76 chore(release) : v1.3.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 14:20:29 +01:00
Matthieu
6caa4a61df chore(frontend) : update submodule — API optimizations, cache invalidation, tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 14:19:24 +01:00
Matthieu
bf55034b2e chore(frontend) : complete frontend refactoring (F1-F7)
Update frontend submodule with 14 conventional commits covering:
- F1.1-F1.4: Decompose mega-components (machine detail/create, ComponentItem/PieceItem)
- F2.1-F2.3: Extract shared helpers (extractCollection, history, types)
- F3.2-F3.3: Migrate composables to TypeScript, eliminate explicit any
- F4.1-F4.2: Enable strict ESLint rules, remove debug console.logs
- F5.1: Split modelUtils into thematic modules
- F6.1-F6.2: Configure Vitest with 54 unit tests
- F7.2-F7.3: DaisyUI confirm modal, extract AppNavbar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 11:20:55 +01:00
Matthieu
ba1114e78b chore(frontend): update refactor plan and remove legacy frontend 2026-02-06 17:17:29 +01:00
Matthieu
5ccc3b30f0 docs : add comprehensive refactoring plan (backend + frontend)
14 phases, 39 tasks covering:
- Backend: security, code duplication, controllers, storage, tests
- Frontend: mega-component split, duplication, TypeScript migration, tests

Includes agent tracking system with status, journal, and rules.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 17:34:43 +01:00
8d83076be0 chore(release) : v1.2.0 2026-01-29 19:55:29 +01:00
Matthieu
997a3ae822 chore(frontend): update submodule to history UI 2026-01-25 21:22:24 +01:00
Matthieu
034c193e4b feat(audit): add history tracking and bump version to 1.1.2 2026-01-25 21:19:42 +01:00
38 changed files with 3433 additions and 12224 deletions

786
REFACTORING_PLAN.md Normal file
View File

@@ -0,0 +1,786 @@
# Plan de Refactoring - Inventory v1.2.0
> **Date de creation :** 2026-02-03
> **Branche de travail :** `refacto/v1.3.0`
> **Base :** `develop` (commit `8d83076`)
---
## Legende des statuts
| Statut | Signification |
| ------ | ---------------------- |
| `[ ]` | A faire |
| `[~]` | En cours |
| `[x]` | Termine |
| `[!]` | Bloque / besoin d'info |
---
## Phase 1 - Securite (CRITIQUE)
> **Priorite :** MAXIMALE - A traiter en premier
### 1.1 Corriger la configuration de securite
- **Statut :** `[ ]`
- **Fichier :** `config/packages/security.yaml`
- **Probleme :** `PUBLIC_ACCESS` applique a toutes les routes `/api` avant la regle `IS_AUTHENTICATED_FULLY`. Le pattern matching "first match wins" rend potentiellement tout public.
- **Action :** Reordonner les regles `access_control` pour que les routes protegees soient listees AVANT les routes publiques.
- **Agent :** -
- **Notes :** -
### 1.2 Ajouter les controles d'autorisation sur les controllers
- **Statut :** `[ ]`
- **Fichiers :**
- `src/Controller/MachineSkeletonController.php`
- `src/Controller/CustomFieldValueController.php`
- `src/Controller/DocumentQueryController.php`
- `src/Controller/SessionProfileController.php`
- `src/Controller/SessionProfilesController.php`
- Tous les `*HistoryController.php`
- **Probleme :** Aucun attribut `#[IsGranted]` sur les controllers custom. Pas de RBAC.
- **Action :** Ajouter `#[IsGranted('IS_AUTHENTICATED_FULLY')]` sur chaque controller (ou route). Definir des roles si necessaire.
- **Agent :** -
- **Notes :** -
### 1.3 Securiser les secrets
- **Statut :** `[ ]`
- **Fichiers :**
- `.env` (JWT_PASSPHRASE en dur, APP_SECRET vide)
- `docker/.env.docker` (credentials `root:root`)
- **Action :**
1. Deplacer `JWT_PASSPHRASE` dans `.env.local` (git-ignore)
2. Generer un `APP_SECRET` valide
3. Ajouter `.env.local` dans `.gitignore` si pas deja fait
4. Documenter la configuration des secrets pour les devs
- **Agent :** -
- **Notes :** -
---
## Phase 2 - Elimination de la duplication de code
> **Priorite :** HAUTE - Impact direct sur la maintenabilite
### 2.1 Refactorer les 3 Audit Subscribers en un seul generique
- **Statut :** `[ ]`
- **Fichiers concernes :**
- `src/EventSubscriber/ProductAuditSubscriber.php` (298 LOC)
- `src/EventSubscriber/PieceAuditSubscriber.php` (300 LOC)
- `src/EventSubscriber/ComposantAuditSubscriber.php` (300 LOC)
- **Probleme :** ~900 LOC dupliquees a ~95%. Les methodes `onFlush()`, `buildDiffFromChangeSet()`, `resolveActorProfileId()`, `mergeDiffs()`, `normalizeCollection()` sont identiques. Seules les methodes `snapshot*()` different legerement.
- **Action :**
1. Creer un `AbstractAuditSubscriber` ou un `GenericAuditSubscriber` parametrable
2. Extraire la logique commune (onFlush, buildDiff, resolveActor, mergeDiffs, normalizeCollection)
3. Utiliser un systeme de configuration par entite (map `entityClass => entityType + snapshotMethod`)
4. Supprimer les 3 fichiers redondants
5. Verifier que l'audit fonctionne toujours sur Product, Piece et Composant
- **Agent :** -
- **Notes :** Tester manuellement les logs d'audit apres refacto.
### 2.2 Extraire un CuidGenerator utilitaire
- **Statut :** `[ ]`
- **Fichiers concernes :** 18 entites contenant `generateCuid()` en prive
- **Probleme :** Methode `generateCuid()` dupliquee dans chaque entite. De plus, `AuditLog.php` utilise une variante differente (base_convert).
- **Action :**
1. Creer `src/Util/CuidGenerator.php` avec une methode statique `generate(): string`
2. Uniformiser l'implementation (choisir une seule methode)
3. Remplacer tous les appels dans les 18 entites
4. Supprimer les methodes privees devenues inutiles
- **Agent :** -
- **Notes :** Attention a l'inconsistance entre AuditLog et les autres entites.
### 2.3 Factoriser la logique de liaison dans MachineSkeletonController
- **Statut :** `[ ]`
- **Fichier :** `src/Controller/MachineSkeletonController.php` (756 LOC)
- **Probleme :** Les methodes `applyComponentLinks()`, `applyPieceLinks()`, `applyProductLinks()` sont quasi identiques (~90 LOC chacune).
- **Action :**
1. Extraire une methode generique `applyLinks(Machine $machine, array $links, string $type)`
2. Parametrer par le type d'entite liee (Composant, Piece, Product)
3. Reduire le controller a ~400 LOC max
- **Agent :** -
- **Notes :** -
---
## Phase 3 - Restructuration des controllers
> **Priorite :** MOYENNE - Amelioration de la lisibilite et maintenabilite
### 3.1 Decouper MachineSkeletonController
- **Statut :** `[ ]`
- **Fichier :** `src/Controller/MachineSkeletonController.php` (756 LOC)
- **Action :**
1. Extraire la logique metier dans un `MachineSkeletonService`
2. Le controller ne doit gerer que la requete/reponse HTTP
3. Le service gere la logique de skeleton (get, update, applyLinks)
4. Extraire les helpers (`resolveIdentifier`, `indexLinksById`, `applyOverrides`, `normalizeMachineSkeletonResponse`) dans le service
- **Agent :** -
- **Notes :** Depend de la phase 2.3 (factorisation des liens).
### 3.2 Ajouter un try-catch et du logging dans les controllers
- **Statut :** `[ ]`
- **Fichiers :** Tous les controllers dans `src/Controller/`
- **Probleme :** Aucun try-catch autour des `flush()` et `persist()`. Pas de logging d'erreurs.
- **Action :**
1. Ajouter `try-catch` autour des operations Doctrine dans chaque controller
2. Logger les erreurs avec le `LoggerInterface` de Symfony (Monolog)
3. Retourner des reponses JSON coherentes en cas d'erreur serveur (500)
- **Agent :** -
- **Notes :** -
### 3.3 Renforcer la validation des entrees
- **Statut :** `[ ]`
- **Fichiers :**
- `src/Controller/CustomFieldValueController.php`
- `src/Controller/MachineSkeletonController.php`
- **Probleme :** Pas de validation de longueur max, pas de regex sur les IDs, pas de controle de profondeur JSON.
- **Action :**
1. Valider le format des IDs (regex CUID : `/^cl[a-f0-9]{24}$/`)
2. Ajouter des limites de longueur sur les champs string
3. Utiliser le composant Validator de Symfony pour les DTOs si pertinent
- **Agent :** -
- **Notes :** -
---
## Phase 4 - Amelioration du stockage
> **Priorite :** MOYENNE - Performance et scalabilite
### 4.1 Migrer le stockage PDF de base64 vers le filesystem
- **Statut :** `[ ]`
- **Fichiers :**
- `src/Entity/Document.php`
- `src/Command/CompressPdfCommand.php`
- `src/Service/PdfCompressorService.php`
- **Probleme :** Les PDFs sont stockes en base64 dans la colonne `path` (TEXT) de la BDD. Risque de DoS et mauvaise perf sur des gros fichiers.
- **Action :**
1. Utiliser `vich/uploader-bundle` (deja installe) pour le stockage fichier
2. Configurer un repertoire de stockage (`var/uploads/documents/`)
3. Migrer les documents existants (script de migration)
4. Adapter `PdfCompressorService` pour lire/ecrire sur le filesystem
5. Mettre a jour l'entite Document
- **Agent :** -
- **Notes :** Prevoir une migration de donnees pour les documents existants.
### 4.2 Corriger les types de prix (string -> decimal)
- **Statut :** `[ ]`
- **Fichiers :**
- `src/Entity/Machine.php` (`$prix`)
- `src/Entity/Product.php` (`$supplierPrice`)
- **Probleme :** Les prix sont types `?string` en PHP alors que la colonne est `DECIMAL(10,2)` en BDD.
- **Action :**
1. Changer le type PHP en `?float` ou utiliser `brick/money`
2. Adapter les getters/setters
3. Verifier la serialisation API Platform
- **Agent :** -
- **Notes :** Impact potentiel sur le frontend (format des nombres).
---
## Phase 5 - Utilisation du Process Component
> **Priorite :** BASSE - Bonne pratique
### 5.1 Remplacer exec() par Symfony Process
- **Statut :** `[ ]`
- **Fichiers :**
- `src/Command/CompressPdfCommand.php` (lignes 42, 98-101)
- `src/Service/PdfCompressorService.php` (lignes 37-41)
- **Probleme :** Utilisation de `exec()` directe pour appeler `qpdf`.
- **Action :**
1. Remplacer par `Symfony\Component\Process\Process`
2. Gerer le timeout et les erreurs proprement
3. Tester que la compression fonctionne toujours
- **Agent :** -
- **Notes :** `escapeshellarg()` est deja utilise, donc pas de faille de securite immediate.
---
## Phase 6 - Tests
> **Priorite :** HAUTE - Indispensable avant toute refacto majeure
### 6.1 Mettre en place les tests unitaires
- **Statut :** `[ ]`
- **Fichiers a creer :**
- `tests/Unit/Util/CuidGeneratorTest.php`
- `tests/Unit/Entity/MachineTest.php`
- `tests/Unit/Entity/ProductTest.php`
- `tests/Unit/Service/PdfCompressorServiceTest.php`
- **Action :**
1. Tester le CuidGenerator (format, unicite)
2. Tester les entites (validation, lifecycle callbacks)
3. Tester le PdfCompressorService
- **Agent :** -
- **Notes :** -
### 6.2 Mettre en place les tests fonctionnels (API)
- **Statut :** `[ ]`
- **Fichiers a creer :**
- `tests/Functional/Api/MachineTest.php`
- `tests/Functional/Api/ProductTest.php`
- `tests/Functional/Api/AuthenticationTest.php`
- `tests/Functional/Api/MachineSkeletonTest.php`
- **Action :**
1. Configurer une base de test (SQLite ou PostgreSQL de test)
2. Creer des fixtures de test
3. Tester les endpoints CRUD
4. Tester l'authentification JWT
5. Tester les endpoints custom (skeleton, custom fields)
- **Agent :** -
- **Notes :** Utiliser `ApiTestCase` de API Platform.
### 6.3 Tests des Audit Subscribers
- **Statut :** `[ ]`
- **Fichiers a creer :**
- `tests/Unit/EventSubscriber/AuditSubscriberTest.php`
- **Action :**
1. Tester la creation de logs sur insert/update/delete
2. Tester le format des diffs et snapshots
3. Tester la resolution de l'acteur
- **Agent :** -
- **Notes :** A faire APRES la phase 2.1 (refacto des subscribers).
---
## Phase 7 - Nett oyage et conventions
> **Priorite :** BASSE - Polish final
### 7.1 Supprimer les fichiers inutiles
- **Statut :** `[ ]`
- **Fichiers a verifier :**
- `frontend/` (dossier legacy ? vs `Inventory_frontend/`)
- `src/ApiResource/` (repertoire vide)
- Fichiers SQL a la racine (`backup_v1.0.0.sql`, `data_norm.sql`, `fullasse.sql`, `fulldata.sql`)
- **Action :** Confirmer avec l'equipe quels fichiers sont obsoletes et les supprimer.
- **Agent :** -
- **Notes :** Ne pas supprimer sans validation.
### 7.2 Uniformiser la gestion des null
- **Statut :** `[ ]`
- **Fichiers :** Toutes les entites dans `src/Entity/`
- **Action :** S'assurer que les types nullable sont coherents entre PHP et la BDD (colonnes NOT NULL vs nullable).
- **Agent :** -
- **Notes :** -
---
---
# FRONTEND (`Inventory_frontend/`)
---
## Phase F1 - Decoupage des mega-composants (CRITIQUE)
> **Priorite :** MAXIMALE - Les fichiers actuels sont inmaintenables
### F1.1 Decouper `machine/[id].vue` (2989 LOC → 219 LOC)
- **Statut :** `[x]`
- **Fichier :** `Inventory_frontend/app/pages/machine/[id].vue`
- **Resultat :** Page decomposee en 2 composables + 7 composants. Orchestrateur = 219 LOC.
- **Fichiers crees :**
- `composables/useMachineDetailData.ts` (1404 LOC) — state + logique metier
- `composables/useMachineSkeletonEditor.ts` (843 LOC) — logique skeleton
- `components/machine/MachineDetailHeader.vue` (76 LOC)
- `components/machine/MachineInfoCard.vue` (185 LOC)
- `components/machine/MachineDocumentsCard.vue` (116 LOC)
- `components/machine/MachineProductsCard.vue` (62 LOC)
- `components/machine/MachineComponentsCard.vue` (53 LOC)
- `components/machine/MachinePiecesCard.vue` (34 LOC)
- `components/machine/MachineSkeletonSummary.vue` (199 LOC)
- **Pattern :** Props + Events (pas de provide/inject). Composables avec injection de dependances (interface Deps).
- **Notes :** Typecheck 0 erreurs. Lint OK.
### F1.2 Decouper `machines/new.vue` (1231 LOC → 196 LOC)
- **Statut :** `[x]`
- **Fichier :** `Inventory_frontend/app/pages/machines/new.vue`
- **Resultat :** Page decomposee en 1 composable + 5 composants. Orchestrateur = 196 LOC.
- **Fichiers crees :**
- `composables/useMachineCreatePage.ts` (460 LOC) — state, entity lookups, options, creation
- `components/machine/create/RequirementComponentSelector.vue` (126 LOC)
- `components/machine/create/RequirementPieceSelector.vue` (130 LOC)
- `components/machine/create/RequirementProductSelector.vue` (142 LOC)
- `components/machine/create/MachineCreatePreview.vue` (205 LOC)
- `components/machine/create/PreviewRequirementGroup.vue` (59 LOC)
- **Pattern :** Props + Events. Composable consolide entity lookups, options, label helpers, creation.
- **Notes :** Typecheck 0 erreurs. Lint OK. Corrige aussi un bug F1.1 (defineProps dans mauvais script block de MachineSkeletonSummary.vue).
### F1.3 Decouper les pages de creation/edition (Piece, Component, Product)
- **Statut :** `[x]`
- **Fichiers :**
- `pages/component/create.vue` (1282 LOC)
- `pages/component/[id]/edit.vue` (1629 LOC)
- `pages/pieces/create.vue` (817 LOC)
- `pages/pieces/[id]/edit.vue` (1327 LOC)
- `pages/product/[id]/edit.vue` (936 LOC)
- **Probleme :** Formulaires monolithiques avec sections multiples (infos generales, fournisseurs, documents, custom fields, etc.).
- **Action :**
1. Identifier les sections communes entre create/edit (factoriser)
2. Extraire chaque section en composant reutilisable :
- `EntityFormGeneral.vue` (nom, reference, description)
- `EntityFormSuppliers.vue` (constructeurs)
- `EntityFormDocuments.vue` (documents)
- `EntityFormCustomFields.vue` (champs personnalises)
3. Objectif par page : <400 LOC
- **Agent :** -
- **Notes :** Les formulaires create et edit partagent beaucoup de code. Factoriser.
- **Sous-taches :**
- [x] F1.3a Extraire `customFieldFormUtils.ts` (duplique dans 5 fichiers)
- [x] F1.3b Extraire `documentDisplayUtils.ts` (duplique dans 3 pages edit)
- [x] F1.3c Extraire `historyDisplayUtils.ts` (duplique dans 3 pages edit)
- [x] F1.3d Rewire les 5 pages create/edit sur les modules extraits
- [x] F1.3e Typecheck + commit F1.3 (erreurs F1.3 corrigees, 120 erreurs preexistantes documentees)
### F1.4 Reduire PieceItem.vue (1588 LOC) et ComponentItem.vue (1336 LOC)
- **Statut :** `[x]`
- **Fichiers :**
- `Inventory_frontend/app/components/PieceItem.vue` (1588 → 740 LOC)
- `Inventory_frontend/app/components/ComponentItem.vue` (1336 → 585 LOC)
- **Probleme :** ~700 LOC de logique dupliquee entre les deux composants (champs personnalises, documents, affichage produit).
- **Action realisee :**
1. Extraction de la logique pure custom fields dans `shared/utils/entityCustomFieldLogic.ts` (~350 LOC)
2. Creation de `composables/useEntityCustomFields.ts` (composable reactif, ~180 LOC)
3. Creation de `composables/useEntityDocuments.ts` (CRUD documents + preview, ~120 LOC)
4. Creation de `composables/useEntityProductDisplay.ts` (affichage produit, ~100 LOC)
5. Import des helpers document depuis `shared/utils/documentDisplayUtils.ts` (existant)
6. Rewrite des deux composants pour utiliser les modules partages
7. Typecheck 0 erreurs, lint 0 erreurs
- **Sous-taches :**
- [x] F1.4a Extraire `entityCustomFieldLogic.ts` (fonctions pures)
- [x] F1.4b Creer `useEntityCustomFields.ts` (composable reactif)
- [x] F1.4c Creer `useEntityDocuments.ts` (composable documents)
- [x] F1.4d Creer `useEntityProductDisplay.ts` (composable produit)
- [x] F1.4e Rewrite ComponentItem.vue (1336 → 585 LOC, script 900 → 150 LOC)
- [x] F1.4f Rewrite PieceItem.vue (1588 → 740 LOC, script 1100 → 255 LOC)
- [x] F1.4g Typecheck + lint (0 erreurs)
- **Notes :** Les templates restent volumineux (~430-480 LOC) car le contenu UI est dense. Une extraction en sous-composants (DocumentList, ProductDisplay, CustomFieldForm) serait une etape future optionnelle.
---
## Phase F2 - Elimination de la duplication frontend
> **Priorite :** HAUTE - DRY
### F2.1 Extraire `extractCollection()` dans un utilitaire partage
- **Statut :** `[x]`
- **Fichiers concernes :**
- `composables/useSites.ts`
- `composables/useProducts.ts`
- `composables/usePieces.ts`
- `composables/useComposants.ts`
- `composables/useMachineTypesApi.js`
- `composables/useConstructeurs.ts`
- `composables/useDocuments.ts`
- `composables/useMachineCreateSelections.ts`
- `components/ComponentStructureAssignmentNode.vue`
- `components/model-types/ManagementView.vue`
- **Probleme :** La fonction `extractCollection()` (parsing `hydra:member` / `member` / `items` / `data` / array) etait dupliquee dans 10 fichiers.
- **Action :**
1. [x] Creer `shared/utils/apiHelpers.ts` avec `extractCollection<T>()` generique
2. [x] Remplacer les 10 implementations locales par un import
- **Agent :** -
- **Notes :** Gere aussi `items` (utilise par ManagementView.vue). `extractRelationId()` et `normalizeRelationIds()` restent dans `shared/apiRelations.ts` (deja partages).
### F2.2 Fusionner les 3 composables d'historique
- **Statut :** `[x]`
- **Fichiers concernes :**
- `composables/useComponentHistory.ts` (67 → 13 LOC, thin wrapper)
- `composables/usePieceHistory.ts` (67 → 13 LOC, thin wrapper)
- `composables/useProductHistory.ts` (67 → 13 LOC, thin wrapper)
- `composables/useEntityHistory.ts` (NEW, 65 LOC, logique generique)
- **Probleme :** 3 fichiers quasi identiques (seul le endpoint differait).
- **Action :**
1. [x] Creer `composables/useEntityHistory.ts` parametrable par type d'entite
2. [x] Reecrire les 3 fichiers specifiques en wrappers backward-compatible
- **Agent :** -
- **Notes :** Les wrappers preservent l'API existante (types + fonction), aucun consommateur a modifier.
### F2.3 Factoriser les composables de types (Component/Piece/Product)
- **Statut :** `[x]`
- **Fichiers concernes :**
- `composables/useComponentTypes.ts` (165 → 30 LOC, thin wrapper)
- `composables/usePieceTypes.ts` (165 → 30 LOC, thin wrapper)
- `composables/useProductTypes.ts` (160 → 28 LOC, thin wrapper)
- `composables/useEntityTypes.ts` (NEW, 172 LOC, logique generique)
- **Probleme :** 3 composables tres similaires pour gerer les categories/types.
- **Action :**
1. [x] Creer `composables/useEntityTypes.ts` generique (CRUD + singleton state par categorie)
2. [x] Reecrire les 3 fichiers specifiques en wrappers avec renommage des champs
- **Agent :** -
- **Notes :** Les wrappers renomment `types``componentTypes`/`pieceTypes`/`productTypes`, preservent `getXxxTypes()` et `isXxxTypeLoading()`. Etat partage via `stateByCategory` map module-level.
---
## Phase F3 - Migration TypeScript
> **Priorite :** HAUTE - Securite du typage
### F3.1 Definir les types pour les reponses API
- **Statut :** `[x]` (partiellement — types definis dans chaque composable + `ApiResponse<T>` dans useApi.ts)
- **Fichiers :**
- `composables/useApi.ts``ApiResponse<T>` generique (success/data/error/status)
- `composables/useMachines.ts``Machine` interface
- `composables/useMachineTypesApi.ts``MachineType`, `MachineTypeRequirement` interfaces
- `composables/useToast.ts``Toast`, `ToastType` types
- `composables/useProfiles.ts``Profile` interface
- `composables/useCustomFields.ts``CustomFieldValue` interface
- **Notes :** Les types sont definis dans chaque composable (colocation). Types entite existants : `Product`, `Piece`, `Composant`, `Constructeur`, `Site`, `Document` dans leurs composables respectifs (.ts). `shared/types/inventory.ts` contient les types de structure de modele.
### F3.2 Convertir les composables JS en TS
- **Statut :** `[x]`
- **Fichiers convertis (7 fichiers JS → TS) :**
- [x] `useToast.js``useToast.ts` (72 LOC, types: `Toast`, `ToastType`)
- [x] `useProfiles.js``useProfiles.ts` (68 LOC, type: `Profile`)
- [x] `useProfileSession.js``useProfileSession.ts` (85 LOC, importe `Profile`)
- [x] `useApi.js``useApi.ts` (106 LOC → 120 LOC, types: `ApiResponse<T>`, `ApiCallOptions`, ajout `put()`)
- [x] `useCustomFields.js``useCustomFields.ts` (105 LOC, type: `CustomFieldValue`)
- [x] `useMachineTypesApi.js``useMachineTypesApi.ts` (173 → 188 LOC, types: `MachineType`, `MachineTypeRequirement`)
- [x] `useMachines.js``useMachines.ts` (267 LOC, type: `Machine`, utilise `extractCollection`)
- **Fichiers deja TS :** `useProducts.ts`, `usePieces.ts`, `useComposants.ts`, `useConstructeurs.ts`, `useSites.ts`, `useDocuments.ts`
- **Fichiers JS restants (deprecated) :** `useComponentModels.js`, `usePieceModels.js` (stubs deprecated, a supprimer)
- **Notes :** `ApiResponse<T = any>` par defaut `any` pour backward-compat. Les callers existants fonctionnent sans changement ; le nouveau code peut opt-in strict via `get<MyType>()`.
### F3.3 Eliminer les `any` restants
- **Statut :** `[x]`
- **Fichiers concernes :**
- `components/ProductSelect.vue` — 1 `any` restant (slot template, incompressible)
- `components/model-types/ManagementView.vue` — remplace `data?: any``Record<string, unknown>`, `error: any``error: unknown`, `item: any``item: unknown`
- `components/ComponentStructureAssignmentNode.vue` — 12 casts `(definition as any).typePiece/typeProduct` elimines grace a l'extension des types
- `components/ComponentModelStructureEditor.vue``Promise<any>``Promise<unknown>`
- `components/model-types/ModelTypeForm.vue``(incoming as any).description` → cast `Record<string, unknown>`
- `shared/types/inventory.ts``ComponentModelPiece.typePiece?` et `ComponentModelProduct.typeProduct?` ajoutes, 3 casts `(value as any)` supprimes
- **Probleme :** 20+ usages de `any` type identifies.
- **Action :** Etendre les interfaces de types pour supporter les formes alternatives de l'API. Remplacer les `any` par `unknown` ou `Record<string, unknown>` la ou possible.
- **Agent :** Claude
- **Notes :** ~15 casts `any` elimines. Les `Record<string, any>` restants dans ComponentModelStructureEditor sont justifies (manipulation dynamique interne de custom fields). Typecheck 0 erreurs.
---
## Phase F4 - Qualite du code frontend
> **Priorite :** MOYENNE
### F4.1 Activer les regles ESLint critiques
- **Statut :** `[x]` DONE
- **Fichier :** `Inventory_frontend/eslint.config.mjs`
- **Probleme :** Presque toutes les regles etaient desactivees (`no-console: off`, `no-unused-vars: off`, `no-explicit-any: off`).
- **Action realisee :**
1. [x] Active `@typescript-eslint/no-explicit-any: warn` (526 warnings — amelioration progressive)
2. [x] Active `no-console: warn` avec `allow: ['error']` — 0 violations (deja nettoye en F4.2)
3. [x] Active `@typescript-eslint/no-unused-vars: warn` avec ignore `^_` — 0 violations (26 corrigees)
4. [x] Corrige les 26 violations `no-unused-vars` : imports inutilises supprimes, variables prefixees `_`, destructurations nettoyees
- **Agent :** Claude
- **Notes :** 16 fichiers modifies. Regles organisees par categorie (vue, console, typescript, formatting). 0 erreurs, 526 warnings `no-explicit-any` restants (warn, pas bloquant).
### F4.2 Nettoyer les console.log/console.error
- **Statut :** `[x]` (console.log supprime, console.error conserve)
- **Fichiers modifies :** 8 fichiers (useMachineTypesApi.ts, useSites.ts, type/[id].vue, type/edit/[id].vue, TypeEditPieceRequirementsSection.vue, SearchSelect.vue, app.vue)
- **Probleme :** 19 appels `console.log` de debug laisses dans le code de production.
- **Action :**
1. [x] Supprimer les 19 `console.log` de debug (normalizeRequirementList, page loading, route params, etc.)
2. [ ] Les 72 `console.error` restants sont conserves (gestion d'erreur legitime). Migration vers un logger centralise a faire en F4.3.
- **Agent :** Claude
- **Notes :** 0 `console.log/warn/debug/info` restants dans le frontend.
### F4.3 Centraliser la gestion d'erreurs API
- **Statut :** `[ ]`
- **Fichier :** `Inventory_frontend/app/composables/useApi.js` (105 LOC)
- **Probleme :** Gestion d'erreur basique (juste un toast). Pas de retry, pas d'intercepteur, erreurs silencieuses dans certains composables.
- **Action :**
1. Ajouter un systeme de retry configurable (1-3 tentatives)
2. Centraliser la gestion des erreurs HTTP (401 -> redirect login, 500 -> message explicite)
3. Ajouter des intercepteurs request/response
4. Uniformiser le pattern dans tous les composables
- **Agent :** -
- **Notes :** -
---
## Phase F5 - Reduire le fichier modelUtils.ts (1017 LOC)
> **Priorite :** MOYENNE
### F5.1 Decouper `shared/modelUtils.ts`
- **Statut :** `[x]`
- **Fichier :** `Inventory_frontend/app/shared/modelUtils.ts` (1017 LOC → 37 LOC barrel)
- **Probleme :** Fichier utilitaire monolithique de 1017 lignes regroupant toute la logique de manipulation de modeles.
- **Action :**
1. Identifier les groupes de fonctions (structure, custom fields, requirements, serialization)
2. Decouper en 3 modules thematiques :
- `shared/model/componentStructure.ts` (~590 LOC) — helpers, sanitize, hydrate, normalize, extract, format pour composants
- `shared/model/pieceProductStructure.ts` (~155 LOC) — structure piece/produit (clone, sanitize, hydrate, format)
- `shared/model/definitionOverrides.ts` (~50 LOC) — sanitization des overrides de definition
3. Re-exporter depuis `shared/modelUtils.ts` (barrel) pour ne pas casser les imports
- **Agent :** Claude
- **Notes :** 11 fichiers consommateurs inchanges (barrel preserve la retro-compat). Typecheck 0 erreurs.
---
## Phase F6 - Tests frontend
> **Priorite :** HAUTE - Aucun test actuellement
### F6.1 Configurer Vitest
- **Statut :** `[x]` DONE
- **Fichiers crees :**
- `vitest.config.ts` — config Vitest avec happy-dom, alias `~` et `#imports`
- `tests/__mocks__/imports.ts` — mock des auto-imports Nuxt (useRuntimeConfig, useRoute, etc.)
- `tests/shared/inventory-types.test.ts` — 9 tests smoke (validator, empty structures)
- **Action realisee :**
1. [x] Installe `vitest`, `@vue/test-utils`, `happy-dom`
2. [x] Configure Vitest avec environment happy-dom et resolution d'alias
3. [x] Ajoute scripts `test` et `test:watch` dans `package.json`
4. [x] Premier test suite : `componentModelStructureValidator` (9 tests, 100% pass)
- **Agent :** Claude
- **Notes :** `npm test` → 9 tests, 0 failures, <1s. Alias `#imports` pointe vers un mock minimal extensible.
### F6.2 Tests unitaires des composables
- **Statut :** `[x]` DONE (base)
- **Fichiers crees :**
- `tests/shared/apiHelpers.test.ts` — 10 tests (extractCollection, tous formats API)
- `tests/shared/modelUtils.test.ts` — 18 tests (isPlainObject, clone, stats, format, piece/product)
- `tests/shared/inventory-types.test.ts` — 9 tests (validator, empty structures)
- `tests/composables/useToast.test.ts` — 9 tests (add, types, max limit, clear, singleton)
- `tests/composables/useConfirm.test.ts` — 8 tests (open, confirm, cancel, options, singleton)
- **Action realisee :**
1. [x] Teste `extractCollection()` : array, hydra:member, member, items, data, null, undefined
2. [x] Teste `useToast` : ajout, types, max 3 toasts, clearAll, removeToast, singleton
3. [x] Teste `useConfirm` : open/close, resolve true/false, custom options, singleton state
4. [x] Teste `modelUtils` : clone, stats, preview, isPlainObject, piece/product variants
5. [x] Teste `componentModelStructureValidator` : valid/invalid, custom fields, subcomponents
- **Agent :** Claude
- **Notes :** 54 tests, 5 fichiers, 100% pass, <2s. Tests `useApi` et CRUD composables necessitent mock fetch (phase ulterieure).
### F6.3 Tests de composants
- **Statut :** `[ ]`
- **Fichiers a creer :**
- `tests/components/Pagination.test.ts`
- `tests/components/SearchSelect.test.ts`
- `tests/components/MachineHeader.test.ts` (apres F1.1)
- **Action :**
1. Tester les composants communs (Pagination, SearchSelect)
2. Tester le rendu conditionnel et les events
- **Agent :** -
- **Notes :** -
---
## Phase F7 - Ameliorations UX/DX
> **Priorite :** BASSE - Polish
### F7.1 Reduire le props drilling
- **Statut :** `[ ]`
- **Probleme :** Props passees sur 3+ niveaux (ex: machine data dans les sous-composants).
- **Action :**
1. Identifier les cas de props drilling >2 niveaux
2. Utiliser `provide/inject` ou des composables partages
3. Documenter le pattern choisi
- **Agent :** -
- **Notes :** A traiter apres F1 (decoupage des composants).
### F7.2 Remplacer `confirm()` natif par des modales DaisyUI
- **Statut :** `[x]` DONE
- **Probleme :** Les confirmations de suppression utilisaient `window.confirm()` (UI native, non-stylee).
- **Action realisee :**
1. [x] Cree `composables/useConfirm.ts` — composable promise-based avec etat reactif partage
2. [x] Cree `components/common/ConfirmModal.vue` — modale DaisyUI teleportee (backdrop blur, btn-error)
3. [x] Monte `ConfirmModal` globalement dans `app.vue`
4. [x] Remplace les 10 `confirm()` natifs dans 10 fichiers :
- `constructeurs.vue`, `profiles/manage.vue`, `ManagementView.vue`
- `product-catalog.vue`, `index.vue`, `machines/index.vue`
- `machine-skeleton/index.vue`, `pieces-catalog.vue`, `component-catalog.vue`
- `useSiteManagement.ts` (composable — import explicite)
- **Agent :** Claude
- **Notes :** API : `const { confirm } = useConfirm(); const ok = await confirm({ message: '...' })`. Auto-import Nuxt pour les SFC, import explicite pour les composables.
### F7.3 Nettoyer `app.vue` (861 LOC)
- **Statut :** `[x]` DONE
- **Fichier :** `Inventory_frontend/app/app.vue` (861 → 49 LOC)
- **Probleme :** Le fichier racine contenait le layout principal, la navbar (~676 LOC dupliquee mobile/desktop), et du state management.
- **Action realisee :**
1. Cree `composables/useNavDropdown.ts` (~65 LOC) — gestion etat dropdowns navbar
2. Cree `components/layout/AppNavbar.vue` (~310 LOC) — navbar data-driven avec `v-for` eliminant duplication mobile/desktop
3. `app.vue` reecrit en orchestrateur minimal (49 LOC) + converti en TypeScript
4. Supprime 4 imports d'icones inutilises
- **Agent :** Claude
- **Notes :** Approche data-driven : liens et groupes definis comme tableaux types (`NavLink[]`, `NavGroup[]`), rendus par `v-for` pour mobile et desktop
---
## Ordre d'execution recommande
```
=== BACKEND === === FRONTEND ===
Phase 6.1 (Tests unitaires) Phase F6.1 (Config Vitest)
| |
v v
Phase 1 (Securite) Phase F1 (Decoupage mega-composants)
| |
v v
Phase 2 (Duplication backend) Phase F2 (Duplication frontend)
| |
v v
Phase 3 (Controllers) Phase F3 (Migration TypeScript)
| |
v v
Phase 6.2 (Tests API) Phase F4 (Qualite code) + Phase F5 (modelUtils)
| |
v v
Phase 4 (Stockage) Phase F6.2-F6.3 (Tests frontend)
| |
v v
Phase 5 + Phase 7 (Nettoyage) Phase F7 (UX/DX polish)
|
v
Phase 6.3 (Tests audit)
```
> Les colonnes backend et frontend peuvent etre executees **en parallele** par des agents differents.
---
## Journal des modifications
| Date | Phase | Tache | Agent | Statut | Notes |
| ---------- | ----- | ------------------------- | --------------- | ------- | ---------------------------------------------- |
| 2026-02-03 | - | Creation du plan backend | Claude Opus 4.5 | Termine | Analyse initiale backend (7 phases, 17 taches) |
| 2026-02-03 | - | Creation du plan frontend | Claude Opus 4.5 | Termine | Analyse frontend (7 phases, 22 taches) |
| | | | | | |
---
## Commandes de verification
> **Contexte :** Le backend tourne dans Docker (`docker compose`), le frontend est en local.
> Les commandes ci-dessous sont executees **depuis la racine du projet** (`/home/matthieu/dev_malio/Inventory/`).
### Frontend (Nuxt 3 / Vue 3 / TypeScript)
| Commande | Description | Quand l'utiliser |
| -------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `npx nuxi typecheck` | Verification des types TypeScript via `vue-tsc` | Apres chaque modification de fichier `.vue` ou `.ts`. C'est la commande principale de validation. |
| `npm run lint` | ESLint (config dans `eslint.config.mjs`) | Apres chaque modification pour verifier le style et les erreurs statiques. |
| `npm run lint:fix` | ESLint avec auto-fix | Pour corriger automatiquement les erreurs de formatage. |
| `npm run build` | Build de production Nuxt (inclut le typecheck) | Avant un commit pour s'assurer que tout compile. Plus lent que `typecheck` seul. |
| `npx nuxi prepare` | Regenerer les types auto-generes (`.nuxt/`) | Si les imports auto (composables, components) ne sont pas reconnus par le typecheck. |
> **Toutes les commandes frontend** sont executees depuis `Inventory_frontend/` :
>
> ```bash
> cd Inventory_frontend && npx nuxi typecheck
> ```
> **Note sur les erreurs pre-existantes :** Il y a ~120 erreurs TypeScript pre-existantes documentees
> (anterieures a la refacto). L'objectif est de ne pas en ajouter de nouvelles.
> Pour verifier : comparer le nombre d'erreurs avant/apres modification.
### Backend (Symfony 8 / PHP 8.4)
| Commande | Description | Quand l'utiliser |
| ---------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------- |
| `vendor/bin/php-cs-fixer fix --dry-run --diff` | Verifie le style PHP (PSR-12 + Symfony) sans modifier | Apres chaque modification PHP. |
| `vendor/bin/php-cs-fixer fix` | Corrige automatiquement le style PHP | Avant chaque commit. |
| `bin/phpunit` | Lance les tests PHPUnit | Apres chaque modification backend. |
| `php bin/console cache:clear` | Vide le cache Symfony | Si des erreurs bizarres apparaissent apres un changement de config. |
> **Les commandes backend** sont executees **dans le conteneur Docker** :
>
> ```bash
> docker compose exec web vendor/bin/php-cs-fixer fix --dry-run --diff
> docker compose exec web bin/phpunit
> ```
### Workflow de verification (checklist par tache)
```
1. Lire les fichiers concernes (AVANT toute modification)
2. Effectuer les modifications
3. Frontend : npx nuxi typecheck → verifier pas de nouvelles erreurs
4. Frontend : npm run lint:fix → corriger le formatage
5. Backend : php-cs-fixer fix → corriger le style PHP
6. Backend : bin/phpunit → verifier la non-regression
7. Commit si tout est OK
```
---
## Regles pour les agents
1. **Avant de commencer une tache :**
- Mettre le statut a `[~]` dans ce fichier
- Inscrire son nom/ID dans la colonne "Agent"
- Lire les fichiers concernes AVANT de modifier quoi que ce soit
2. **Pendant le travail :**
- Ne modifier QUE les fichiers listes dans la tache
- Respecter les conventions existantes (PSR-12, strict_types)
- Ne pas introduire de nouvelles dependances sans justification
- Lancer `php-cs-fixer` apres les modifications
3. **Apres avoir termine :**
- Mettre le statut a `[x]`
- Ajouter une entree dans le "Journal des modifications"
- Lancer les tests existants (`make test`) pour verifier la non-regression
- Decrire brievement les changements effectues dans "Notes"
4. **En cas de blocage :**
- Mettre le statut a `[!]`
- Documenter le blocage dans "Notes"
- Ne PAS passer a une autre tache sans signaler le blocage
5. **Regles specifiques au frontend :**
- Ecrire en TypeScript (pas de JS pour les nouveaux fichiers)
- Pas de `any` - utiliser des types concrets
- Pas de `console.log` - utiliser le logger ou `useToast`
- Composants Vue : max 400 LOC par fichier
- Utiliser les composants DaisyUI existants (pas de CSS custom)
- Tester avec Vitest quand la config est en place
6. **Regles specifiques au backend :**
- `declare(strict_types=1)` obligatoire
- Respecter PSR-12 + regles Symfony (php-cs-fixer)
- Pas de `exec()` direct - utiliser Symfony Process
- Tester avec PHPUnit

View File

@@ -1 +1 @@
1.1.1
1.5.0

View File

@@ -1,7 +1,9 @@
api_platform:
title: Hello API Platform
version: 1.1.1
version: 1.4.0
defaults:
stateless: false
cache_headers:
vary: ['Content-Type', 'Authorization', 'Origin']
pagination_items_per_page: 30
pagination_maximum_items_per_page: 200

View File

@@ -21,3 +21,15 @@ services:
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
App\EventSubscriber\ProductAuditSubscriber:
tags:
- { name: doctrine.event_subscriber }
App\EventSubscriber\PieceAuditSubscriber:
tags:
- { name: doctrine.event_subscriber }
App\EventSubscriber\ComposantAuditSubscriber:
tags:
- { name: doctrine.event_subscriber }

View File

@@ -6,4 +6,4 @@ POSTGRES_DB=inventory
POSTGRES_USER=root
POSTGRES_PASSWORD=root
POSTGRES_PORT=5432
XDEBUG_CLIENT_HOST=host.docker.internal
XDEBUG_CLIENT_HOST=host.docker.internal

24
frontend/.gitignore vendored
View File

@@ -1,24 +0,0 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

View File

@@ -1,75 +0,0 @@
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

View File

@@ -1,3 +0,0 @@
<template>
<NuxtPage/>
</template>

View File

@@ -1,9 +0,0 @@
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
ssr: false,
modules: ['@nuxtjs/tailwindcss'],
typescript: {
strict: true
}
})

11892
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +0,0 @@
{
"name": "frontend",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
},
"dependencies": {
"nuxt": "^4.2.2",
"vue": "^3.5.26",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@nuxtjs/tailwindcss": "^6.14.0"
}
}

View File

@@ -1,9 +0,0 @@
<template>
<div class="min-h-screen flex items-center justify-center">
<h1 class="text-3xl font-bold">Nuxt OK </h1>
</div>
</template>
<script setup lang="ts">
</script>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -1,2 +0,0 @@
User-Agent: *
Disallow:

View File

@@ -1,18 +0,0 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"files": [],
"references": [
{
"path": "./.nuxt/tsconfig.app.json"
},
{
"path": "./.nuxt/tsconfig.server.json"
},
{
"path": "./.nuxt/tsconfig.shared.json"
},
{
"path": "./.nuxt/tsconfig.node.json"
}
]
}

View File

@@ -20,165 +20,792 @@ final class Version20260125143939 extends AbstractMigration
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER INDEX idx_f95a3199df92e79b RENAME TO IDX_F95A3199CC8A4CEE');
$this->addSql('ALTER INDEX idx_f95a3199a3fdb2a7 RENAME TO IDX_F95A319936799605');
$this->addSql('ALTER TABLE _composantconstructeurs DROP CONSTRAINT "_ComposantConstructeurs_A_fkey"');
$this->addSql('ALTER TABLE _composantconstructeurs DROP CONSTRAINT "_ComposantConstructeurs_B_fkey"');
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_f95a3199df92e79b') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_f95a3199df92e79b RENAME TO IDX_F95A3199CC8A4CEE';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_f95a3199a3fdb2a7') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_f95a3199a3fdb2a7 RENAME TO IDX_F95A319936799605';
END IF;
END $$;
SQL
);
$this->addSql('ALTER TABLE _composantconstructeurs DROP CONSTRAINT IF EXISTS "_ComposantConstructeurs_A_fkey"');
$this->addSql('ALTER TABLE _composantconstructeurs DROP CONSTRAINT IF EXISTS "_ComposantConstructeurs_B_fkey"');
$this->addSql('ALTER TABLE _composantconstructeurs ALTER A TYPE VARCHAR(36)');
$this->addSql('ALTER TABLE _composantconstructeurs ALTER B TYPE VARCHAR(36)');
$this->addSql('ALTER TABLE _composantconstructeurs ADD CONSTRAINT FK_60760125D3D99E8B FOREIGN KEY (A) REFERENCES composants (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE _composantconstructeurs ADD CONSTRAINT FK_607601254AD0CF31 FOREIGN KEY (B) REFERENCES constructeurs (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE _composantconstructeurs ADD PRIMARY KEY (A, B)');
$this->addSql('ALTER INDEX idx_5b97d813e8b7be43 RENAME TO IDX_60760125D3D99E8B');
$this->addSql('ALTER INDEX _composantconstructeurs_b_index RENAME TO IDX_607601254AD0CF31');
$this->addSql('ALTER INDEX idx_6b64d7ff6736d61 RENAME TO IDX_6B64D7FF5C4A705F');
$this->addSql('ALTER INDEX idx_6b64d7fff6bae05f RENAME TO IDX_6B64D7FF633EC4FD');
$this->addSql('ALTER INDEX idx_6b64d7ffa1dac1c6 RENAME TO IDX_6B64D7FF345EE564');
$this->addSql('ALTER INDEX idx_6b64d7ff96428d73 RENAME TO IDX_6B64D7FF3C6A9D1');
$this->addSql('ALTER INDEX idx_6b64d7ffa3fdb2a7 RENAME TO IDX_6B64D7FF36799605');
$this->addSql('ALTER INDEX idx_4a48378c158582c3 RENAME TO IDX_4A48378C2F024C2');
$this->addSql('ALTER INDEX idx_4a48378cdf92e79b RENAME TO IDX_4A48378CCC8A4CEE');
$this->addSql('ALTER INDEX idx_4a48378c4ca601c8 RENAME TO IDX_4A48378C169F1CF6');
$this->addSql('ALTER INDEX idx_4a48378c40c2d03b RENAME TO IDX_4A48378C57B7763A');
$this->addSql('ALTER INDEX idx_a2b07288f6bae05f RENAME TO IDX_A2B07288633EC4FD');
$this->addSql('ALTER INDEX idx_a2b07288a1dac1c6 RENAME TO IDX_A2B07288345EE564');
$this->addSql('ALTER INDEX idx_a2b0728896428d73 RENAME TO IDX_A2B072883C6A9D1');
$this->addSql('ALTER INDEX idx_a2b07288a3fdb2a7 RENAME TO IDX_A2B0728836799605');
$this->addSql('ALTER INDEX idx_a2b07288fcf7805f RENAME TO IDX_A2B072886973A4FD');
$this->addSql('ALTER INDEX idx_528efe19f6bae05f RENAME TO IDX_528EFE19633EC4FD');
$this->addSql('ALTER INDEX idx_528efe19a1dac1c6 RENAME TO IDX_528EFE19345EE564');
$this->addSql('ALTER INDEX idx_528efe197d44d2df RENAME TO IDX_528EFE19EF6CF34B');
$this->addSql('ALTER INDEX idx_528efe19bcced9e3 RENAME TO IDX_528EFE19C44B383C');
$this->addSql('ALTER INDEX idx_62941615f6bae05f RENAME TO IDX_62941615633EC4FD');
$this->addSql('ALTER INDEX idx_6294161596428d73 RENAME TO IDX_629416153C6A9D1');
$this->addSql('ALTER INDEX idx_629416157d44d2df RENAME TO IDX_62941615EF6CF34B');
$this->addSql('ALTER INDEX idx_6294161532c54aaf RENAME TO IDX_62941615F957D314');
$this->addSql('ALTER INDEX machine_product_links_machineid_idx RENAME TO IDX_8CC32259633EC4FD');
$this->addSql('ALTER INDEX machine_product_links_productid_idx RENAME TO IDX_8CC3225936799605');
$this->addSql('ALTER INDEX idx_8cc32259357fdbff RENAME TO IDX_8CC32259B590B209');
$this->addSql('ALTER INDEX idx_8cc322597d44d2df RENAME TO IDX_8CC32259EF6CF34B');
$this->addSql('ALTER INDEX idx_8cc32259bcd7dad6 RENAME TO IDX_8CC32259A63AC5DC');
$this->addSql('ALTER INDEX idx_8cc3225987ceb33f RENAME TO IDX_8CC32259937A1D7C');
$this->addSql('ALTER INDEX idx_f1ce8dedfcf7805f RENAME TO IDX_F1CE8DED6973A4FD');
$this->addSql('ALTER INDEX idx_f1ce8ded158582c3 RENAME TO IDX_F1CE8DED2F024C2');
$this->addSql('ALTER TABLE _machineconstructeurs DROP CONSTRAINT "_MachineConstructeurs_B_fkey"');
$this->addSql('ALTER TABLE _machineconstructeurs DROP CONSTRAINT "_MachineConstructeurs_A_fkey"');
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_5b97d813e8b7be43') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_5b97d813e8b7be43 RENAME TO IDX_60760125D3D99E8B';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('_composantconstructeurs_b_index') IS NOT NULL THEN
EXECUTE 'ALTER INDEX _composantconstructeurs_b_index RENAME TO IDX_607601254AD0CF31';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_6b64d7ff6736d61') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_6b64d7ff6736d61 RENAME TO IDX_6B64D7FF5C4A705F';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_6b64d7fff6bae05f') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_6b64d7fff6bae05f RENAME TO IDX_6B64D7FF633EC4FD';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_6b64d7ffa1dac1c6') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_6b64d7ffa1dac1c6 RENAME TO IDX_6B64D7FF345EE564';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_6b64d7ff96428d73') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_6b64d7ff96428d73 RENAME TO IDX_6B64D7FF3C6A9D1';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_6b64d7ffa3fdb2a7') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_6b64d7ffa3fdb2a7 RENAME TO IDX_6B64D7FF36799605';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_4a48378c158582c3') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_4a48378c158582c3 RENAME TO IDX_4A48378C2F024C2';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_4a48378cdf92e79b') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_4a48378cdf92e79b RENAME TO IDX_4A48378CCC8A4CEE';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_4a48378c4ca601c8') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_4a48378c4ca601c8 RENAME TO IDX_4A48378C169F1CF6';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_4a48378c40c2d03b') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_4a48378c40c2d03b RENAME TO IDX_4A48378C57B7763A';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_a2b07288f6bae05f') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_a2b07288f6bae05f RENAME TO IDX_A2B07288633EC4FD';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_a2b07288a1dac1c6') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_a2b07288a1dac1c6 RENAME TO IDX_A2B07288345EE564';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_a2b0728896428d73') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_a2b0728896428d73 RENAME TO IDX_A2B072883C6A9D1';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_a2b07288a3fdb2a7') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_a2b07288a3fdb2a7 RENAME TO IDX_A2B0728836799605';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_a2b07288fcf7805f') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_a2b07288fcf7805f RENAME TO IDX_A2B072886973A4FD';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_528efe19f6bae05f') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_528efe19f6bae05f RENAME TO IDX_528EFE19633EC4FD';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_528efe19a1dac1c6') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_528efe19a1dac1c6 RENAME TO IDX_528EFE19345EE564';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_528efe197d44d2df') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_528efe197d44d2df RENAME TO IDX_528EFE19EF6CF34B';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_528efe19bcced9e3') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_528efe19bcced9e3 RENAME TO IDX_528EFE19C44B383C';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_62941615f6bae05f') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_62941615f6bae05f RENAME TO IDX_62941615633EC4FD';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_6294161596428d73') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_6294161596428d73 RENAME TO IDX_629416153C6A9D1';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_629416157d44d2df') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_629416157d44d2df RENAME TO IDX_62941615EF6CF34B';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_6294161532c54aaf') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_6294161532c54aaf RENAME TO IDX_62941615F957D314';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('machine_product_links_machineid_idx') IS NOT NULL THEN
EXECUTE 'ALTER INDEX machine_product_links_machineid_idx RENAME TO IDX_8CC32259633EC4FD';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('machine_product_links_productid_idx') IS NOT NULL THEN
EXECUTE 'ALTER INDEX machine_product_links_productid_idx RENAME TO IDX_8CC3225936799605';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_8cc32259357fdbff') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_8cc32259357fdbff RENAME TO IDX_8CC32259B590B209';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_8cc322597d44d2df') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_8cc322597d44d2df RENAME TO IDX_8CC32259EF6CF34B';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_8cc32259bcd7dad6') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_8cc32259bcd7dad6 RENAME TO IDX_8CC32259A63AC5DC';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_8cc3225987ceb33f') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_8cc3225987ceb33f RENAME TO IDX_8CC32259937A1D7C';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_f1ce8dedfcf7805f') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_f1ce8dedfcf7805f RENAME TO IDX_F1CE8DED6973A4FD';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_f1ce8ded158582c3') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_f1ce8ded158582c3 RENAME TO IDX_F1CE8DED2F024C2';
END IF;
END $$;
SQL
);
$this->addSql('ALTER TABLE _machineconstructeurs DROP CONSTRAINT IF EXISTS "_MachineConstructeurs_B_fkey"');
$this->addSql('ALTER TABLE _machineconstructeurs DROP CONSTRAINT IF EXISTS "_MachineConstructeurs_A_fkey"');
$this->addSql('ALTER TABLE _machineconstructeurs ALTER A TYPE VARCHAR(36)');
$this->addSql('ALTER TABLE _machineconstructeurs ALTER B TYPE VARCHAR(36)');
$this->addSql('ALTER TABLE _machineconstructeurs ADD CONSTRAINT FK_E6A040CCD3D99E8B FOREIGN KEY (A) REFERENCES machines (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE _machineconstructeurs ADD CONSTRAINT FK_E6A040CC4AD0CF31 FOREIGN KEY (B) REFERENCES constructeurs (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE _machineconstructeurs ADD PRIMARY KEY (A, B)');
$this->addSql('ALTER INDEX idx_4f225b32e8b7be43 RENAME TO IDX_E6A040CCD3D99E8B');
$this->addSql('ALTER INDEX _machineconstructeurs_b_index RENAME TO IDX_E6A040CC4AD0CF31');
$this->addSql('DROP INDEX "ModelType_category_name_key"');
$this->addSql('DROP INDEX "ModelType_code_key"');
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_4f225b32e8b7be43') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_4f225b32e8b7be43 RENAME TO IDX_E6A040CCD3D99E8B';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('_machineconstructeurs_b_index') IS NOT NULL THEN
EXECUTE 'ALTER INDEX _machineconstructeurs_b_index RENAME TO IDX_E6A040CC4AD0CF31';
END IF;
END $$;
SQL
);
$this->addSql('ALTER TABLE model_types DROP CONSTRAINT IF EXISTS "ModelType_category_name_key"');
$this->addSql('ALTER TABLE model_types DROP CONSTRAINT IF EXISTS "ModelType_code_key"');
$this->addSql('ALTER TABLE model_types ALTER id TYPE VARCHAR(36)');
$this->addSql('ALTER TABLE model_types ALTER category TYPE VARCHAR(255)');
$this->addSql('ALTER TABLE model_types ALTER createdAt DROP DEFAULT');
$this->addSql('ALTER TABLE model_types ALTER componentSkeleton TYPE JSON');
$this->addSql('ALTER TABLE model_types ALTER pieceSkeleton TYPE JSON');
$this->addSql('ALTER TABLE model_types ALTER productSkeleton TYPE JSON');
$this->addSql('ALTER INDEX idx_b92d74724ca601c8 RENAME TO IDX_B92D7472169F1CF6');
$this->addSql('ALTER INDEX idx_b92d7472a3fdb2a7 RENAME TO IDX_B92D747236799605');
$this->addSql('ALTER TABLE _piececonstructeurs DROP CONSTRAINT "_PieceConstructeurs_A_fkey"');
$this->addSql('ALTER TABLE _piececonstructeurs DROP CONSTRAINT "_PieceConstructeurs_B_fkey"');
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_b92d74724ca601c8') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_b92d74724ca601c8 RENAME TO IDX_B92D7472169F1CF6';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_b92d7472a3fdb2a7') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_b92d7472a3fdb2a7 RENAME TO IDX_B92D747236799605';
END IF;
END $$;
SQL
);
$this->addSql('ALTER TABLE _piececonstructeurs DROP CONSTRAINT IF EXISTS "_PieceConstructeurs_A_fkey"');
$this->addSql('ALTER TABLE _piececonstructeurs DROP CONSTRAINT IF EXISTS "_PieceConstructeurs_B_fkey"');
$this->addSql('ALTER TABLE _piececonstructeurs ALTER A TYPE VARCHAR(36)');
$this->addSql('ALTER TABLE _piececonstructeurs ALTER B TYPE VARCHAR(36)');
$this->addSql('ALTER TABLE _piececonstructeurs ADD CONSTRAINT FK_E94732E5D3D99E8B FOREIGN KEY (A) REFERENCES pieces (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE _piececonstructeurs ADD CONSTRAINT FK_E94732E54AD0CF31 FOREIGN KEY (B) REFERENCES constructeurs (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE _piececonstructeurs ADD PRIMARY KEY (A, B)');
$this->addSql('ALTER INDEX idx_77fc120e8b7be43 RENAME TO IDX_E94732E5D3D99E8B');
$this->addSql('ALTER INDEX _piececonstructeurs_b_index RENAME TO IDX_E94732E54AD0CF31');
$this->addSql('ALTER INDEX idx_b3ba5a5a40c2d03b RENAME TO IDX_B3BA5A5A57B7763A');
$this->addSql('ALTER TABLE _productconstructeurs DROP CONSTRAINT "_ProductConstructeurs_B_fkey"');
$this->addSql('ALTER TABLE _productconstructeurs DROP CONSTRAINT "_ProductConstructeurs_A_fkey"');
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_77fc120e8b7be43') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_77fc120e8b7be43 RENAME TO IDX_E94732E5D3D99E8B';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('_piececonstructeurs_b_index') IS NOT NULL THEN
EXECUTE 'ALTER INDEX _piececonstructeurs_b_index RENAME TO IDX_E94732E54AD0CF31';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_b3ba5a5a40c2d03b') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_b3ba5a5a40c2d03b RENAME TO IDX_B3BA5A5A57B7763A';
END IF;
END $$;
SQL
);
$this->addSql('ALTER TABLE _productconstructeurs DROP CONSTRAINT IF EXISTS "_ProductConstructeurs_B_fkey"');
$this->addSql('ALTER TABLE _productconstructeurs DROP CONSTRAINT IF EXISTS "_ProductConstructeurs_A_fkey"');
$this->addSql('ALTER TABLE _productconstructeurs ALTER A TYPE VARCHAR(36)');
$this->addSql('ALTER TABLE _productconstructeurs ALTER B TYPE VARCHAR(36)');
// Clean orphaned relations before re-adding foreign keys.
$this->addSql('DELETE FROM _productconstructeurs WHERE A IS NULL OR B IS NULL');
$this->addSql('DELETE FROM _productconstructeurs pc WHERE NOT EXISTS (SELECT 1 FROM products p WHERE p.id = pc.A)');
$this->addSql('DELETE FROM _productconstructeurs pc WHERE NOT EXISTS (SELECT 1 FROM constructeurs c WHERE c.id = pc.B)');
$this->addSql('ALTER TABLE _productconstructeurs ADD CONSTRAINT FK_CF7403FCD3D99E8B FOREIGN KEY (A) REFERENCES products (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE _productconstructeurs ADD CONSTRAINT FK_CF7403FC4AD0CF31 FOREIGN KEY (B) REFERENCES constructeurs (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE _productconstructeurs ADD PRIMARY KEY (A, B)');
$this->addSql('ALTER INDEX idx_66f61802e8b7be43 RENAME TO IDX_CF7403FCD3D99E8B');
$this->addSql('ALTER INDEX _productconstructeurs_b_index RENAME TO IDX_CF7403FC4AD0CF31');
$this->addSql('DROP INDEX uniq_profiles_email');
$this->addSql('ALTER INDEX idx_96958790158582c3 RENAME TO IDX_969587902F024C2');
$this->addSql('ALTER INDEX idx_96958790df92e79b RENAME TO IDX_96958790CC8A4CEE');
$this->addSql('ALTER INDEX idx_f609e59e158582c3 RENAME TO IDX_F609E59E2F024C2');
$this->addSql('ALTER INDEX idx_f609e59e4ca601c8 RENAME TO IDX_F609E59E169F1CF6');
$this->addSql('ALTER INDEX idx_29a51f98158582c3 RENAME TO IDX_29A51F982F024C2');
$this->addSql('ALTER INDEX idx_29a51f9840c2d03b RENAME TO IDX_29A51F9857B7763A');
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_66f61802e8b7be43') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_66f61802e8b7be43 RENAME TO IDX_CF7403FCD3D99E8B';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('_productconstructeurs_b_index') IS NOT NULL THEN
EXECUTE 'ALTER INDEX _productconstructeurs_b_index RENAME TO IDX_CF7403FC4AD0CF31';
END IF;
END $$;
SQL
);
$this->addSql('DROP INDEX IF EXISTS uniq_profiles_email');
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_96958790158582c3') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_96958790158582c3 RENAME TO IDX_969587902F024C2';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_96958790df92e79b') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_96958790df92e79b RENAME TO IDX_96958790CC8A4CEE';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_f609e59e158582c3') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_f609e59e158582c3 RENAME TO IDX_F609E59E2F024C2';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_f609e59e4ca601c8') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_f609e59e4ca601c8 RENAME TO IDX_F609E59E169F1CF6';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_29a51f98158582c3') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_29a51f98158582c3 RENAME TO IDX_29A51F982F024C2';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_29a51f9840c2d03b') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_29a51f9840c2d03b RENAME TO IDX_29A51F9857B7763A';
END IF;
END $$;
SQL
);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE _ComposantConstructeurs DROP CONSTRAINT FK_60760125D3D99E8B');
$this->addSql('ALTER TABLE _ComposantConstructeurs DROP CONSTRAINT FK_607601254AD0CF31');
$this->addSql('ALTER TABLE _ComposantConstructeurs DROP CONSTRAINT _ComposantConstructeurs_pkey');
$this->addSql('ALTER TABLE _ComposantConstructeurs DROP CONSTRAINT IF EXISTS FK_60760125D3D99E8B');
$this->addSql('ALTER TABLE _ComposantConstructeurs DROP CONSTRAINT IF EXISTS FK_607601254AD0CF31');
$this->addSql('ALTER TABLE _ComposantConstructeurs DROP CONSTRAINT IF EXISTS _ComposantConstructeurs_pkey');
$this->addSql('ALTER TABLE _ComposantConstructeurs ALTER a TYPE TEXT');
$this->addSql('ALTER TABLE _ComposantConstructeurs ALTER b TYPE TEXT');
$this->addSql('ALTER TABLE _ComposantConstructeurs ADD CONSTRAINT "_ComposantConstructeurs_A_fkey" FOREIGN KEY (a) REFERENCES composants (id) ON UPDATE CASCADE ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE _ComposantConstructeurs ADD CONSTRAINT "_ComposantConstructeurs_B_fkey" FOREIGN KEY (b) REFERENCES constructeurs (id) ON UPDATE CASCADE ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER INDEX idx_607601254ad0cf31 RENAME TO "_ComposantConstructeurs_B_index"');
$this->addSql('ALTER INDEX idx_60760125d3d99e8b RENAME TO IDX_5B97D813E8B7BE43');
$this->addSql('ALTER TABLE _MachineConstructeurs DROP CONSTRAINT FK_E6A040CCD3D99E8B');
$this->addSql('ALTER TABLE _MachineConstructeurs DROP CONSTRAINT FK_E6A040CC4AD0CF31');
$this->addSql('ALTER TABLE _MachineConstructeurs DROP CONSTRAINT _MachineConstructeurs_pkey');
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_607601254ad0cf31') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_607601254ad0cf31 RENAME TO "_ComposantConstructeurs_B_index"';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_60760125d3d99e8b') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_60760125d3d99e8b RENAME TO IDX_5B97D813E8B7BE43';
END IF;
END $$;
SQL
);
$this->addSql('ALTER TABLE _MachineConstructeurs DROP CONSTRAINT IF EXISTS FK_E6A040CCD3D99E8B');
$this->addSql('ALTER TABLE _MachineConstructeurs DROP CONSTRAINT IF EXISTS FK_E6A040CC4AD0CF31');
$this->addSql('ALTER TABLE _MachineConstructeurs DROP CONSTRAINT IF EXISTS _MachineConstructeurs_pkey');
$this->addSql('ALTER TABLE _MachineConstructeurs ALTER a TYPE TEXT');
$this->addSql('ALTER TABLE _MachineConstructeurs ALTER b TYPE TEXT');
$this->addSql('ALTER TABLE _MachineConstructeurs ADD CONSTRAINT "_MachineConstructeurs_B_fkey" FOREIGN KEY (b) REFERENCES constructeurs (id) ON UPDATE CASCADE ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE _MachineConstructeurs ADD CONSTRAINT "_MachineConstructeurs_A_fkey" FOREIGN KEY (a) REFERENCES machines (id) ON UPDATE CASCADE ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER INDEX idx_e6a040cc4ad0cf31 RENAME TO "_MachineConstructeurs_B_index"');
$this->addSql('ALTER INDEX idx_e6a040ccd3d99e8b RENAME TO IDX_4F225B32E8B7BE43');
$this->addSql('ALTER TABLE _PieceConstructeurs DROP CONSTRAINT FK_E94732E5D3D99E8B');
$this->addSql('ALTER TABLE _PieceConstructeurs DROP CONSTRAINT FK_E94732E54AD0CF31');
$this->addSql('ALTER TABLE _PieceConstructeurs DROP CONSTRAINT _PieceConstructeurs_pkey');
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_e6a040cc4ad0cf31') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_e6a040cc4ad0cf31 RENAME TO "_MachineConstructeurs_B_index"';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_e6a040ccd3d99e8b') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_e6a040ccd3d99e8b RENAME TO IDX_4F225B32E8B7BE43';
END IF;
END $$;
SQL
);
$this->addSql('ALTER TABLE _PieceConstructeurs DROP CONSTRAINT IF EXISTS FK_E94732E5D3D99E8B');
$this->addSql('ALTER TABLE _PieceConstructeurs DROP CONSTRAINT IF EXISTS FK_E94732E54AD0CF31');
$this->addSql('ALTER TABLE _PieceConstructeurs DROP CONSTRAINT IF EXISTS _PieceConstructeurs_pkey');
$this->addSql('ALTER TABLE _PieceConstructeurs ALTER a TYPE TEXT');
$this->addSql('ALTER TABLE _PieceConstructeurs ALTER b TYPE TEXT');
$this->addSql('ALTER TABLE _PieceConstructeurs ADD CONSTRAINT "_PieceConstructeurs_A_fkey" FOREIGN KEY (a) REFERENCES pieces (id) ON UPDATE CASCADE ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE _PieceConstructeurs ADD CONSTRAINT "_PieceConstructeurs_B_fkey" FOREIGN KEY (b) REFERENCES constructeurs (id) ON UPDATE CASCADE ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER INDEX idx_e94732e54ad0cf31 RENAME TO "_PieceConstructeurs_B_index"');
$this->addSql('ALTER INDEX idx_e94732e5d3d99e8b RENAME TO IDX_77FC120E8B7BE43');
$this->addSql('ALTER TABLE _ProductConstructeurs DROP CONSTRAINT FK_CF7403FCD3D99E8B');
$this->addSql('ALTER TABLE _ProductConstructeurs DROP CONSTRAINT FK_CF7403FC4AD0CF31');
$this->addSql('ALTER TABLE _ProductConstructeurs DROP CONSTRAINT _ProductConstructeurs_pkey');
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_e94732e54ad0cf31') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_e94732e54ad0cf31 RENAME TO "_PieceConstructeurs_B_index"';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_e94732e5d3d99e8b') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_e94732e5d3d99e8b RENAME TO IDX_77FC120E8B7BE43';
END IF;
END $$;
SQL
);
$this->addSql('ALTER TABLE _ProductConstructeurs DROP CONSTRAINT IF EXISTS FK_CF7403FCD3D99E8B');
$this->addSql('ALTER TABLE _ProductConstructeurs DROP CONSTRAINT IF EXISTS FK_CF7403FC4AD0CF31');
$this->addSql('ALTER TABLE _ProductConstructeurs DROP CONSTRAINT IF EXISTS _ProductConstructeurs_pkey');
$this->addSql('ALTER TABLE _ProductConstructeurs ALTER a TYPE TEXT');
$this->addSql('ALTER TABLE _ProductConstructeurs ALTER b TYPE TEXT');
$this->addSql('ALTER TABLE _ProductConstructeurs ADD CONSTRAINT "_ProductConstructeurs_B_fkey" FOREIGN KEY (b) REFERENCES products (id) ON UPDATE CASCADE ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE _ProductConstructeurs ADD CONSTRAINT "_ProductConstructeurs_A_fkey" FOREIGN KEY (a) REFERENCES constructeurs (id) ON UPDATE CASCADE ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER INDEX idx_cf7403fc4ad0cf31 RENAME TO "_ProductConstructeurs_B_index"');
$this->addSql('ALTER INDEX idx_cf7403fcd3d99e8b RENAME TO IDX_66F61802E8B7BE43');
$this->addSql('ALTER INDEX idx_f95a319936799605 RENAME TO IDX_F95A3199A3FDB2A7');
$this->addSql('ALTER INDEX idx_f95a3199cc8a4cee RENAME TO IDX_F95A3199DF92E79B');
$this->addSql('ALTER INDEX idx_6b64d7ff345ee564 RENAME TO IDX_6B64D7FFA1DAC1C6');
$this->addSql('ALTER INDEX idx_6b64d7ff5c4a705f RENAME TO IDX_6B64D7FF6736D61');
$this->addSql('ALTER INDEX idx_6b64d7ff633ec4fd RENAME TO IDX_6B64D7FFF6BAE05F');
$this->addSql('ALTER INDEX idx_6b64d7ff3c6a9d1 RENAME TO IDX_6B64D7FF96428D73');
$this->addSql('ALTER INDEX idx_6b64d7ff36799605 RENAME TO IDX_6B64D7FFA3FDB2A7');
$this->addSql('ALTER INDEX idx_4a48378c57b7763a RENAME TO IDX_4A48378C40C2D03B');
$this->addSql('ALTER INDEX idx_4a48378c2f024c2 RENAME TO IDX_4A48378C158582C3');
$this->addSql('ALTER INDEX idx_4a48378c169f1cf6 RENAME TO IDX_4A48378C4CA601C8');
$this->addSql('ALTER INDEX idx_4a48378ccc8a4cee RENAME TO IDX_4A48378CDF92E79B');
$this->addSql('ALTER INDEX idx_a2b07288345ee564 RENAME TO IDX_A2B07288A1DAC1C6');
$this->addSql('ALTER INDEX idx_a2b07288633ec4fd RENAME TO IDX_A2B07288F6BAE05F');
$this->addSql('ALTER INDEX idx_a2b072886973a4fd RENAME TO IDX_A2B07288FCF7805F');
$this->addSql('ALTER INDEX idx_a2b072883c6a9d1 RENAME TO IDX_A2B0728896428D73');
$this->addSql('ALTER INDEX idx_a2b0728836799605 RENAME TO IDX_A2B07288A3FDB2A7');
$this->addSql('ALTER INDEX idx_528efe19345ee564 RENAME TO IDX_528EFE19A1DAC1C6');
$this->addSql('ALTER INDEX idx_528efe19633ec4fd RENAME TO IDX_528EFE19F6BAE05F');
$this->addSql('ALTER INDEX idx_528efe19ef6cf34b RENAME TO IDX_528EFE197D44D2DF');
$this->addSql('ALTER INDEX idx_528efe19c44b383c RENAME TO IDX_528EFE19BCCED9E3');
$this->addSql('ALTER INDEX idx_62941615ef6cf34b RENAME TO IDX_629416157D44D2DF');
$this->addSql('ALTER INDEX idx_62941615633ec4fd RENAME TO IDX_62941615F6BAE05F');
$this->addSql('ALTER INDEX idx_629416153c6a9d1 RENAME TO IDX_6294161596428D73');
$this->addSql('ALTER INDEX idx_62941615f957d314 RENAME TO IDX_6294161532C54AAF');
$this->addSql('ALTER INDEX idx_8cc32259633ec4fd RENAME TO "machine_product_links_machineId_idx"');
$this->addSql('ALTER INDEX idx_8cc3225936799605 RENAME TO "machine_product_links_productId_idx"');
$this->addSql('ALTER INDEX idx_8cc32259ef6cf34b RENAME TO IDX_8CC322597D44D2DF');
$this->addSql('ALTER INDEX idx_8cc32259b590b209 RENAME TO IDX_8CC32259357FDBFF');
$this->addSql('ALTER INDEX idx_8cc32259a63ac5dc RENAME TO IDX_8CC32259BCD7DAD6');
$this->addSql('ALTER INDEX idx_8cc32259937a1d7c RENAME TO IDX_8CC3225987CEB33F');
$this->addSql('ALTER INDEX idx_f1ce8ded2f024c2 RENAME TO IDX_F1CE8DED158582C3');
$this->addSql('ALTER INDEX idx_f1ce8ded6973a4fd RENAME TO IDX_F1CE8DEDFCF7805F');
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_cf7403fc4ad0cf31') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_cf7403fc4ad0cf31 RENAME TO "_ProductConstructeurs_B_index"';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_cf7403fcd3d99e8b') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_cf7403fcd3d99e8b RENAME TO IDX_66F61802E8B7BE43';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_f95a319936799605') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_f95a319936799605 RENAME TO IDX_F95A3199A3FDB2A7';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_f95a3199cc8a4cee') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_f95a3199cc8a4cee RENAME TO IDX_F95A3199DF92E79B';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_6b64d7ff345ee564') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_6b64d7ff345ee564 RENAME TO IDX_6B64D7FFA1DAC1C6';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_6b64d7ff5c4a705f') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_6b64d7ff5c4a705f RENAME TO IDX_6B64D7FF6736D61';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_6b64d7ff633ec4fd') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_6b64d7ff633ec4fd RENAME TO IDX_6B64D7FFF6BAE05F';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_6b64d7ff3c6a9d1') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_6b64d7ff3c6a9d1 RENAME TO IDX_6B64D7FF96428D73';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_6b64d7ff36799605') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_6b64d7ff36799605 RENAME TO IDX_6B64D7FFA3FDB2A7';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_4a48378c57b7763a') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_4a48378c57b7763a RENAME TO IDX_4A48378C40C2D03B';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_4a48378c2f024c2') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_4a48378c2f024c2 RENAME TO IDX_4A48378C158582C3';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_4a48378c169f1cf6') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_4a48378c169f1cf6 RENAME TO IDX_4A48378C4CA601C8';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_4a48378ccc8a4cee') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_4a48378ccc8a4cee RENAME TO IDX_4A48378CDF92E79B';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_a2b07288345ee564') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_a2b07288345ee564 RENAME TO IDX_A2B07288A1DAC1C6';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_a2b07288633ec4fd') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_a2b07288633ec4fd RENAME TO IDX_A2B07288F6BAE05F';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_a2b072886973a4fd') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_a2b072886973a4fd RENAME TO IDX_A2B07288FCF7805F';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_a2b072883c6a9d1') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_a2b072883c6a9d1 RENAME TO IDX_A2B0728896428D73';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_a2b0728836799605') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_a2b0728836799605 RENAME TO IDX_A2B07288A3FDB2A7';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_528efe19345ee564') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_528efe19345ee564 RENAME TO IDX_528EFE19A1DAC1C6';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_528efe19633ec4fd') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_528efe19633ec4fd RENAME TO IDX_528EFE19F6BAE05F';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_528efe19ef6cf34b') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_528efe19ef6cf34b RENAME TO IDX_528EFE197D44D2DF';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_528efe19c44b383c') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_528efe19c44b383c RENAME TO IDX_528EFE19BCCED9E3';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_62941615ef6cf34b') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_62941615ef6cf34b RENAME TO IDX_629416157D44D2DF';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_62941615633ec4fd') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_62941615633ec4fd RENAME TO IDX_62941615F6BAE05F';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_629416153c6a9d1') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_629416153c6a9d1 RENAME TO IDX_6294161596428D73';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_62941615f957d314') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_62941615f957d314 RENAME TO IDX_6294161532C54AAF';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_8cc32259633ec4fd') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_8cc32259633ec4fd RENAME TO "machine_product_links_machineId_idx"';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_8cc3225936799605') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_8cc3225936799605 RENAME TO "machine_product_links_productId_idx"';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_8cc32259ef6cf34b') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_8cc32259ef6cf34b RENAME TO IDX_8CC322597D44D2DF';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_8cc32259b590b209') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_8cc32259b590b209 RENAME TO IDX_8CC32259357FDBFF';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_8cc32259a63ac5dc') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_8cc32259a63ac5dc RENAME TO IDX_8CC32259BCD7DAD6';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_8cc32259937a1d7c') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_8cc32259937a1d7c RENAME TO IDX_8CC3225987CEB33F';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_f1ce8ded2f024c2') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_f1ce8ded2f024c2 RENAME TO IDX_F1CE8DED158582C3';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_f1ce8ded6973a4fd') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_f1ce8ded6973a4fd RENAME TO IDX_F1CE8DEDFCF7805F';
END IF;
END $$;
SQL
);
$this->addSql('ALTER TABLE model_types ALTER id TYPE TEXT');
$this->addSql('ALTER TABLE model_types ALTER category TYPE VARCHAR');
$this->addSql('ALTER TABLE model_types ALTER componentskeleton TYPE JSONB');
@@ -187,15 +814,78 @@ final class Version20260125143939 extends AbstractMigration
$this->addSql('ALTER TABLE model_types ALTER createdat SET DEFAULT CURRENT_TIMESTAMP');
$this->addSql('CREATE UNIQUE INDEX "ModelType_category_name_key" ON model_types (category, name)');
$this->addSql('CREATE UNIQUE INDEX "ModelType_code_key" ON model_types (code)');
$this->addSql('ALTER INDEX idx_b92d7472169f1cf6 RENAME TO IDX_B92D74724CA601C8');
$this->addSql('ALTER INDEX idx_b92d747236799605 RENAME TO IDX_B92D7472A3FDB2A7');
$this->addSql('ALTER INDEX idx_b3ba5a5a57b7763a RENAME TO IDX_B3BA5A5A40C2D03B');
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_b92d7472169f1cf6') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_b92d7472169f1cf6 RENAME TO IDX_B92D74724CA601C8';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_b92d747236799605') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_b92d747236799605 RENAME TO IDX_B92D7472A3FDB2A7';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_b3ba5a5a57b7763a') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_b3ba5a5a57b7763a RENAME TO IDX_B3BA5A5A40C2D03B';
END IF;
END $$;
SQL
);
$this->addSql('CREATE UNIQUE INDEX uniq_profiles_email ON profiles (email)');
$this->addSql('ALTER INDEX idx_969587902f024c2 RENAME TO IDX_96958790158582C3');
$this->addSql('ALTER INDEX idx_96958790cc8a4cee RENAME TO IDX_96958790DF92E79B');
$this->addSql('ALTER INDEX idx_f609e59e169f1cf6 RENAME TO IDX_F609E59E4CA601C8');
$this->addSql('ALTER INDEX idx_f609e59e2f024c2 RENAME TO IDX_F609E59E158582C3');
$this->addSql('ALTER INDEX idx_29a51f9857b7763a RENAME TO IDX_29A51F9840C2D03B');
$this->addSql('ALTER INDEX idx_29a51f982f024c2 RENAME TO IDX_29A51F98158582C3');
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_969587902f024c2') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_969587902f024c2 RENAME TO IDX_96958790158582C3';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_96958790cc8a4cee') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_96958790cc8a4cee RENAME TO IDX_96958790DF92E79B';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_f609e59e169f1cf6') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_f609e59e169f1cf6 RENAME TO IDX_F609E59E4CA601C8';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_f609e59e2f024c2') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_f609e59e2f024c2 RENAME TO IDX_F609E59E158582C3';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_29a51f9857b7763a') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_29a51f9857b7763a RENAME TO IDX_29A51F9840C2D03B';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_29a51f982f024c2') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_29a51f982f024c2 RENAME TO IDX_29A51F98158582C3';
END IF;
END $$;
SQL
);
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260125170000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add audit_logs table to store per-entity history entries.';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE audit_logs (
id VARCHAR(36) NOT NULL,
entityType VARCHAR(50) NOT NULL,
entityId VARCHAR(36) NOT NULL,
action VARCHAR(20) NOT NULL,
diff JSON DEFAULT NULL,
snapshot JSON DEFAULT NULL,
actorProfileId VARCHAR(36) DEFAULT NULL,
createdAt TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
PRIMARY KEY(id)
)
SQL);
$this->addSql('CREATE INDEX idx_audit_entity ON audit_logs (entityType, entityId)');
$this->addSql('CREATE INDEX idx_audit_created_at ON audit_logs (createdAt)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE audit_logs');
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\AuditLogRepository;
use App\Repository\ProfileRepository;
use DateTimeInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
final class ActivityLogController
{
public function __construct(
private readonly AuditLogRepository $auditLogs,
private readonly ProfileRepository $profiles,
) {}
#[Route('/api/activity-logs', name: 'api_activity_logs', methods: ['GET'])]
public function __invoke(Request $request): JsonResponse
{
$page = max(1, $request->query->getInt('page', 1));
$itemsPerPage = min(100, max(1, $request->query->getInt('itemsPerPage', 30)));
$filters = [];
if ($entityType = $request->query->get('entityType')) {
$filters['entityType'] = $entityType;
}
if ($action = $request->query->get('action')) {
$filters['action'] = $action;
}
$result = $this->auditLogs->findAllPaginated($page, $itemsPerPage, $filters);
$actorIds = array_values(array_unique(array_filter(array_map(
static fn ($log) => $log->getActorProfileId(),
$result['items'],
))));
$actorMap = [];
if ([] !== $actorIds) {
$profiles = $this->profiles->findBy(['id' => $actorIds]);
foreach ($profiles as $profile) {
$label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
if ('' === $label) {
$label = $profile->getEmail() ?? $profile->getId();
}
$actorMap[$profile->getId()] = $label;
}
}
$items = array_map(
static function ($log) use ($actorMap) {
$actorId = $log->getActorProfileId();
$snapshot = $log->getSnapshot();
return [
'id' => $log->getId(),
'entityType' => $log->getEntityType(),
'entityId' => $log->getEntityId(),
'entityName' => $snapshot['name'] ?? null,
'entityRef' => $snapshot['reference'] ?? null,
'action' => $log->getAction(),
'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
'actor' => $actorId
? [
'id' => $actorId,
'label' => $actorMap[$actorId] ?? $actorId,
]
: null,
'diff' => $log->getDiff(),
'snapshot' => $snapshot,
];
},
$result['items'],
);
return new JsonResponse([
'items' => array_values($items),
'total' => $result['total'],
'page' => $page,
'itemsPerPage' => $itemsPerPage,
]);
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\AuditLogRepository;
use App\Repository\ComposantRepository;
use App\Repository\ProfileRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class ComposantHistoryController
{
public function __construct(
private readonly ComposantRepository $components,
private readonly AuditLogRepository $auditLogs,
private readonly ProfileRepository $profiles,
) {
}
#[Route('/api/composants/{id}/history', name: 'api_composant_history', methods: ['GET'])]
public function __invoke(string $id): JsonResponse
{
$component = $this->components->find($id);
if (!$component) {
return new JsonResponse(
['message' => 'Composant introuvable.'],
Response::HTTP_NOT_FOUND,
);
}
$logs = $this->auditLogs->findEntityHistory('composant', $id, 200);
$actorIds = array_values(array_unique(array_filter(array_map(
static fn ($log) => $log->getActorProfileId(),
$logs,
))));
$actorMap = [];
if ($actorIds !== []) {
$profiles = $this->profiles->findBy(['id' => $actorIds]);
foreach ($profiles as $profile) {
$label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
if ($label === '') {
$label = $profile->getEmail() ?? $profile->getId();
}
$actorMap[$profile->getId()] = $label;
}
}
$items = array_map(
static function ($log) use ($actorMap) {
$actorId = $log->getActorProfileId();
return [
'id' => $log->getId(),
'action' => $log->getAction(),
'createdAt' => $log->getCreatedAt()->format(\DateTimeInterface::ATOM),
'actor' => $actorId
? [
'id' => $actorId,
'label' => $actorMap[$actorId] ?? $actorId,
]
: null,
'diff' => $log->getDiff(),
'snapshot' => $log->getSnapshot(),
];
},
$logs,
);
return new JsonResponse([
'items' => array_values($items),
'total' => count($items),
]);
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\AuditLogRepository;
use App\Repository\PieceRepository;
use App\Repository\ProfileRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class PieceHistoryController
{
public function __construct(
private readonly PieceRepository $pieces,
private readonly AuditLogRepository $auditLogs,
private readonly ProfileRepository $profiles,
) {
}
#[Route('/api/pieces/{id}/history', name: 'api_piece_history', methods: ['GET'])]
public function __invoke(string $id): JsonResponse
{
$piece = $this->pieces->find($id);
if (!$piece) {
return new JsonResponse(
['message' => 'Pièce introuvable.'],
Response::HTTP_NOT_FOUND,
);
}
$logs = $this->auditLogs->findEntityHistory('piece', $id, 200);
$actorIds = array_values(array_unique(array_filter(array_map(
static fn ($log) => $log->getActorProfileId(),
$logs,
))));
$actorMap = [];
if ($actorIds !== []) {
$profiles = $this->profiles->findBy(['id' => $actorIds]);
foreach ($profiles as $profile) {
$label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
if ($label === '') {
$label = $profile->getEmail() ?? $profile->getId();
}
$actorMap[$profile->getId()] = $label;
}
}
$items = array_map(
static function ($log) use ($actorMap) {
$actorId = $log->getActorProfileId();
return [
'id' => $log->getId(),
'action' => $log->getAction(),
'createdAt' => $log->getCreatedAt()->format(\DateTimeInterface::ATOM),
'actor' => $actorId
? [
'id' => $actorId,
'label' => $actorMap[$actorId] ?? $actorId,
]
: null,
'diff' => $log->getDiff(),
'snapshot' => $log->getSnapshot(),
];
},
$logs,
);
return new JsonResponse([
'items' => array_values($items),
'total' => count($items),
]);
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\AuditLogRepository;
use App\Repository\ProductRepository;
use App\Repository\ProfileRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class ProductHistoryController
{
public function __construct(
private readonly ProductRepository $products,
private readonly AuditLogRepository $auditLogs,
private readonly ProfileRepository $profiles,
) {
}
#[Route('/api/products/{id}/history', name: 'api_product_history', methods: ['GET'])]
public function __invoke(string $id): JsonResponse
{
$product = $this->products->find($id);
if (!$product) {
return new JsonResponse(
['message' => 'Produit introuvable.'],
Response::HTTP_NOT_FOUND,
);
}
$logs = $this->auditLogs->findEntityHistory('product', $id, 200);
$actorIds = array_values(array_unique(array_filter(array_map(
static fn ($log) => $log->getActorProfileId(),
$logs,
))));
$actorMap = [];
if ($actorIds !== []) {
$profiles = $this->profiles->findBy(['id' => $actorIds]);
foreach ($profiles as $profile) {
$label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
if ($label === '') {
$label = $profile->getEmail() ?? $profile->getId();
}
$actorMap[$profile->getId()] = $label;
}
}
$items = array_map(
static function ($log) use ($actorMap) {
$actorId = $log->getActorProfileId();
return [
'id' => $log->getId(),
'action' => $log->getAction(),
'createdAt' => $log->getCreatedAt()->format(\DateTimeInterface::ATOM),
'actor' => $actorId
? [
'id' => $actorId,
'label' => $actorMap[$actorId] ?? $actorId,
]
: null,
'diff' => $log->getDiff(),
'snapshot' => $log->getSnapshot(),
];
},
$logs,
);
return new JsonResponse([
'items' => array_values($items),
'total' => count($items),
]);
}
}

117
src/Entity/AuditLog.php Normal file
View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\AuditLogRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AuditLogRepository::class)]
#[ORM\Table(name: 'audit_logs')]
#[ORM\Index(name: 'idx_audit_entity', columns: ['entityType', 'entityId'])]
#[ORM\Index(name: 'idx_audit_created_at', columns: ['createdAt'])]
#[ORM\HasLifecycleCallbacks]
class AuditLog
{
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 50)]
private string $entityType;
#[ORM\Column(type: Types::STRING, length: 36)]
private string $entityId;
#[ORM\Column(type: Types::STRING, length: 20)]
private string $action;
#[ORM\Column(type: Types::JSON, nullable: true)]
private ?array $diff = null;
#[ORM\Column(type: Types::JSON, nullable: true)]
private ?array $snapshot = null;
#[ORM\Column(type: Types::STRING, length: 36, nullable: true)]
private ?string $actorProfileId = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private DateTimeImmutable $createdAt;
public function __construct(
string $entityType,
string $entityId,
string $action,
?array $diff = null,
?array $snapshot = null,
?string $actorProfileId = null,
) {
$this->entityType = $entityType;
$this->entityId = $entityId;
$this->action = $action;
$this->diff = $diff;
$this->snapshot = $snapshot;
$this->actorProfileId = $actorProfileId;
}
#[ORM\PrePersist]
public function initializeAuditLog(): void
{
if (!isset($this->createdAt)) {
$this->createdAt = new DateTimeImmutable();
}
if ($this->id === null) {
$this->id = $this->generateCuid();
}
}
public function getId(): ?string
{
return $this->id;
}
public function getEntityType(): string
{
return $this->entityType;
}
public function getEntityId(): string
{
return $this->entityId;
}
public function getAction(): string
{
return $this->action;
}
public function getDiff(): ?array
{
return $this->diff;
}
public function getSnapshot(): ?array
{
return $this->snapshot;
}
public function getActorProfileId(): ?string
{
return $this->actorProfileId;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
private function generateCuid(): string
{
// Keep the same lightweight CUID-like strategy used across the project.
return 'cl'.substr(strtolower(base_convert(bin2hex(random_bytes(12)), 16, 36)), 0, 24);
}
}

View File

@@ -24,7 +24,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
normalizationContext: ['groups' => ['composant:read']],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 500
paginationMaximumItemsPerPage: 200
)]
class Composant
{
@@ -144,7 +144,7 @@ class Composant
public function setName(string $name): static
{
$this->name = $name;
$this->name = mb_strtoupper(mb_substr($name, 0, 1)).mb_substr($name, 1);
return $this;
}

View File

@@ -17,7 +17,7 @@ use Doctrine\ORM\Mapping as ORM;
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 500
paginationMaximumItemsPerPage: 200
)]
class Constructeur
{

View File

@@ -6,10 +6,12 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\CustomFieldRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: CustomFieldRepository::class)]
#[ORM\Table(name: 'custom_fields')]
@@ -19,24 +21,30 @@ class CustomField
{
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255)]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private string $name;
#[ORM\Column(type: Types::STRING, length: 50)]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private string $type;
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => false])]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private bool $required = false;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'defaultValue')]
private ?string $defaultValue = null;
#[ORM\Column(type: Types::JSON, nullable: true)]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private ?array $options = null;
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0], name: 'orderIndex')]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private int $orderIndex = 0;
#[ORM\ManyToOne(targetEntity: TypeMachine::class, inversedBy: 'customFields')]
@@ -62,10 +70,10 @@ class CustomField
private Collection $customFieldValues;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private \DateTimeImmutable $createdAt;
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private \DateTimeImmutable $updatedAt;
private DateTimeImmutable $updatedAt;
public function __construct()
{
@@ -75,11 +83,11 @@ class CustomField
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$now = new \DateTimeImmutable();
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
if ($this->id === null) {
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}
@@ -87,12 +95,7 @@ class CustomField
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
private function generateCuid(): string
{
return 'cl' . bin2hex(random_bytes(12));
$this->updatedAt = new DateTimeImmutable();
}
public function getId(): ?string
@@ -191,13 +194,18 @@ class CustomField
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): \DateTimeImmutable
public function getUpdatedAt(): DateTimeImmutable
{
return $this->updatedAt;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -6,8 +6,10 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\CustomFieldValueRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: CustomFieldValueRepository::class)]
#[ORM\Table(name: 'custom_field_values')]
@@ -17,13 +19,16 @@ class CustomFieldValue
{
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255)]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private string $value;
#[ORM\ManyToOne(targetEntity: CustomField::class, inversedBy: 'customFieldValues')]
#[ORM\JoinColumn(name: 'customFieldId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private CustomField $customField;
#[ORM\ManyToOne(targetEntity: Machine::class, inversedBy: 'customFieldValues')]
@@ -43,19 +48,21 @@ class CustomFieldValue
private ?Product $product = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private \DateTimeImmutable $createdAt;
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private \DateTimeImmutable $updatedAt;
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private DateTimeImmutable $updatedAt;
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$now = new \DateTimeImmutable();
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
if ($this->id === null) {
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}
@@ -63,12 +70,7 @@ class CustomFieldValue
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
private function generateCuid(): string
{
return 'cl' . bin2hex(random_bytes(12));
$this->updatedAt = new DateTimeImmutable();
}
public function getId(): ?string
@@ -155,13 +157,18 @@ class CustomFieldValue
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): \DateTimeImmutable
public function getUpdatedAt(): DateTimeImmutable
{
return $this->updatedAt;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -6,6 +6,7 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\DocumentRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
@@ -13,7 +14,10 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: DocumentRepository::class)]
#[ORM\Table(name: 'documents')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource]
#[ApiResource(
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 200
)]
class Document
{
#[ORM\Id]
@@ -62,19 +66,19 @@ class Document
private ?Site $site = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private \DateTimeImmutable $createdAt;
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private \DateTimeImmutable $updatedAt;
private DateTimeImmutable $updatedAt;
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$now = new \DateTimeImmutable();
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
if ($this->id === null) {
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}
@@ -82,12 +86,7 @@ class Document
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
private function generateCuid(): string
{
return 'cl' . bin2hex(random_bytes(12));
$this->updatedAt = new DateTimeImmutable();
}
public function getId(): ?string
@@ -222,13 +221,18 @@ class Document
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): \DateTimeImmutable
public function getUpdatedAt(): DateTimeImmutable
{
return $this->updatedAt;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
@@ -21,9 +22,10 @@ use Symfony\Component\Serializer\Annotation\Groups;
#[ORM\UniqueConstraint(name: 'unique_category_name', columns: ['category', 'name'])]
#[ORM\HasLifecycleCallbacks]
#[ApiFilter(SearchFilter::class, properties: ['category' => 'exact', 'name' => 'ipartial'])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])]
#[ApiResource(
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 500
paginationMaximumItemsPerPage: 200
)]
class ModelType
{
@@ -178,7 +180,7 @@ class ModelType
public function setName(string $name): static
{
$this->name = $name;
$this->name = mb_strtoupper(mb_substr($name, 0, 1)).mb_substr($name, 1);
return $this;
}

View File

@@ -24,7 +24,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
normalizationContext: ['groups' => ['piece:read']],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 500
paginationMaximumItemsPerPage: 200
)]
class Piece
{

View File

@@ -24,7 +24,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
normalizationContext: ['groups' => ['product:read']],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 500
paginationMaximumItemsPerPage: 200
)]
class Product
{

View File

@@ -30,7 +30,7 @@ use Symfony\Component\Validator\Constraints as Assert;
new Delete(),
],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 500
paginationMaximumItemsPerPage: 200
)]
class Site
{

View File

@@ -34,7 +34,7 @@ use Symfony\Component\Validator\Constraints as Assert;
new Delete(),
],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 500
paginationMaximumItemsPerPage: 200
)]
class TypeMachine
{

View File

@@ -0,0 +1,397 @@
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\AuditLog;
use App\Entity\Composant;
use App\Entity\CustomFieldValue;
use App\Entity\ModelType;
use App\Entity\Product;
use App\Entity\Profile;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\PersistentCollection;
use Doctrine\ORM\UnitOfWork;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Throwable;
use function is_array;
use function is_object;
use function is_scalar;
use function method_exists;
#[AsDoctrineListener(event: Events::onFlush)]
final class ComposantAuditSubscriber implements EventSubscriber
{
public function __construct(
private readonly RequestStack $requestStack,
private readonly Security $security,
) {}
public function getSubscribedEvents(): array
{
return [
Events::onFlush,
];
}
public function onFlush(OnFlushEventArgs $args): void
{
$em = $args->getObjectManager();
if (!$em instanceof EntityManagerInterface) {
return;
}
$uow = $em->getUnitOfWork();
$actorProfileId = $this->resolveActorProfileId();
$pendingUpdates = [];
$pendingSnapshots = [];
$pendingComponents = [];
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!$entity instanceof Composant) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
$snapshot = $this->snapshotComposant($entity);
$this->persistAuditLog($em, new AuditLog('composant', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof Composant) {
continue;
}
$componentId = (string) $entity->getId();
if ('' === $componentId) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ([] !== $diff) {
$pendingUpdates[$componentId] = $this->mergeDiffs($pendingUpdates[$componentId] ?? [], $diff);
$pendingSnapshots[$componentId] = $this->snapshotComposant($entity);
$pendingComponents[$componentId] = $entity;
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if (!$entity instanceof Composant) {
continue;
}
$snapshot = $this->snapshotComposant($entity);
$this->persistAuditLog($em, new AuditLog('composant', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingComponents);
}
foreach ($uow->getScheduledCollectionDeletions() as $collection) {
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingComponents);
}
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingComponents);
foreach ($pendingUpdates as $componentId => $diff) {
if ([] === $diff) {
continue;
}
$component = $pendingComponents[$componentId] ?? null;
if (!$component instanceof Composant) {
continue;
}
$snapshot = $pendingSnapshots[$componentId] ?? $this->snapshotComposant($component);
$this->persistAuditLog($em, new AuditLog('composant', $componentId, 'update', $diff, $snapshot, $actorProfileId));
}
}
/**
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
* @param array<string, array<string, mixed>> $pendingSnapshots
* @param array<string, Composant> $pendingComponents
*/
private function collectCollectionUpdate(
object $collection,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingComponents,
): void {
if (!$collection instanceof PersistentCollection) {
return;
}
$owner = $collection->getOwner();
if (!$owner instanceof Composant) {
return;
}
$componentId = (string) $owner->getId();
if ('' === $componentId) {
return;
}
$mapping = $collection->getMapping();
$fieldName = $mapping['fieldName'] ?? null;
if ('constructeurs' !== $fieldName) {
return;
}
$before = $this->normalizeCollection($collection->getSnapshot());
$after = $this->normalizeCollection($collection->toArray());
if ($before === $after) {
return;
}
$diff = [
'constructeurIds' => [
'from' => $before,
'to' => $after,
],
];
$pendingUpdates[$componentId] = $this->mergeDiffs($pendingUpdates[$componentId] ?? [], $diff);
$pendingSnapshots[$componentId] = $this->snapshotComposant($owner);
$pendingComponents[$componentId] = $owner;
}
/**
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
* @param array<string, array<string, mixed>> $pendingSnapshots
* @param array<string, Composant> $pendingComponents
*/
private function collectCustomFieldValueChanges(
UnitOfWork $uow,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingComponents,
): void {
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if ($entity instanceof CustomFieldValue) {
$this->trackCustomFieldValueChange($entity, null, $entity->getValue(), $pendingUpdates, $pendingSnapshots, $pendingComponents);
}
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof CustomFieldValue) {
continue;
}
$changeSet = $uow->getEntityChangeSet($entity);
if (!isset($changeSet['value'])) {
continue;
}
[$oldVal, $newVal] = $changeSet['value'];
if ($oldVal !== $newVal) {
$this->trackCustomFieldValueChange($entity, $oldVal, $newVal, $pendingUpdates, $pendingSnapshots, $pendingComponents);
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if ($entity instanceof CustomFieldValue) {
$this->trackCustomFieldValueChange($entity, $entity->getValue(), null, $pendingUpdates, $pendingSnapshots, $pendingComponents);
}
}
}
/**
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
* @param array<string, array<string, mixed>> $pendingSnapshots
* @param array<string, Composant> $pendingComponents
*/
private function trackCustomFieldValueChange(
CustomFieldValue $cfv,
mixed $from,
mixed $to,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingComponents,
): void {
$owner = $cfv->getComposant();
if (!$owner instanceof Composant) {
return;
}
$ownerId = (string) $owner->getId();
if ('' === $ownerId) {
return;
}
$fieldName = 'customField:'.$cfv->getCustomField()->getName();
$diff = [$fieldName => ['from' => $from, 'to' => $to]];
$pendingUpdates[$ownerId] = $this->mergeDiffs($pendingUpdates[$ownerId] ?? [], $diff);
$pendingSnapshots[$ownerId] = $this->snapshotComposant($owner);
$pendingComponents[$ownerId] = $owner;
}
private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
{
$uow = $em->getUnitOfWork();
$log->initializeAuditLog();
$em->persist($log);
$meta = $em->getClassMetadata(AuditLog::class);
$uow->computeChangeSet($meta, $log);
}
/**
* @param array<string, array{0:mixed, 1:mixed}> $changeSet
*
* @return array<string, array{from:mixed, to:mixed}>
*/
private function buildDiffFromChangeSet(array $changeSet): array
{
$diff = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) {
if ('updatedAt' === $field || 'createdAt' === $field) {
continue;
}
$normalizedOld = $this->normalizeValue($oldValue);
$normalizedNew = $this->normalizeValue($newValue);
if ($normalizedOld === $normalizedNew) {
continue;
}
$diff[$field] = [
'from' => $normalizedOld,
'to' => $normalizedNew,
];
}
return $diff;
}
private function snapshotComposant(Composant $component): array
{
return [
'id' => $component->getId(),
'name' => $component->getName(),
'reference' => $component->getReference(),
'prix' => $component->getPrix(),
'structure' => $component->getStructure(),
'typeComposant' => $this->normalizeValue($component->getTypeComposant()),
'product' => $this->normalizeValue($component->getProduct()),
'constructeurIds' => $this->normalizeCollection($component->getConstructeurs()),
];
}
/**
* @param iterable<mixed> $items
*
* @return list<array{id: string, name: string}|string>
*/
private function normalizeCollection(iterable $items): array
{
$entries = [];
$seen = [];
foreach ($items as $item) {
if (is_object($item) && method_exists($item, 'getId')) {
$id = $item->getId();
if (null === $id || '' === $id || isset($seen[(string) $id])) {
continue;
}
$seen[(string) $id] = true;
if (method_exists($item, 'getName')) {
$entries[] = ['id' => (string) $id, 'name' => (string) $item->getName()];
} else {
$entries[] = (string) $id;
}
}
}
return $entries;
}
private function normalizeValue(mixed $value): mixed
{
if (null === $value || is_scalar($value)) {
return $value;
}
if ($value instanceof DateTimeInterface) {
return $value->format(DateTimeInterface::ATOM);
}
if ($value instanceof ModelType) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
'code' => $value->getCode(),
];
}
if ($value instanceof Product) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
'reference' => $value->getReference(),
];
}
if ($value instanceof Collection) {
return $this->normalizeCollection($value);
}
if (is_object($value) && method_exists($value, 'getId')) {
return (string) $value->getId();
}
if (is_array($value)) {
return $value;
}
return (string) $value;
}
/**
* @param array<string, array{from:mixed, to:mixed}> $base
* @param array<string, array{from:mixed, to:mixed}> $extra
*
* @return array<string, array{from:mixed, to:mixed}>
*/
private function mergeDiffs(array $base, array $extra): array
{
foreach ($extra as $field => $change) {
$base[$field] = $change;
}
return $base;
}
private function resolveActorProfileId(): ?string
{
try {
$session = $this->requestStack->getSession();
if ($session instanceof SessionInterface) {
$profileId = $session->get('profileId');
if ($profileId) {
return (string) $profileId;
}
}
} catch (Throwable) {
// No session available (CLI context, etc.)
}
$user = $this->security->getUser();
if ($user instanceof Profile) {
return $user->getId();
}
return null;
}
}

View File

@@ -0,0 +1,397 @@
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\AuditLog;
use App\Entity\CustomFieldValue;
use App\Entity\ModelType;
use App\Entity\Piece;
use App\Entity\Product;
use App\Entity\Profile;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\PersistentCollection;
use Doctrine\ORM\UnitOfWork;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Throwable;
use function is_array;
use function is_object;
use function is_scalar;
use function method_exists;
#[AsDoctrineListener(event: Events::onFlush)]
final class PieceAuditSubscriber implements EventSubscriber
{
public function __construct(
private readonly RequestStack $requestStack,
private readonly Security $security,
) {}
public function getSubscribedEvents(): array
{
return [
Events::onFlush,
];
}
public function onFlush(OnFlushEventArgs $args): void
{
$em = $args->getObjectManager();
if (!$em instanceof EntityManagerInterface) {
return;
}
$uow = $em->getUnitOfWork();
$actorProfileId = $this->resolveActorProfileId();
$pendingUpdates = [];
$pendingSnapshots = [];
$pendingPieces = [];
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!$entity instanceof Piece) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
$snapshot = $this->snapshotPiece($entity);
$this->persistAuditLog($em, new AuditLog('piece', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof Piece) {
continue;
}
$pieceId = (string) $entity->getId();
if ('' === $pieceId) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ([] !== $diff) {
$pendingUpdates[$pieceId] = $this->mergeDiffs($pendingUpdates[$pieceId] ?? [], $diff);
$pendingSnapshots[$pieceId] = $this->snapshotPiece($entity);
$pendingPieces[$pieceId] = $entity;
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if (!$entity instanceof Piece) {
continue;
}
$snapshot = $this->snapshotPiece($entity);
$this->persistAuditLog($em, new AuditLog('piece', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingPieces);
}
foreach ($uow->getScheduledCollectionDeletions() as $collection) {
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingPieces);
}
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingPieces);
foreach ($pendingUpdates as $pieceId => $diff) {
if ([] === $diff) {
continue;
}
$piece = $pendingPieces[$pieceId] ?? null;
if (!$piece instanceof Piece) {
continue;
}
$snapshot = $pendingSnapshots[$pieceId] ?? $this->snapshotPiece($piece);
$this->persistAuditLog($em, new AuditLog('piece', $pieceId, 'update', $diff, $snapshot, $actorProfileId));
}
}
/**
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
* @param array<string, array<string, mixed>> $pendingSnapshots
* @param array<string, Piece> $pendingPieces
*/
private function collectCollectionUpdate(
object $collection,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingPieces,
): void {
if (!$collection instanceof PersistentCollection) {
return;
}
$owner = $collection->getOwner();
if (!$owner instanceof Piece) {
return;
}
$pieceId = (string) $owner->getId();
if ('' === $pieceId) {
return;
}
$mapping = $collection->getMapping();
$fieldName = $mapping['fieldName'] ?? null;
if ('constructeurs' !== $fieldName) {
return;
}
$before = $this->normalizeCollection($collection->getSnapshot());
$after = $this->normalizeCollection($collection->toArray());
if ($before === $after) {
return;
}
$diff = [
'constructeurIds' => [
'from' => $before,
'to' => $after,
],
];
$pendingUpdates[$pieceId] = $this->mergeDiffs($pendingUpdates[$pieceId] ?? [], $diff);
$pendingSnapshots[$pieceId] = $this->snapshotPiece($owner);
$pendingPieces[$pieceId] = $owner;
}
/**
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
* @param array<string, array<string, mixed>> $pendingSnapshots
* @param array<string, Piece> $pendingPieces
*/
private function collectCustomFieldValueChanges(
UnitOfWork $uow,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingPieces,
): void {
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if ($entity instanceof CustomFieldValue) {
$this->trackCustomFieldValueChange($entity, null, $entity->getValue(), $pendingUpdates, $pendingSnapshots, $pendingPieces);
}
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof CustomFieldValue) {
continue;
}
$changeSet = $uow->getEntityChangeSet($entity);
if (!isset($changeSet['value'])) {
continue;
}
[$oldVal, $newVal] = $changeSet['value'];
if ($oldVal !== $newVal) {
$this->trackCustomFieldValueChange($entity, $oldVal, $newVal, $pendingUpdates, $pendingSnapshots, $pendingPieces);
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if ($entity instanceof CustomFieldValue) {
$this->trackCustomFieldValueChange($entity, $entity->getValue(), null, $pendingUpdates, $pendingSnapshots, $pendingPieces);
}
}
}
/**
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
* @param array<string, array<string, mixed>> $pendingSnapshots
* @param array<string, Piece> $pendingPieces
*/
private function trackCustomFieldValueChange(
CustomFieldValue $cfv,
mixed $from,
mixed $to,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingPieces,
): void {
$owner = $cfv->getPiece();
if (!$owner instanceof Piece) {
return;
}
$ownerId = (string) $owner->getId();
if ('' === $ownerId) {
return;
}
$fieldName = 'customField:'.$cfv->getCustomField()->getName();
$diff = [$fieldName => ['from' => $from, 'to' => $to]];
$pendingUpdates[$ownerId] = $this->mergeDiffs($pendingUpdates[$ownerId] ?? [], $diff);
$pendingSnapshots[$ownerId] = $this->snapshotPiece($owner);
$pendingPieces[$ownerId] = $owner;
}
private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
{
$uow = $em->getUnitOfWork();
$log->initializeAuditLog();
$em->persist($log);
$meta = $em->getClassMetadata(AuditLog::class);
$uow->computeChangeSet($meta, $log);
}
/**
* @param array<string, array{0:mixed, 1:mixed}> $changeSet
*
* @return array<string, array{from:mixed, to:mixed}>
*/
private function buildDiffFromChangeSet(array $changeSet): array
{
$diff = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) {
if ('updatedAt' === $field || 'createdAt' === $field) {
continue;
}
$normalizedOld = $this->normalizeValue($oldValue);
$normalizedNew = $this->normalizeValue($newValue);
if ($normalizedOld === $normalizedNew) {
continue;
}
$diff[$field] = [
'from' => $normalizedOld,
'to' => $normalizedNew,
];
}
return $diff;
}
private function snapshotPiece(Piece $piece): array
{
return [
'id' => $piece->getId(),
'name' => $piece->getName(),
'reference' => $piece->getReference(),
'prix' => $piece->getPrix(),
'typePiece' => $this->normalizeValue($piece->getTypePiece()),
'product' => $this->normalizeValue($piece->getProduct()),
'productIds' => $piece->getProductIds(),
'constructeurIds' => $this->normalizeCollection($piece->getConstructeurs()),
];
}
/**
* @param iterable<mixed> $items
*
* @return list<array{id: string, name: string}|string>
*/
private function normalizeCollection(iterable $items): array
{
$entries = [];
$seen = [];
foreach ($items as $item) {
if (is_object($item) && method_exists($item, 'getId')) {
$id = $item->getId();
if (null === $id || '' === $id || isset($seen[(string) $id])) {
continue;
}
$seen[(string) $id] = true;
if (method_exists($item, 'getName')) {
$entries[] = ['id' => (string) $id, 'name' => (string) $item->getName()];
} else {
$entries[] = (string) $id;
}
}
}
return $entries;
}
private function normalizeValue(mixed $value): mixed
{
if (null === $value || is_scalar($value)) {
return $value;
}
if ($value instanceof DateTimeInterface) {
return $value->format(DateTimeInterface::ATOM);
}
if ($value instanceof ModelType) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
'code' => $value->getCode(),
];
}
if ($value instanceof Product) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
'reference' => $value->getReference(),
];
}
if ($value instanceof Collection) {
return $this->normalizeCollection($value);
}
if (is_object($value) && method_exists($value, 'getId')) {
return (string) $value->getId();
}
if (is_array($value)) {
return $value;
}
return (string) $value;
}
/**
* @param array<string, array{from:mixed, to:mixed}> $base
* @param array<string, array{from:mixed, to:mixed}> $extra
*
* @return array<string, array{from:mixed, to:mixed}>
*/
private function mergeDiffs(array $base, array $extra): array
{
foreach ($extra as $field => $change) {
$base[$field] = $change;
}
return $base;
}
private function resolveActorProfileId(): ?string
{
try {
$session = $this->requestStack->getSession();
if ($session instanceof SessionInterface) {
$profileId = $session->get('profileId');
if ($profileId) {
return (string) $profileId;
}
}
} catch (Throwable) {
// No session available (CLI context, etc.)
}
$user = $this->security->getUser();
if ($user instanceof Profile) {
return $user->getId();
}
return null;
}
}

View File

@@ -0,0 +1,396 @@
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\AuditLog;
use App\Entity\CustomFieldValue;
use App\Entity\ModelType;
use App\Entity\Product;
use App\Entity\Profile;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\PersistentCollection;
use Doctrine\ORM\UnitOfWork;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Throwable;
use function is_array;
use function is_object;
use function is_scalar;
use function method_exists;
/**
* Record a lightweight, per-product audit trail.
*
* This MVP focuses on Product updates and captures:
* - scalar field changes (from Doctrine change sets)
* - constructeur collection changes (from collection updates)
*/
#[AsDoctrineListener(event: Events::onFlush)]
final class ProductAuditSubscriber implements EventSubscriber
{
public function __construct(
private readonly RequestStack $requestStack,
private readonly Security $security,
) {}
public function getSubscribedEvents(): array
{
return [
Events::onFlush,
];
}
public function onFlush(OnFlushEventArgs $args): void
{
$em = $args->getObjectManager();
if (!$em instanceof EntityManagerInterface) {
return;
}
$uow = $em->getUnitOfWork();
$actorProfileId = $this->resolveActorProfileId();
$pendingUpdates = [];
$pendingSnapshots = [];
$pendingProducts = [];
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!$entity instanceof Product) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
$snapshot = $this->snapshotProduct($entity);
$this->persistAuditLog($em, new AuditLog('product', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof Product) {
continue;
}
$productId = (string) $entity->getId();
if ('' === $productId) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ([] !== $diff) {
$pendingUpdates[$productId] = $this->mergeDiffs($pendingUpdates[$productId] ?? [], $diff);
$pendingSnapshots[$productId] = $this->snapshotProduct($entity);
$pendingProducts[$productId] = $entity;
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if (!$entity instanceof Product) {
continue;
}
$snapshot = $this->snapshotProduct($entity);
$this->persistAuditLog($em, new AuditLog('product', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
}
// Capture constructeur collection updates, which are not included in the change set.
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingProducts);
}
foreach ($uow->getScheduledCollectionDeletions() as $collection) {
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingProducts);
}
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingProducts);
foreach ($pendingUpdates as $productId => $diff) {
if ([] === $diff) {
continue;
}
$product = $pendingProducts[$productId] ?? null;
if (!$product instanceof Product) {
continue;
}
$snapshot = $pendingSnapshots[$productId] ?? $this->snapshotProduct($product);
$this->persistAuditLog($em, new AuditLog('product', $productId, 'update', $diff, $snapshot, $actorProfileId));
}
}
/**
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
* @param array<string, array<string, mixed>> $pendingSnapshots
* @param array<string, Product> $pendingProducts
*/
private function collectCollectionUpdate(
object $collection,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingProducts,
): void {
if (!$collection instanceof PersistentCollection) {
return;
}
$owner = $collection->getOwner();
if (!$owner instanceof Product) {
return;
}
$productId = (string) $owner->getId();
if ('' === $productId) {
return;
}
$mapping = $collection->getMapping();
$fieldName = $mapping['fieldName'] ?? null;
if ('constructeurs' !== $fieldName) {
return;
}
$before = $this->normalizeCollection($collection->getSnapshot());
$after = $this->normalizeCollection($collection->toArray());
if ($before === $after) {
return;
}
$diff = [
'constructeurIds' => [
'from' => $before,
'to' => $after,
],
];
$pendingUpdates[$productId] = $this->mergeDiffs($pendingUpdates[$productId] ?? [], $diff);
$pendingSnapshots[$productId] = $this->snapshotProduct($owner);
$pendingProducts[$productId] = $owner;
}
/**
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
* @param array<string, array<string, mixed>> $pendingSnapshots
* @param array<string, Product> $pendingProducts
*/
private function collectCustomFieldValueChanges(
UnitOfWork $uow,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingProducts,
): void {
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if ($entity instanceof CustomFieldValue) {
$this->trackCustomFieldValueChange($entity, null, $entity->getValue(), $pendingUpdates, $pendingSnapshots, $pendingProducts);
}
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof CustomFieldValue) {
continue;
}
$changeSet = $uow->getEntityChangeSet($entity);
if (!isset($changeSet['value'])) {
continue;
}
[$oldVal, $newVal] = $changeSet['value'];
if ($oldVal !== $newVal) {
$this->trackCustomFieldValueChange($entity, $oldVal, $newVal, $pendingUpdates, $pendingSnapshots, $pendingProducts);
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if ($entity instanceof CustomFieldValue) {
$this->trackCustomFieldValueChange($entity, $entity->getValue(), null, $pendingUpdates, $pendingSnapshots, $pendingProducts);
}
}
}
/**
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
* @param array<string, array<string, mixed>> $pendingSnapshots
* @param array<string, Product> $pendingProducts
*/
private function trackCustomFieldValueChange(
CustomFieldValue $cfv,
mixed $from,
mixed $to,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingProducts,
): void {
$owner = $cfv->getProduct();
if (!$owner instanceof Product) {
return;
}
$ownerId = (string) $owner->getId();
if ('' === $ownerId) {
return;
}
$fieldName = 'customField:'.$cfv->getCustomField()->getName();
$diff = [$fieldName => ['from' => $from, 'to' => $to]];
$pendingUpdates[$ownerId] = $this->mergeDiffs($pendingUpdates[$ownerId] ?? [], $diff);
$pendingSnapshots[$ownerId] = $this->snapshotProduct($owner);
$pendingProducts[$ownerId] = $owner;
}
private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
{
$uow = $em->getUnitOfWork();
// Ensure identifiers and timestamps are set even when persisting during onFlush.
$log->initializeAuditLog();
$em->persist($log);
$meta = $em->getClassMetadata(AuditLog::class);
$uow->computeChangeSet($meta, $log);
}
/**
* @param array<string, array{0:mixed, 1:mixed}> $changeSet
*
* @return array<string, array{from:mixed, to:mixed}>
*/
private function buildDiffFromChangeSet(array $changeSet): array
{
$diff = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) {
// Skip noisy timestamps managed automatically.
if ('updatedAt' === $field || 'createdAt' === $field) {
continue;
}
$normalizedOld = $this->normalizeValue($oldValue);
$normalizedNew = $this->normalizeValue($newValue);
if ($normalizedOld === $normalizedNew) {
continue;
}
$diff[$field] = [
'from' => $normalizedOld,
'to' => $normalizedNew,
];
}
return $diff;
}
private function snapshotProduct(Product $product): array
{
return [
'id' => $product->getId(),
'name' => $product->getName(),
'reference' => $product->getReference(),
'supplierPrice' => $product->getSupplierPrice(),
'typeProduct' => $this->normalizeValue($product->getTypeProduct()),
'constructeurIds' => $this->normalizeCollection($product->getConstructeurs()),
];
}
/**
* @param array<string, array{from:mixed, to:mixed}> $base
* @param array<string, array{from:mixed, to:mixed}> $extra
*
* @return array<string, array{from:mixed, to:mixed}>
*/
private function mergeDiffs(array $base, array $extra): array
{
foreach ($extra as $field => $change) {
$base[$field] = $change;
}
return $base;
}
/**
* @param iterable<mixed> $items
*
* @return list<array{id: string, name: string}|string>
*/
private function normalizeCollection(iterable $items): array
{
$entries = [];
$seen = [];
foreach ($items as $item) {
if (is_object($item) && method_exists($item, 'getId')) {
$id = $item->getId();
if (null === $id || '' === $id || isset($seen[(string) $id])) {
continue;
}
$seen[(string) $id] = true;
if (method_exists($item, 'getName')) {
$entries[] = ['id' => (string) $id, 'name' => (string) $item->getName()];
} else {
$entries[] = (string) $id;
}
}
}
return $entries;
}
private function normalizeValue(mixed $value): mixed
{
if (null === $value || is_scalar($value)) {
return $value;
}
if ($value instanceof DateTimeInterface) {
return $value->format(DateTimeInterface::ATOM);
}
if ($value instanceof ModelType) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
'code' => $value->getCode(),
];
}
if ($value instanceof Collection) {
return $this->normalizeCollection($value);
}
if (is_object($value) && method_exists($value, 'getId')) {
return (string) $value->getId();
}
if (is_array($value)) {
return $value;
}
return (string) $value;
}
private function resolveActorProfileId(): ?string
{
try {
$session = $this->requestStack->getSession();
if ($session instanceof SessionInterface) {
$profileId = $session->get('profileId');
if ($profileId) {
return (string) $profileId;
}
}
} catch (Throwable) {
// No session available (CLI context, etc.)
}
$user = $this->security->getUser();
if ($user instanceof Profile) {
return $user->getId();
}
return null;
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\AuditLog;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AuditLog>
*/
final class AuditLogRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AuditLog::class);
}
/**
* @return list<AuditLog>
*/
public function findEntityHistory(string $entityType, string $entityId, int $limit = 100): array
{
return $this->createQueryBuilder('a')
->andWhere('a.entityType = :entityType')
->andWhere('a.entityId = :entityId')
->setParameter('entityType', $entityType)
->setParameter('entityId', $entityId)
->orderBy('a.createdAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult()
;
}
/**
* @param array{entityType?: string, action?: string} $filters
*
* @return array{items: list<AuditLog>, total: int}
*/
public function findAllPaginated(int $page = 1, int $itemsPerPage = 30, array $filters = []): array
{
$qb = $this->createQueryBuilder('a')
->orderBy('a.createdAt', 'DESC')
;
if (!empty($filters['entityType'])) {
$qb->andWhere('a.entityType = :entityType')
->setParameter('entityType', $filters['entityType'])
;
}
if (!empty($filters['action'])) {
$qb->andWhere('a.action = :action')
->setParameter('action', $filters['action'])
;
}
$countQb = clone $qb;
$countQb->select('COUNT(a.id)')
->resetDQLPart('orderBy')
;
$total = (int) $countQb->getQuery()->getSingleScalarResult();
$qb->setFirstResult(($page - 1) * $itemsPerPage)
->setMaxResults($itemsPerPage)
;
return [
'items' => $qb->getQuery()->getResult(),
'total' => $total,
];
}
}