Compare commits

...

26 Commits

Author SHA1 Message Date
Matthieu
02ff8b1a96 feat(audit) : extend audit logging to machines, constructeurs, model types, documents and conversions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 14:51:26 +01:00
Matthieu
2156df22c6 chore(release) : bump version to 1.6.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 14:27:47 +01:00
Matthieu
cd2a3fac55 feat(categories) : add bidirectional piece/component category conversion
Backend service and controller for converting piece categories to component
categories (and vice-versa). Uses raw SQL in a transaction to preserve IDs
and transfer all related data (documents, custom fields, constructeurs).
Includes php-cs-fixer formatting pass on existing controllers/entities.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 14:27:07 +01:00
Matthieu
6300a3588a chore(docker) : replace pgAdmin with Adminer for lighter DB management
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 12:10:52 +01:00
Matthieu
45213103e4 Merge branch 'develop' into master — fix documents OOM 2026-02-11 17:16:41 +01:00
Matthieu
91b8b424d6 fix(documents) : add serialization groups to prevent OOM on collection endpoint
The path field (base64 data URIs) is now excluded from GetCollection
via document:list group. Individual GET returns path via document:detail
group. Related entities expose id+name in document:list for attachment
display. Frontend lazy-loads path on download/preview click.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 17:16:27 +01:00
Matthieu
0d1c9277e5 Merge branch 'develop' into master — changelog page 2026-02-11 17:01:53 +01:00
Matthieu
db16d26103 chore(frontend) : update submodule — changelog page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 17:01:45 +01:00
Matthieu
0eb64d0975 Merge branch 'develop' into master — v1.5.0 2026-02-11 16:51:22 +01:00
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
48 changed files with 3063 additions and 921 deletions

View File

@@ -9,7 +9,7 @@
## Legende des statuts ## Legende des statuts
| Statut | Signification | | Statut | Signification |
|--------|--------------| | ------ | ---------------------- |
| `[ ]` | A faire | | `[ ]` | A faire |
| `[~]` | En cours | | `[~]` | En cours |
| `[x]` | Termine | | `[x]` | Termine |
@@ -22,6 +22,7 @@
> **Priorite :** MAXIMALE - A traiter en premier > **Priorite :** MAXIMALE - A traiter en premier
### 1.1 Corriger la configuration de securite ### 1.1 Corriger la configuration de securite
- **Statut :** `[ ]` - **Statut :** `[ ]`
- **Fichier :** `config/packages/security.yaml` - **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. - **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.
@@ -30,6 +31,7 @@
- **Notes :** - - **Notes :** -
### 1.2 Ajouter les controles d'autorisation sur les controllers ### 1.2 Ajouter les controles d'autorisation sur les controllers
- **Statut :** `[ ]` - **Statut :** `[ ]`
- **Fichiers :** - **Fichiers :**
- `src/Controller/MachineSkeletonController.php` - `src/Controller/MachineSkeletonController.php`
@@ -44,6 +46,7 @@
- **Notes :** - - **Notes :** -
### 1.3 Securiser les secrets ### 1.3 Securiser les secrets
- **Statut :** `[ ]` - **Statut :** `[ ]`
- **Fichiers :** - **Fichiers :**
- `.env` (JWT_PASSPHRASE en dur, APP_SECRET vide) - `.env` (JWT_PASSPHRASE en dur, APP_SECRET vide)
@@ -63,6 +66,7 @@
> **Priorite :** HAUTE - Impact direct sur la maintenabilite > **Priorite :** HAUTE - Impact direct sur la maintenabilite
### 2.1 Refactorer les 3 Audit Subscribers en un seul generique ### 2.1 Refactorer les 3 Audit Subscribers en un seul generique
- **Statut :** `[ ]` - **Statut :** `[ ]`
- **Fichiers concernes :** - **Fichiers concernes :**
- `src/EventSubscriber/ProductAuditSubscriber.php` (298 LOC) - `src/EventSubscriber/ProductAuditSubscriber.php` (298 LOC)
@@ -79,6 +83,7 @@
- **Notes :** Tester manuellement les logs d'audit apres refacto. - **Notes :** Tester manuellement les logs d'audit apres refacto.
### 2.2 Extraire un CuidGenerator utilitaire ### 2.2 Extraire un CuidGenerator utilitaire
- **Statut :** `[ ]` - **Statut :** `[ ]`
- **Fichiers concernes :** 18 entites contenant `generateCuid()` en prive - **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). - **Probleme :** Methode `generateCuid()` dupliquee dans chaque entite. De plus, `AuditLog.php` utilise une variante differente (base_convert).
@@ -91,6 +96,7 @@
- **Notes :** Attention a l'inconsistance entre AuditLog et les autres entites. - **Notes :** Attention a l'inconsistance entre AuditLog et les autres entites.
### 2.3 Factoriser la logique de liaison dans MachineSkeletonController ### 2.3 Factoriser la logique de liaison dans MachineSkeletonController
- **Statut :** `[ ]` - **Statut :** `[ ]`
- **Fichier :** `src/Controller/MachineSkeletonController.php` (756 LOC) - **Fichier :** `src/Controller/MachineSkeletonController.php` (756 LOC)
- **Probleme :** Les methodes `applyComponentLinks()`, `applyPieceLinks()`, `applyProductLinks()` sont quasi identiques (~90 LOC chacune). - **Probleme :** Les methodes `applyComponentLinks()`, `applyPieceLinks()`, `applyProductLinks()` sont quasi identiques (~90 LOC chacune).
@@ -108,6 +114,7 @@
> **Priorite :** MOYENNE - Amelioration de la lisibilite et maintenabilite > **Priorite :** MOYENNE - Amelioration de la lisibilite et maintenabilite
### 3.1 Decouper MachineSkeletonController ### 3.1 Decouper MachineSkeletonController
- **Statut :** `[ ]` - **Statut :** `[ ]`
- **Fichier :** `src/Controller/MachineSkeletonController.php` (756 LOC) - **Fichier :** `src/Controller/MachineSkeletonController.php` (756 LOC)
- **Action :** - **Action :**
@@ -119,6 +126,7 @@
- **Notes :** Depend de la phase 2.3 (factorisation des liens). - **Notes :** Depend de la phase 2.3 (factorisation des liens).
### 3.2 Ajouter un try-catch et du logging dans les controllers ### 3.2 Ajouter un try-catch et du logging dans les controllers
- **Statut :** `[ ]` - **Statut :** `[ ]`
- **Fichiers :** Tous les controllers dans `src/Controller/` - **Fichiers :** Tous les controllers dans `src/Controller/`
- **Probleme :** Aucun try-catch autour des `flush()` et `persist()`. Pas de logging d'erreurs. - **Probleme :** Aucun try-catch autour des `flush()` et `persist()`. Pas de logging d'erreurs.
@@ -130,6 +138,7 @@
- **Notes :** - - **Notes :** -
### 3.3 Renforcer la validation des entrees ### 3.3 Renforcer la validation des entrees
- **Statut :** `[ ]` - **Statut :** `[ ]`
- **Fichiers :** - **Fichiers :**
- `src/Controller/CustomFieldValueController.php` - `src/Controller/CustomFieldValueController.php`
@@ -149,6 +158,7 @@
> **Priorite :** MOYENNE - Performance et scalabilite > **Priorite :** MOYENNE - Performance et scalabilite
### 4.1 Migrer le stockage PDF de base64 vers le filesystem ### 4.1 Migrer le stockage PDF de base64 vers le filesystem
- **Statut :** `[ ]` - **Statut :** `[ ]`
- **Fichiers :** - **Fichiers :**
- `src/Entity/Document.php` - `src/Entity/Document.php`
@@ -165,6 +175,7 @@
- **Notes :** Prevoir une migration de donnees pour les documents existants. - **Notes :** Prevoir une migration de donnees pour les documents existants.
### 4.2 Corriger les types de prix (string -> decimal) ### 4.2 Corriger les types de prix (string -> decimal)
- **Statut :** `[ ]` - **Statut :** `[ ]`
- **Fichiers :** - **Fichiers :**
- `src/Entity/Machine.php` (`$prix`) - `src/Entity/Machine.php` (`$prix`)
@@ -184,6 +195,7 @@
> **Priorite :** BASSE - Bonne pratique > **Priorite :** BASSE - Bonne pratique
### 5.1 Remplacer exec() par Symfony Process ### 5.1 Remplacer exec() par Symfony Process
- **Statut :** `[ ]` - **Statut :** `[ ]`
- **Fichiers :** - **Fichiers :**
- `src/Command/CompressPdfCommand.php` (lignes 42, 98-101) - `src/Command/CompressPdfCommand.php` (lignes 42, 98-101)
@@ -203,6 +215,7 @@
> **Priorite :** HAUTE - Indispensable avant toute refacto majeure > **Priorite :** HAUTE - Indispensable avant toute refacto majeure
### 6.1 Mettre en place les tests unitaires ### 6.1 Mettre en place les tests unitaires
- **Statut :** `[ ]` - **Statut :** `[ ]`
- **Fichiers a creer :** - **Fichiers a creer :**
- `tests/Unit/Util/CuidGeneratorTest.php` - `tests/Unit/Util/CuidGeneratorTest.php`
@@ -217,6 +230,7 @@
- **Notes :** - - **Notes :** -
### 6.2 Mettre en place les tests fonctionnels (API) ### 6.2 Mettre en place les tests fonctionnels (API)
- **Statut :** `[ ]` - **Statut :** `[ ]`
- **Fichiers a creer :** - **Fichiers a creer :**
- `tests/Functional/Api/MachineTest.php` - `tests/Functional/Api/MachineTest.php`
@@ -233,6 +247,7 @@
- **Notes :** Utiliser `ApiTestCase` de API Platform. - **Notes :** Utiliser `ApiTestCase` de API Platform.
### 6.3 Tests des Audit Subscribers ### 6.3 Tests des Audit Subscribers
- **Statut :** `[ ]` - **Statut :** `[ ]`
- **Fichiers a creer :** - **Fichiers a creer :**
- `tests/Unit/EventSubscriber/AuditSubscriberTest.php` - `tests/Unit/EventSubscriber/AuditSubscriberTest.php`
@@ -250,6 +265,7 @@
> **Priorite :** BASSE - Polish final > **Priorite :** BASSE - Polish final
### 7.1 Supprimer les fichiers inutiles ### 7.1 Supprimer les fichiers inutiles
- **Statut :** `[ ]` - **Statut :** `[ ]`
- **Fichiers a verifier :** - **Fichiers a verifier :**
- `frontend/` (dossier legacy ? vs `Inventory_frontend/`) - `frontend/` (dossier legacy ? vs `Inventory_frontend/`)
@@ -260,6 +276,7 @@
- **Notes :** Ne pas supprimer sans validation. - **Notes :** Ne pas supprimer sans validation.
### 7.2 Uniformiser la gestion des null ### 7.2 Uniformiser la gestion des null
- **Statut :** `[ ]` - **Statut :** `[ ]`
- **Fichiers :** Toutes les entites dans `src/Entity/` - **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). - **Action :** S'assurer que les types nullable sont coherents entre PHP et la BDD (colonnes NOT NULL vs nullable).
@@ -278,39 +295,41 @@
> **Priorite :** MAXIMALE - Les fichiers actuels sont inmaintenables > **Priorite :** MAXIMALE - Les fichiers actuels sont inmaintenables
### F1.1 Decouper `machine/[id].vue` (4308 LOC) ### F1.1 Decouper `machine/[id].vue` (2989 LOC → 219 LOC)
- **Statut :** `[ ]`
- **Fichier :** `Inventory_frontend/app/pages/machine/[id].vue`
- **Probleme :** Page monolithique de 4308 lignes. Contient la vue detail, l'edition, la gestion du skeleton, les composants, les pieces, les produits, les documents, l'historique.
- **Action :**
1. Identifier les sections logiques (header, detail, skeleton, composants, pieces, produits, documents, historique)
2. Extraire chaque section en composant dedie :
- `components/machine/MachineHeader.vue`
- `components/machine/MachineDetail.vue`
- `components/machine/MachineSkeletonEditor.vue`
- `components/machine/MachineComponentsList.vue`
- `components/machine/MachinePiecesList.vue`
- `components/machine/MachineProductsList.vue`
- `components/machine/MachineDocuments.vue`
- `components/machine/MachineHistory.vue`
3. La page `[id].vue` ne doit plus etre qu'un orchestrateur (<300 LOC)
4. Utiliser `provide/inject` ou un composable partage pour l'etat machine
- **Agent :** -
- **Notes :** Tache la plus impactante du frontend. A faire en premier.
### F1.2 Decouper `machines/new.vue` (2313 LOC) - **Statut :** `[x]`
- **Statut :** `[ ]` - **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` - **Fichier :** `Inventory_frontend/app/pages/machines/new.vue`
- **Probleme :** Page de creation de machine avec selection de type et heritage de structure, trop volumineuse. - **Resultat :** Page decomposee en 1 composable + 5 composants. Orchestrateur = 196 LOC.
- **Action :** - **Fichiers crees :**
1. Extraire le formulaire de creation en composant `MachineCreateForm.vue` - `composables/useMachineCreatePage.ts` (460 LOC) — state, entity lookups, options, creation
2. Extraire la selection de type en `MachineTypeSelector.vue` - `components/machine/create/RequirementComponentSelector.vue` (126 LOC)
3. Extraire l'apercu de structure en composant separe - `components/machine/create/RequirementPieceSelector.vue` (130 LOC)
4. Objectif : page <200 LOC - `components/machine/create/RequirementProductSelector.vue` (142 LOC)
- **Agent :** - - `components/machine/create/MachineCreatePreview.vue` (205 LOC)
- **Notes :** - - `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) ### F1.3 Decouper les pages de creation/edition (Piece, Component, Product)
- **Statut :** `[x]` - **Statut :** `[x]`
- **Fichiers :** - **Fichiers :**
- `pages/component/create.vue` (1282 LOC) - `pages/component/create.vue` (1282 LOC)
@@ -337,17 +356,29 @@
- [x] F1.3e Typecheck + commit F1.3 (erreurs F1.3 corrigees, 120 erreurs preexistantes documentees) - [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) ### F1.4 Reduire PieceItem.vue (1588 LOC) et ComponentItem.vue (1336 LOC)
- **Statut :** `[ ]`
- **Statut :** `[x]`
- **Fichiers :** - **Fichiers :**
- `Inventory_frontend/app/components/PieceItem.vue` (1588 LOC) - `Inventory_frontend/app/components/PieceItem.vue` (1588 → 740 LOC)
- `Inventory_frontend/app/components/ComponentItem.vue` (1336 LOC) - `Inventory_frontend/app/components/ComponentItem.vue` (1336 → 585 LOC)
- **Probleme :** Composants d'affichage/edition inline tres volumineux. - **Probleme :** ~700 LOC de logique dupliquee entre les deux composants (champs personnalises, documents, affichage produit).
- **Action :** - **Action realisee :**
1. Separer la vue lecture de la vue edition 1. Extraction de la logique pure custom fields dans `shared/utils/entityCustomFieldLogic.ts` (~350 LOC)
2. Extraire les sous-sections (details, documents, fournisseurs) en sous-composants 2. Creation de `composables/useEntityCustomFields.ts` (composable reactif, ~180 LOC)
3. Objectif : <400 LOC par composant 3. Creation de `composables/useEntityDocuments.ts` (CRUD documents + preview, ~120 LOC)
- **Agent :** - 4. Creation de `composables/useEntityProductDisplay.ts` (affichage produit, ~100 LOC)
- **Notes :** - 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.
--- ---
@@ -356,47 +387,55 @@
> **Priorite :** HAUTE - DRY > **Priorite :** HAUTE - DRY
### F2.1 Extraire `extractCollection()` dans un utilitaire partage ### F2.1 Extraire `extractCollection()` dans un utilitaire partage
- **Statut :** `[ ]`
- **Statut :** `[x]`
- **Fichiers concernes :** - **Fichiers concernes :**
- `composables/useSites.js` - `composables/useSites.ts`
- `composables/useProducts.js` - `composables/useProducts.ts`
- `composables/usePieces.js` - `composables/usePieces.ts`
- `composables/useComposants.js` - `composables/useComposants.ts`
- `composables/useMachineTypesApi.js` - `composables/useMachineTypesApi.js`
- `composables/useConstructeurs.js` - `composables/useConstructeurs.ts`
- **Probleme :** La fonction `extractCollection()` (parsing `hydra:member` / `member` / array) est dupliquee dans 6+ fichiers. - `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 :** - **Action :**
1. Creer `shared/utils/apiHelpers.ts` 1. [x] Creer `shared/utils/apiHelpers.ts` avec `extractCollection<T>()` generique
2. Y placer `extractCollection()`, `extractRelationId()`, `normalizeRelationIds()` 2. [x] Remplacer les 10 implementations locales par un import
3. Remplacer les implementations locales par un import
- **Agent :** - - **Agent :** -
- **Notes :** - - **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 ### F2.2 Fusionner les 3 composables d'historique
- **Statut :** `[ ]`
- **Statut :** `[x]`
- **Fichiers concernes :** - **Fichiers concernes :**
- `composables/useComponentHistory.ts` (67 LOC) - `composables/useComponentHistory.ts` (67 → 13 LOC, thin wrapper)
- `composables/usePieceHistory.ts` (67 LOC) - `composables/usePieceHistory.ts` (67 → 13 LOC, thin wrapper)
- `composables/useProductHistory.ts` (67 LOC) - `composables/useProductHistory.ts` (67 → 13 LOC, thin wrapper)
- **Probleme :** 3 fichiers quasi identiques (seul le endpoint differe). - `composables/useEntityHistory.ts` (NEW, 65 LOC, logique generique)
- **Probleme :** 3 fichiers quasi identiques (seul le endpoint differait).
- **Action :** - **Action :**
1. Creer `composables/useEntityHistory.ts` parametrable par type d'entite 1. [x] Creer `composables/useEntityHistory.ts` parametrable par type d'entite
2. Supprimer les 3 fichiers specifiques 2. [x] Reecrire les 3 fichiers specifiques en wrappers backward-compatible
- **Agent :** - - **Agent :** -
- **Notes :** - - **Notes :** Les wrappers preservent l'API existante (types + fonction), aucun consommateur a modifier.
### F2.3 Factoriser les composables de types (Component/Piece/Product) ### F2.3 Factoriser les composables de types (Component/Piece/Product)
- **Statut :** `[ ]`
- **Statut :** `[x]`
- **Fichiers concernes :** - **Fichiers concernes :**
- `composables/useComponentTypes.js` (140 LOC) - `composables/useComponentTypes.ts` (165 → 30 LOC, thin wrapper)
- `composables/usePieceTypes.js` (140 LOC) - `composables/usePieceTypes.ts` (165 → 30 LOC, thin wrapper)
- `composables/useProductTypes.js` (132 LOC) - `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. - **Probleme :** 3 composables tres similaires pour gerer les categories/types.
- **Action :** - **Action :**
1. Creer `composables/useEntityTypes.ts` generique 1. [x] Creer `composables/useEntityTypes.ts` generique (CRUD + singleton state par categorie)
2. Parametrer par type d'entite et endpoint 2. [x] Reecrire les 3 fichiers specifiques en wrappers avec renommage des champs
- **Agent :** - - **Agent :** -
- **Notes :** - - **Notes :** Les wrappers renomment `types``componentTypes`/`pieceTypes`/`productTypes`, preservent `getXxxTypes()` et `isXxxTypeLoading()`. Etat partage via `stateByCategory` map module-level.
--- ---
@@ -405,53 +444,46 @@
> **Priorite :** HAUTE - Securite du typage > **Priorite :** HAUTE - Securite du typage
### F3.1 Definir les types pour les reponses API ### F3.1 Definir les types pour les reponses API
- **Statut :** `[ ]`
- **Fichier a creer :** `Inventory_frontend/app/shared/types/api.ts` - **Statut :** `[x]` (partiellement — types definis dans chaque composable + `ApiResponse<T>` dans useApi.ts)
- **Probleme :** Aucun type pour les reponses API. Les composables travaillent avec `any` implicite. - **Fichiers :**
- **Action :** - `composables/useApi.ts``ApiResponse<T>` generique (success/data/error/status)
1. Definir les interfaces pour chaque entite API : - `composables/useMachines.ts``Machine` interface
- `ApiMachine`, `ApiProduct`, `ApiPiece`, `ApiComposant` - `composables/useMachineTypesApi.ts``MachineType`, `MachineTypeRequirement` interfaces
- `ApiSite`, `ApiDocument`, `ApiConstructeur` - `composables/useToast.ts``Toast`, `ToastType` types
- `ApiAuditLog`, `ApiProfile`, `ApiCustomField` - `composables/useProfiles.ts``Profile` interface
2. Definir `ApiCollectionResponse<T>` (hydra:member, totalItems, etc.) - `composables/useCustomFields.ts``CustomFieldValue` interface
3. Definir `ApiErrorResponse` - **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.
4. Typer les retours de `useApi()`
- **Agent :** -
- **Notes :** Partir de `shared/types/inventory.ts` (289 LOC) qui contient deja des types partiels.
### F3.2 Convertir les composables JS en TS ### F3.2 Convertir les composables JS en TS
- **Statut :** `[ ]`
- **Fichiers concernes (10 fichiers JS) :** - **Statut :** `[x]`
- `useApi.js` -> `useApi.ts` - **Fichiers convertis (7 fichiers JS → TS) :**
- `useMachines.js` -> `useMachines.ts` - [x] `useToast.js` `useToast.ts` (72 LOC, types: `Toast`, `ToastType`)
- `useProducts.js` -> `useProducts.ts` - [x] `useProfiles.js` `useProfiles.ts` (68 LOC, type: `Profile`)
- `usePieces.js` -> `usePieces.ts` - [x] `useProfileSession.js` `useProfileSession.ts` (85 LOC, importe `Profile`)
- `useComposants.js` -> `useComposants.ts` - [x] `useApi.js` `useApi.ts` (106 LOC → 120 LOC, types: `ApiResponse<T>`, `ApiCallOptions`, ajout `put()`)
- `useConstructeurs.js` -> `useConstructeurs.ts` - [x] `useCustomFields.js` `useCustomFields.ts` (105 LOC, type: `CustomFieldValue`)
- `useSites.js` -> `useSites.ts` - [x] `useMachineTypesApi.js` `useMachineTypesApi.ts` (173 → 188 LOC, types: `MachineType`, `MachineTypeRequirement`)
- `useDocuments.js` -> `useDocuments.ts` - [x] `useMachines.js` `useMachines.ts` (267 LOC, type: `Machine`, utilise `extractCollection`)
- `useMachineTypesApi.js` -> `useMachineTypesApi.ts` - **Fichiers deja TS :** `useProducts.ts`, `usePieces.ts`, `useComposants.ts`, `useConstructeurs.ts`, `useSites.ts`, `useDocuments.ts`
- `useCustomFields.js` -> `useCustomFields.ts` - **Fichiers JS restants (deprecated) :** `useComponentModels.js`, `usePieceModels.js` (stubs deprecated, a supprimer)
- **Action :** - **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>()`.
1. Renommer `.js` en `.ts`
2. Ajouter les types de retour, parametres, et variables reactives
3. Utiliser les types API definis en F3.1
4. Eliminer tous les `any` explicites et implicites
- **Agent :** -
- **Notes :** Depend de F3.1 (types API).
### F3.3 Eliminer les `any` restants ### F3.3 Eliminer les `any` restants
- **Statut :** `[ ]`
- **Statut :** `[x]`
- **Fichiers concernes :** - **Fichiers concernes :**
- `components/common/ProductSelect.vue` - `components/ProductSelect.vue` — 1 `any` restant (slot template, incompressible)
- `components/common/ManagementView.vue` - `components/model-types/ManagementView.vue` — remplace `data?: any``Record<string, unknown>`, `error: any``error: unknown`, `item: any``item: unknown`
- `components/ComponentStructureAssignmentNode.vue` - `components/ComponentStructureAssignmentNode.vue` — 12 casts `(definition as any).typePiece/typeProduct` elimines grace a l'extension des types
- `components/model-types/ComponentModelStructureEditor.vue` - `components/ComponentModelStructureEditor.vue``Promise<any>``Promise<unknown>`
- `components/model-types/ModelTypeForm.vue` - `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. - **Probleme :** 20+ usages de `any` type identifies.
- **Action :** Remplacer chaque `any` par un type concret ou un type union. - **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 :** - - **Agent :** Claude
- **Notes :** - - **Notes :** ~15 casts `any` elimines. Les `Record<string, any>` restants dans ComponentModelStructureEditor sont justifies (manipulation dynamique interne de custom fields). Typecheck 0 erreurs.
--- ---
@@ -460,29 +492,31 @@
> **Priorite :** MOYENNE > **Priorite :** MOYENNE
### F4.1 Activer les regles ESLint critiques ### F4.1 Activer les regles ESLint critiques
- **Statut :** `[ ]`
- **Statut :** `[x]` DONE
- **Fichier :** `Inventory_frontend/eslint.config.mjs` - **Fichier :** `Inventory_frontend/eslint.config.mjs`
- **Probleme :** Presque toutes les regles sont desactivees (`no-console: off`, `no-unused-vars: off`, `no-explicit-any: off`). - **Probleme :** Presque toutes les regles etaient desactivees (`no-console: off`, `no-unused-vars: off`, `no-explicit-any: off`).
- **Action :** - **Action realisee :**
1. Activer `@typescript-eslint/no-explicit-any: warn` 1. [x] Active `@typescript-eslint/no-explicit-any: warn` (526 warnings — amelioration progressive)
2. Activer `no-console: warn` (ou `error` sauf pour `console.error`) 2. [x] Active `no-console: warn` avec `allow: ['error']` — 0 violations (deja nettoye en F4.2)
3. Activer `no-unused-vars: warn` 3. [x] Active `@typescript-eslint/no-unused-vars: warn` avec ignore `^_` — 0 violations (26 corrigees)
4. Fixer les violations progressivement 4. [x] Corrige les 26 violations `no-unused-vars` : imports inutilises supprimes, variables prefixees `_`, destructurations nettoyees
- **Agent :** - - **Agent :** Claude
- **Notes :** 94 appels `console.*` a nettoyer. - **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 ### F4.2 Nettoyer les console.log/console.error
- **Statut :** `[ ]`
- **Fichiers :** ~94 occurrences dans le frontend - **Statut :** `[x]` (console.log supprime, console.error conserve)
- **Probleme :** Appels de debug laisses dans le code de production. - **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 :** - **Action :**
1. Remplacer les `console.error` utiles par un logger centralise (ou `useToast`) 1. [x] Supprimer les 19 `console.log` de debug (normalizeRequirementList, page loading, route params, etc.)
2. Supprimer les `console.log` de debug 2. [ ] Les 72 `console.error` restants sont conserves (gestion d'erreur legitime). Migration vers un logger centralise a faire en F4.3.
3. Creer un utilitaire `logger.ts` si necessaire (qui respecte `enableDebug` du runtime config) - **Agent :** Claude
- **Agent :** - - **Notes :** 0 `console.log/warn/debug/info` restants dans le frontend.
- **Notes :** -
### F4.3 Centraliser la gestion d'erreurs API ### F4.3 Centraliser la gestion d'erreurs API
- **Statut :** `[ ]` - **Statut :** `[ ]`
- **Fichier :** `Inventory_frontend/app/composables/useApi.js` (105 LOC) - **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. - **Probleme :** Gestion d'erreur basique (juste un toast). Pas de retry, pas d'intercepteur, erreurs silencieuses dans certains composables.
@@ -501,19 +535,19 @@
> **Priorite :** MOYENNE > **Priorite :** MOYENNE
### F5.1 Decouper `shared/modelUtils.ts` ### F5.1 Decouper `shared/modelUtils.ts`
- **Statut :** `[ ]`
- **Fichier :** `Inventory_frontend/app/shared/modelUtils.ts` (1017 LOC) - **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. - **Probleme :** Fichier utilitaire monolithique de 1017 lignes regroupant toute la logique de manipulation de modeles.
- **Action :** - **Action :**
1. Identifier les groupes de fonctions (structure, custom fields, requirements, serialization) 1. Identifier les groupes de fonctions (structure, custom fields, requirements, serialization)
2. Decouper en modules : 2. Decouper en 3 modules thematiques :
- `shared/model/structureUtils.ts` - `shared/model/componentStructure.ts` (~590 LOC) — helpers, sanitize, hydrate, normalize, extract, format pour composants
- `shared/model/customFieldUtils.ts` - `shared/model/pieceProductStructure.ts` (~155 LOC) — structure piece/produit (clone, sanitize, hydrate, format)
- `shared/model/requirementUtils.ts` - `shared/model/definitionOverrides.ts` (~50 LOC) — sanitization des overrides de definition
- `shared/model/serializationUtils.ts` 3. Re-exporter depuis `shared/modelUtils.ts` (barrel) pour ne pas casser les imports
3. Re-exporter depuis un `shared/model/index.ts` pour ne pas casser les imports - **Agent :** Claude
- **Agent :** - - **Notes :** 11 fichiers consommateurs inchanges (barrel preserve la retro-compat). Typecheck 0 erreurs.
- **Notes :** -
--- ---
@@ -522,32 +556,40 @@
> **Priorite :** HAUTE - Aucun test actuellement > **Priorite :** HAUTE - Aucun test actuellement
### F6.1 Configurer Vitest ### F6.1 Configurer Vitest
- **Statut :** `[ ]`
- **Fichier a creer :** `Inventory_frontend/vitest.config.ts` - **Statut :** `[x]` DONE
- **Action :** - **Fichiers crees :**
1. Installer `vitest`, `@vue/test-utils`, `@nuxt/test-utils` - `vitest.config.ts` — config Vitest avec happy-dom, alias `~` et `#imports`
2. Configurer Vitest avec support Vue/Nuxt - `tests/__mocks__/imports.ts` — mock des auto-imports Nuxt (useRuntimeConfig, useRoute, etc.)
3. Ajouter un script `test` dans `package.json` - `tests/shared/inventory-types.test.ts` — 9 tests smoke (validator, empty structures)
4. Creer un premier test smoke - **Action realisee :**
- **Agent :** - 1. [x] Installe `vitest`, `@vue/test-utils`, `happy-dom`
- **Notes :** - 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 ### F6.2 Tests unitaires des composables
- **Statut :** `[ ]`
- **Fichiers a creer :** - **Statut :** `[x]` DONE (base)
- `tests/composables/useApi.test.ts` - **Fichiers crees :**
- `tests/composables/useProducts.test.ts` - `tests/shared/apiHelpers.test.ts` — 10 tests (extractCollection, tous formats API)
- `tests/composables/useToast.test.ts` - `tests/shared/modelUtils.test.ts` — 18 tests (isPlainObject, clone, stats, format, piece/product)
- `tests/shared/apiHelpers.test.ts` (apres F2.1) - `tests/shared/inventory-types.test.ts` — 9 tests (validator, empty structures)
- **Action :** - `tests/composables/useToast.test.ts` — 9 tests (add, types, max limit, clear, singleton)
1. Tester `useApi` (requetes, timeout, erreurs) - `tests/composables/useConfirm.test.ts` — 8 tests (open, confirm, cancel, options, singleton)
2. Tester `extractCollection()` (tous les formats de reponse) - **Action realisee :**
3. Tester les composables CRUD (mock des appels API) 1. [x] Teste `extractCollection()` : array, hydra:member, member, items, data, null, undefined
4. Tester le toast (ajout, suppression, max toasts) 2. [x] Teste `useToast` : ajout, types, max 3 toasts, clearAll, removeToast, singleton
- **Agent :** - 3. [x] Teste `useConfirm` : open/close, resolve true/false, custom options, singleton state
- **Notes :** - 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 ### F6.3 Tests de composants
- **Statut :** `[ ]` - **Statut :** `[ ]`
- **Fichiers a creer :** - **Fichiers a creer :**
- `tests/components/Pagination.test.ts` - `tests/components/Pagination.test.ts`
@@ -566,6 +608,7 @@
> **Priorite :** BASSE - Polish > **Priorite :** BASSE - Polish
### F7.1 Reduire le props drilling ### F7.1 Reduire le props drilling
- **Statut :** `[ ]` - **Statut :** `[ ]`
- **Probleme :** Props passees sur 3+ niveaux (ex: machine data dans les sous-composants). - **Probleme :** Props passees sur 3+ niveaux (ex: machine data dans les sous-composants).
- **Action :** - **Action :**
@@ -576,26 +619,33 @@
- **Notes :** A traiter apres F1 (decoupage des composants). - **Notes :** A traiter apres F1 (decoupage des composants).
### F7.2 Remplacer `confirm()` natif par des modales DaisyUI ### F7.2 Remplacer `confirm()` natif par des modales DaisyUI
- **Statut :** `[ ]`
- **Probleme :** Les confirmations de suppression utilisent `window.confirm()` (UI native, non-stylee). - **Statut :** `[x]` DONE
- **Action :** - **Probleme :** Les confirmations de suppression utilisaient `window.confirm()` (UI native, non-stylee).
1. Creer un composant `ConfirmModal.vue` reutilisable - **Action realisee :**
2. Ou creer un composable `useConfirm()` qui affiche une modale DaisyUI 1. [x] Cree `composables/useConfirm.ts` — composable promise-based avec etat reactif partage
3. Remplacer tous les `confirm()` dans les composants 2. [x] Cree `components/common/ConfirmModal.vue` — modale DaisyUI teleportee (backdrop blur, btn-error)
- **Agent :** - 3. [x] Monte `ConfirmModal` globalement dans `app.vue`
- **Notes :** - 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) ### F7.3 Nettoyer `app.vue` (861 LOC)
- **Statut :** `[ ]`
- **Fichier :** `Inventory_frontend/app/app.vue` (861 LOC) - **Statut :** `[x]` DONE
- **Probleme :** Le fichier racine contient le layout principal, la navbar, la sidebar, et du state management. - **Fichier :** `Inventory_frontend/app/app.vue` (861 → 49 LOC)
- **Action :** - **Probleme :** Le fichier racine contenait le layout principal, la navbar (~676 LOC dupliquee mobile/desktop), et du state management.
1. Extraire la navbar en `components/layout/AppNavbar.vue` - **Action realisee :**
2. Extraire la sidebar en `components/layout/AppSidebar.vue` 1. Cree `composables/useNavDropdown.ts` (~65 LOC) — gestion etat dropdowns navbar
3. Utiliser un layout Nuxt (`layouts/default.vue`) 2. Cree `components/layout/AppNavbar.vue` (~310 LOC) — navbar data-driven avec `v-for` eliminant duplication mobile/desktop
4. `app.vue` ne doit contenir que `<NuxtLayout>` et les providers globaux 3. `app.vue` reecrit en orchestrateur minimal (49 LOC) + converti en TypeScript
- **Agent :** - 4. Supprime 4 imports d'icones inutilises
- **Notes :** - - **Agent :** Claude
- **Notes :** Approche data-driven : liens et groupes definis comme tableaux types (`NavLink[]`, `NavGroup[]`), rendus par `v-for` pour mobile et desktop
--- ---
@@ -635,13 +685,68 @@ Phase 6.3 (Tests audit)
## Journal des modifications ## Journal des modifications
| Date | Phase | Tache | Agent | Statut | Notes | | 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 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) | | 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 ## Regles pour les agents
1. **Avant de commencer une tache :** 1. **Avant de commencer une tache :**

View File

@@ -1 +1 @@
1.2.0 1.6.1

View File

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

View File

@@ -45,34 +45,17 @@ services:
- "${POSTGRES_PORT:-5433}:5432" - "${POSTGRES_PORT:-5433}:5432"
restart: unless-stopped restart: unless-stopped
pgadmin: adminer:
container_name: pgadmin-${DOCKER_APP_NAME} container_name: adminer-${DOCKER_APP_NAME}
image: dpage/pgadmin4:latest image: adminer:latest
user: root
environment: environment:
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@admin.com} ADMINER_DEFAULT_SERVER: db
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-admin} ADMINER_DESIGN: dracula
PGADMIN_CONFIG_SERVER_MODE: 'False'
PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False'
PGADMIN_SERVER_JSON_FILE: '/pgadmin4/servers.json'
volumes:
- pgadmin_data:/var/lib/pgadmin
- ./docker/pgadmin/servers.json:/pgadmin4/servers.json:ro
- ./docker/pgadmin/pgpass:/pgadmin4/pgpass:ro
ports: ports:
- "${PGADMIN_PORT:-5050}:80" - "${ADMINER_PORT:-5050}:8080"
depends_on: depends_on:
- db - db
restart: unless-stopped restart: unless-stopped
entrypoint: >
/bin/sh -c "
mkdir -p /var/lib/pgadmin &&
cp /pgadmin4/pgpass /var/lib/pgadmin/pgpass &&
chmod 600 /var/lib/pgadmin/pgpass &&
chown 5050:5050 /var/lib/pgadmin/pgpass &&
/entrypoint.sh
"
volumes: volumes:
pg_data: pg_data:
pgadmin_data:

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

@@ -7,6 +7,7 @@ namespace App\Controller;
use App\Repository\AuditLogRepository; use App\Repository\AuditLogRepository;
use App\Repository\ComposantRepository; use App\Repository\ComposantRepository;
use App\Repository\ProfileRepository; use App\Repository\ProfileRepository;
use DateTimeInterface;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -17,8 +18,7 @@ final class ComposantHistoryController
private readonly ComposantRepository $components, private readonly ComposantRepository $components,
private readonly AuditLogRepository $auditLogs, private readonly AuditLogRepository $auditLogs,
private readonly ProfileRepository $profiles, private readonly ProfileRepository $profiles,
) { ) {}
}
#[Route('/api/composants/{id}/history', name: 'api_composant_history', methods: ['GET'])] #[Route('/api/composants/{id}/history', name: 'api_composant_history', methods: ['GET'])]
public function __invoke(string $id): JsonResponse public function __invoke(string $id): JsonResponse
@@ -39,11 +39,11 @@ final class ComposantHistoryController
)))); ))));
$actorMap = []; $actorMap = [];
if ($actorIds !== []) { if ([] !== $actorIds) {
$profiles = $this->profiles->findBy(['id' => $actorIds]); $profiles = $this->profiles->findBy(['id' => $actorIds]);
foreach ($profiles as $profile) { foreach ($profiles as $profile) {
$label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName())); $label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
if ($label === '') { if ('' === $label) {
$label = $profile->getEmail() ?? $profile->getId(); $label = $profile->getEmail() ?? $profile->getId();
} }
$actorMap[$profile->getId()] = $label; $actorMap[$profile->getId()] = $label;
@@ -57,7 +57,7 @@ final class ComposantHistoryController
return [ return [
'id' => $log->getId(), 'id' => $log->getId(),
'action' => $log->getAction(), 'action' => $log->getAction(),
'createdAt' => $log->getCreatedAt()->format(\DateTimeInterface::ATOM), 'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
'actor' => $actorId 'actor' => $actorId
? [ ? [
'id' => $actorId, 'id' => $actorId,
@@ -77,4 +77,3 @@ final class ComposantHistoryController
]); ]);
} }
} }

View File

@@ -29,8 +29,7 @@ class CustomFieldValueController extends AbstractController
private readonly ComposantRepository $composantRepository, private readonly ComposantRepository $composantRepository,
private readonly PieceRepository $pieceRepository, private readonly PieceRepository $pieceRepository,
private readonly ProductRepository $productRepository, private readonly ProductRepository $productRepository,
) { ) {}
}
#[Route('', name: 'custom_field_values_create', methods: ['POST'])] #[Route('', name: 'custom_field_values_create', methods: ['POST'])]
public function create(Request $request): JsonResponse public function create(Request $request): JsonResponse
@@ -173,7 +172,7 @@ class CustomFieldValueController extends AbstractController
private function resolveCustomField(array $payload): CustomField|JsonResponse private function resolveCustomField(array $payload): CustomField|JsonResponse
{ {
$customFieldId = isset($payload['customFieldId']) ? trim((string) $payload['customFieldId']) : ''; $customFieldId = isset($payload['customFieldId']) ? trim((string) $payload['customFieldId']) : '';
if ($customFieldId !== '') { if ('' !== $customFieldId) {
$customField = $this->customFieldRepository->find($customFieldId); $customField = $this->customFieldRepository->find($customFieldId);
if ($customField instanceof CustomField) { if ($customField instanceof CustomField) {
return $customField; return $customField;
@@ -183,7 +182,7 @@ class CustomFieldValueController extends AbstractController
} }
$customFieldName = isset($payload['customFieldName']) ? trim((string) $payload['customFieldName']) : ''; $customFieldName = isset($payload['customFieldName']) ? trim((string) $payload['customFieldName']) : '';
if ($customFieldName === '') { if ('' === $customFieldName) {
return $this->json(['success' => false, 'error' => 'customFieldId or customFieldName is required.'], 400); return $this->json(['success' => false, 'error' => 'customFieldId or customFieldName is required.'], 400);
} }
@@ -207,7 +206,7 @@ class CustomFieldValueController extends AbstractController
$entityType = isset($payload['entityType']) ? strtolower((string) $payload['entityType']) : ''; $entityType = isset($payload['entityType']) ? strtolower((string) $payload['entityType']) : '';
$entityId = isset($payload['entityId']) ? trim((string) $payload['entityId']) : ''; $entityId = isset($payload['entityId']) ? trim((string) $payload['entityId']) : '';
if ($entityType === '' || $entityId === '') { if ('' === $entityType || '' === $entityId) {
foreach (['machine', 'composant', 'piece', 'product'] as $candidate) { foreach (['machine', 'composant', 'piece', 'product'] as $candidate) {
$key = $candidate.'Id'; $key = $candidate.'Id';
if (!isset($payload[$key])) { if (!isset($payload[$key])) {
@@ -215,11 +214,12 @@ class CustomFieldValueController extends AbstractController
} }
$entityType = $candidate; $entityType = $candidate;
$entityId = trim((string) $payload[$key]); $entityId = trim((string) $payload[$key]);
break; break;
} }
} }
if ($entityType === '' || $entityId === '') { if ('' === $entityType || '' === $entityId) {
return $this->json(['success' => false, 'error' => 'Entity target is missing.'], 400); return $this->json(['success' => false, 'error' => 'Entity target is missing.'], 400);
} }
@@ -247,15 +247,22 @@ class CustomFieldValueController extends AbstractController
switch ($type) { switch ($type) {
case 'machine': case 'machine':
$value->setMachine($entity); $value->setMachine($entity);
break; break;
case 'composant': case 'composant':
$value->setComposant($entity); $value->setComposant($entity);
break; break;
case 'piece': case 'piece':
$value->setPiece($entity); $value->setPiece($entity);
break; break;
case 'product': case 'product':
$value->setProduct($entity); $value->setProduct($entity);
break; break;
} }
} }

View File

@@ -25,8 +25,7 @@ class DocumentQueryController extends AbstractController
private readonly ComposantRepository $composantRepository, private readonly ComposantRepository $composantRepository,
private readonly PieceRepository $pieceRepository, private readonly PieceRepository $pieceRepository,
private readonly ProductRepository $productRepository, private readonly ProductRepository $productRepository,
) { ) {}
}
#[Route('/site/{id}', name: 'documents_by_site', methods: ['GET'])] #[Route('/site/{id}', name: 'documents_by_site', methods: ['GET'])]
public function listBySite(string $id): JsonResponse public function listBySite(string $id): JsonResponse

View File

@@ -21,8 +21,7 @@ class MachineCustomFieldsController extends AbstractController
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly MachineRepository $machineRepository, private readonly MachineRepository $machineRepository,
private readonly CustomFieldValueRepository $customFieldValueRepository, private readonly CustomFieldValueRepository $customFieldValueRepository,
) { ) {}
}
#[Route('/{id}/add-custom-fields', name: 'machine_add_custom_fields', methods: ['POST'])] #[Route('/{id}/add-custom-fields', name: 'machine_add_custom_fields', methods: ['POST'])]
public function addMissingCustomFields(string $id): JsonResponse public function addMissingCustomFields(string $id): JsonResponse

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\AuditLogRepository;
use App\Repository\MachineRepository;
use App\Repository\ProfileRepository;
use DateTimeInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class MachineHistoryController
{
public function __construct(
private readonly MachineRepository $machines,
private readonly AuditLogRepository $auditLogs,
private readonly ProfileRepository $profiles,
) {}
#[Route('/api/machines/{id}/history', name: 'api_machine_history', methods: ['GET'])]
public function __invoke(string $id): JsonResponse
{
$machine = $this->machines->find($id);
if (!$machine) {
return new JsonResponse(
['message' => 'Machine introuvable.'],
Response::HTTP_NOT_FOUND,
);
}
$logs = $this->auditLogs->findEntityHistory('machine', $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

@@ -47,8 +47,7 @@ class MachineSkeletonController extends AbstractController
private readonly TypeMachineComponentRequirementRepository $componentRequirementRepository, private readonly TypeMachineComponentRequirementRepository $componentRequirementRepository,
private readonly TypeMachinePieceRequirementRepository $pieceRequirementRepository, private readonly TypeMachinePieceRequirementRepository $pieceRequirementRepository,
private readonly TypeMachineProductRequirementRepository $productRequirementRepository, private readonly TypeMachineProductRequirementRepository $productRequirementRepository,
) { ) {}
}
#[Route('/{id}/skeleton', name: 'machine_skeleton_get', methods: ['GET'])] #[Route('/{id}/skeleton', name: 'machine_skeleton_get', methods: ['GET'])]
public function getSkeleton(string $id): JsonResponse public function getSkeleton(string $id): JsonResponse
@@ -117,6 +116,7 @@ class MachineSkeletonController extends AbstractController
if (!is_array($value)) { if (!is_array($value)) {
return []; return [];
} }
return array_values(array_filter($value, static fn ($item) => is_array($item))); return array_values(array_filter($value, static fn ($item) => is_array($item)));
} }
@@ -653,7 +653,7 @@ class MachineSkeletonController extends AbstractController
$reference = method_exists($link, 'getReferenceOverride') ? $link->getReferenceOverride() : null; $reference = method_exists($link, 'getReferenceOverride') ? $link->getReferenceOverride() : null;
$prix = method_exists($link, 'getPrixOverride') ? $link->getPrixOverride() : null; $prix = method_exists($link, 'getPrixOverride') ? $link->getPrixOverride() : null;
if ($name === null && $reference === null && $prix === null) { if (null === $name && null === $reference && null === $prix) {
return null; return null;
} }
@@ -683,12 +683,12 @@ class MachineSkeletonController extends AbstractController
private function stringOrNull(mixed $value): ?string private function stringOrNull(mixed $value): ?string
{ {
if ($value === null) { if (null === $value) {
return null; return null;
} }
$string = trim((string) $value); $string = trim((string) $value);
return $string === '' ? null : $string; return '' === $string ? null : $string;
} }
private function resolveIdentifier(array $entry, array $keys): ?string private function resolveIdentifier(array $entry, array $keys): ?string
@@ -698,9 +698,10 @@ class MachineSkeletonController extends AbstractController
continue; continue;
} }
$value = $entry[$key]; $value = $entry[$key];
if ($value === null || $value === '') { if (null === $value || '' === $value) {
continue; continue;
} }
return (string) $value; return (string) $value;
} }
@@ -709,6 +710,7 @@ class MachineSkeletonController extends AbstractController
/** /**
* @param array<array-key, object> $links * @param array<array-key, object> $links
*
* @return array<string, object> * @return array<string, object>
*/ */
private function indexLinksById(array $links): array private function indexLinksById(array $links): array

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\ModelTypeRepository;
use App\Service\ModelTypeCategoryConversionService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class ModelTypeConversionController
{
public function __construct(
private readonly ModelTypeRepository $modelTypes,
private readonly ModelTypeCategoryConversionService $conversionService,
) {}
#[Route('/api/model_types/{id}/conversion-check', name: 'api_model_type_conversion_check', methods: ['GET'])]
public function check(string $id): JsonResponse
{
$modelType = $this->modelTypes->find($id);
if (!$modelType) {
return new JsonResponse(
['message' => 'Catégorie introuvable.'],
Response::HTTP_NOT_FOUND,
);
}
return new JsonResponse($this->conversionService->checkConversion($id));
}
#[Route('/api/model_types/{id}/convert', name: 'api_model_type_convert', methods: ['POST'])]
public function convert(string $id): JsonResponse
{
$modelType = $this->modelTypes->find($id);
if (!$modelType) {
return new JsonResponse(
['message' => 'Catégorie introuvable.'],
Response::HTTP_NOT_FOUND,
);
}
$result = $this->conversionService->convert($id);
if (!$result['success']) {
return new JsonResponse($result, Response::HTTP_CONFLICT);
}
return new JsonResponse($result);
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Controller;
use App\Repository\AuditLogRepository; use App\Repository\AuditLogRepository;
use App\Repository\PieceRepository; use App\Repository\PieceRepository;
use App\Repository\ProfileRepository; use App\Repository\ProfileRepository;
use DateTimeInterface;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -17,8 +18,7 @@ final class PieceHistoryController
private readonly PieceRepository $pieces, private readonly PieceRepository $pieces,
private readonly AuditLogRepository $auditLogs, private readonly AuditLogRepository $auditLogs,
private readonly ProfileRepository $profiles, private readonly ProfileRepository $profiles,
) { ) {}
}
#[Route('/api/pieces/{id}/history', name: 'api_piece_history', methods: ['GET'])] #[Route('/api/pieces/{id}/history', name: 'api_piece_history', methods: ['GET'])]
public function __invoke(string $id): JsonResponse public function __invoke(string $id): JsonResponse
@@ -39,11 +39,11 @@ final class PieceHistoryController
)))); ))));
$actorMap = []; $actorMap = [];
if ($actorIds !== []) { if ([] !== $actorIds) {
$profiles = $this->profiles->findBy(['id' => $actorIds]); $profiles = $this->profiles->findBy(['id' => $actorIds]);
foreach ($profiles as $profile) { foreach ($profiles as $profile) {
$label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName())); $label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
if ($label === '') { if ('' === $label) {
$label = $profile->getEmail() ?? $profile->getId(); $label = $profile->getEmail() ?? $profile->getId();
} }
$actorMap[$profile->getId()] = $label; $actorMap[$profile->getId()] = $label;
@@ -57,7 +57,7 @@ final class PieceHistoryController
return [ return [
'id' => $log->getId(), 'id' => $log->getId(),
'action' => $log->getAction(), 'action' => $log->getAction(),
'createdAt' => $log->getCreatedAt()->format(\DateTimeInterface::ATOM), 'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
'actor' => $actorId 'actor' => $actorId
? [ ? [
'id' => $actorId, 'id' => $actorId,
@@ -77,4 +77,3 @@ final class PieceHistoryController
]); ]);
} }
} }

View File

@@ -7,6 +7,7 @@ namespace App\Controller;
use App\Repository\AuditLogRepository; use App\Repository\AuditLogRepository;
use App\Repository\ProductRepository; use App\Repository\ProductRepository;
use App\Repository\ProfileRepository; use App\Repository\ProfileRepository;
use DateTimeInterface;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -17,8 +18,7 @@ final class ProductHistoryController
private readonly ProductRepository $products, private readonly ProductRepository $products,
private readonly AuditLogRepository $auditLogs, private readonly AuditLogRepository $auditLogs,
private readonly ProfileRepository $profiles, private readonly ProfileRepository $profiles,
) { ) {}
}
#[Route('/api/products/{id}/history', name: 'api_product_history', methods: ['GET'])] #[Route('/api/products/{id}/history', name: 'api_product_history', methods: ['GET'])]
public function __invoke(string $id): JsonResponse public function __invoke(string $id): JsonResponse
@@ -39,11 +39,11 @@ final class ProductHistoryController
)))); ))));
$actorMap = []; $actorMap = [];
if ($actorIds !== []) { if ([] !== $actorIds) {
$profiles = $this->profiles->findBy(['id' => $actorIds]); $profiles = $this->profiles->findBy(['id' => $actorIds]);
foreach ($profiles as $profile) { foreach ($profiles as $profile) {
$label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName())); $label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
if ($label === '') { if ('' === $label) {
$label = $profile->getEmail() ?? $profile->getId(); $label = $profile->getEmail() ?? $profile->getId();
} }
$actorMap[$profile->getId()] = $label; $actorMap[$profile->getId()] = $label;
@@ -57,7 +57,7 @@ final class ProductHistoryController
return [ return [
'id' => $log->getId(), 'id' => $log->getId(),
'action' => $log->getAction(), 'action' => $log->getAction(),
'createdAt' => $log->getCreatedAt()->format(\DateTimeInterface::ATOM), 'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
'actor' => $actorId 'actor' => $actorId
? [ ? [
'id' => $actorId, 'id' => $actorId,
@@ -77,4 +77,3 @@ final class ProductHistoryController
]); ]);
} }
} }

View File

@@ -12,9 +12,7 @@ use Symfony\Component\Routing\Attribute\Route;
final class SessionProfileController final class SessionProfileController
{ {
public function __construct(private readonly ProfileRepository $profiles) public function __construct(private readonly ProfileRepository $profiles) {}
{
}
#[Route('/api/session/profile', name: 'api_session_profile_get', methods: ['GET'])] #[Route('/api/session/profile', name: 'api_session_profile_get', methods: ['GET'])]
public function getActiveProfile(Request $request): JsonResponse public function getActiveProfile(Request $request): JsonResponse
@@ -32,6 +30,7 @@ final class SessionProfileController
$profile = $this->profiles->find($profileId); $profile = $this->profiles->find($profileId);
if (!$profile || !$profile->isActive()) { if (!$profile || !$profile->isActive()) {
$session->remove('profileId'); $session->remove('profileId');
return new JsonResponse(['message' => 'Profil introuvable ou inactif.'], JsonResponse::HTTP_UNAUTHORIZED); return new JsonResponse(['message' => 'Profil introuvable ou inactif.'], JsonResponse::HTTP_UNAUTHORIZED);
} }

View File

@@ -16,8 +16,7 @@ final class SessionProfilesController
public function __construct( public function __construct(
private readonly ProfileRepository $profiles, private readonly ProfileRepository $profiles,
private readonly EntityManagerInterface $entityManager private readonly EntityManagerInterface $entityManager
) { ) {}
}
#[Route('/api/session/profiles', name: 'api_session_profiles_list', methods: ['GET'])] #[Route('/api/session/profiles', name: 'api_session_profiles_list', methods: ['GET'])]
public function list(): JsonResponse public function list(): JsonResponse
@@ -27,7 +26,8 @@ final class SessionProfilesController
->setParameter('active', true) ->setParameter('active', true)
->orderBy('p.firstName', 'ASC') ->orderBy('p.firstName', 'ASC')
->getQuery() ->getQuery()
->getResult(); ->getResult()
;
return new JsonResponse(array_map([$this, 'serializeProfile'], $items)); return new JsonResponse(array_map([$this, 'serializeProfile'], $items));
} }
@@ -39,7 +39,7 @@ final class SessionProfilesController
$firstName = trim((string) ($payload['firstName'] ?? '')); $firstName = trim((string) ($payload['firstName'] ?? ''));
$lastName = trim((string) ($payload['lastName'] ?? '')); $lastName = trim((string) ($payload['lastName'] ?? ''));
if ($firstName === '' || $lastName === '') { if ('' === $firstName || '' === $lastName) {
return new JsonResponse(['message' => 'firstName et lastName sont requis.'], JsonResponse::HTTP_BAD_REQUEST); return new JsonResponse(['message' => 'firstName et lastName sont requis.'], JsonResponse::HTTP_BAD_REQUEST);
} }

View File

@@ -82,6 +82,7 @@ final class AlwaysQuoteStrategy implements QuoteStrategy
foreach ($class->identifier as $fieldName) { foreach ($class->identifier as $fieldName) {
if (isset($class->fieldMappings[$fieldName])) { if (isset($class->fieldMappings[$fieldName])) {
$quotedColumnNames[] = $this->getColumnName($fieldName, $class, $platform); $quotedColumnNames[] = $this->getColumnName($fieldName, $class, $platform);
continue; continue;
} }
@@ -103,7 +104,7 @@ final class AlwaysQuoteStrategy implements QuoteStrategy
string $columnName, string $columnName,
int $counter, int $counter,
AbstractPlatform $platform, AbstractPlatform $platform,
ClassMetadata|null $class = null, ?ClassMetadata $class = null,
): string { ): string {
return $this->getSQLResultCasing($platform, $columnName.'_'.$counter); return $this->getSQLResultCasing($platform, $columnName.'_'.$counter);
} }

View File

@@ -64,7 +64,7 @@ class AuditLog
$this->createdAt = new DateTimeImmutable(); $this->createdAt = new DateTimeImmutable();
} }
if ($this->id === null) { if (null === $this->id) {
$this->id = $this->generateCuid(); $this->id = $this->generateCuid();
} }
} }

View File

@@ -24,17 +24,17 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource( #[ApiResource(
normalizationContext: ['groups' => ['composant:read']], normalizationContext: ['groups' => ['composant:read']],
paginationClientItemsPerPage: true, paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 500 paginationMaximumItemsPerPage: 200
)] )]
class Composant class Composant
{ {
#[ORM\Id] #[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)] #[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['composant:read'])] #[Groups(['composant:read', 'document:list'])]
private ?string $id = null; private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255, unique: true)] #[ORM\Column(type: Types::STRING, length: 255, unique: true)]
#[Groups(['composant:read'])] #[Groups(['composant:read', 'document:list'])]
private string $name; private string $name;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)] #[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
@@ -144,7 +144,7 @@ class Composant
public function setName(string $name): static public function setName(string $name): static
{ {
$this->name = $name; $this->name = mb_strtoupper(mb_substr($name, 0, 1)).mb_substr($name, 1);
return $this; return $this;
} }

View File

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

View File

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

View File

@@ -5,7 +5,13 @@ declare(strict_types=1);
namespace App\Entity; namespace App\Entity;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Repository\DocumentRepository; use App\Repository\DocumentRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
@@ -13,68 +19,84 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: DocumentRepository::class)] #[ORM\Entity(repositoryClass: DocumentRepository::class)]
#[ORM\Table(name: 'documents')] #[ORM\Table(name: 'documents')]
#[ORM\HasLifecycleCallbacks] #[ORM\HasLifecycleCallbacks]
#[ApiResource] #[ApiResource(
operations: [
new GetCollection(normalizationContext: ['groups' => ['document:list']]),
new Get(normalizationContext: ['groups' => ['document:list', 'document:detail']]),
new Post(),
new Put(),
new Delete(),
],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 200
)]
class Document class Document
{ {
#[ORM\Id] #[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)] #[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['document:read', 'composant:read', 'piece:read', 'product:read'])] #[Groups(['document:list', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
private ?string $id = null; private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255)] #[ORM\Column(type: Types::STRING, length: 255)]
#[Groups(['document:read', 'composant:read', 'piece:read', 'product:read'])] #[Groups(['document:list', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
private string $name; private string $name;
#[ORM\Column(type: Types::STRING, length: 255)] #[ORM\Column(type: Types::STRING, length: 255)]
#[Groups(['document:read', 'composant:read', 'piece:read', 'product:read'])] #[Groups(['document:list', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
private string $filename; private string $filename;
#[ORM\Column(type: Types::TEXT)] #[ORM\Column(type: Types::TEXT)]
#[Groups(['document:read', 'composant:read', 'piece:read', 'product:read'])] #[Groups(['document:detail', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
private string $path; private string $path;
#[ORM\Column(type: Types::STRING, length: 100, name: 'mimeType')] #[ORM\Column(type: Types::STRING, length: 100, name: 'mimeType')]
#[Groups(['document:read', 'composant:read', 'piece:read', 'product:read'])] #[Groups(['document:list', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
private string $mimeType; private string $mimeType;
#[ORM\Column(type: Types::INTEGER)] #[ORM\Column(type: Types::INTEGER)]
#[Groups(['document:read', 'composant:read', 'piece:read', 'product:read'])] #[Groups(['document:list', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
private int $size; private int $size;
#[ORM\ManyToOne(targetEntity: Machine::class, inversedBy: 'documents')] #[ORM\ManyToOne(targetEntity: Machine::class, inversedBy: 'documents')]
#[ORM\JoinColumn(name: 'machineId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] #[ORM\JoinColumn(name: 'machineId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['document:list'])]
private ?Machine $machine = null; private ?Machine $machine = null;
#[ORM\ManyToOne(targetEntity: Composant::class, inversedBy: 'documents')] #[ORM\ManyToOne(targetEntity: Composant::class, inversedBy: 'documents')]
#[ORM\JoinColumn(name: 'composantId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] #[ORM\JoinColumn(name: 'composantId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['document:list'])]
private ?Composant $composant = null; private ?Composant $composant = null;
#[ORM\ManyToOne(targetEntity: Piece::class, inversedBy: 'documents')] #[ORM\ManyToOne(targetEntity: Piece::class, inversedBy: 'documents')]
#[ORM\JoinColumn(name: 'pieceId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] #[ORM\JoinColumn(name: 'pieceId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['document:list'])]
private ?Piece $piece = null; private ?Piece $piece = null;
#[ORM\ManyToOne(targetEntity: Product::class, inversedBy: 'documents')] #[ORM\ManyToOne(targetEntity: Product::class, inversedBy: 'documents')]
#[ORM\JoinColumn(name: 'productId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] #[ORM\JoinColumn(name: 'productId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['document:list'])]
private ?Product $product = null; private ?Product $product = null;
#[ORM\ManyToOne(targetEntity: Site::class, inversedBy: 'documents')] #[ORM\ManyToOne(targetEntity: Site::class, inversedBy: 'documents')]
#[ORM\JoinColumn(name: 'siteId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] #[ORM\JoinColumn(name: 'siteId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['document:list'])]
private ?Site $site = null; private ?Site $site = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private \DateTimeImmutable $createdAt; #[Groups(['document:list'])]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private \DateTimeImmutable $updatedAt; private DateTimeImmutable $updatedAt;
#[ORM\PrePersist] #[ORM\PrePersist]
public function setCreatedAtValue(): void public function setCreatedAtValue(): void
{ {
$now = new \DateTimeImmutable(); $now = new DateTimeImmutable();
$this->createdAt = $now; $this->createdAt = $now;
$this->updatedAt = $now; $this->updatedAt = $now;
if ($this->id === null) { if (null === $this->id) {
$this->id = $this->generateCuid(); $this->id = $this->generateCuid();
} }
} }
@@ -82,12 +104,7 @@ class Document
#[ORM\PreUpdate] #[ORM\PreUpdate]
public function setUpdatedAtValue(): void public function setUpdatedAtValue(): void
{ {
$this->updatedAt = new \DateTimeImmutable(); $this->updatedAt = new DateTimeImmutable();
}
private function generateCuid(): string
{
return 'cl' . bin2hex(random_bytes(12));
} }
public function getId(): ?string public function getId(): ?string
@@ -222,13 +239,18 @@ class Document
return $this; return $this;
} }
public function getCreatedAt(): \DateTimeImmutable public function getCreatedAt(): DateTimeImmutable
{ {
return $this->createdAt; return $this->createdAt;
} }
public function getUpdatedAt(): \DateTimeImmutable public function getUpdatedAt(): DateTimeImmutable
{ {
return $this->updatedAt; return $this->updatedAt;
} }
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
} }

View File

@@ -6,10 +6,12 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use App\Repository\MachineRepository; use App\Repository\MachineRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: MachineRepository::class)] #[ORM\Entity(repositoryClass: MachineRepository::class)]
#[ORM\Table(name: 'machines')] #[ORM\Table(name: 'machines')]
@@ -19,9 +21,11 @@ class Machine
{ {
#[ORM\Id] #[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)] #[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['document:list'])]
private ?string $id = null; private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255, unique: true)] #[ORM\Column(type: Types::STRING, length: 255, unique: true)]
#[Groups(['document:list'])]
private string $name; private string $name;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)] #[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
@@ -80,10 +84,10 @@ class Machine
private Collection $customFieldValues; private Collection $customFieldValues;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private \DateTimeImmutable $createdAt; private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private \DateTimeImmutable $updatedAt; private DateTimeImmutable $updatedAt;
public function __construct() public function __construct()
{ {
@@ -98,11 +102,11 @@ class Machine
#[ORM\PrePersist] #[ORM\PrePersist]
public function setCreatedAtValue(): void public function setCreatedAtValue(): void
{ {
$now = new \DateTimeImmutable(); $now = new DateTimeImmutable();
$this->createdAt = $now; $this->createdAt = $now;
$this->updatedAt = $now; $this->updatedAt = $now;
if ($this->id === null) { if (null === $this->id) {
$this->id = $this->generateCuid(); $this->id = $this->generateCuid();
} }
} }
@@ -110,12 +114,7 @@ class Machine
#[ORM\PreUpdate] #[ORM\PreUpdate]
public function setUpdatedAtValue(): void public function setUpdatedAtValue(): void
{ {
$this->updatedAt = new \DateTimeImmutable(); $this->updatedAt = new DateTimeImmutable();
}
private function generateCuid(): string
{
return 'cl' . bin2hex(random_bytes(12));
} }
public function getId(): ?string public function getId(): ?string
@@ -238,13 +237,18 @@ class Machine
return $this->customFieldValues; return $this->customFieldValues;
} }
public function getCreatedAt(): \DateTimeImmutable public function getCreatedAt(): DateTimeImmutable
{ {
return $this->createdAt; return $this->createdAt;
} }
public function getUpdatedAt(): \DateTimeImmutable public function getUpdatedAt(): DateTimeImmutable
{ {
return $this->updatedAt; 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 ApiPlatform\Metadata\ApiResource;
use App\Repository\MachineComponentLinkRepository; use App\Repository\MachineComponentLinkRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
@@ -65,10 +66,10 @@ class MachineComponentLink
private ?string $prixOverride = null; private ?string $prixOverride = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private \DateTimeImmutable $createdAt; private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private \DateTimeImmutable $updatedAt; private DateTimeImmutable $updatedAt;
public function __construct() public function __construct()
{ {
@@ -80,11 +81,11 @@ class MachineComponentLink
#[ORM\PrePersist] #[ORM\PrePersist]
public function setCreatedAtValue(): void public function setCreatedAtValue(): void
{ {
$now = new \DateTimeImmutable(); $now = new DateTimeImmutable();
$this->createdAt = $now; $this->createdAt = $now;
$this->updatedAt = $now; $this->updatedAt = $now;
if ($this->id === null) { if (null === $this->id) {
$this->id = $this->generateCuid(); $this->id = $this->generateCuid();
} }
} }
@@ -92,12 +93,7 @@ class MachineComponentLink
#[ORM\PreUpdate] #[ORM\PreUpdate]
public function setUpdatedAtValue(): void public function setUpdatedAtValue(): void
{ {
$this->updatedAt = new \DateTimeImmutable(); $this->updatedAt = new DateTimeImmutable();
}
private function generateCuid(): string
{
return 'cl' . bin2hex(random_bytes(12));
} }
public function getId(): ?string public function getId(): ?string
@@ -195,4 +191,9 @@ class MachineComponentLink
return $this; return $this;
} }
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
} }

View File

@@ -6,6 +6,7 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use App\Repository\MachinePieceLinkRepository; use App\Repository\MachinePieceLinkRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
@@ -53,10 +54,10 @@ class MachinePieceLink
private ?string $prixOverride = null; private ?string $prixOverride = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private \DateTimeImmutable $createdAt; private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private \DateTimeImmutable $updatedAt; private DateTimeImmutable $updatedAt;
public function __construct() public function __construct()
{ {
@@ -66,11 +67,11 @@ class MachinePieceLink
#[ORM\PrePersist] #[ORM\PrePersist]
public function setCreatedAtValue(): void public function setCreatedAtValue(): void
{ {
$now = new \DateTimeImmutable(); $now = new DateTimeImmutable();
$this->createdAt = $now; $this->createdAt = $now;
$this->updatedAt = $now; $this->updatedAt = $now;
if ($this->id === null) { if (null === $this->id) {
$this->id = $this->generateCuid(); $this->id = $this->generateCuid();
} }
} }
@@ -78,12 +79,7 @@ class MachinePieceLink
#[ORM\PreUpdate] #[ORM\PreUpdate]
public function setUpdatedAtValue(): void public function setUpdatedAtValue(): void
{ {
$this->updatedAt = new \DateTimeImmutable(); $this->updatedAt = new DateTimeImmutable();
}
private function generateCuid(): string
{
return 'cl' . bin2hex(random_bytes(12));
} }
public function getId(): ?string public function getId(): ?string
@@ -181,4 +177,9 @@ class MachinePieceLink
return $this; return $this;
} }
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
} }

View File

@@ -6,6 +6,7 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use App\Repository\MachineProductLinkRepository; use App\Repository\MachineProductLinkRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
@@ -52,10 +53,10 @@ class MachineProductLink
private ?MachinePieceLink $parentPieceLink = null; private ?MachinePieceLink $parentPieceLink = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private \DateTimeImmutable $createdAt; private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private \DateTimeImmutable $updatedAt; private DateTimeImmutable $updatedAt;
public function __construct() public function __construct()
{ {
@@ -65,11 +66,11 @@ class MachineProductLink
#[ORM\PrePersist] #[ORM\PrePersist]
public function setCreatedAtValue(): void public function setCreatedAtValue(): void
{ {
$now = new \DateTimeImmutable(); $now = new DateTimeImmutable();
$this->createdAt = $now; $this->createdAt = $now;
$this->updatedAt = $now; $this->updatedAt = $now;
if ($this->id === null) { if (null === $this->id) {
$this->id = $this->generateCuid(); $this->id = $this->generateCuid();
} }
} }
@@ -77,12 +78,7 @@ class MachineProductLink
#[ORM\PreUpdate] #[ORM\PreUpdate]
public function setUpdatedAtValue(): void public function setUpdatedAtValue(): void
{ {
$this->updatedAt = new \DateTimeImmutable(); $this->updatedAt = new DateTimeImmutable();
}
private function generateCuid(): string
{
return 'cl' . bin2hex(random_bytes(12));
} }
public function getId(): ?string public function getId(): ?string
@@ -168,4 +164,9 @@ class MachineProductLink
return $this; return $this;
} }
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
} }

View File

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

View File

@@ -24,17 +24,17 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource( #[ApiResource(
normalizationContext: ['groups' => ['piece:read']], normalizationContext: ['groups' => ['piece:read']],
paginationClientItemsPerPage: true, paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 500 paginationMaximumItemsPerPage: 200
)] )]
class Piece class Piece
{ {
#[ORM\Id] #[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)] #[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['piece:read'])] #[Groups(['piece:read', 'document:list'])]
private ?string $id = null; private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255, unique: true)] #[ORM\Column(type: Types::STRING, length: 255, unique: true)]
#[Groups(['piece:read'])] #[Groups(['piece:read', 'document:list'])]
private string $name; private string $name;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)] #[ORM\Column(type: Types::STRING, length: 255, nullable: true)]

View File

@@ -24,17 +24,17 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource( #[ApiResource(
normalizationContext: ['groups' => ['product:read']], normalizationContext: ['groups' => ['product:read']],
paginationClientItemsPerPage: true, paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 500 paginationMaximumItemsPerPage: 200
)] )]
class Product class Product
{ {
#[ORM\Id] #[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)] #[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['product:read'])] #[Groups(['product:read', 'document:list'])]
private ?string $id = null; private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255, unique: true)] #[ORM\Column(type: Types::STRING, length: 255, unique: true)]
#[Groups(['product:read'])] #[Groups(['product:read', 'document:list'])]
private string $name; private string $name;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)] #[ORM\Column(type: Types::STRING, length: 255, nullable: true)]

View File

@@ -16,6 +16,7 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: SiteRepository::class)] #[ORM\Entity(repositoryClass: SiteRepository::class)]
@@ -30,16 +31,18 @@ use Symfony\Component\Validator\Constraints as Assert;
new Delete(), new Delete(),
], ],
paginationClientItemsPerPage: true, paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 500 paginationMaximumItemsPerPage: 200
)] )]
class Site class Site
{ {
#[ORM\Id] #[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)] #[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['document:list'])]
private ?string $id = null; private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255)] #[ORM\Column(type: Types::STRING, length: 255)]
#[Assert\NotBlank] #[Assert\NotBlank]
#[Groups(['document:list'])]
private string $name; private string $name;
#[ORM\Column(type: Types::STRING, length: 255, options: ['default' => ''], name: 'contactName')] #[ORM\Column(type: Types::STRING, length: 255, options: ['default' => ''], name: 'contactName')]

View File

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

View File

@@ -7,6 +7,7 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use App\Repository\TypeMachineComponentRequirementRepository; use App\Repository\TypeMachineComponentRequirementRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
@@ -65,10 +66,10 @@ class TypeMachineComponentRequirement
private Collection $machineComponentLinks; private Collection $machineComponentLinks;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private \DateTimeImmutable $createdAt; private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private \DateTimeImmutable $updatedAt; private DateTimeImmutable $updatedAt;
public function __construct() public function __construct()
{ {
@@ -78,11 +79,11 @@ class TypeMachineComponentRequirement
#[ORM\PrePersist] #[ORM\PrePersist]
public function setCreatedAtValue(): void public function setCreatedAtValue(): void
{ {
$now = new \DateTimeImmutable(); $now = new DateTimeImmutable();
$this->createdAt = $now; $this->createdAt = $now;
$this->updatedAt = $now; $this->updatedAt = $now;
if ($this->id === null) { if (null === $this->id) {
$this->id = $this->generateCuid(); $this->id = $this->generateCuid();
} }
} }
@@ -90,12 +91,7 @@ class TypeMachineComponentRequirement
#[ORM\PreUpdate] #[ORM\PreUpdate]
public function setUpdatedAtValue(): void public function setUpdatedAtValue(): void
{ {
$this->updatedAt = new \DateTimeImmutable(); $this->updatedAt = new DateTimeImmutable();
}
private function generateCuid(): string
{
return 'cl' . bin2hex(random_bytes(12));
} }
public function getId(): ?string public function getId(): ?string
@@ -205,4 +201,9 @@ class TypeMachineComponentRequirement
return $this; return $this;
} }
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
} }

View File

@@ -7,6 +7,7 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use App\Repository\TypeMachinePieceRequirementRepository; use App\Repository\TypeMachinePieceRequirementRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
@@ -65,10 +66,10 @@ class TypeMachinePieceRequirement
private Collection $machinePieceLinks; private Collection $machinePieceLinks;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private \DateTimeImmutable $createdAt; private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private \DateTimeImmutable $updatedAt; private DateTimeImmutable $updatedAt;
public function __construct() public function __construct()
{ {
@@ -78,11 +79,11 @@ class TypeMachinePieceRequirement
#[ORM\PrePersist] #[ORM\PrePersist]
public function setCreatedAtValue(): void public function setCreatedAtValue(): void
{ {
$now = new \DateTimeImmutable(); $now = new DateTimeImmutable();
$this->createdAt = $now; $this->createdAt = $now;
$this->updatedAt = $now; $this->updatedAt = $now;
if ($this->id === null) { if (null === $this->id) {
$this->id = $this->generateCuid(); $this->id = $this->generateCuid();
} }
} }
@@ -90,12 +91,7 @@ class TypeMachinePieceRequirement
#[ORM\PreUpdate] #[ORM\PreUpdate]
public function setUpdatedAtValue(): void public function setUpdatedAtValue(): void
{ {
$this->updatedAt = new \DateTimeImmutable(); $this->updatedAt = new DateTimeImmutable();
}
private function generateCuid(): string
{
return 'cl' . bin2hex(random_bytes(12));
} }
public function getId(): ?string public function getId(): ?string
@@ -205,4 +201,9 @@ class TypeMachinePieceRequirement
return $this; return $this;
} }
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
} }

View File

@@ -7,6 +7,7 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use App\Repository\TypeMachineProductRequirementRepository; use App\Repository\TypeMachineProductRequirementRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
@@ -65,10 +66,10 @@ class TypeMachineProductRequirement
private Collection $machineProductLinks; private Collection $machineProductLinks;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private \DateTimeImmutable $createdAt; private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private \DateTimeImmutable $updatedAt; private DateTimeImmutable $updatedAt;
public function __construct() public function __construct()
{ {
@@ -78,11 +79,11 @@ class TypeMachineProductRequirement
#[ORM\PrePersist] #[ORM\PrePersist]
public function setCreatedAtValue(): void public function setCreatedAtValue(): void
{ {
$now = new \DateTimeImmutable(); $now = new DateTimeImmutable();
$this->createdAt = $now; $this->createdAt = $now;
$this->updatedAt = $now; $this->updatedAt = $now;
if ($this->id === null) { if (null === $this->id) {
$this->id = $this->generateCuid(); $this->id = $this->generateCuid();
} }
} }
@@ -90,12 +91,7 @@ class TypeMachineProductRequirement
#[ORM\PreUpdate] #[ORM\PreUpdate]
public function setUpdatedAtValue(): void public function setUpdatedAtValue(): void
{ {
$this->updatedAt = new \DateTimeImmutable(); $this->updatedAt = new DateTimeImmutable();
}
private function generateCuid(): string
{
return 'cl' . bin2hex(random_bytes(12));
} }
public function getId(): ?string public function getId(): ?string
@@ -205,4 +201,9 @@ class TypeMachineProductRequirement
return $this; return $this;
} }
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
} }

View File

@@ -6,8 +6,11 @@ namespace App\EventSubscriber;
use App\Entity\AuditLog; use App\Entity\AuditLog;
use App\Entity\Composant; use App\Entity\Composant;
use App\Entity\CustomFieldValue;
use App\Entity\ModelType; use App\Entity\ModelType;
use App\Entity\Product; use App\Entity\Product;
use App\Entity\Profile;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener; use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\Common\EventSubscriber; use Doctrine\Common\EventSubscriber;
@@ -15,15 +18,24 @@ use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs; use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events; use Doctrine\ORM\Events;
use Doctrine\ORM\PersistentCollection; use Doctrine\ORM\PersistentCollection;
use Doctrine\ORM\UnitOfWork;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface; 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)] #[AsDoctrineListener(event: Events::onFlush)]
final class ComposantAuditSubscriber implements EventSubscriber final class ComposantAuditSubscriber implements EventSubscriber
{ {
public function __construct(private readonly RequestStack $requestStack) public function __construct(
{ private readonly RequestStack $requestStack,
} private readonly Security $security,
) {}
public function getSubscribedEvents(): array public function getSubscribedEvents(): array
{ {
@@ -61,12 +73,12 @@ final class ComposantAuditSubscriber implements EventSubscriber
} }
$componentId = (string) $entity->getId(); $componentId = (string) $entity->getId();
if ($componentId === '') { if ('' === $componentId) {
continue; continue;
} }
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity)); $diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ($diff !== []) { if ([] !== $diff) {
$pendingUpdates[$componentId] = $this->mergeDiffs($pendingUpdates[$componentId] ?? [], $diff); $pendingUpdates[$componentId] = $this->mergeDiffs($pendingUpdates[$componentId] ?? [], $diff);
$pendingSnapshots[$componentId] = $this->snapshotComposant($entity); $pendingSnapshots[$componentId] = $this->snapshotComposant($entity);
$pendingComponents[$componentId] = $entity; $pendingComponents[$componentId] = $entity;
@@ -89,8 +101,10 @@ final class ComposantAuditSubscriber implements EventSubscriber
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingComponents); $this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingComponents);
} }
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingComponents);
foreach ($pendingUpdates as $componentId => $diff) { foreach ($pendingUpdates as $componentId => $diff) {
if ($diff === []) { if ([] === $diff) {
continue; continue;
} }
@@ -125,13 +139,13 @@ final class ComposantAuditSubscriber implements EventSubscriber
} }
$componentId = (string) $owner->getId(); $componentId = (string) $owner->getId();
if ($componentId === '') { if ('' === $componentId) {
return; return;
} }
$mapping = $collection->getMapping(); $mapping = $collection->getMapping();
$fieldName = $mapping['fieldName'] ?? null; $fieldName = $mapping['fieldName'] ?? null;
if ($fieldName !== 'constructeurs') { if ('constructeurs' !== $fieldName) {
return; return;
} }
@@ -154,6 +168,75 @@ final class ComposantAuditSubscriber implements EventSubscriber
$pendingComponents[$componentId] = $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 private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
{ {
$uow = $em->getUnitOfWork(); $uow = $em->getUnitOfWork();
@@ -166,13 +249,14 @@ final class ComposantAuditSubscriber implements EventSubscriber
/** /**
* @param array<string, array{0:mixed, 1:mixed}> $changeSet * @param array<string, array{0:mixed, 1:mixed}> $changeSet
*
* @return array<string, array{from:mixed, to:mixed}> * @return array<string, array{from:mixed, to:mixed}>
*/ */
private function buildDiffFromChangeSet(array $changeSet): array private function buildDiffFromChangeSet(array $changeSet): array
{ {
$diff = []; $diff = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) { foreach ($changeSet as $field => [$oldValue, $newValue]) {
if ($field === 'updatedAt' || $field === 'createdAt') { if ('updatedAt' === $field || 'createdAt' === $field) {
continue; continue;
} }
@@ -208,33 +292,39 @@ final class ComposantAuditSubscriber implements EventSubscriber
/** /**
* @param iterable<mixed> $items * @param iterable<mixed> $items
* @return list<string> *
* @return list<array{id: string, name: string}|string>
*/ */
private function normalizeCollection(iterable $items): array private function normalizeCollection(iterable $items): array
{ {
$ids = []; $entries = [];
$seen = [];
foreach ($items as $item) { foreach ($items as $item) {
if (\is_object($item) && \method_exists($item, 'getId')) { if (is_object($item) && method_exists($item, 'getId')) {
$id = $item->getId(); $id = $item->getId();
if ($id !== null && $id !== '') { if (null === $id || '' === $id || isset($seen[(string) $id])) {
$ids[] = (string) $id; continue;
}
$seen[(string) $id] = true;
if (method_exists($item, 'getName')) {
$entries[] = ['id' => (string) $id, 'name' => (string) $item->getName()];
} else {
$entries[] = (string) $id;
} }
} }
} }
sort($ids); return $entries;
return array_values(array_unique($ids));
} }
private function normalizeValue(mixed $value): mixed private function normalizeValue(mixed $value): mixed
{ {
if ($value === null || \is_scalar($value)) { if (null === $value || is_scalar($value)) {
return $value; return $value;
} }
if ($value instanceof \DateTimeInterface) { if ($value instanceof DateTimeInterface) {
return $value->format(\DateTimeInterface::ATOM); return $value->format(DateTimeInterface::ATOM);
} }
if ($value instanceof ModelType) { if ($value instanceof ModelType) {
@@ -257,11 +347,11 @@ final class ComposantAuditSubscriber implements EventSubscriber
return $this->normalizeCollection($value); return $this->normalizeCollection($value);
} }
if (\is_object($value) && \method_exists($value, 'getId')) { if (is_object($value) && method_exists($value, 'getId')) {
return (string) $value->getId(); return (string) $value->getId();
} }
if (\is_array($value)) { if (is_array($value)) {
return $value; return $value;
} }
@@ -271,6 +361,7 @@ final class ComposantAuditSubscriber implements EventSubscriber
/** /**
* @param array<string, array{from:mixed, to:mixed}> $base * @param array<string, array{from:mixed, to:mixed}> $base
* @param array<string, array{from:mixed, to:mixed}> $extra * @param array<string, array{from:mixed, to:mixed}> $extra
*
* @return array<string, array{from:mixed, to:mixed}> * @return array<string, array{from:mixed, to:mixed}>
*/ */
private function mergeDiffs(array $base, array $extra): array private function mergeDiffs(array $base, array $extra): array
@@ -284,17 +375,23 @@ final class ComposantAuditSubscriber implements EventSubscriber
private function resolveActorProfileId(): ?string private function resolveActorProfileId(): ?string
{ {
try {
$session = $this->requestStack->getSession(); $session = $this->requestStack->getSession();
if (!$session instanceof SessionInterface) { if ($session instanceof SessionInterface) {
return null;
}
$profileId = $session->get('profileId'); $profileId = $session->get('profileId');
if (!$profileId) { if ($profileId) {
return null;
}
return (string) $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,168 @@
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\AuditLog;
use App\Entity\Constructeur;
use App\Entity\Profile;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Throwable;
use function is_scalar;
#[AsDoctrineListener(event: Events::onFlush)]
final class ConstructeurAuditSubscriber 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();
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!$entity instanceof Constructeur) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
$snapshot = $this->snapshotConstructeur($entity);
$this->persistAuditLog($em, new AuditLog('constructeur', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof Constructeur) {
continue;
}
$id = (string) $entity->getId();
if ('' === $id) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ([] !== $diff) {
$snapshot = $this->snapshotConstructeur($entity);
$this->persistAuditLog($em, new AuditLog('constructeur', $id, 'update', $diff, $snapshot, $actorProfileId));
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if (!$entity instanceof Constructeur) {
continue;
}
$snapshot = $this->snapshotConstructeur($entity);
$this->persistAuditLog($em, new AuditLog('constructeur', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
}
}
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 snapshotConstructeur(Constructeur $constructeur): array
{
return [
'id' => $constructeur->getId(),
'name' => $constructeur->getName(),
'email' => $constructeur->getEmail(),
'phone' => $constructeur->getPhone(),
];
}
private function normalizeValue(mixed $value): mixed
{
if (null === $value || is_scalar($value)) {
return $value;
}
if ($value instanceof DateTimeInterface) {
return $value->format(DateTimeInterface::ATOM);
}
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,192 @@
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\AuditLog;
use App\Entity\Composant;
use App\Entity\Document;
use App\Entity\Machine;
use App\Entity\Piece;
use App\Entity\Product;
use App\Entity\Profile;
use App\Entity\Site;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Throwable;
use function is_object;
use function is_scalar;
use function method_exists;
#[AsDoctrineListener(event: Events::onFlush)]
final class DocumentAuditSubscriber 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();
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!$entity instanceof Document) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
$snapshot = $this->snapshotDocument($entity);
$this->persistAuditLog($em, new AuditLog('document', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof Document) {
continue;
}
$id = (string) $entity->getId();
if ('' === $id) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ([] !== $diff) {
$snapshot = $this->snapshotDocument($entity);
$this->persistAuditLog($em, new AuditLog('document', $id, 'update', $diff, $snapshot, $actorProfileId));
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if (!$entity instanceof Document) {
continue;
}
$snapshot = $this->snapshotDocument($entity);
$this->persistAuditLog($em, new AuditLog('document', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
}
}
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 snapshotDocument(Document $document): array
{
return [
'id' => $document->getId(),
'name' => $document->getName(),
'filename' => $document->getFilename(),
'mimeType' => $document->getMimeType(),
'size' => $document->getSize(),
'machine' => $this->normalizeValue($document->getMachine()),
'composant' => $this->normalizeValue($document->getComposant()),
'piece' => $this->normalizeValue($document->getPiece()),
'product' => $this->normalizeValue($document->getProduct()),
'site' => $this->normalizeValue($document->getSite()),
];
}
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 Machine || $value instanceof Composant || $value instanceof Piece || $value instanceof Product || $value instanceof Site) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
];
}
if (is_object($value) && method_exists($value, 'getId')) {
return (string) $value->getId();
}
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,412 @@
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\AuditLog;
use App\Entity\CustomFieldValue;
use App\Entity\Machine;
use App\Entity\ModelType;
use App\Entity\Product;
use App\Entity\Profile;
use App\Entity\Site;
use App\Entity\TypeMachine;
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 MachineAuditSubscriber 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 = [];
$pendingMachines = [];
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!$entity instanceof Machine) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
$snapshot = $this->snapshotMachine($entity);
$this->persistAuditLog($em, new AuditLog('machine', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof Machine) {
continue;
}
$machineId = (string) $entity->getId();
if ('' === $machineId) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ([] !== $diff) {
$pendingUpdates[$machineId] = $this->mergeDiffs($pendingUpdates[$machineId] ?? [], $diff);
$pendingSnapshots[$machineId] = $this->snapshotMachine($entity);
$pendingMachines[$machineId] = $entity;
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if (!$entity instanceof Machine) {
continue;
}
$snapshot = $this->snapshotMachine($entity);
$this->persistAuditLog($em, new AuditLog('machine', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingMachines);
}
foreach ($uow->getScheduledCollectionDeletions() as $collection) {
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingMachines);
}
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingMachines);
foreach ($pendingUpdates as $machineId => $diff) {
if ([] === $diff) {
continue;
}
$machine = $pendingMachines[$machineId] ?? null;
if (!$machine instanceof Machine) {
continue;
}
$snapshot = $pendingSnapshots[$machineId] ?? $this->snapshotMachine($machine);
$this->persistAuditLog($em, new AuditLog('machine', $machineId, '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, Machine> $pendingMachines
*/
private function collectCollectionUpdate(
object $collection,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingMachines,
): void {
if (!$collection instanceof PersistentCollection) {
return;
}
$owner = $collection->getOwner();
if (!$owner instanceof Machine) {
return;
}
$machineId = (string) $owner->getId();
if ('' === $machineId) {
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[$machineId] = $this->mergeDiffs($pendingUpdates[$machineId] ?? [], $diff);
$pendingSnapshots[$machineId] = $this->snapshotMachine($owner);
$pendingMachines[$machineId] = $owner;
}
/**
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
* @param array<string, array<string, mixed>> $pendingSnapshots
* @param array<string, Machine> $pendingMachines
*/
private function collectCustomFieldValueChanges(
UnitOfWork $uow,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingMachines,
): void {
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if ($entity instanceof CustomFieldValue) {
$this->trackCustomFieldValueChange($entity, null, $entity->getValue(), $pendingUpdates, $pendingSnapshots, $pendingMachines);
}
}
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, $pendingMachines);
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if ($entity instanceof CustomFieldValue) {
$this->trackCustomFieldValueChange($entity, $entity->getValue(), null, $pendingUpdates, $pendingSnapshots, $pendingMachines);
}
}
}
/**
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
* @param array<string, array<string, mixed>> $pendingSnapshots
* @param array<string, Machine> $pendingMachines
*/
private function trackCustomFieldValueChange(
CustomFieldValue $cfv,
mixed $from,
mixed $to,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingMachines,
): void {
$owner = $cfv->getMachine();
if (!$owner instanceof Machine) {
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->snapshotMachine($owner);
$pendingMachines[$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 snapshotMachine(Machine $machine): array
{
return [
'id' => $machine->getId(),
'name' => $machine->getName(),
'reference' => $machine->getReference(),
'prix' => $machine->getPrix(),
'site' => $this->normalizeValue($machine->getSite()),
'typeMachine' => $this->normalizeValue($machine->getTypeMachine()),
'constructeurIds' => $this->normalizeCollection($machine->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 Site) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
];
}
if ($value instanceof TypeMachine) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
];
}
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,180 @@
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\AuditLog;
use App\Entity\ModelType;
use App\Entity\Profile;
use App\Enum\ModelCategory;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
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_scalar;
#[AsDoctrineListener(event: Events::onFlush)]
final class ModelTypeAuditSubscriber 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();
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!$entity instanceof ModelType) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
$snapshot = $this->snapshotModelType($entity);
$this->persistAuditLog($em, new AuditLog('model_type', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof ModelType) {
continue;
}
$id = (string) $entity->getId();
if ('' === $id) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ([] !== $diff) {
$snapshot = $this->snapshotModelType($entity);
$this->persistAuditLog($em, new AuditLog('model_type', $id, 'update', $diff, $snapshot, $actorProfileId));
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if (!$entity instanceof ModelType) {
continue;
}
$snapshot = $this->snapshotModelType($entity);
$this->persistAuditLog($em, new AuditLog('model_type', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
}
}
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 snapshotModelType(ModelType $modelType): array
{
return [
'id' => $modelType->getId(),
'name' => $modelType->getName(),
'code' => $modelType->getCode(),
'category' => $modelType->getCategory()->value,
'notes' => $modelType->getNotes(),
'description' => $modelType->getDescription(),
];
}
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 ModelCategory) {
return $value->value;
}
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

@@ -5,9 +5,12 @@ declare(strict_types=1);
namespace App\EventSubscriber; namespace App\EventSubscriber;
use App\Entity\AuditLog; use App\Entity\AuditLog;
use App\Entity\CustomFieldValue;
use App\Entity\ModelType; use App\Entity\ModelType;
use App\Entity\Piece; use App\Entity\Piece;
use App\Entity\Product; use App\Entity\Product;
use App\Entity\Profile;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener; use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\Common\EventSubscriber; use Doctrine\Common\EventSubscriber;
@@ -15,15 +18,24 @@ use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs; use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events; use Doctrine\ORM\Events;
use Doctrine\ORM\PersistentCollection; use Doctrine\ORM\PersistentCollection;
use Doctrine\ORM\UnitOfWork;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface; 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)] #[AsDoctrineListener(event: Events::onFlush)]
final class PieceAuditSubscriber implements EventSubscriber final class PieceAuditSubscriber implements EventSubscriber
{ {
public function __construct(private readonly RequestStack $requestStack) public function __construct(
{ private readonly RequestStack $requestStack,
} private readonly Security $security,
) {}
public function getSubscribedEvents(): array public function getSubscribedEvents(): array
{ {
@@ -61,12 +73,12 @@ final class PieceAuditSubscriber implements EventSubscriber
} }
$pieceId = (string) $entity->getId(); $pieceId = (string) $entity->getId();
if ($pieceId === '') { if ('' === $pieceId) {
continue; continue;
} }
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity)); $diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ($diff !== []) { if ([] !== $diff) {
$pendingUpdates[$pieceId] = $this->mergeDiffs($pendingUpdates[$pieceId] ?? [], $diff); $pendingUpdates[$pieceId] = $this->mergeDiffs($pendingUpdates[$pieceId] ?? [], $diff);
$pendingSnapshots[$pieceId] = $this->snapshotPiece($entity); $pendingSnapshots[$pieceId] = $this->snapshotPiece($entity);
$pendingPieces[$pieceId] = $entity; $pendingPieces[$pieceId] = $entity;
@@ -89,8 +101,10 @@ final class PieceAuditSubscriber implements EventSubscriber
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingPieces); $this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingPieces);
} }
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingPieces);
foreach ($pendingUpdates as $pieceId => $diff) { foreach ($pendingUpdates as $pieceId => $diff) {
if ($diff === []) { if ([] === $diff) {
continue; continue;
} }
@@ -125,13 +139,13 @@ final class PieceAuditSubscriber implements EventSubscriber
} }
$pieceId = (string) $owner->getId(); $pieceId = (string) $owner->getId();
if ($pieceId === '') { if ('' === $pieceId) {
return; return;
} }
$mapping = $collection->getMapping(); $mapping = $collection->getMapping();
$fieldName = $mapping['fieldName'] ?? null; $fieldName = $mapping['fieldName'] ?? null;
if ($fieldName !== 'constructeurs') { if ('constructeurs' !== $fieldName) {
return; return;
} }
@@ -154,6 +168,75 @@ final class PieceAuditSubscriber implements EventSubscriber
$pendingPieces[$pieceId] = $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 private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
{ {
$uow = $em->getUnitOfWork(); $uow = $em->getUnitOfWork();
@@ -166,13 +249,14 @@ final class PieceAuditSubscriber implements EventSubscriber
/** /**
* @param array<string, array{0:mixed, 1:mixed}> $changeSet * @param array<string, array{0:mixed, 1:mixed}> $changeSet
*
* @return array<string, array{from:mixed, to:mixed}> * @return array<string, array{from:mixed, to:mixed}>
*/ */
private function buildDiffFromChangeSet(array $changeSet): array private function buildDiffFromChangeSet(array $changeSet): array
{ {
$diff = []; $diff = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) { foreach ($changeSet as $field => [$oldValue, $newValue]) {
if ($field === 'updatedAt' || $field === 'createdAt') { if ('updatedAt' === $field || 'createdAt' === $field) {
continue; continue;
} }
@@ -208,33 +292,39 @@ final class PieceAuditSubscriber implements EventSubscriber
/** /**
* @param iterable<mixed> $items * @param iterable<mixed> $items
* @return list<string> *
* @return list<array{id: string, name: string}|string>
*/ */
private function normalizeCollection(iterable $items): array private function normalizeCollection(iterable $items): array
{ {
$ids = []; $entries = [];
$seen = [];
foreach ($items as $item) { foreach ($items as $item) {
if (\is_object($item) && \method_exists($item, 'getId')) { if (is_object($item) && method_exists($item, 'getId')) {
$id = $item->getId(); $id = $item->getId();
if ($id !== null && $id !== '') { if (null === $id || '' === $id || isset($seen[(string) $id])) {
$ids[] = (string) $id; continue;
}
$seen[(string) $id] = true;
if (method_exists($item, 'getName')) {
$entries[] = ['id' => (string) $id, 'name' => (string) $item->getName()];
} else {
$entries[] = (string) $id;
} }
} }
} }
sort($ids); return $entries;
return array_values(array_unique($ids));
} }
private function normalizeValue(mixed $value): mixed private function normalizeValue(mixed $value): mixed
{ {
if ($value === null || \is_scalar($value)) { if (null === $value || is_scalar($value)) {
return $value; return $value;
} }
if ($value instanceof \DateTimeInterface) { if ($value instanceof DateTimeInterface) {
return $value->format(\DateTimeInterface::ATOM); return $value->format(DateTimeInterface::ATOM);
} }
if ($value instanceof ModelType) { if ($value instanceof ModelType) {
@@ -257,11 +347,11 @@ final class PieceAuditSubscriber implements EventSubscriber
return $this->normalizeCollection($value); return $this->normalizeCollection($value);
} }
if (\is_object($value) && \method_exists($value, 'getId')) { if (is_object($value) && method_exists($value, 'getId')) {
return (string) $value->getId(); return (string) $value->getId();
} }
if (\is_array($value)) { if (is_array($value)) {
return $value; return $value;
} }
@@ -271,6 +361,7 @@ final class PieceAuditSubscriber implements EventSubscriber
/** /**
* @param array<string, array{from:mixed, to:mixed}> $base * @param array<string, array{from:mixed, to:mixed}> $base
* @param array<string, array{from:mixed, to:mixed}> $extra * @param array<string, array{from:mixed, to:mixed}> $extra
*
* @return array<string, array{from:mixed, to:mixed}> * @return array<string, array{from:mixed, to:mixed}>
*/ */
private function mergeDiffs(array $base, array $extra): array private function mergeDiffs(array $base, array $extra): array
@@ -284,17 +375,23 @@ final class PieceAuditSubscriber implements EventSubscriber
private function resolveActorProfileId(): ?string private function resolveActorProfileId(): ?string
{ {
try {
$session = $this->requestStack->getSession(); $session = $this->requestStack->getSession();
if (!$session instanceof SessionInterface) { if ($session instanceof SessionInterface) {
return null;
}
$profileId = $session->get('profileId'); $profileId = $session->get('profileId');
if (!$profileId) { if ($profileId) {
return null;
}
return (string) $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

@@ -16,9 +16,7 @@ use Doctrine\ORM\Events;
*/ */
final class PieceProductSyncSubscriber implements EventSubscriber final class PieceProductSyncSubscriber implements EventSubscriber
{ {
public function __construct(private readonly ProductRepository $productRepository) public function __construct(private readonly ProductRepository $productRepository) {}
{
}
public function getSubscribedEvents(): array public function getSubscribedEvents(): array
{ {
@@ -56,7 +54,7 @@ final class PieceProductSyncSubscriber implements EventSubscriber
{ {
$productIds = $piece->getProductIds(); $productIds = $piece->getProductIds();
if ($productIds === []) { if ([] === $productIds) {
// If no explicit list is provided, keep the legacy relation as-is. // If no explicit list is provided, keep the legacy relation as-is.
return; return;
} }
@@ -77,4 +75,3 @@ final class PieceProductSyncSubscriber implements EventSubscriber
$piece->setProduct($primaryProduct); $piece->setProduct($primaryProduct);
} }
} }

View File

@@ -5,8 +5,11 @@ declare(strict_types=1);
namespace App\EventSubscriber; namespace App\EventSubscriber;
use App\Entity\AuditLog; use App\Entity\AuditLog;
use App\Entity\CustomFieldValue;
use App\Entity\ModelType; use App\Entity\ModelType;
use App\Entity\Product; use App\Entity\Product;
use App\Entity\Profile;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener; use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\Common\EventSubscriber; use Doctrine\Common\EventSubscriber;
@@ -14,8 +17,16 @@ use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs; use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events; use Doctrine\ORM\Events;
use Doctrine\ORM\PersistentCollection; use Doctrine\ORM\PersistentCollection;
use Doctrine\ORM\UnitOfWork;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface; 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. * Record a lightweight, per-product audit trail.
@@ -27,9 +38,10 @@ use Symfony\Component\HttpFoundation\Session\SessionInterface;
#[AsDoctrineListener(event: Events::onFlush)] #[AsDoctrineListener(event: Events::onFlush)]
final class ProductAuditSubscriber implements EventSubscriber final class ProductAuditSubscriber implements EventSubscriber
{ {
public function __construct(private readonly RequestStack $requestStack) public function __construct(
{ private readonly RequestStack $requestStack,
} private readonly Security $security,
) {}
public function getSubscribedEvents(): array public function getSubscribedEvents(): array
{ {
@@ -67,12 +79,12 @@ final class ProductAuditSubscriber implements EventSubscriber
} }
$productId = (string) $entity->getId(); $productId = (string) $entity->getId();
if ($productId === '') { if ('' === $productId) {
continue; continue;
} }
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity)); $diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ($diff !== []) { if ([] !== $diff) {
$pendingUpdates[$productId] = $this->mergeDiffs($pendingUpdates[$productId] ?? [], $diff); $pendingUpdates[$productId] = $this->mergeDiffs($pendingUpdates[$productId] ?? [], $diff);
$pendingSnapshots[$productId] = $this->snapshotProduct($entity); $pendingSnapshots[$productId] = $this->snapshotProduct($entity);
$pendingProducts[$productId] = $entity; $pendingProducts[$productId] = $entity;
@@ -96,8 +108,10 @@ final class ProductAuditSubscriber implements EventSubscriber
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingProducts); $this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingProducts);
} }
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingProducts);
foreach ($pendingUpdates as $productId => $diff) { foreach ($pendingUpdates as $productId => $diff) {
if ($diff === []) { if ([] === $diff) {
continue; continue;
} }
@@ -132,13 +146,13 @@ final class ProductAuditSubscriber implements EventSubscriber
} }
$productId = (string) $owner->getId(); $productId = (string) $owner->getId();
if ($productId === '') { if ('' === $productId) {
return; return;
} }
$mapping = $collection->getMapping(); $mapping = $collection->getMapping();
$fieldName = $mapping['fieldName'] ?? null; $fieldName = $mapping['fieldName'] ?? null;
if ($fieldName !== 'constructeurs') { if ('constructeurs' !== $fieldName) {
return; return;
} }
@@ -161,6 +175,75 @@ final class ProductAuditSubscriber implements EventSubscriber
$pendingProducts[$productId] = $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 private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
{ {
$uow = $em->getUnitOfWork(); $uow = $em->getUnitOfWork();
@@ -174,6 +257,7 @@ final class ProductAuditSubscriber implements EventSubscriber
/** /**
* @param array<string, array{0:mixed, 1:mixed}> $changeSet * @param array<string, array{0:mixed, 1:mixed}> $changeSet
*
* @return array<string, array{from:mixed, to:mixed}> * @return array<string, array{from:mixed, to:mixed}>
*/ */
private function buildDiffFromChangeSet(array $changeSet): array private function buildDiffFromChangeSet(array $changeSet): array
@@ -181,7 +265,7 @@ final class ProductAuditSubscriber implements EventSubscriber
$diff = []; $diff = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) { foreach ($changeSet as $field => [$oldValue, $newValue]) {
// Skip noisy timestamps managed automatically. // Skip noisy timestamps managed automatically.
if ($field === 'updatedAt' || $field === 'createdAt') { if ('updatedAt' === $field || 'createdAt' === $field) {
continue; continue;
} }
@@ -216,6 +300,7 @@ final class ProductAuditSubscriber implements EventSubscriber
/** /**
* @param array<string, array{from:mixed, to:mixed}> $base * @param array<string, array{from:mixed, to:mixed}> $base
* @param array<string, array{from:mixed, to:mixed}> $extra * @param array<string, array{from:mixed, to:mixed}> $extra
*
* @return array<string, array{from:mixed, to:mixed}> * @return array<string, array{from:mixed, to:mixed}>
*/ */
private function mergeDiffs(array $base, array $extra): array private function mergeDiffs(array $base, array $extra): array
@@ -229,33 +314,39 @@ final class ProductAuditSubscriber implements EventSubscriber
/** /**
* @param iterable<mixed> $items * @param iterable<mixed> $items
* @return list<string> *
* @return list<array{id: string, name: string}|string>
*/ */
private function normalizeCollection(iterable $items): array private function normalizeCollection(iterable $items): array
{ {
$ids = []; $entries = [];
$seen = [];
foreach ($items as $item) { foreach ($items as $item) {
if (\is_object($item) && \method_exists($item, 'getId')) { if (is_object($item) && method_exists($item, 'getId')) {
$id = $item->getId(); $id = $item->getId();
if ($id !== null && $id !== '') { if (null === $id || '' === $id || isset($seen[(string) $id])) {
$ids[] = (string) $id; continue;
}
$seen[(string) $id] = true;
if (method_exists($item, 'getName')) {
$entries[] = ['id' => (string) $id, 'name' => (string) $item->getName()];
} else {
$entries[] = (string) $id;
} }
} }
} }
sort($ids); return $entries;
return array_values(array_unique($ids));
} }
private function normalizeValue(mixed $value): mixed private function normalizeValue(mixed $value): mixed
{ {
if ($value === null || \is_scalar($value)) { if (null === $value || is_scalar($value)) {
return $value; return $value;
} }
if ($value instanceof \DateTimeInterface) { if ($value instanceof DateTimeInterface) {
return $value->format(\DateTimeInterface::ATOM); return $value->format(DateTimeInterface::ATOM);
} }
if ($value instanceof ModelType) { if ($value instanceof ModelType) {
@@ -270,11 +361,11 @@ final class ProductAuditSubscriber implements EventSubscriber
return $this->normalizeCollection($value); return $this->normalizeCollection($value);
} }
if (\is_object($value) && \method_exists($value, 'getId')) { if (is_object($value) && method_exists($value, 'getId')) {
return (string) $value->getId(); return (string) $value->getId();
} }
if (\is_array($value)) { if (is_array($value)) {
return $value; return $value;
} }
@@ -283,16 +374,23 @@ final class ProductAuditSubscriber implements EventSubscriber
private function resolveActorProfileId(): ?string private function resolveActorProfileId(): ?string
{ {
try {
$session = $this->requestStack->getSession(); $session = $this->requestStack->getSession();
if (!$session instanceof SessionInterface) { if ($session instanceof SessionInterface) {
return null;
}
$profileId = $session->get('profileId'); $profileId = $session->get('profileId');
if (!$profileId) { if ($profileId) {
return null;
}
return (string) $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

@@ -9,6 +9,7 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\ExceptionEvent; use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\HttpKernel\KernelEvents;
use Throwable;
final class UniqueConstraintSubscriber implements EventSubscriberInterface final class UniqueConstraintSubscriber implements EventSubscriberInterface
{ {
@@ -36,9 +37,9 @@ final class UniqueConstraintSubscriber implements EventSubscriberInterface
)); ));
} }
private function findUniqueConstraintViolation(\Throwable $throwable): ?UniqueConstraintViolationException private function findUniqueConstraintViolation(Throwable $throwable): ?UniqueConstraintViolationException
{ {
for ($current = $throwable; $current !== null; $current = $current->getPrevious()) { for ($current = $throwable; null !== $current; $current = $current->getPrevious()) {
if ($current instanceof UniqueConstraintViolationException) { if ($current instanceof UniqueConstraintViolationException) {
return $current; return $current;
} }

View File

@@ -31,7 +31,46 @@ final class AuditLogRepository extends ServiceEntityRepository
->orderBy('a.createdAt', 'DESC') ->orderBy('a.createdAt', 'DESC')
->setMaxResults($limit) ->setMaxResults($limit)
->getQuery() ->getQuery()
->getResult(); ->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,
];
}
}

View File

@@ -0,0 +1,494 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Profile;
use App\Enum\ModelCategory;
use App\Repository\ModelTypeRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Throwable;
final class ModelTypeCategoryConversionService
{
public function __construct(
private readonly Connection $connection,
private readonly ModelTypeRepository $modelTypes,
private readonly RequestStack $requestStack,
private readonly Security $security,
) {}
/**
* @return array{canConvert: bool, direction: null|string, itemCount: int, names: list<string>, blockers: list<string>}
*/
public function checkConversion(string $modelTypeId): array
{
$modelType = $this->modelTypes->find($modelTypeId);
if (!$modelType) {
return [
'canConvert' => false,
'direction' => null,
'itemCount' => 0,
'names' => [],
'blockers' => ['Catégorie introuvable.'],
];
}
$category = $modelType->getCategory();
if (ModelCategory::PRODUCT === $category) {
return [
'canConvert' => false,
'direction' => null,
'itemCount' => 0,
'names' => [],
'blockers' => ['La conversion n\'est pas disponible pour les catégories de produit.'],
];
}
if (ModelCategory::PIECE === $category) {
return $this->checkPieceToComponent($modelTypeId, $modelType->getName());
}
return $this->checkComponentToPiece($modelTypeId, $modelType->getName());
}
/**
* @return array{success: bool, convertedCount: int, error: null|string}
*/
public function convert(string $modelTypeId): array
{
$check = $this->checkConversion($modelTypeId);
if (!$check['canConvert']) {
return [
'success' => false,
'convertedCount' => 0,
'error' => implode(' ', $check['blockers']),
];
}
$modelType = $this->modelTypes->find($modelTypeId);
if (!$modelType) {
return ['success' => false, 'convertedCount' => 0, 'error' => 'Catégorie introuvable.'];
}
$category = $modelType->getCategory();
$direction = ModelCategory::PIECE === $category ? 'piece_to_component' : 'component_to_piece';
$names = $check['names'];
$modelName = $modelType->getName();
$modelCode = $modelType->getCode();
$this->connection->beginTransaction();
try {
if (ModelCategory::PIECE === $category) {
$count = $this->convertPieceToComponent($modelTypeId);
} else {
$count = $this->convertComponentToPiece($modelTypeId);
}
$this->logConversionAudit($modelTypeId, $modelName, $modelCode, $direction, $count, $names);
$this->connection->commit();
return ['success' => true, 'convertedCount' => $count, 'error' => null];
} catch (Throwable $e) {
$this->connection->rollBack();
return ['success' => false, 'convertedCount' => 0, 'error' => $e->getMessage()];
}
}
/**
* @return array{canConvert: bool, direction: string, itemCount: int, names: list<string>, blockers: list<string>}
*/
private function checkPieceToComponent(string $modelTypeId, string $modelTypeName): array
{
$blockers = [];
$pieceCount = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM pieces WHERE typepieceid = :id',
['id' => $modelTypeId],
);
$names = $this->connection->fetchFirstColumn(
'SELECT name FROM pieces WHERE typepieceid = :id ORDER BY name',
['id' => $modelTypeId],
);
// Check machine links
$machineLinked = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM machine_piece_links mpl
JOIN pieces p ON mpl.pieceid = p.id
WHERE p.typepieceid = :id',
['id' => $modelTypeId],
);
if ($machineLinked > 0) {
$blockers[] = sprintf('%d pièce(s) liée(s) à des machines.', $machineLinked);
}
// Check type machine requirements
$requirementCount = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM type_machine_piece_requirements WHERE typepieceid = :id',
['id' => $modelTypeId],
);
if ($requirementCount > 0) {
$blockers[] = sprintf('Utilisé dans %d modèle(s) de type de machine.', $requirementCount);
}
// Check name collision with existing composants
$collisions = $this->connection->fetchFirstColumn(
'SELECT p.name FROM pieces p
WHERE p.typepieceid = :id
AND p.name IN (SELECT c.name FROM composants c)',
['id' => $modelTypeId],
);
if ([] !== $collisions) {
$blockers[] = sprintf(
'Collision de nom avec des composants existants : %s.',
implode(', ', $collisions),
);
}
// Check ModelType name collision
$nameCollision = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM model_types WHERE category = :cat AND name = :name AND id != :id',
['cat' => ModelCategory::COMPONENT->value, 'name' => $modelTypeName, 'id' => $modelTypeId],
);
if ($nameCollision > 0) {
$blockers[] = sprintf('Une catégorie de composant « %s » existe déjà.', $modelTypeName);
}
return [
'canConvert' => [] === $blockers,
'direction' => 'piece_to_component',
'itemCount' => $pieceCount,
'names' => $names,
'blockers' => $blockers,
];
}
/**
* @return array{canConvert: bool, direction: string, itemCount: int, names: list<string>, blockers: list<string>}
*/
private function checkComponentToPiece(string $modelTypeId, string $modelTypeName): array
{
$blockers = [];
$composantCount = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM composants WHERE typecomposantid = :id',
['id' => $modelTypeId],
);
$names = $this->connection->fetchFirstColumn(
'SELECT name FROM composants WHERE typecomposantid = :id ORDER BY name',
['id' => $modelTypeId],
);
// Check machine links
$machineLinked = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM machine_component_links mcl
JOIN composants c ON mcl.composantid = c.id
WHERE c.typecomposantid = :id',
['id' => $modelTypeId],
);
if ($machineLinked > 0) {
$blockers[] = sprintf('%d composant(s) lié(s) à des machines.', $machineLinked);
}
// Check type machine requirements
$requirementCount = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM type_machine_component_requirements WHERE typecomposantid = :id',
['id' => $modelTypeId],
);
if ($requirementCount > 0) {
$blockers[] = sprintf('Utilisé dans %d modèle(s) de type de machine.', $requirementCount);
}
// Check if any composant has pieces or sub-components in structure
$withStructure = $this->connection->fetchAllAssociative(
'SELECT name, structure FROM composants WHERE typecomposantid = :id AND structure IS NOT NULL',
['id' => $modelTypeId],
);
foreach ($withStructure as $row) {
$structure = json_decode($row['structure'], true);
if (!is_array($structure)) {
continue;
}
$hasPieces = !empty($structure['pieces']);
$hasSubcomponents = !empty($structure['subcomponents']);
if ($hasPieces || $hasSubcomponents) {
$parts = [];
if ($hasPieces) {
$parts[] = 'pièces';
}
if ($hasSubcomponents) {
$parts[] = 'sous-composants';
}
$blockers[] = sprintf(
'Le composant « %s » contient des %s dans sa structure.',
$row['name'],
implode(' et ', $parts),
);
}
}
// Check name collision with existing pieces
$collisions = $this->connection->fetchFirstColumn(
'SELECT c.name FROM composants c
WHERE c.typecomposantid = :id
AND c.name IN (SELECT p.name FROM pieces p)',
['id' => $modelTypeId],
);
if ([] !== $collisions) {
$blockers[] = sprintf(
'Collision de nom avec des pièces existantes : %s.',
implode(', ', $collisions),
);
}
// Check ModelType name collision
$nameCollision = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM model_types WHERE category = :cat AND name = :name AND id != :id',
['cat' => ModelCategory::PIECE->value, 'name' => $modelTypeName, 'id' => $modelTypeId],
);
if ($nameCollision > 0) {
$blockers[] = sprintf('Une catégorie de pièce « %s » existe déjà.', $modelTypeName);
}
return [
'canConvert' => [] === $blockers,
'direction' => 'component_to_piece',
'itemCount' => $composantCount,
'names' => $names,
'blockers' => $blockers,
];
}
private function convertPieceToComponent(string $modelTypeId): int
{
// 1. Insert into composants from pieces
$count = $this->connection->executeStatement(
'INSERT INTO composants (id, name, reference, prix, structure, typecomposantid, productid, createdat, updatedat)
SELECT id, name, reference, prix, NULL, typepieceid, productid, createdat, updatedat
FROM pieces
WHERE typepieceid = :id',
['id' => $modelTypeId],
);
// 2. Transfer constructeur associations
$this->connection->executeStatement(
'INSERT INTO _composantconstructeurs (a, b)
SELECT pc.a, pc.b FROM _piececonstructeurs pc
WHERE pc.a IN (SELECT id FROM composants WHERE typecomposantid = :id)',
['id' => $modelTypeId],
);
$this->connection->executeStatement(
'DELETE FROM _piececonstructeurs
WHERE a IN (SELECT id FROM pieces WHERE typepieceid = :id)',
['id' => $modelTypeId],
);
// 3. Transfer document references
$this->connection->executeStatement(
'UPDATE documents SET composantid = pieceid, pieceid = NULL
WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
['id' => $modelTypeId],
);
// 4. Transfer custom_field_values references
$this->connection->executeStatement(
'UPDATE custom_field_values SET composantid = pieceid, pieceid = NULL
WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
['id' => $modelTypeId],
);
// 5. Transfer custom_fields from typePiece to typeComposant
$this->connection->executeStatement(
'UPDATE custom_fields SET typecomposantid = typepieceid, typepieceid = NULL
WHERE typepieceid = :id',
['id' => $modelTypeId],
);
// 6. Delete original pieces
$this->connection->executeStatement(
'DELETE FROM pieces WHERE typepieceid = :id',
['id' => $modelTypeId],
);
// 7. Update ModelType
$this->connection->executeStatement(
'UPDATE model_types
SET category = :cat,
componentskeleton = pieceskeleton,
pieceskeleton = NULL,
updatedat = :now
WHERE id = :id',
[
'cat' => ModelCategory::COMPONENT->value,
'now' => new DateTimeImmutable()->format('Y-m-d H:i:s'),
'id' => $modelTypeId,
],
);
return $count;
}
private function convertComponentToPiece(string $modelTypeId): int
{
// 1. Insert into pieces from composants
$count = $this->connection->executeStatement(
'INSERT INTO pieces (id, name, reference, prix, productids, typepieceid, productid, createdat, updatedat)
SELECT id, name, reference, prix, NULL, typecomposantid, productid, createdat, updatedat
FROM composants
WHERE typecomposantid = :id',
['id' => $modelTypeId],
);
// 2. Transfer constructeur associations
$this->connection->executeStatement(
'INSERT INTO _piececonstructeurs (a, b)
SELECT cc.a, cc.b FROM _composantconstructeurs cc
WHERE cc.a IN (SELECT id FROM pieces WHERE typepieceid = :id)',
['id' => $modelTypeId],
);
$this->connection->executeStatement(
'DELETE FROM _composantconstructeurs
WHERE a IN (SELECT id FROM composants WHERE typecomposantid = :id)',
['id' => $modelTypeId],
);
// 3. Transfer document references
$this->connection->executeStatement(
'UPDATE documents SET pieceid = composantid, composantid = NULL
WHERE composantid IN (SELECT id FROM composants WHERE typecomposantid = :id)',
['id' => $modelTypeId],
);
// 4. Transfer custom_field_values references
$this->connection->executeStatement(
'UPDATE custom_field_values SET pieceid = composantid, composantid = NULL
WHERE composantid IN (SELECT id FROM composants WHERE typecomposantid = :id)',
['id' => $modelTypeId],
);
// 5. Transfer custom_fields from typeComposant to typePiece
$this->connection->executeStatement(
'UPDATE custom_fields SET typepieceid = typecomposantid, typecomposantid = NULL
WHERE typecomposantid = :id',
['id' => $modelTypeId],
);
// 6. Delete original composants
$this->connection->executeStatement(
'DELETE FROM composants WHERE typecomposantid = :id',
['id' => $modelTypeId],
);
// 7. Update ModelType
$this->connection->executeStatement(
'UPDATE model_types
SET category = :cat,
pieceskeleton = componentskeleton,
componentskeleton = NULL,
updatedat = :now
WHERE id = :id',
[
'cat' => ModelCategory::PIECE->value,
'now' => new DateTimeImmutable()->format('Y-m-d H:i:s'),
'id' => $modelTypeId,
],
);
return $count;
}
/**
* @param list<string> $names
*/
private function logConversionAudit(
string $modelTypeId,
string $modelName,
string $modelCode,
string $direction,
int $convertedCount,
array $names,
): void {
$now = new DateTimeImmutable()->format('Y-m-d H:i:s');
$id = 'cl'.bin2hex(random_bytes(12));
$snapshot = [
'id' => $modelTypeId,
'name' => $modelName,
'code' => $modelCode,
];
$diff = [
'direction' => ['from' => null, 'to' => $direction],
'convertedCount' => ['from' => null, 'to' => $convertedCount],
'convertedNames' => ['from' => null, 'to' => $names],
];
$this->connection->executeStatement(
'INSERT INTO audit_logs (id, entitytype, entityid, action, diff, snapshot, actorprofileid, createdat)
VALUES (:id, :entityType, :entityId, :action, :diff, :snapshot, :actor, :now)',
[
'id' => $id,
'entityType' => 'model_type',
'entityId' => $modelTypeId,
'action' => 'convert',
'diff' => json_encode($diff),
'snapshot' => json_encode($snapshot),
'actor' => $this->resolveActorProfileId(),
'now' => $now,
],
);
}
private function resolveActorProfileId(): ?string
{
try {
$session = $this->requestStack->getSession();
if ($session instanceof SessionInterface) {
$profileId = $session->get('profileId');
if ($profileId) {
return (string) $profileId;
}
}
} catch (Throwable) {
}
$user = $this->security->getUser();
if ($user instanceof Profile) {
return $user->getId();
}
return null;
}
}