Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adc44b99d3 | ||
|
|
60afeb4cfd | ||
|
|
02ff8b1a96 | ||
|
|
2156df22c6 | ||
|
|
cd2a3fac55 | ||
|
|
6300a3588a | ||
|
|
45213103e4 | ||
|
|
91b8b424d6 | ||
|
|
0d1c9277e5 | ||
|
|
db16d26103 | ||
|
|
0eb64d0975 | ||
|
|
39e503ae18 | ||
|
|
70ed354c42 | ||
|
|
ba98ae37f4 | ||
|
|
906d39793f | ||
|
|
f970c1928d | ||
|
|
2a1d966b87 | ||
|
|
a393b62e9f | ||
|
|
1247f72af6 | ||
|
|
6735bf252c | ||
|
|
508066d39f | ||
|
|
70956c204e | ||
|
|
16a7eac0c6 | ||
|
|
37ac08b182 | ||
|
|
5ef80b362e | ||
|
|
78f19daf76 | ||
|
|
6caa4a61df | ||
|
|
bf55034b2e | ||
|
|
ba1114e78b | ||
|
|
5ccc3b30f0 |
Submodule Inventory_frontend updated: 3705b8daed...6bed715b7f
786
REFACTORING_PLAN.md
Normal file
786
REFACTORING_PLAN.md
Normal file
@@ -0,0 +1,786 @@
|
||||
# Plan de Refactoring - Inventory v1.2.0
|
||||
|
||||
> **Date de creation :** 2026-02-03
|
||||
> **Branche de travail :** `refacto/v1.3.0`
|
||||
> **Base :** `develop` (commit `8d83076`)
|
||||
|
||||
---
|
||||
|
||||
## Legende des statuts
|
||||
|
||||
| Statut | Signification |
|
||||
| ------ | ---------------------- |
|
||||
| `[ ]` | A faire |
|
||||
| `[~]` | En cours |
|
||||
| `[x]` | Termine |
|
||||
| `[!]` | Bloque / besoin d'info |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 - Securite (CRITIQUE)
|
||||
|
||||
> **Priorite :** MAXIMALE - A traiter en premier
|
||||
|
||||
### 1.1 Corriger la configuration de securite
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichier :** `config/packages/security.yaml`
|
||||
- **Probleme :** `PUBLIC_ACCESS` applique a toutes les routes `/api` avant la regle `IS_AUTHENTICATED_FULLY`. Le pattern matching "first match wins" rend potentiellement tout public.
|
||||
- **Action :** Reordonner les regles `access_control` pour que les routes protegees soient listees AVANT les routes publiques.
|
||||
- **Agent :** -
|
||||
- **Notes :** -
|
||||
|
||||
### 1.2 Ajouter les controles d'autorisation sur les controllers
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichiers :**
|
||||
- `src/Controller/MachineSkeletonController.php`
|
||||
- `src/Controller/CustomFieldValueController.php`
|
||||
- `src/Controller/DocumentQueryController.php`
|
||||
- `src/Controller/SessionProfileController.php`
|
||||
- `src/Controller/SessionProfilesController.php`
|
||||
- Tous les `*HistoryController.php`
|
||||
- **Probleme :** Aucun attribut `#[IsGranted]` sur les controllers custom. Pas de RBAC.
|
||||
- **Action :** Ajouter `#[IsGranted('IS_AUTHENTICATED_FULLY')]` sur chaque controller (ou route). Definir des roles si necessaire.
|
||||
- **Agent :** -
|
||||
- **Notes :** -
|
||||
|
||||
### 1.3 Securiser les secrets
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichiers :**
|
||||
- `.env` (JWT_PASSPHRASE en dur, APP_SECRET vide)
|
||||
- `docker/.env.docker` (credentials `root:root`)
|
||||
- **Action :**
|
||||
1. Deplacer `JWT_PASSPHRASE` dans `.env.local` (git-ignore)
|
||||
2. Generer un `APP_SECRET` valide
|
||||
3. Ajouter `.env.local` dans `.gitignore` si pas deja fait
|
||||
4. Documenter la configuration des secrets pour les devs
|
||||
- **Agent :** -
|
||||
- **Notes :** -
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 - Elimination de la duplication de code
|
||||
|
||||
> **Priorite :** HAUTE - Impact direct sur la maintenabilite
|
||||
|
||||
### 2.1 Refactorer les 3 Audit Subscribers en un seul generique
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichiers concernes :**
|
||||
- `src/EventSubscriber/ProductAuditSubscriber.php` (298 LOC)
|
||||
- `src/EventSubscriber/PieceAuditSubscriber.php` (300 LOC)
|
||||
- `src/EventSubscriber/ComposantAuditSubscriber.php` (300 LOC)
|
||||
- **Probleme :** ~900 LOC dupliquees a ~95%. Les methodes `onFlush()`, `buildDiffFromChangeSet()`, `resolveActorProfileId()`, `mergeDiffs()`, `normalizeCollection()` sont identiques. Seules les methodes `snapshot*()` different legerement.
|
||||
- **Action :**
|
||||
1. Creer un `AbstractAuditSubscriber` ou un `GenericAuditSubscriber` parametrable
|
||||
2. Extraire la logique commune (onFlush, buildDiff, resolveActor, mergeDiffs, normalizeCollection)
|
||||
3. Utiliser un systeme de configuration par entite (map `entityClass => entityType + snapshotMethod`)
|
||||
4. Supprimer les 3 fichiers redondants
|
||||
5. Verifier que l'audit fonctionne toujours sur Product, Piece et Composant
|
||||
- **Agent :** -
|
||||
- **Notes :** Tester manuellement les logs d'audit apres refacto.
|
||||
|
||||
### 2.2 Extraire un CuidGenerator utilitaire
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichiers concernes :** 18 entites contenant `generateCuid()` en prive
|
||||
- **Probleme :** Methode `generateCuid()` dupliquee dans chaque entite. De plus, `AuditLog.php` utilise une variante differente (base_convert).
|
||||
- **Action :**
|
||||
1. Creer `src/Util/CuidGenerator.php` avec une methode statique `generate(): string`
|
||||
2. Uniformiser l'implementation (choisir une seule methode)
|
||||
3. Remplacer tous les appels dans les 18 entites
|
||||
4. Supprimer les methodes privees devenues inutiles
|
||||
- **Agent :** -
|
||||
- **Notes :** Attention a l'inconsistance entre AuditLog et les autres entites.
|
||||
|
||||
### 2.3 Factoriser la logique de liaison dans MachineSkeletonController
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichier :** `src/Controller/MachineSkeletonController.php` (756 LOC)
|
||||
- **Probleme :** Les methodes `applyComponentLinks()`, `applyPieceLinks()`, `applyProductLinks()` sont quasi identiques (~90 LOC chacune).
|
||||
- **Action :**
|
||||
1. Extraire une methode generique `applyLinks(Machine $machine, array $links, string $type)`
|
||||
2. Parametrer par le type d'entite liee (Composant, Piece, Product)
|
||||
3. Reduire le controller a ~400 LOC max
|
||||
- **Agent :** -
|
||||
- **Notes :** -
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 - Restructuration des controllers
|
||||
|
||||
> **Priorite :** MOYENNE - Amelioration de la lisibilite et maintenabilite
|
||||
|
||||
### 3.1 Decouper MachineSkeletonController
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichier :** `src/Controller/MachineSkeletonController.php` (756 LOC)
|
||||
- **Action :**
|
||||
1. Extraire la logique metier dans un `MachineSkeletonService`
|
||||
2. Le controller ne doit gerer que la requete/reponse HTTP
|
||||
3. Le service gere la logique de skeleton (get, update, applyLinks)
|
||||
4. Extraire les helpers (`resolveIdentifier`, `indexLinksById`, `applyOverrides`, `normalizeMachineSkeletonResponse`) dans le service
|
||||
- **Agent :** -
|
||||
- **Notes :** Depend de la phase 2.3 (factorisation des liens).
|
||||
|
||||
### 3.2 Ajouter un try-catch et du logging dans les controllers
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichiers :** Tous les controllers dans `src/Controller/`
|
||||
- **Probleme :** Aucun try-catch autour des `flush()` et `persist()`. Pas de logging d'erreurs.
|
||||
- **Action :**
|
||||
1. Ajouter `try-catch` autour des operations Doctrine dans chaque controller
|
||||
2. Logger les erreurs avec le `LoggerInterface` de Symfony (Monolog)
|
||||
3. Retourner des reponses JSON coherentes en cas d'erreur serveur (500)
|
||||
- **Agent :** -
|
||||
- **Notes :** -
|
||||
|
||||
### 3.3 Renforcer la validation des entrees
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichiers :**
|
||||
- `src/Controller/CustomFieldValueController.php`
|
||||
- `src/Controller/MachineSkeletonController.php`
|
||||
- **Probleme :** Pas de validation de longueur max, pas de regex sur les IDs, pas de controle de profondeur JSON.
|
||||
- **Action :**
|
||||
1. Valider le format des IDs (regex CUID : `/^cl[a-f0-9]{24}$/`)
|
||||
2. Ajouter des limites de longueur sur les champs string
|
||||
3. Utiliser le composant Validator de Symfony pour les DTOs si pertinent
|
||||
- **Agent :** -
|
||||
- **Notes :** -
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 - Amelioration du stockage
|
||||
|
||||
> **Priorite :** MOYENNE - Performance et scalabilite
|
||||
|
||||
### 4.1 Migrer le stockage PDF de base64 vers le filesystem
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichiers :**
|
||||
- `src/Entity/Document.php`
|
||||
- `src/Command/CompressPdfCommand.php`
|
||||
- `src/Service/PdfCompressorService.php`
|
||||
- **Probleme :** Les PDFs sont stockes en base64 dans la colonne `path` (TEXT) de la BDD. Risque de DoS et mauvaise perf sur des gros fichiers.
|
||||
- **Action :**
|
||||
1. Utiliser `vich/uploader-bundle` (deja installe) pour le stockage fichier
|
||||
2. Configurer un repertoire de stockage (`var/uploads/documents/`)
|
||||
3. Migrer les documents existants (script de migration)
|
||||
4. Adapter `PdfCompressorService` pour lire/ecrire sur le filesystem
|
||||
5. Mettre a jour l'entite Document
|
||||
- **Agent :** -
|
||||
- **Notes :** Prevoir une migration de donnees pour les documents existants.
|
||||
|
||||
### 4.2 Corriger les types de prix (string -> decimal)
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichiers :**
|
||||
- `src/Entity/Machine.php` (`$prix`)
|
||||
- `src/Entity/Product.php` (`$supplierPrice`)
|
||||
- **Probleme :** Les prix sont types `?string` en PHP alors que la colonne est `DECIMAL(10,2)` en BDD.
|
||||
- **Action :**
|
||||
1. Changer le type PHP en `?float` ou utiliser `brick/money`
|
||||
2. Adapter les getters/setters
|
||||
3. Verifier la serialisation API Platform
|
||||
- **Agent :** -
|
||||
- **Notes :** Impact potentiel sur le frontend (format des nombres).
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 - Utilisation du Process Component
|
||||
|
||||
> **Priorite :** BASSE - Bonne pratique
|
||||
|
||||
### 5.1 Remplacer exec() par Symfony Process
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichiers :**
|
||||
- `src/Command/CompressPdfCommand.php` (lignes 42, 98-101)
|
||||
- `src/Service/PdfCompressorService.php` (lignes 37-41)
|
||||
- **Probleme :** Utilisation de `exec()` directe pour appeler `qpdf`.
|
||||
- **Action :**
|
||||
1. Remplacer par `Symfony\Component\Process\Process`
|
||||
2. Gerer le timeout et les erreurs proprement
|
||||
3. Tester que la compression fonctionne toujours
|
||||
- **Agent :** -
|
||||
- **Notes :** `escapeshellarg()` est deja utilise, donc pas de faille de securite immediate.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 - Tests
|
||||
|
||||
> **Priorite :** HAUTE - Indispensable avant toute refacto majeure
|
||||
|
||||
### 6.1 Mettre en place les tests unitaires
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichiers a creer :**
|
||||
- `tests/Unit/Util/CuidGeneratorTest.php`
|
||||
- `tests/Unit/Entity/MachineTest.php`
|
||||
- `tests/Unit/Entity/ProductTest.php`
|
||||
- `tests/Unit/Service/PdfCompressorServiceTest.php`
|
||||
- **Action :**
|
||||
1. Tester le CuidGenerator (format, unicite)
|
||||
2. Tester les entites (validation, lifecycle callbacks)
|
||||
3. Tester le PdfCompressorService
|
||||
- **Agent :** -
|
||||
- **Notes :** -
|
||||
|
||||
### 6.2 Mettre en place les tests fonctionnels (API)
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichiers a creer :**
|
||||
- `tests/Functional/Api/MachineTest.php`
|
||||
- `tests/Functional/Api/ProductTest.php`
|
||||
- `tests/Functional/Api/AuthenticationTest.php`
|
||||
- `tests/Functional/Api/MachineSkeletonTest.php`
|
||||
- **Action :**
|
||||
1. Configurer une base de test (SQLite ou PostgreSQL de test)
|
||||
2. Creer des fixtures de test
|
||||
3. Tester les endpoints CRUD
|
||||
4. Tester l'authentification JWT
|
||||
5. Tester les endpoints custom (skeleton, custom fields)
|
||||
- **Agent :** -
|
||||
- **Notes :** Utiliser `ApiTestCase` de API Platform.
|
||||
|
||||
### 6.3 Tests des Audit Subscribers
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichiers a creer :**
|
||||
- `tests/Unit/EventSubscriber/AuditSubscriberTest.php`
|
||||
- **Action :**
|
||||
1. Tester la creation de logs sur insert/update/delete
|
||||
2. Tester le format des diffs et snapshots
|
||||
3. Tester la resolution de l'acteur
|
||||
- **Agent :** -
|
||||
- **Notes :** A faire APRES la phase 2.1 (refacto des subscribers).
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 - Nett oyage et conventions
|
||||
|
||||
> **Priorite :** BASSE - Polish final
|
||||
|
||||
### 7.1 Supprimer les fichiers inutiles
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichiers a verifier :**
|
||||
- `frontend/` (dossier legacy ? vs `Inventory_frontend/`)
|
||||
- `src/ApiResource/` (repertoire vide)
|
||||
- Fichiers SQL a la racine (`backup_v1.0.0.sql`, `data_norm.sql`, `fullasse.sql`, `fulldata.sql`)
|
||||
- **Action :** Confirmer avec l'equipe quels fichiers sont obsoletes et les supprimer.
|
||||
- **Agent :** -
|
||||
- **Notes :** Ne pas supprimer sans validation.
|
||||
|
||||
### 7.2 Uniformiser la gestion des null
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichiers :** Toutes les entites dans `src/Entity/`
|
||||
- **Action :** S'assurer que les types nullable sont coherents entre PHP et la BDD (colonnes NOT NULL vs nullable).
|
||||
- **Agent :** -
|
||||
- **Notes :** -
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
# FRONTEND (`Inventory_frontend/`)
|
||||
|
||||
---
|
||||
|
||||
## Phase F1 - Decoupage des mega-composants (CRITIQUE)
|
||||
|
||||
> **Priorite :** MAXIMALE - Les fichiers actuels sont inmaintenables
|
||||
|
||||
### F1.1 Decouper `machine/[id].vue` (2989 LOC → 219 LOC)
|
||||
|
||||
- **Statut :** `[x]`
|
||||
- **Fichier :** `Inventory_frontend/app/pages/machine/[id].vue`
|
||||
- **Resultat :** Page decomposee en 2 composables + 7 composants. Orchestrateur = 219 LOC.
|
||||
- **Fichiers crees :**
|
||||
- `composables/useMachineDetailData.ts` (1404 LOC) — state + logique metier
|
||||
- `composables/useMachineSkeletonEditor.ts` (843 LOC) — logique skeleton
|
||||
- `components/machine/MachineDetailHeader.vue` (76 LOC)
|
||||
- `components/machine/MachineInfoCard.vue` (185 LOC)
|
||||
- `components/machine/MachineDocumentsCard.vue` (116 LOC)
|
||||
- `components/machine/MachineProductsCard.vue` (62 LOC)
|
||||
- `components/machine/MachineComponentsCard.vue` (53 LOC)
|
||||
- `components/machine/MachinePiecesCard.vue` (34 LOC)
|
||||
- `components/machine/MachineSkeletonSummary.vue` (199 LOC)
|
||||
- **Pattern :** Props + Events (pas de provide/inject). Composables avec injection de dependances (interface Deps).
|
||||
- **Notes :** Typecheck 0 erreurs. Lint OK.
|
||||
|
||||
### F1.2 Decouper `machines/new.vue` (1231 LOC → 196 LOC)
|
||||
|
||||
- **Statut :** `[x]`
|
||||
- **Fichier :** `Inventory_frontend/app/pages/machines/new.vue`
|
||||
- **Resultat :** Page decomposee en 1 composable + 5 composants. Orchestrateur = 196 LOC.
|
||||
- **Fichiers crees :**
|
||||
- `composables/useMachineCreatePage.ts` (460 LOC) — state, entity lookups, options, creation
|
||||
- `components/machine/create/RequirementComponentSelector.vue` (126 LOC)
|
||||
- `components/machine/create/RequirementPieceSelector.vue` (130 LOC)
|
||||
- `components/machine/create/RequirementProductSelector.vue` (142 LOC)
|
||||
- `components/machine/create/MachineCreatePreview.vue` (205 LOC)
|
||||
- `components/machine/create/PreviewRequirementGroup.vue` (59 LOC)
|
||||
- **Pattern :** Props + Events. Composable consolide entity lookups, options, label helpers, creation.
|
||||
- **Notes :** Typecheck 0 erreurs. Lint OK. Corrige aussi un bug F1.1 (defineProps dans mauvais script block de MachineSkeletonSummary.vue).
|
||||
|
||||
### F1.3 Decouper les pages de creation/edition (Piece, Component, Product)
|
||||
|
||||
- **Statut :** `[x]`
|
||||
- **Fichiers :**
|
||||
- `pages/component/create.vue` (1282 LOC)
|
||||
- `pages/component/[id]/edit.vue` (1629 LOC)
|
||||
- `pages/pieces/create.vue` (817 LOC)
|
||||
- `pages/pieces/[id]/edit.vue` (1327 LOC)
|
||||
- `pages/product/[id]/edit.vue` (936 LOC)
|
||||
- **Probleme :** Formulaires monolithiques avec sections multiples (infos generales, fournisseurs, documents, custom fields, etc.).
|
||||
- **Action :**
|
||||
1. Identifier les sections communes entre create/edit (factoriser)
|
||||
2. Extraire chaque section en composant reutilisable :
|
||||
- `EntityFormGeneral.vue` (nom, reference, description)
|
||||
- `EntityFormSuppliers.vue` (constructeurs)
|
||||
- `EntityFormDocuments.vue` (documents)
|
||||
- `EntityFormCustomFields.vue` (champs personnalises)
|
||||
3. Objectif par page : <400 LOC
|
||||
- **Agent :** -
|
||||
- **Notes :** Les formulaires create et edit partagent beaucoup de code. Factoriser.
|
||||
- **Sous-taches :**
|
||||
- [x] F1.3a Extraire `customFieldFormUtils.ts` (duplique dans 5 fichiers)
|
||||
- [x] F1.3b Extraire `documentDisplayUtils.ts` (duplique dans 3 pages edit)
|
||||
- [x] F1.3c Extraire `historyDisplayUtils.ts` (duplique dans 3 pages edit)
|
||||
- [x] F1.3d Rewire les 5 pages create/edit sur les modules extraits
|
||||
- [x] F1.3e Typecheck + commit F1.3 (erreurs F1.3 corrigees, 120 erreurs preexistantes documentees)
|
||||
|
||||
### F1.4 Reduire PieceItem.vue (1588 LOC) et ComponentItem.vue (1336 LOC)
|
||||
|
||||
- **Statut :** `[x]`
|
||||
- **Fichiers :**
|
||||
- `Inventory_frontend/app/components/PieceItem.vue` (1588 → 740 LOC)
|
||||
- `Inventory_frontend/app/components/ComponentItem.vue` (1336 → 585 LOC)
|
||||
- **Probleme :** ~700 LOC de logique dupliquee entre les deux composants (champs personnalises, documents, affichage produit).
|
||||
- **Action realisee :**
|
||||
1. Extraction de la logique pure custom fields dans `shared/utils/entityCustomFieldLogic.ts` (~350 LOC)
|
||||
2. Creation de `composables/useEntityCustomFields.ts` (composable reactif, ~180 LOC)
|
||||
3. Creation de `composables/useEntityDocuments.ts` (CRUD documents + preview, ~120 LOC)
|
||||
4. Creation de `composables/useEntityProductDisplay.ts` (affichage produit, ~100 LOC)
|
||||
5. Import des helpers document depuis `shared/utils/documentDisplayUtils.ts` (existant)
|
||||
6. Rewrite des deux composants pour utiliser les modules partages
|
||||
7. Typecheck 0 erreurs, lint 0 erreurs
|
||||
- **Sous-taches :**
|
||||
- [x] F1.4a Extraire `entityCustomFieldLogic.ts` (fonctions pures)
|
||||
- [x] F1.4b Creer `useEntityCustomFields.ts` (composable reactif)
|
||||
- [x] F1.4c Creer `useEntityDocuments.ts` (composable documents)
|
||||
- [x] F1.4d Creer `useEntityProductDisplay.ts` (composable produit)
|
||||
- [x] F1.4e Rewrite ComponentItem.vue (1336 → 585 LOC, script 900 → 150 LOC)
|
||||
- [x] F1.4f Rewrite PieceItem.vue (1588 → 740 LOC, script 1100 → 255 LOC)
|
||||
- [x] F1.4g Typecheck + lint (0 erreurs)
|
||||
- **Notes :** Les templates restent volumineux (~430-480 LOC) car le contenu UI est dense. Une extraction en sous-composants (DocumentList, ProductDisplay, CustomFieldForm) serait une etape future optionnelle.
|
||||
|
||||
---
|
||||
|
||||
## Phase F2 - Elimination de la duplication frontend
|
||||
|
||||
> **Priorite :** HAUTE - DRY
|
||||
|
||||
### F2.1 Extraire `extractCollection()` dans un utilitaire partage
|
||||
|
||||
- **Statut :** `[x]`
|
||||
- **Fichiers concernes :**
|
||||
- `composables/useSites.ts`
|
||||
- `composables/useProducts.ts`
|
||||
- `composables/usePieces.ts`
|
||||
- `composables/useComposants.ts`
|
||||
- `composables/useMachineTypesApi.js`
|
||||
- `composables/useConstructeurs.ts`
|
||||
- `composables/useDocuments.ts`
|
||||
- `composables/useMachineCreateSelections.ts`
|
||||
- `components/ComponentStructureAssignmentNode.vue`
|
||||
- `components/model-types/ManagementView.vue`
|
||||
- **Probleme :** La fonction `extractCollection()` (parsing `hydra:member` / `member` / `items` / `data` / array) etait dupliquee dans 10 fichiers.
|
||||
- **Action :**
|
||||
1. [x] Creer `shared/utils/apiHelpers.ts` avec `extractCollection<T>()` generique
|
||||
2. [x] Remplacer les 10 implementations locales par un import
|
||||
- **Agent :** -
|
||||
- **Notes :** Gere aussi `items` (utilise par ManagementView.vue). `extractRelationId()` et `normalizeRelationIds()` restent dans `shared/apiRelations.ts` (deja partages).
|
||||
|
||||
### F2.2 Fusionner les 3 composables d'historique
|
||||
|
||||
- **Statut :** `[x]`
|
||||
- **Fichiers concernes :**
|
||||
- `composables/useComponentHistory.ts` (67 → 13 LOC, thin wrapper)
|
||||
- `composables/usePieceHistory.ts` (67 → 13 LOC, thin wrapper)
|
||||
- `composables/useProductHistory.ts` (67 → 13 LOC, thin wrapper)
|
||||
- `composables/useEntityHistory.ts` (NEW, 65 LOC, logique generique)
|
||||
- **Probleme :** 3 fichiers quasi identiques (seul le endpoint differait).
|
||||
- **Action :**
|
||||
1. [x] Creer `composables/useEntityHistory.ts` parametrable par type d'entite
|
||||
2. [x] Reecrire les 3 fichiers specifiques en wrappers backward-compatible
|
||||
- **Agent :** -
|
||||
- **Notes :** Les wrappers preservent l'API existante (types + fonction), aucun consommateur a modifier.
|
||||
|
||||
### F2.3 Factoriser les composables de types (Component/Piece/Product)
|
||||
|
||||
- **Statut :** `[x]`
|
||||
- **Fichiers concernes :**
|
||||
- `composables/useComponentTypes.ts` (165 → 30 LOC, thin wrapper)
|
||||
- `composables/usePieceTypes.ts` (165 → 30 LOC, thin wrapper)
|
||||
- `composables/useProductTypes.ts` (160 → 28 LOC, thin wrapper)
|
||||
- `composables/useEntityTypes.ts` (NEW, 172 LOC, logique generique)
|
||||
- **Probleme :** 3 composables tres similaires pour gerer les categories/types.
|
||||
- **Action :**
|
||||
1. [x] Creer `composables/useEntityTypes.ts` generique (CRUD + singleton state par categorie)
|
||||
2. [x] Reecrire les 3 fichiers specifiques en wrappers avec renommage des champs
|
||||
- **Agent :** -
|
||||
- **Notes :** Les wrappers renomment `types` → `componentTypes`/`pieceTypes`/`productTypes`, preservent `getXxxTypes()` et `isXxxTypeLoading()`. Etat partage via `stateByCategory` map module-level.
|
||||
|
||||
---
|
||||
|
||||
## Phase F3 - Migration TypeScript
|
||||
|
||||
> **Priorite :** HAUTE - Securite du typage
|
||||
|
||||
### F3.1 Definir les types pour les reponses API
|
||||
|
||||
- **Statut :** `[x]` (partiellement — types definis dans chaque composable + `ApiResponse<T>` dans useApi.ts)
|
||||
- **Fichiers :**
|
||||
- `composables/useApi.ts` — `ApiResponse<T>` generique (success/data/error/status)
|
||||
- `composables/useMachines.ts` — `Machine` interface
|
||||
- `composables/useMachineTypesApi.ts` — `MachineType`, `MachineTypeRequirement` interfaces
|
||||
- `composables/useToast.ts` — `Toast`, `ToastType` types
|
||||
- `composables/useProfiles.ts` — `Profile` interface
|
||||
- `composables/useCustomFields.ts` — `CustomFieldValue` interface
|
||||
- **Notes :** Les types sont definis dans chaque composable (colocation). Types entite existants : `Product`, `Piece`, `Composant`, `Constructeur`, `Site`, `Document` dans leurs composables respectifs (.ts). `shared/types/inventory.ts` contient les types de structure de modele.
|
||||
|
||||
### F3.2 Convertir les composables JS en TS
|
||||
|
||||
- **Statut :** `[x]`
|
||||
- **Fichiers convertis (7 fichiers JS → TS) :**
|
||||
- [x] `useToast.js` → `useToast.ts` (72 LOC, types: `Toast`, `ToastType`)
|
||||
- [x] `useProfiles.js` → `useProfiles.ts` (68 LOC, type: `Profile`)
|
||||
- [x] `useProfileSession.js` → `useProfileSession.ts` (85 LOC, importe `Profile`)
|
||||
- [x] `useApi.js` → `useApi.ts` (106 LOC → 120 LOC, types: `ApiResponse<T>`, `ApiCallOptions`, ajout `put()`)
|
||||
- [x] `useCustomFields.js` → `useCustomFields.ts` (105 LOC, type: `CustomFieldValue`)
|
||||
- [x] `useMachineTypesApi.js` → `useMachineTypesApi.ts` (173 → 188 LOC, types: `MachineType`, `MachineTypeRequirement`)
|
||||
- [x] `useMachines.js` → `useMachines.ts` (267 LOC, type: `Machine`, utilise `extractCollection`)
|
||||
- **Fichiers deja TS :** `useProducts.ts`, `usePieces.ts`, `useComposants.ts`, `useConstructeurs.ts`, `useSites.ts`, `useDocuments.ts`
|
||||
- **Fichiers JS restants (deprecated) :** `useComponentModels.js`, `usePieceModels.js` (stubs deprecated, a supprimer)
|
||||
- **Notes :** `ApiResponse<T = any>` par defaut `any` pour backward-compat. Les callers existants fonctionnent sans changement ; le nouveau code peut opt-in strict via `get<MyType>()`.
|
||||
|
||||
### F3.3 Eliminer les `any` restants
|
||||
|
||||
- **Statut :** `[x]`
|
||||
- **Fichiers concernes :**
|
||||
- `components/ProductSelect.vue` — 1 `any` restant (slot template, incompressible)
|
||||
- `components/model-types/ManagementView.vue` — remplace `data?: any` → `Record<string, unknown>`, `error: any` → `error: unknown`, `item: any` → `item: unknown`
|
||||
- `components/ComponentStructureAssignmentNode.vue` — 12 casts `(definition as any).typePiece/typeProduct` elimines grace a l'extension des types
|
||||
- `components/ComponentModelStructureEditor.vue` — `Promise<any>` → `Promise<unknown>`
|
||||
- `components/model-types/ModelTypeForm.vue` — `(incoming as any).description` → cast `Record<string, unknown>`
|
||||
- `shared/types/inventory.ts` — `ComponentModelPiece.typePiece?` et `ComponentModelProduct.typeProduct?` ajoutes, 3 casts `(value as any)` supprimes
|
||||
- **Probleme :** 20+ usages de `any` type identifies.
|
||||
- **Action :** Etendre les interfaces de types pour supporter les formes alternatives de l'API. Remplacer les `any` par `unknown` ou `Record<string, unknown>` la ou possible.
|
||||
- **Agent :** Claude
|
||||
- **Notes :** ~15 casts `any` elimines. Les `Record<string, any>` restants dans ComponentModelStructureEditor sont justifies (manipulation dynamique interne de custom fields). Typecheck 0 erreurs.
|
||||
|
||||
---
|
||||
|
||||
## Phase F4 - Qualite du code frontend
|
||||
|
||||
> **Priorite :** MOYENNE
|
||||
|
||||
### F4.1 Activer les regles ESLint critiques
|
||||
|
||||
- **Statut :** `[x]` DONE
|
||||
- **Fichier :** `Inventory_frontend/eslint.config.mjs`
|
||||
- **Probleme :** Presque toutes les regles etaient desactivees (`no-console: off`, `no-unused-vars: off`, `no-explicit-any: off`).
|
||||
- **Action realisee :**
|
||||
1. [x] Active `@typescript-eslint/no-explicit-any: warn` (526 warnings — amelioration progressive)
|
||||
2. [x] Active `no-console: warn` avec `allow: ['error']` — 0 violations (deja nettoye en F4.2)
|
||||
3. [x] Active `@typescript-eslint/no-unused-vars: warn` avec ignore `^_` — 0 violations (26 corrigees)
|
||||
4. [x] Corrige les 26 violations `no-unused-vars` : imports inutilises supprimes, variables prefixees `_`, destructurations nettoyees
|
||||
- **Agent :** Claude
|
||||
- **Notes :** 16 fichiers modifies. Regles organisees par categorie (vue, console, typescript, formatting). 0 erreurs, 526 warnings `no-explicit-any` restants (warn, pas bloquant).
|
||||
|
||||
### F4.2 Nettoyer les console.log/console.error
|
||||
|
||||
- **Statut :** `[x]` (console.log supprime, console.error conserve)
|
||||
- **Fichiers modifies :** 8 fichiers (useMachineTypesApi.ts, useSites.ts, type/[id].vue, type/edit/[id].vue, TypeEditPieceRequirementsSection.vue, SearchSelect.vue, app.vue)
|
||||
- **Probleme :** 19 appels `console.log` de debug laisses dans le code de production.
|
||||
- **Action :**
|
||||
1. [x] Supprimer les 19 `console.log` de debug (normalizeRequirementList, page loading, route params, etc.)
|
||||
2. [ ] Les 72 `console.error` restants sont conserves (gestion d'erreur legitime). Migration vers un logger centralise a faire en F4.3.
|
||||
- **Agent :** Claude
|
||||
- **Notes :** 0 `console.log/warn/debug/info` restants dans le frontend.
|
||||
|
||||
### F4.3 Centraliser la gestion d'erreurs API
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichier :** `Inventory_frontend/app/composables/useApi.js` (105 LOC)
|
||||
- **Probleme :** Gestion d'erreur basique (juste un toast). Pas de retry, pas d'intercepteur, erreurs silencieuses dans certains composables.
|
||||
- **Action :**
|
||||
1. Ajouter un systeme de retry configurable (1-3 tentatives)
|
||||
2. Centraliser la gestion des erreurs HTTP (401 -> redirect login, 500 -> message explicite)
|
||||
3. Ajouter des intercepteurs request/response
|
||||
4. Uniformiser le pattern dans tous les composables
|
||||
- **Agent :** -
|
||||
- **Notes :** -
|
||||
|
||||
---
|
||||
|
||||
## Phase F5 - Reduire le fichier modelUtils.ts (1017 LOC)
|
||||
|
||||
> **Priorite :** MOYENNE
|
||||
|
||||
### F5.1 Decouper `shared/modelUtils.ts`
|
||||
|
||||
- **Statut :** `[x]`
|
||||
- **Fichier :** `Inventory_frontend/app/shared/modelUtils.ts` (1017 LOC → 37 LOC barrel)
|
||||
- **Probleme :** Fichier utilitaire monolithique de 1017 lignes regroupant toute la logique de manipulation de modeles.
|
||||
- **Action :**
|
||||
1. Identifier les groupes de fonctions (structure, custom fields, requirements, serialization)
|
||||
2. Decouper en 3 modules thematiques :
|
||||
- `shared/model/componentStructure.ts` (~590 LOC) — helpers, sanitize, hydrate, normalize, extract, format pour composants
|
||||
- `shared/model/pieceProductStructure.ts` (~155 LOC) — structure piece/produit (clone, sanitize, hydrate, format)
|
||||
- `shared/model/definitionOverrides.ts` (~50 LOC) — sanitization des overrides de definition
|
||||
3. Re-exporter depuis `shared/modelUtils.ts` (barrel) pour ne pas casser les imports
|
||||
- **Agent :** Claude
|
||||
- **Notes :** 11 fichiers consommateurs inchanges (barrel preserve la retro-compat). Typecheck 0 erreurs.
|
||||
|
||||
---
|
||||
|
||||
## Phase F6 - Tests frontend
|
||||
|
||||
> **Priorite :** HAUTE - Aucun test actuellement
|
||||
|
||||
### F6.1 Configurer Vitest
|
||||
|
||||
- **Statut :** `[x]` DONE
|
||||
- **Fichiers crees :**
|
||||
- `vitest.config.ts` — config Vitest avec happy-dom, alias `~` et `#imports`
|
||||
- `tests/__mocks__/imports.ts` — mock des auto-imports Nuxt (useRuntimeConfig, useRoute, etc.)
|
||||
- `tests/shared/inventory-types.test.ts` — 9 tests smoke (validator, empty structures)
|
||||
- **Action realisee :**
|
||||
1. [x] Installe `vitest`, `@vue/test-utils`, `happy-dom`
|
||||
2. [x] Configure Vitest avec environment happy-dom et resolution d'alias
|
||||
3. [x] Ajoute scripts `test` et `test:watch` dans `package.json`
|
||||
4. [x] Premier test suite : `componentModelStructureValidator` (9 tests, 100% pass)
|
||||
- **Agent :** Claude
|
||||
- **Notes :** `npm test` → 9 tests, 0 failures, <1s. Alias `#imports` pointe vers un mock minimal extensible.
|
||||
|
||||
### F6.2 Tests unitaires des composables
|
||||
|
||||
- **Statut :** `[x]` DONE (base)
|
||||
- **Fichiers crees :**
|
||||
- `tests/shared/apiHelpers.test.ts` — 10 tests (extractCollection, tous formats API)
|
||||
- `tests/shared/modelUtils.test.ts` — 18 tests (isPlainObject, clone, stats, format, piece/product)
|
||||
- `tests/shared/inventory-types.test.ts` — 9 tests (validator, empty structures)
|
||||
- `tests/composables/useToast.test.ts` — 9 tests (add, types, max limit, clear, singleton)
|
||||
- `tests/composables/useConfirm.test.ts` — 8 tests (open, confirm, cancel, options, singleton)
|
||||
- **Action realisee :**
|
||||
1. [x] Teste `extractCollection()` : array, hydra:member, member, items, data, null, undefined
|
||||
2. [x] Teste `useToast` : ajout, types, max 3 toasts, clearAll, removeToast, singleton
|
||||
3. [x] Teste `useConfirm` : open/close, resolve true/false, custom options, singleton state
|
||||
4. [x] Teste `modelUtils` : clone, stats, preview, isPlainObject, piece/product variants
|
||||
5. [x] Teste `componentModelStructureValidator` : valid/invalid, custom fields, subcomponents
|
||||
- **Agent :** Claude
|
||||
- **Notes :** 54 tests, 5 fichiers, 100% pass, <2s. Tests `useApi` et CRUD composables necessitent mock fetch (phase ulterieure).
|
||||
|
||||
### F6.3 Tests de composants
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichiers a creer :**
|
||||
- `tests/components/Pagination.test.ts`
|
||||
- `tests/components/SearchSelect.test.ts`
|
||||
- `tests/components/MachineHeader.test.ts` (apres F1.1)
|
||||
- **Action :**
|
||||
1. Tester les composants communs (Pagination, SearchSelect)
|
||||
2. Tester le rendu conditionnel et les events
|
||||
- **Agent :** -
|
||||
- **Notes :** -
|
||||
|
||||
---
|
||||
|
||||
## Phase F7 - Ameliorations UX/DX
|
||||
|
||||
> **Priorite :** BASSE - Polish
|
||||
|
||||
### F7.1 Reduire le props drilling
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Probleme :** Props passees sur 3+ niveaux (ex: machine data dans les sous-composants).
|
||||
- **Action :**
|
||||
1. Identifier les cas de props drilling >2 niveaux
|
||||
2. Utiliser `provide/inject` ou des composables partages
|
||||
3. Documenter le pattern choisi
|
||||
- **Agent :** -
|
||||
- **Notes :** A traiter apres F1 (decoupage des composants).
|
||||
|
||||
### F7.2 Remplacer `confirm()` natif par des modales DaisyUI
|
||||
|
||||
- **Statut :** `[x]` DONE
|
||||
- **Probleme :** Les confirmations de suppression utilisaient `window.confirm()` (UI native, non-stylee).
|
||||
- **Action realisee :**
|
||||
1. [x] Cree `composables/useConfirm.ts` — composable promise-based avec etat reactif partage
|
||||
2. [x] Cree `components/common/ConfirmModal.vue` — modale DaisyUI teleportee (backdrop blur, btn-error)
|
||||
3. [x] Monte `ConfirmModal` globalement dans `app.vue`
|
||||
4. [x] Remplace les 10 `confirm()` natifs dans 10 fichiers :
|
||||
- `constructeurs.vue`, `profiles/manage.vue`, `ManagementView.vue`
|
||||
- `product-catalog.vue`, `index.vue`, `machines/index.vue`
|
||||
- `machine-skeleton/index.vue`, `pieces-catalog.vue`, `component-catalog.vue`
|
||||
- `useSiteManagement.ts` (composable — import explicite)
|
||||
- **Agent :** Claude
|
||||
- **Notes :** API : `const { confirm } = useConfirm(); const ok = await confirm({ message: '...' })`. Auto-import Nuxt pour les SFC, import explicite pour les composables.
|
||||
|
||||
### F7.3 Nettoyer `app.vue` (861 LOC)
|
||||
|
||||
- **Statut :** `[x]` DONE
|
||||
- **Fichier :** `Inventory_frontend/app/app.vue` (861 → 49 LOC)
|
||||
- **Probleme :** Le fichier racine contenait le layout principal, la navbar (~676 LOC dupliquee mobile/desktop), et du state management.
|
||||
- **Action realisee :**
|
||||
1. Cree `composables/useNavDropdown.ts` (~65 LOC) — gestion etat dropdowns navbar
|
||||
2. Cree `components/layout/AppNavbar.vue` (~310 LOC) — navbar data-driven avec `v-for` eliminant duplication mobile/desktop
|
||||
3. `app.vue` reecrit en orchestrateur minimal (49 LOC) + converti en TypeScript
|
||||
4. Supprime 4 imports d'icones inutilises
|
||||
- **Agent :** Claude
|
||||
- **Notes :** Approche data-driven : liens et groupes definis comme tableaux types (`NavLink[]`, `NavGroup[]`), rendus par `v-for` pour mobile et desktop
|
||||
|
||||
---
|
||||
|
||||
## Ordre d'execution recommande
|
||||
|
||||
```
|
||||
=== BACKEND === === FRONTEND ===
|
||||
|
||||
Phase 6.1 (Tests unitaires) Phase F6.1 (Config Vitest)
|
||||
| |
|
||||
v v
|
||||
Phase 1 (Securite) Phase F1 (Decoupage mega-composants)
|
||||
| |
|
||||
v v
|
||||
Phase 2 (Duplication backend) Phase F2 (Duplication frontend)
|
||||
| |
|
||||
v v
|
||||
Phase 3 (Controllers) Phase F3 (Migration TypeScript)
|
||||
| |
|
||||
v v
|
||||
Phase 6.2 (Tests API) Phase F4 (Qualite code) + Phase F5 (modelUtils)
|
||||
| |
|
||||
v v
|
||||
Phase 4 (Stockage) Phase F6.2-F6.3 (Tests frontend)
|
||||
| |
|
||||
v v
|
||||
Phase 5 + Phase 7 (Nettoyage) Phase F7 (UX/DX polish)
|
||||
|
|
||||
v
|
||||
Phase 6.3 (Tests audit)
|
||||
```
|
||||
|
||||
> Les colonnes backend et frontend peuvent etre executees **en parallele** par des agents differents.
|
||||
|
||||
---
|
||||
|
||||
## Journal des modifications
|
||||
|
||||
| Date | Phase | Tache | Agent | Statut | Notes |
|
||||
| ---------- | ----- | ------------------------- | --------------- | ------- | ---------------------------------------------- |
|
||||
| 2026-02-03 | - | Creation du plan backend | Claude Opus 4.5 | Termine | Analyse initiale backend (7 phases, 17 taches) |
|
||||
| 2026-02-03 | - | Creation du plan frontend | Claude Opus 4.5 | Termine | Analyse frontend (7 phases, 22 taches) |
|
||||
| | | | | | |
|
||||
|
||||
---
|
||||
|
||||
## Commandes de verification
|
||||
|
||||
> **Contexte :** Le backend tourne dans Docker (`docker compose`), le frontend est en local.
|
||||
> Les commandes ci-dessous sont executees **depuis la racine du projet** (`/home/matthieu/dev_malio/Inventory/`).
|
||||
|
||||
### Frontend (Nuxt 3 / Vue 3 / TypeScript)
|
||||
|
||||
| Commande | Description | Quand l'utiliser |
|
||||
| -------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| `npx nuxi typecheck` | Verification des types TypeScript via `vue-tsc` | Apres chaque modification de fichier `.vue` ou `.ts`. C'est la commande principale de validation. |
|
||||
| `npm run lint` | ESLint (config dans `eslint.config.mjs`) | Apres chaque modification pour verifier le style et les erreurs statiques. |
|
||||
| `npm run lint:fix` | ESLint avec auto-fix | Pour corriger automatiquement les erreurs de formatage. |
|
||||
| `npm run build` | Build de production Nuxt (inclut le typecheck) | Avant un commit pour s'assurer que tout compile. Plus lent que `typecheck` seul. |
|
||||
| `npx nuxi prepare` | Regenerer les types auto-generes (`.nuxt/`) | Si les imports auto (composables, components) ne sont pas reconnus par le typecheck. |
|
||||
|
||||
> **Toutes les commandes frontend** sont executees depuis `Inventory_frontend/` :
|
||||
>
|
||||
> ```bash
|
||||
> cd Inventory_frontend && npx nuxi typecheck
|
||||
> ```
|
||||
|
||||
> **Note sur les erreurs pre-existantes :** Il y a ~120 erreurs TypeScript pre-existantes documentees
|
||||
> (anterieures a la refacto). L'objectif est de ne pas en ajouter de nouvelles.
|
||||
> Pour verifier : comparer le nombre d'erreurs avant/apres modification.
|
||||
|
||||
### Backend (Symfony 8 / PHP 8.4)
|
||||
|
||||
| Commande | Description | Quand l'utiliser |
|
||||
| ---------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `vendor/bin/php-cs-fixer fix --dry-run --diff` | Verifie le style PHP (PSR-12 + Symfony) sans modifier | Apres chaque modification PHP. |
|
||||
| `vendor/bin/php-cs-fixer fix` | Corrige automatiquement le style PHP | Avant chaque commit. |
|
||||
| `bin/phpunit` | Lance les tests PHPUnit | Apres chaque modification backend. |
|
||||
| `php bin/console cache:clear` | Vide le cache Symfony | Si des erreurs bizarres apparaissent apres un changement de config. |
|
||||
|
||||
> **Les commandes backend** sont executees **dans le conteneur Docker** :
|
||||
>
|
||||
> ```bash
|
||||
> docker compose exec web vendor/bin/php-cs-fixer fix --dry-run --diff
|
||||
> docker compose exec web bin/phpunit
|
||||
> ```
|
||||
|
||||
### Workflow de verification (checklist par tache)
|
||||
|
||||
```
|
||||
1. Lire les fichiers concernes (AVANT toute modification)
|
||||
2. Effectuer les modifications
|
||||
3. Frontend : npx nuxi typecheck → verifier pas de nouvelles erreurs
|
||||
4. Frontend : npm run lint:fix → corriger le formatage
|
||||
5. Backend : php-cs-fixer fix → corriger le style PHP
|
||||
6. Backend : bin/phpunit → verifier la non-regression
|
||||
7. Commit si tout est OK
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Regles pour les agents
|
||||
|
||||
1. **Avant de commencer une tache :**
|
||||
- Mettre le statut a `[~]` dans ce fichier
|
||||
- Inscrire son nom/ID dans la colonne "Agent"
|
||||
- Lire les fichiers concernes AVANT de modifier quoi que ce soit
|
||||
|
||||
2. **Pendant le travail :**
|
||||
- Ne modifier QUE les fichiers listes dans la tache
|
||||
- Respecter les conventions existantes (PSR-12, strict_types)
|
||||
- Ne pas introduire de nouvelles dependances sans justification
|
||||
- Lancer `php-cs-fixer` apres les modifications
|
||||
|
||||
3. **Apres avoir termine :**
|
||||
- Mettre le statut a `[x]`
|
||||
- Ajouter une entree dans le "Journal des modifications"
|
||||
- Lancer les tests existants (`make test`) pour verifier la non-regression
|
||||
- Decrire brievement les changements effectues dans "Notes"
|
||||
|
||||
4. **En cas de blocage :**
|
||||
- Mettre le statut a `[!]`
|
||||
- Documenter le blocage dans "Notes"
|
||||
- Ne PAS passer a une autre tache sans signaler le blocage
|
||||
|
||||
5. **Regles specifiques au frontend :**
|
||||
- Ecrire en TypeScript (pas de JS pour les nouveaux fichiers)
|
||||
- Pas de `any` - utiliser des types concrets
|
||||
- Pas de `console.log` - utiliser le logger ou `useToast`
|
||||
- Composants Vue : max 400 LOC par fichier
|
||||
- Utiliser les composants DaisyUI existants (pas de CSS custom)
|
||||
- Tester avec Vitest quand la config est en place
|
||||
|
||||
6. **Regles specifiques au backend :**
|
||||
- `declare(strict_types=1)` obligatoire
|
||||
- Respecter PSR-12 + regles Symfony (php-cs-fixer)
|
||||
- Pas de `exec()` direct - utiliser Symfony Process
|
||||
- Tester avec PHPUnit
|
||||
@@ -1,7 +1,9 @@
|
||||
api_platform:
|
||||
title: Hello API Platform
|
||||
version: 1.2.0
|
||||
version: 1.4.0
|
||||
defaults:
|
||||
stateless: false
|
||||
cache_headers:
|
||||
vary: ['Content-Type', 'Authorization', 'Origin']
|
||||
pagination_items_per_page: 30
|
||||
pagination_maximum_items_per_page: 200
|
||||
|
||||
@@ -45,34 +45,17 @@ services:
|
||||
- "${POSTGRES_PORT:-5433}:5432"
|
||||
restart: unless-stopped
|
||||
|
||||
pgadmin:
|
||||
container_name: pgadmin-${DOCKER_APP_NAME}
|
||||
image: dpage/pgadmin4:latest
|
||||
user: root
|
||||
adminer:
|
||||
container_name: adminer-${DOCKER_APP_NAME}
|
||||
image: adminer:latest
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@admin.com}
|
||||
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-admin}
|
||||
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
|
||||
ADMINER_DEFAULT_SERVER: db
|
||||
ADMINER_DESIGN: dracula
|
||||
ports:
|
||||
- "${PGADMIN_PORT:-5050}:80"
|
||||
- "${ADMINER_PORT:-5050}:8080"
|
||||
depends_on:
|
||||
- db
|
||||
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:
|
||||
pg_data:
|
||||
pgadmin_data:
|
||||
|
||||
@@ -6,4 +6,4 @@ POSTGRES_DB=inventory
|
||||
POSTGRES_USER=root
|
||||
POSTGRES_PASSWORD=root
|
||||
POSTGRES_PORT=5432
|
||||
XDEBUG_CLIENT_HOST=host.docker.internal
|
||||
XDEBUG_CLIENT_HOST=host.docker.internal
|
||||
24
frontend/.gitignore
vendored
24
frontend/.gitignore
vendored
@@ -1,24 +0,0 @@
|
||||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -1,75 +0,0 @@
|
||||
# Nuxt Minimal Starter
|
||||
|
||||
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||
|
||||
## Setup
|
||||
|
||||
Make sure to install dependencies:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm install
|
||||
|
||||
# pnpm
|
||||
pnpm install
|
||||
|
||||
# yarn
|
||||
yarn install
|
||||
|
||||
# bun
|
||||
bun install
|
||||
```
|
||||
|
||||
## Development Server
|
||||
|
||||
Start the development server on `http://localhost:3000`:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run dev
|
||||
|
||||
# pnpm
|
||||
pnpm dev
|
||||
|
||||
# yarn
|
||||
yarn dev
|
||||
|
||||
# bun
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Production
|
||||
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run build
|
||||
|
||||
# pnpm
|
||||
pnpm build
|
||||
|
||||
# yarn
|
||||
yarn build
|
||||
|
||||
# bun
|
||||
bun run build
|
||||
```
|
||||
|
||||
Locally preview production build:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run preview
|
||||
|
||||
# pnpm
|
||||
pnpm preview
|
||||
|
||||
# yarn
|
||||
yarn preview
|
||||
|
||||
# bun
|
||||
bun run preview
|
||||
```
|
||||
|
||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||
@@ -1,3 +0,0 @@
|
||||
<template>
|
||||
<NuxtPage/>
|
||||
</template>
|
||||
@@ -1,9 +0,0 @@
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: true },
|
||||
ssr: false,
|
||||
modules: ['@nuxtjs/tailwindcss'],
|
||||
typescript: {
|
||||
strict: true
|
||||
}
|
||||
})
|
||||
11892
frontend/package-lock.json
generated
11892
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare",
|
||||
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"nuxt": "^4.2.2",
|
||||
"vue": "^3.5.26",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center">
|
||||
<h1 class="text-3xl font-bold">Nuxt OK ✅</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
@@ -1,2 +0,0 @@
|
||||
User-Agent: *
|
||||
Disallow:
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
// https://nuxt.com/docs/guide/concepts/typescript
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.server.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.shared.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
87
src/Controller/ActivityLogController.php
Normal file
87
src/Controller/ActivityLogController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace App\Controller;
|
||||
use App\Repository\AuditLogRepository;
|
||||
use App\Repository\ComposantRepository;
|
||||
use App\Repository\ProfileRepository;
|
||||
use DateTimeInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -17,8 +18,7 @@ final class ComposantHistoryController
|
||||
private readonly ComposantRepository $components,
|
||||
private readonly AuditLogRepository $auditLogs,
|
||||
private readonly ProfileRepository $profiles,
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
#[Route('/api/composants/{id}/history', name: 'api_composant_history', methods: ['GET'])]
|
||||
public function __invoke(string $id): JsonResponse
|
||||
@@ -39,11 +39,11 @@ final class ComposantHistoryController
|
||||
))));
|
||||
|
||||
$actorMap = [];
|
||||
if ($actorIds !== []) {
|
||||
if ([] !== $actorIds) {
|
||||
$profiles = $this->profiles->findBy(['id' => $actorIds]);
|
||||
foreach ($profiles as $profile) {
|
||||
$label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
|
||||
if ($label === '') {
|
||||
if ('' === $label) {
|
||||
$label = $profile->getEmail() ?? $profile->getId();
|
||||
}
|
||||
$actorMap[$profile->getId()] = $label;
|
||||
@@ -55,16 +55,16 @@ final class ComposantHistoryController
|
||||
$actorId = $log->getActorProfileId();
|
||||
|
||||
return [
|
||||
'id' => $log->getId(),
|
||||
'action' => $log->getAction(),
|
||||
'createdAt' => $log->getCreatedAt()->format(\DateTimeInterface::ATOM),
|
||||
'actor' => $actorId
|
||||
'id' => $log->getId(),
|
||||
'action' => $log->getAction(),
|
||||
'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
|
||||
'actor' => $actorId
|
||||
? [
|
||||
'id' => $actorId,
|
||||
'id' => $actorId,
|
||||
'label' => $actorMap[$actorId] ?? $actorId,
|
||||
]
|
||||
: null,
|
||||
'diff' => $log->getDiff(),
|
||||
'diff' => $log->getDiff(),
|
||||
'snapshot' => $log->getSnapshot(),
|
||||
];
|
||||
},
|
||||
@@ -77,4 +77,3 @@ final class ComposantHistoryController
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,8 +29,7 @@ class CustomFieldValueController extends AbstractController
|
||||
private readonly ComposantRepository $composantRepository,
|
||||
private readonly PieceRepository $pieceRepository,
|
||||
private readonly ProductRepository $productRepository,
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
#[Route('', name: 'custom_field_values_create', methods: ['POST'])]
|
||||
public function create(Request $request): JsonResponse
|
||||
@@ -80,7 +79,7 @@ class CustomFieldValueController extends AbstractController
|
||||
}
|
||||
|
||||
$existing = $this->customFieldValueRepository->findOneBy([
|
||||
'customField' => $customField,
|
||||
'customField' => $customField,
|
||||
$target['type'] => $target['entity'],
|
||||
]);
|
||||
|
||||
@@ -107,7 +106,7 @@ class CustomFieldValueController extends AbstractController
|
||||
{
|
||||
$target = $this->resolveTarget([
|
||||
'entityType' => $entityType,
|
||||
'entityId' => $entityId,
|
||||
'entityId' => $entityId,
|
||||
]);
|
||||
|
||||
if ($target instanceof JsonResponse) {
|
||||
@@ -173,7 +172,7 @@ class CustomFieldValueController extends AbstractController
|
||||
private function resolveCustomField(array $payload): CustomField|JsonResponse
|
||||
{
|
||||
$customFieldId = isset($payload['customFieldId']) ? trim((string) $payload['customFieldId']) : '';
|
||||
if ($customFieldId !== '') {
|
||||
if ('' !== $customFieldId) {
|
||||
$customField = $this->customFieldRepository->find($customFieldId);
|
||||
if ($customField instanceof CustomField) {
|
||||
return $customField;
|
||||
@@ -183,7 +182,7 @@ class CustomFieldValueController extends AbstractController
|
||||
}
|
||||
|
||||
$customFieldName = isset($payload['customFieldName']) ? trim((string) $payload['customFieldName']) : '';
|
||||
if ($customFieldName === '') {
|
||||
if ('' === $customFieldName) {
|
||||
return $this->json(['success' => false, 'error' => 'customFieldId or customFieldName is required.'], 400);
|
||||
}
|
||||
|
||||
@@ -205,30 +204,31 @@ class CustomFieldValueController extends AbstractController
|
||||
private function resolveTarget(array $payload): array|JsonResponse
|
||||
{
|
||||
$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) {
|
||||
$key = $candidate . 'Id';
|
||||
$key = $candidate.'Id';
|
||||
if (!isset($payload[$key])) {
|
||||
continue;
|
||||
}
|
||||
$entityType = $candidate;
|
||||
$entityId = trim((string) $payload[$key]);
|
||||
$entityId = trim((string) $payload[$key]);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($entityType === '' || $entityId === '') {
|
||||
if ('' === $entityType || '' === $entityId) {
|
||||
return $this->json(['success' => false, 'error' => 'Entity target is missing.'], 400);
|
||||
}
|
||||
|
||||
return match ($entityType) {
|
||||
'machine' => $this->resolveEntity('machine', $entityId, $this->machineRepository),
|
||||
'machine' => $this->resolveEntity('machine', $entityId, $this->machineRepository),
|
||||
'composant' => $this->resolveEntity('composant', $entityId, $this->composantRepository),
|
||||
'piece' => $this->resolveEntity('piece', $entityId, $this->pieceRepository),
|
||||
'product' => $this->resolveEntity('product', $entityId, $this->productRepository),
|
||||
default => $this->json(['success' => false, 'error' => 'Unsupported entity type.'], 400),
|
||||
'piece' => $this->resolveEntity('piece', $entityId, $this->pieceRepository),
|
||||
'product' => $this->resolveEntity('product', $entityId, $this->productRepository),
|
||||
default => $this->json(['success' => false, 'error' => 'Unsupported entity type.'], 400),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -247,15 +247,22 @@ class CustomFieldValueController extends AbstractController
|
||||
switch ($type) {
|
||||
case 'machine':
|
||||
$value->setMachine($entity);
|
||||
|
||||
break;
|
||||
|
||||
case 'composant':
|
||||
$value->setComposant($entity);
|
||||
|
||||
break;
|
||||
|
||||
case 'piece':
|
||||
$value->setPiece($entity);
|
||||
|
||||
break;
|
||||
|
||||
case 'product':
|
||||
$value->setProduct($entity);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -265,23 +272,23 @@ class CustomFieldValueController extends AbstractController
|
||||
$customField = $value->getCustomField();
|
||||
|
||||
return [
|
||||
'id' => $value->getId(),
|
||||
'value' => $value->getValue(),
|
||||
'id' => $value->getId(),
|
||||
'value' => $value->getValue(),
|
||||
'customFieldId' => $customField->getId(),
|
||||
'customField' => [
|
||||
'id' => $customField->getId(),
|
||||
'name' => $customField->getName(),
|
||||
'type' => $customField->getType(),
|
||||
'required' => $customField->isRequired(),
|
||||
'options' => $customField->getOptions(),
|
||||
'customField' => [
|
||||
'id' => $customField->getId(),
|
||||
'name' => $customField->getName(),
|
||||
'type' => $customField->getType(),
|
||||
'required' => $customField->isRequired(),
|
||||
'options' => $customField->getOptions(),
|
||||
'orderIndex' => $customField->getOrderIndex(),
|
||||
],
|
||||
'machineId' => $value->getMachine()?->getId(),
|
||||
'machineId' => $value->getMachine()?->getId(),
|
||||
'composantId' => $value->getComposant()?->getId(),
|
||||
'pieceId' => $value->getPiece()?->getId(),
|
||||
'productId' => $value->getProduct()?->getId(),
|
||||
'createdAt' => $value->getCreatedAt()->format(DATE_ATOM),
|
||||
'updatedAt' => $value->getUpdatedAt()->format(DATE_ATOM),
|
||||
'pieceId' => $value->getPiece()?->getId(),
|
||||
'productId' => $value->getProduct()?->getId(),
|
||||
'createdAt' => $value->getCreatedAt()->format(DATE_ATOM),
|
||||
'updatedAt' => $value->getUpdatedAt()->format(DATE_ATOM),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,7 @@ class DocumentQueryController extends AbstractController
|
||||
private readonly ComposantRepository $composantRepository,
|
||||
private readonly PieceRepository $pieceRepository,
|
||||
private readonly ProductRepository $productRepository,
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
#[Route('/site/{id}', name: 'documents_by_site', methods: ['GET'])]
|
||||
public function listBySite(string $id): JsonResponse
|
||||
@@ -100,19 +99,19 @@ class DocumentQueryController extends AbstractController
|
||||
{
|
||||
return array_map(static function (Document $document): array {
|
||||
return [
|
||||
'id' => $document->getId(),
|
||||
'name' => $document->getName(),
|
||||
'filename' => $document->getFilename(),
|
||||
'path' => $document->getPath(),
|
||||
'mimeType' => $document->getMimeType(),
|
||||
'size' => $document->getSize(),
|
||||
'siteId' => $document->getSite()?->getId(),
|
||||
'machineId' => $document->getMachine()?->getId(),
|
||||
'id' => $document->getId(),
|
||||
'name' => $document->getName(),
|
||||
'filename' => $document->getFilename(),
|
||||
'path' => $document->getPath(),
|
||||
'mimeType' => $document->getMimeType(),
|
||||
'size' => $document->getSize(),
|
||||
'siteId' => $document->getSite()?->getId(),
|
||||
'machineId' => $document->getMachine()?->getId(),
|
||||
'composantId' => $document->getComposant()?->getId(),
|
||||
'pieceId' => $document->getPiece()?->getId(),
|
||||
'productId' => $document->getProduct()?->getId(),
|
||||
'createdAt' => $document->getCreatedAt()->format(DATE_ATOM),
|
||||
'updatedAt' => $document->getUpdatedAt()->format(DATE_ATOM),
|
||||
'pieceId' => $document->getPiece()?->getId(),
|
||||
'productId' => $document->getProduct()?->getId(),
|
||||
'createdAt' => $document->getCreatedAt()->format(DATE_ATOM),
|
||||
'updatedAt' => $document->getUpdatedAt()->format(DATE_ATOM),
|
||||
];
|
||||
}, $documents);
|
||||
}
|
||||
|
||||
@@ -21,8 +21,7 @@ class MachineCustomFieldsController extends AbstractController
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly MachineRepository $machineRepository,
|
||||
private readonly CustomFieldValueRepository $customFieldValueRepository,
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
#[Route('/{id}/add-custom-fields', name: 'machine_add_custom_fields', methods: ['POST'])]
|
||||
public function addMissingCustomFields(string $id): JsonResponse
|
||||
@@ -42,7 +41,7 @@ class MachineCustomFieldsController extends AbstractController
|
||||
continue;
|
||||
}
|
||||
$existing = $this->customFieldValueRepository->findOneBy([
|
||||
'machine' => $machine,
|
||||
'machine' => $machine,
|
||||
'customField' => $customField,
|
||||
]);
|
||||
if ($existing instanceof CustomFieldValue) {
|
||||
@@ -61,12 +60,12 @@ class MachineCustomFieldsController extends AbstractController
|
||||
$values = $this->customFieldValueRepository->findBy(['machine' => $machine]);
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'machineId' => $machine->getId(),
|
||||
'success' => true,
|
||||
'machineId' => $machine->getId(),
|
||||
'customFieldValues' => array_map(
|
||||
static fn (CustomFieldValue $value) => [
|
||||
'id' => $value->getId(),
|
||||
'value' => $value->getValue(),
|
||||
'id' => $value->getId(),
|
||||
'value' => $value->getValue(),
|
||||
'customFieldId' => $value->getCustomField()->getId(),
|
||||
],
|
||||
$values
|
||||
|
||||
79
src/Controller/MachineHistoryController.php
Normal file
79
src/Controller/MachineHistoryController.php
Normal 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Composant;
|
||||
use App\Entity\CustomField;
|
||||
use App\Entity\Machine;
|
||||
use App\Entity\MachineComponentLink;
|
||||
use App\Entity\MachinePieceLink;
|
||||
@@ -47,8 +48,7 @@ class MachineSkeletonController extends AbstractController
|
||||
private readonly TypeMachineComponentRequirementRepository $componentRequirementRepository,
|
||||
private readonly TypeMachinePieceRequirementRepository $pieceRequirementRepository,
|
||||
private readonly TypeMachineProductRequirementRepository $productRequirementRepository,
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
#[Route('/{id}/skeleton', name: 'machine_skeleton_get', methods: ['GET'])]
|
||||
public function getSkeleton(string $id): JsonResponse
|
||||
@@ -59,8 +59,8 @@ class MachineSkeletonController extends AbstractController
|
||||
}
|
||||
|
||||
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $machine]);
|
||||
$pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $machine]);
|
||||
$productLinks = $this->machineProductLinkRepository->findBy(['machine' => $machine]);
|
||||
$pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $machine]);
|
||||
$productLinks = $this->machineProductLinkRepository->findBy(['machine' => $machine]);
|
||||
|
||||
return $this->json($this->normalizeMachineSkeletonResponse(
|
||||
$machine,
|
||||
@@ -84,8 +84,8 @@ class MachineSkeletonController extends AbstractController
|
||||
}
|
||||
|
||||
$componentLinksPayload = $this->normalizePayloadList($payload['componentLinks'] ?? []);
|
||||
$pieceLinksPayload = $this->normalizePayloadList($payload['pieceLinks'] ?? []);
|
||||
$productLinksPayload = $this->normalizePayloadList($payload['productLinks'] ?? []);
|
||||
$pieceLinksPayload = $this->normalizePayloadList($payload['pieceLinks'] ?? []);
|
||||
$productLinksPayload = $this->normalizePayloadList($payload['productLinks'] ?? []);
|
||||
|
||||
$componentLinks = $this->applyComponentLinks($machine, $componentLinksPayload);
|
||||
if ($componentLinks instanceof JsonResponse) {
|
||||
@@ -117,19 +117,20 @@ class MachineSkeletonController extends AbstractController
|
||||
if (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter($value, static fn ($item) => is_array($item)));
|
||||
}
|
||||
|
||||
private function applyComponentLinks(Machine $machine, array $payload): array|JsonResponse
|
||||
{
|
||||
$existing = $this->indexLinksById($this->machineComponentLinkRepository->findBy(['machine' => $machine]));
|
||||
$keepIds = [];
|
||||
$existing = $this->indexLinksById($this->machineComponentLinkRepository->findBy(['machine' => $machine]));
|
||||
$keepIds = [];
|
||||
$pendingParents = [];
|
||||
$links = [];
|
||||
$links = [];
|
||||
|
||||
foreach ($payload as $entry) {
|
||||
$linkId = $this->resolveIdentifier($entry, ['id', 'linkId']);
|
||||
$link = $linkId && isset($existing[$linkId]) ? $existing[$linkId] : new MachineComponentLink();
|
||||
$link = $linkId && isset($existing[$linkId]) ? $existing[$linkId] : new MachineComponentLink();
|
||||
if (!$linkId) {
|
||||
$linkId = $this->generateCuid();
|
||||
}
|
||||
@@ -167,7 +168,7 @@ class MachineSkeletonController extends AbstractController
|
||||
|
||||
$this->entityManager->persist($link);
|
||||
$links[$linkId] = $link;
|
||||
$keepIds[] = $linkId;
|
||||
$keepIds[] = $linkId;
|
||||
}
|
||||
|
||||
foreach ($pendingParents as $linkId => $parentId) {
|
||||
@@ -190,15 +191,15 @@ class MachineSkeletonController extends AbstractController
|
||||
|
||||
private function applyPieceLinks(Machine $machine, array $payload, array $componentLinks): array|JsonResponse
|
||||
{
|
||||
$existing = $this->indexLinksById($this->machinePieceLinkRepository->findBy(['machine' => $machine]));
|
||||
$existing = $this->indexLinksById($this->machinePieceLinkRepository->findBy(['machine' => $machine]));
|
||||
$componentIndex = $this->indexLinksById($componentLinks);
|
||||
$keepIds = [];
|
||||
$keepIds = [];
|
||||
$pendingParents = [];
|
||||
$links = [];
|
||||
$links = [];
|
||||
|
||||
foreach ($payload as $entry) {
|
||||
$linkId = $this->resolveIdentifier($entry, ['id', 'linkId']);
|
||||
$link = $linkId && isset($existing[$linkId]) ? $existing[$linkId] : new MachinePieceLink();
|
||||
$link = $linkId && isset($existing[$linkId]) ? $existing[$linkId] : new MachinePieceLink();
|
||||
if (!$linkId) {
|
||||
$linkId = $this->generateCuid();
|
||||
}
|
||||
@@ -236,7 +237,7 @@ class MachineSkeletonController extends AbstractController
|
||||
|
||||
$this->entityManager->persist($link);
|
||||
$links[$linkId] = $link;
|
||||
$keepIds[] = $linkId;
|
||||
$keepIds[] = $linkId;
|
||||
}
|
||||
|
||||
foreach ($pendingParents as $linkId => $parentId) {
|
||||
@@ -263,16 +264,16 @@ class MachineSkeletonController extends AbstractController
|
||||
array $componentLinks,
|
||||
array $pieceLinks,
|
||||
): array|JsonResponse {
|
||||
$existing = $this->indexLinksById($this->machineProductLinkRepository->findBy(['machine' => $machine]));
|
||||
$existing = $this->indexLinksById($this->machineProductLinkRepository->findBy(['machine' => $machine]));
|
||||
$componentIndex = $this->indexLinksById($componentLinks);
|
||||
$pieceIndex = $this->indexLinksById($pieceLinks);
|
||||
$keepIds = [];
|
||||
$pieceIndex = $this->indexLinksById($pieceLinks);
|
||||
$keepIds = [];
|
||||
$pendingParents = [];
|
||||
$links = [];
|
||||
$links = [];
|
||||
|
||||
foreach ($payload as $entry) {
|
||||
$linkId = $this->resolveIdentifier($entry, ['id', 'linkId']);
|
||||
$link = $linkId && isset($existing[$linkId]) ? $existing[$linkId] : new MachineProductLink();
|
||||
$link = $linkId && isset($existing[$linkId]) ? $existing[$linkId] : new MachineProductLink();
|
||||
if (!$linkId) {
|
||||
$linkId = $this->generateCuid();
|
||||
}
|
||||
@@ -302,13 +303,13 @@ class MachineSkeletonController extends AbstractController
|
||||
|
||||
$pendingParents[$linkId] = [
|
||||
'parentComponentLinkId' => $this->resolveIdentifier($entry, ['parentComponentLinkId']),
|
||||
'parentPieceLinkId' => $this->resolveIdentifier($entry, ['parentPieceLinkId']),
|
||||
'parentLinkId' => $this->resolveIdentifier($entry, ['parentLinkId']),
|
||||
'parentPieceLinkId' => $this->resolveIdentifier($entry, ['parentPieceLinkId']),
|
||||
'parentLinkId' => $this->resolveIdentifier($entry, ['parentLinkId']),
|
||||
];
|
||||
|
||||
$this->entityManager->persist($link);
|
||||
$links[$linkId] = $link;
|
||||
$keepIds[] = $linkId;
|
||||
$keepIds[] = $linkId;
|
||||
}
|
||||
|
||||
foreach ($pendingParents as $linkId => $parentIds) {
|
||||
@@ -338,26 +339,33 @@ class MachineSkeletonController extends AbstractController
|
||||
array $productLinks,
|
||||
): array {
|
||||
$normalizedComponentLinks = $this->normalizeComponentLinks($componentLinks);
|
||||
$componentIndex = $this->indexNormalizedLinks($normalizedComponentLinks);
|
||||
$normalizedPieceLinks = $this->normalizePieceLinks($pieceLinks);
|
||||
$componentIndex = $this->indexNormalizedLinks($normalizedComponentLinks);
|
||||
$normalizedPieceLinks = $this->normalizePieceLinks($pieceLinks);
|
||||
|
||||
// Build component hierarchy
|
||||
foreach ($normalizedComponentLinks as &$link) {
|
||||
// Build component hierarchy – track which IDs are children
|
||||
$childIds = [];
|
||||
foreach ($normalizedComponentLinks as $link) {
|
||||
$parentId = $link['parentComponentLinkId'] ?? null;
|
||||
if ($parentId && isset($componentIndex[$parentId])) {
|
||||
$componentIndex[$parentId]['childLinks'][] = &$link;
|
||||
$componentIndex[$parentId]['childLinks'][] = $link;
|
||||
$childIds[$link['id']] = true;
|
||||
}
|
||||
}
|
||||
unset($link);
|
||||
|
||||
// Add pieces to components recursively
|
||||
$this->attachPiecesToComponents($componentIndex, $normalizedPieceLinks);
|
||||
|
||||
// Only return root-level components (exclude children already nested)
|
||||
$rootComponents = array_filter(
|
||||
$componentIndex,
|
||||
static fn (array $link) => !isset($childIds[$link['id']]),
|
||||
);
|
||||
|
||||
return [
|
||||
'machine' => $this->normalizeMachine($machine),
|
||||
'componentLinks' => array_values($componentIndex),
|
||||
'pieceLinks' => $normalizedPieceLinks,
|
||||
'productLinks' => $this->normalizeProductLinks($productLinks),
|
||||
'machine' => $this->normalizeMachine($machine),
|
||||
'componentLinks' => array_values($rootComponents),
|
||||
'pieceLinks' => $normalizedPieceLinks,
|
||||
'productLinks' => $this->normalizeProductLinks($productLinks),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -400,26 +408,26 @@ class MachineSkeletonController extends AbstractController
|
||||
|
||||
private function normalizeMachine(Machine $machine): array
|
||||
{
|
||||
$site = $machine->getSite();
|
||||
$site = $machine->getSite();
|
||||
$typeMachine = $machine->getTypeMachine();
|
||||
|
||||
return [
|
||||
'id' => $machine->getId(),
|
||||
'name' => $machine->getName(),
|
||||
'id' => $machine->getId(),
|
||||
'name' => $machine->getName(),
|
||||
'reference' => $machine->getReference(),
|
||||
'prix' => $machine->getPrix(),
|
||||
'siteId' => $site->getId(),
|
||||
'site' => [
|
||||
'id' => $site->getId(),
|
||||
'prix' => $machine->getPrix(),
|
||||
'siteId' => $site->getId(),
|
||||
'site' => [
|
||||
'id' => $site->getId(),
|
||||
'name' => $site->getName(),
|
||||
],
|
||||
'typeMachineId' => $typeMachine?->getId(),
|
||||
'typeMachine' => $typeMachine ? [
|
||||
'id' => $typeMachine->getId(),
|
||||
'name' => $typeMachine->getName(),
|
||||
'category' => $typeMachine->getCategory(),
|
||||
'description' => $typeMachine->getDescription(),
|
||||
'customFields' => $this->normalizeCustomFields($typeMachine->getCustomFields()),
|
||||
'typeMachine' => $typeMachine ? [
|
||||
'id' => $typeMachine->getId(),
|
||||
'name' => $typeMachine->getName(),
|
||||
'category' => $typeMachine->getCategory(),
|
||||
'description' => $typeMachine->getDescription(),
|
||||
'customFields' => $this->normalizeCustomFields($typeMachine->getCustomFields()),
|
||||
'componentRequirements' => $typeMachine->getComponentRequirements()
|
||||
->map(fn (TypeMachineComponentRequirement $req) => $this->normalizeComponentRequirement($req))
|
||||
->toArray(),
|
||||
@@ -430,8 +438,8 @@ class MachineSkeletonController extends AbstractController
|
||||
->map(fn (TypeMachineProductRequirement $req) => $this->normalizeProductRequirement($req))
|
||||
->toArray(),
|
||||
] : null,
|
||||
'constructeurs' => $this->normalizeConstructeurs($machine->getConstructeurs()),
|
||||
'documents' => null,
|
||||
'constructeurs' => $this->normalizeConstructeurs($machine->getConstructeurs()),
|
||||
'documents' => null,
|
||||
'customFieldValues' => null,
|
||||
];
|
||||
}
|
||||
@@ -444,13 +452,13 @@ class MachineSkeletonController extends AbstractController
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'id' => $customField->getId(),
|
||||
'name' => $customField->getName(),
|
||||
'type' => $customField->getType(),
|
||||
'required' => $customField->isRequired(),
|
||||
'options' => $customField->getOptions(),
|
||||
'id' => $customField->getId(),
|
||||
'name' => $customField->getName(),
|
||||
'type' => $customField->getType(),
|
||||
'required' => $customField->isRequired(),
|
||||
'options' => $customField->getOptions(),
|
||||
'defaultValue' => $customField->getDefaultValue(),
|
||||
'orderIndex' => $customField->getOrderIndex(),
|
||||
'orderIndex' => $customField->getOrderIndex(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -460,26 +468,26 @@ class MachineSkeletonController extends AbstractController
|
||||
private function normalizeComponentLinks(array $links): array
|
||||
{
|
||||
return array_map(function (MachineComponentLink $link): array {
|
||||
$composant = $link->getComposant();
|
||||
$requirement = $link->getTypeMachineComponentRequirement();
|
||||
$parentLink = $link->getParentLink();
|
||||
$composant = $link->getComposant();
|
||||
$requirement = $link->getTypeMachineComponentRequirement();
|
||||
$parentLink = $link->getParentLink();
|
||||
$parentRequirementId = $parentLink?->getTypeMachineComponentRequirement()?->getId();
|
||||
|
||||
return [
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'composantId' => $composant->getId(),
|
||||
'composant' => $this->normalizeComposant($composant),
|
||||
'typeMachineComponentRequirementId' => $requirement?->getId(),
|
||||
'typeMachineComponentRequirement' => $requirement ? $this->normalizeComponentRequirement($requirement) : null,
|
||||
'parentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentId' => $parentLink?->getComposant()->getId(),
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'composantId' => $composant->getId(),
|
||||
'composant' => $this->normalizeComposant($composant),
|
||||
'typeMachineComponentRequirementId' => $requirement?->getId(),
|
||||
'typeMachineComponentRequirement' => $requirement ? $this->normalizeComponentRequirement($requirement) : null,
|
||||
'parentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentId' => $parentLink?->getComposant()->getId(),
|
||||
'parentMachineComponentRequirementId' => $parentRequirementId,
|
||||
'overrides' => $this->normalizeOverrides($link),
|
||||
'childLinks' => [],
|
||||
'pieceLinks' => [],
|
||||
'overrides' => $this->normalizeOverrides($link),
|
||||
'childLinks' => [],
|
||||
'pieceLinks' => [],
|
||||
];
|
||||
}, $links);
|
||||
}
|
||||
@@ -487,24 +495,24 @@ class MachineSkeletonController extends AbstractController
|
||||
private function normalizePieceLinks(array $links): array
|
||||
{
|
||||
return array_map(function (MachinePieceLink $link): array {
|
||||
$piece = $link->getPiece();
|
||||
$requirement = $link->getTypeMachinePieceRequirement();
|
||||
$parentLink = $link->getParentLink();
|
||||
$piece = $link->getPiece();
|
||||
$requirement = $link->getTypeMachinePieceRequirement();
|
||||
$parentLink = $link->getParentLink();
|
||||
$parentRequirementId = $parentLink?->getTypeMachineComponentRequirement()?->getId();
|
||||
|
||||
return [
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'pieceId' => $piece->getId(),
|
||||
'piece' => $this->normalizePiece($piece),
|
||||
'typeMachinePieceRequirementId' => $requirement?->getId(),
|
||||
'typeMachinePieceRequirement' => $requirement ? $this->normalizePieceRequirement($requirement) : null,
|
||||
'parentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentId' => $parentLink?->getComposant()->getId(),
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'pieceId' => $piece->getId(),
|
||||
'piece' => $this->normalizePiece($piece),
|
||||
'typeMachinePieceRequirementId' => $requirement?->getId(),
|
||||
'typeMachinePieceRequirement' => $requirement ? $this->normalizePieceRequirement($requirement) : null,
|
||||
'parentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentId' => $parentLink?->getComposant()->getId(),
|
||||
'parentMachineComponentRequirementId' => $parentRequirementId,
|
||||
'overrides' => $this->normalizeOverrides($link),
|
||||
'overrides' => $this->normalizeOverrides($link),
|
||||
];
|
||||
}, $links);
|
||||
}
|
||||
@@ -512,20 +520,20 @@ class MachineSkeletonController extends AbstractController
|
||||
private function normalizeProductLinks(array $links): array
|
||||
{
|
||||
return array_map(function (MachineProductLink $link): array {
|
||||
$product = $link->getProduct();
|
||||
$product = $link->getProduct();
|
||||
$requirement = $link->getTypeMachineProductRequirement();
|
||||
|
||||
return [
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'productId' => $product->getId(),
|
||||
'product' => $this->normalizeProduct($product),
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'productId' => $product->getId(),
|
||||
'product' => $this->normalizeProduct($product),
|
||||
'typeMachineProductRequirementId' => $requirement?->getId(),
|
||||
'typeMachineProductRequirement' => $requirement ? $this->normalizeProductRequirement($requirement) : null,
|
||||
'parentLinkId' => $link->getParentLink()?->getId(),
|
||||
'parentComponentLinkId' => $link->getParentComponentLink()?->getId(),
|
||||
'parentPieceLinkId' => $link->getParentPieceLink()?->getId(),
|
||||
'typeMachineProductRequirement' => $requirement ? $this->normalizeProductRequirement($requirement) : null,
|
||||
'parentLinkId' => $link->getParentLink()?->getId(),
|
||||
'parentComponentLinkId' => $link->getParentComponentLink()?->getId(),
|
||||
'parentPieceLinkId' => $link->getParentPieceLink()?->getId(),
|
||||
];
|
||||
}, $links);
|
||||
}
|
||||
@@ -533,49 +541,49 @@ class MachineSkeletonController extends AbstractController
|
||||
private function normalizeComposant(Composant $composant): array
|
||||
{
|
||||
return [
|
||||
'id' => $composant->getId(),
|
||||
'name' => $composant->getName(),
|
||||
'reference' => $composant->getReference(),
|
||||
'prix' => $composant->getPrix(),
|
||||
'id' => $composant->getId(),
|
||||
'name' => $composant->getName(),
|
||||
'reference' => $composant->getReference(),
|
||||
'prix' => $composant->getPrix(),
|
||||
'typeComposantId' => $composant->getTypeComposant()?->getId(),
|
||||
'typeComposant' => $this->normalizeModelType($composant->getTypeComposant()),
|
||||
'productId' => $composant->getProduct()?->getId(),
|
||||
'product' => $composant->getProduct() ? $this->normalizeProduct($composant->getProduct()) : null,
|
||||
'constructeurs' => $this->normalizeConstructeurs($composant->getConstructeurs()),
|
||||
'documents' => [],
|
||||
'customFields' => [],
|
||||
'typeComposant' => $this->normalizeModelType($composant->getTypeComposant()),
|
||||
'productId' => $composant->getProduct()?->getId(),
|
||||
'product' => $composant->getProduct() ? $this->normalizeProduct($composant->getProduct()) : null,
|
||||
'constructeurs' => $this->normalizeConstructeurs($composant->getConstructeurs()),
|
||||
'documents' => [],
|
||||
'customFields' => [],
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizePiece(Piece $piece): array
|
||||
{
|
||||
return [
|
||||
'id' => $piece->getId(),
|
||||
'name' => $piece->getName(),
|
||||
'reference' => $piece->getReference(),
|
||||
'prix' => $piece->getPrix(),
|
||||
'typePieceId' => $piece->getTypePiece()?->getId(),
|
||||
'typePiece' => $this->normalizeModelType($piece->getTypePiece()),
|
||||
'productId' => $piece->getProduct()?->getId(),
|
||||
'product' => $piece->getProduct() ? $this->normalizeProduct($piece->getProduct()) : null,
|
||||
'id' => $piece->getId(),
|
||||
'name' => $piece->getName(),
|
||||
'reference' => $piece->getReference(),
|
||||
'prix' => $piece->getPrix(),
|
||||
'typePieceId' => $piece->getTypePiece()?->getId(),
|
||||
'typePiece' => $this->normalizeModelType($piece->getTypePiece()),
|
||||
'productId' => $piece->getProduct()?->getId(),
|
||||
'product' => $piece->getProduct() ? $this->normalizeProduct($piece->getProduct()) : null,
|
||||
'constructeurs' => $this->normalizeConstructeurs($piece->getConstructeurs()),
|
||||
'documents' => [],
|
||||
'customFields' => [],
|
||||
'documents' => [],
|
||||
'customFields' => [],
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeProduct(Product $product): array
|
||||
{
|
||||
return [
|
||||
'id' => $product->getId(),
|
||||
'name' => $product->getName(),
|
||||
'reference' => $product->getReference(),
|
||||
'id' => $product->getId(),
|
||||
'name' => $product->getName(),
|
||||
'reference' => $product->getReference(),
|
||||
'supplierPrice' => $product->getSupplierPrice(),
|
||||
'typeProductId' => $product->getTypeProduct()?->getId(),
|
||||
'typeProduct' => $this->normalizeModelType($product->getTypeProduct()),
|
||||
'typeProduct' => $this->normalizeModelType($product->getTypeProduct()),
|
||||
'constructeurs' => $this->normalizeConstructeurs($product->getConstructeurs()),
|
||||
'documents' => [],
|
||||
'customFields' => [],
|
||||
'documents' => [],
|
||||
'customFields' => [],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -586,9 +594,9 @@ class MachineSkeletonController extends AbstractController
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $type->getId(),
|
||||
'name' => $type->getName(),
|
||||
'code' => $type->getCode(),
|
||||
'id' => $type->getId(),
|
||||
'name' => $type->getName(),
|
||||
'code' => $type->getCode(),
|
||||
'category' => $type->getCategory()->value,
|
||||
];
|
||||
}
|
||||
@@ -596,39 +604,39 @@ class MachineSkeletonController extends AbstractController
|
||||
private function normalizeComponentRequirement(TypeMachineComponentRequirement $requirement): array
|
||||
{
|
||||
return [
|
||||
'id' => $requirement->getId(),
|
||||
'label' => $requirement->getLabel(),
|
||||
'minCount' => $requirement->getMinCount(),
|
||||
'maxCount' => $requirement->getMaxCount(),
|
||||
'required' => $requirement->isRequired(),
|
||||
'id' => $requirement->getId(),
|
||||
'label' => $requirement->getLabel(),
|
||||
'minCount' => $requirement->getMinCount(),
|
||||
'maxCount' => $requirement->getMaxCount(),
|
||||
'required' => $requirement->isRequired(),
|
||||
'typeComposantId' => $requirement->getTypeComposant()->getId(),
|
||||
'typeComposant' => $this->normalizeModelType($requirement->getTypeComposant()),
|
||||
'typeComposant' => $this->normalizeModelType($requirement->getTypeComposant()),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizePieceRequirement(TypeMachinePieceRequirement $requirement): array
|
||||
{
|
||||
return [
|
||||
'id' => $requirement->getId(),
|
||||
'label' => $requirement->getLabel(),
|
||||
'minCount' => $requirement->getMinCount(),
|
||||
'maxCount' => $requirement->getMaxCount(),
|
||||
'required' => $requirement->isRequired(),
|
||||
'id' => $requirement->getId(),
|
||||
'label' => $requirement->getLabel(),
|
||||
'minCount' => $requirement->getMinCount(),
|
||||
'maxCount' => $requirement->getMaxCount(),
|
||||
'required' => $requirement->isRequired(),
|
||||
'typePieceId' => $requirement->getTypePiece()->getId(),
|
||||
'typePiece' => $this->normalizeModelType($requirement->getTypePiece()),
|
||||
'typePiece' => $this->normalizeModelType($requirement->getTypePiece()),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeProductRequirement(TypeMachineProductRequirement $requirement): array
|
||||
{
|
||||
return [
|
||||
'id' => $requirement->getId(),
|
||||
'label' => $requirement->getLabel(),
|
||||
'minCount' => $requirement->getMinCount(),
|
||||
'maxCount' => $requirement->getMaxCount(),
|
||||
'required' => $requirement->isRequired(),
|
||||
'id' => $requirement->getId(),
|
||||
'label' => $requirement->getLabel(),
|
||||
'minCount' => $requirement->getMinCount(),
|
||||
'maxCount' => $requirement->getMaxCount(),
|
||||
'required' => $requirement->isRequired(),
|
||||
'typeProductId' => $requirement->getTypeProduct()->getId(),
|
||||
'typeProduct' => $this->normalizeModelType($requirement->getTypeProduct()),
|
||||
'typeProduct' => $this->normalizeModelType($requirement->getTypeProduct()),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -637,8 +645,8 @@ class MachineSkeletonController extends AbstractController
|
||||
$items = [];
|
||||
foreach ($constructeurs as $constructeur) {
|
||||
$items[] = [
|
||||
'id' => $constructeur->getId(),
|
||||
'name' => $constructeur->getName(),
|
||||
'id' => $constructeur->getId(),
|
||||
'name' => $constructeur->getName(),
|
||||
'email' => $constructeur->getEmail(),
|
||||
'phone' => $constructeur->getPhone(),
|
||||
];
|
||||
@@ -649,18 +657,18 @@ class MachineSkeletonController extends AbstractController
|
||||
|
||||
private function normalizeOverrides(object $link): ?array
|
||||
{
|
||||
$name = method_exists($link, 'getNameOverride') ? $link->getNameOverride() : null;
|
||||
$name = method_exists($link, 'getNameOverride') ? $link->getNameOverride() : 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 [
|
||||
'name' => $name,
|
||||
'name' => $name,
|
||||
'reference' => $reference,
|
||||
'prix' => $prix,
|
||||
'prix' => $prix,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -683,12 +691,12 @@ class MachineSkeletonController extends AbstractController
|
||||
|
||||
private function stringOrNull(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
$string = trim((string) $value);
|
||||
|
||||
return $string === '' ? null : $string;
|
||||
return '' === $string ? null : $string;
|
||||
}
|
||||
|
||||
private function resolveIdentifier(array $entry, array $keys): ?string
|
||||
@@ -698,9 +706,10 @@ class MachineSkeletonController extends AbstractController
|
||||
continue;
|
||||
}
|
||||
$value = $entry[$key];
|
||||
if ($value === null || $value === '') {
|
||||
if (null === $value || '' === $value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
@@ -709,6 +718,7 @@ class MachineSkeletonController extends AbstractController
|
||||
|
||||
/**
|
||||
* @param array<array-key, object> $links
|
||||
*
|
||||
* @return array<string, object>
|
||||
*/
|
||||
private function indexLinksById(array $links): array
|
||||
@@ -751,6 +761,6 @@ class MachineSkeletonController extends AbstractController
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl' . bin2hex(random_bytes(12));
|
||||
return 'cl'.bin2hex(random_bytes(12));
|
||||
}
|
||||
}
|
||||
|
||||
55
src/Controller/ModelTypeConversionController.php
Normal file
55
src/Controller/ModelTypeConversionController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace App\Controller;
|
||||
use App\Repository\AuditLogRepository;
|
||||
use App\Repository\PieceRepository;
|
||||
use App\Repository\ProfileRepository;
|
||||
use DateTimeInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -17,8 +18,7 @@ final class PieceHistoryController
|
||||
private readonly PieceRepository $pieces,
|
||||
private readonly AuditLogRepository $auditLogs,
|
||||
private readonly ProfileRepository $profiles,
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
#[Route('/api/pieces/{id}/history', name: 'api_piece_history', methods: ['GET'])]
|
||||
public function __invoke(string $id): JsonResponse
|
||||
@@ -39,11 +39,11 @@ final class PieceHistoryController
|
||||
))));
|
||||
|
||||
$actorMap = [];
|
||||
if ($actorIds !== []) {
|
||||
if ([] !== $actorIds) {
|
||||
$profiles = $this->profiles->findBy(['id' => $actorIds]);
|
||||
foreach ($profiles as $profile) {
|
||||
$label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
|
||||
if ($label === '') {
|
||||
if ('' === $label) {
|
||||
$label = $profile->getEmail() ?? $profile->getId();
|
||||
}
|
||||
$actorMap[$profile->getId()] = $label;
|
||||
@@ -55,16 +55,16 @@ final class PieceHistoryController
|
||||
$actorId = $log->getActorProfileId();
|
||||
|
||||
return [
|
||||
'id' => $log->getId(),
|
||||
'action' => $log->getAction(),
|
||||
'createdAt' => $log->getCreatedAt()->format(\DateTimeInterface::ATOM),
|
||||
'actor' => $actorId
|
||||
'id' => $log->getId(),
|
||||
'action' => $log->getAction(),
|
||||
'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
|
||||
'actor' => $actorId
|
||||
? [
|
||||
'id' => $actorId,
|
||||
'id' => $actorId,
|
||||
'label' => $actorMap[$actorId] ?? $actorId,
|
||||
]
|
||||
: null,
|
||||
'diff' => $log->getDiff(),
|
||||
'diff' => $log->getDiff(),
|
||||
'snapshot' => $log->getSnapshot(),
|
||||
];
|
||||
},
|
||||
@@ -77,4 +77,3 @@ final class PieceHistoryController
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Controller;
|
||||
use App\Repository\AuditLogRepository;
|
||||
use App\Repository\ProductRepository;
|
||||
use App\Repository\ProfileRepository;
|
||||
use DateTimeInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -17,8 +18,7 @@ final class ProductHistoryController
|
||||
private readonly ProductRepository $products,
|
||||
private readonly AuditLogRepository $auditLogs,
|
||||
private readonly ProfileRepository $profiles,
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
#[Route('/api/products/{id}/history', name: 'api_product_history', methods: ['GET'])]
|
||||
public function __invoke(string $id): JsonResponse
|
||||
@@ -39,11 +39,11 @@ final class ProductHistoryController
|
||||
))));
|
||||
|
||||
$actorMap = [];
|
||||
if ($actorIds !== []) {
|
||||
if ([] !== $actorIds) {
|
||||
$profiles = $this->profiles->findBy(['id' => $actorIds]);
|
||||
foreach ($profiles as $profile) {
|
||||
$label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
|
||||
if ($label === '') {
|
||||
if ('' === $label) {
|
||||
$label = $profile->getEmail() ?? $profile->getId();
|
||||
}
|
||||
$actorMap[$profile->getId()] = $label;
|
||||
@@ -55,16 +55,16 @@ final class ProductHistoryController
|
||||
$actorId = $log->getActorProfileId();
|
||||
|
||||
return [
|
||||
'id' => $log->getId(),
|
||||
'action' => $log->getAction(),
|
||||
'createdAt' => $log->getCreatedAt()->format(\DateTimeInterface::ATOM),
|
||||
'actor' => $actorId
|
||||
'id' => $log->getId(),
|
||||
'action' => $log->getAction(),
|
||||
'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
|
||||
'actor' => $actorId
|
||||
? [
|
||||
'id' => $actorId,
|
||||
'id' => $actorId,
|
||||
'label' => $actorMap[$actorId] ?? $actorId,
|
||||
]
|
||||
: null,
|
||||
'diff' => $log->getDiff(),
|
||||
'diff' => $log->getDiff(),
|
||||
'snapshot' => $log->getSnapshot(),
|
||||
];
|
||||
},
|
||||
@@ -77,4 +77,3 @@ final class ProductHistoryController
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
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'])]
|
||||
public function getActiveProfile(Request $request): JsonResponse
|
||||
@@ -32,16 +30,17 @@ final class SessionProfileController
|
||||
$profile = $this->profiles->find($profileId);
|
||||
if (!$profile || !$profile->isActive()) {
|
||||
$session->remove('profileId');
|
||||
|
||||
return new JsonResponse(['message' => 'Profil introuvable ou inactif.'], JsonResponse::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'id' => $profile->getId(),
|
||||
'id' => $profile->getId(),
|
||||
'firstName' => $profile->getFirstName(),
|
||||
'lastName' => $profile->getLastName(),
|
||||
'email' => $profile->getEmail(),
|
||||
'isActive' => $profile->isActive(),
|
||||
'roles' => $profile->getRoles(),
|
||||
'lastName' => $profile->getLastName(),
|
||||
'email' => $profile->getEmail(),
|
||||
'isActive' => $profile->isActive(),
|
||||
'roles' => $profile->getRoles(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -53,7 +52,7 @@ final class SessionProfileController
|
||||
return new JsonResponse(['message' => 'Session indisponible.'], JsonResponse::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$payload = $request->toArray();
|
||||
$payload = $request->toArray();
|
||||
$profileId = $payload['profileId'] ?? null;
|
||||
|
||||
if (!$profileId) {
|
||||
@@ -68,12 +67,12 @@ final class SessionProfileController
|
||||
$session->set('profileId', $profile->getId());
|
||||
|
||||
return new JsonResponse([
|
||||
'id' => $profile->getId(),
|
||||
'id' => $profile->getId(),
|
||||
'firstName' => $profile->getFirstName(),
|
||||
'lastName' => $profile->getLastName(),
|
||||
'email' => $profile->getEmail(),
|
||||
'isActive' => $profile->isActive(),
|
||||
'roles' => $profile->getRoles(),
|
||||
'lastName' => $profile->getLastName(),
|
||||
'email' => $profile->getEmail(),
|
||||
'isActive' => $profile->isActive(),
|
||||
'roles' => $profile->getRoles(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,7 @@ final class SessionProfilesController
|
||||
public function __construct(
|
||||
private readonly ProfileRepository $profiles,
|
||||
private readonly EntityManagerInterface $entityManager
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
#[Route('/api/session/profiles', name: 'api_session_profiles_list', methods: ['GET'])]
|
||||
public function list(): JsonResponse
|
||||
@@ -27,7 +26,8 @@ final class SessionProfilesController
|
||||
->setParameter('active', true)
|
||||
->orderBy('p.firstName', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
->getResult()
|
||||
;
|
||||
|
||||
return new JsonResponse(array_map([$this, 'serializeProfile'], $items));
|
||||
}
|
||||
@@ -35,11 +35,11 @@ final class SessionProfilesController
|
||||
#[Route('/api/session/profiles', name: 'api_session_profiles_create', methods: ['POST'])]
|
||||
public function create(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->toArray();
|
||||
$payload = $request->toArray();
|
||||
$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);
|
||||
}
|
||||
|
||||
@@ -71,10 +71,10 @@ final class SessionProfilesController
|
||||
private function serializeProfile(Profile $profile): array
|
||||
{
|
||||
return [
|
||||
'id' => $profile->getId(),
|
||||
'id' => $profile->getId(),
|
||||
'firstName' => $profile->getFirstName(),
|
||||
'lastName' => $profile->getLastName(),
|
||||
'isActive' => $profile->isActive(),
|
||||
'lastName' => $profile->getLastName(),
|
||||
'isActive' => $profile->isActive(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +33,8 @@ final class AlwaysQuoteStrategy implements QuoteStrategy
|
||||
{
|
||||
$tableName = $platform->quoteSingleIdentifier($class->table['name']);
|
||||
|
||||
if (! empty($class->table['schema'])) {
|
||||
return $platform->quoteSingleIdentifier($class->table['schema']) . '.' . $tableName;
|
||||
if (!empty($class->table['schema'])) {
|
||||
return $platform->quoteSingleIdentifier($class->table['schema']).'.'.$tableName;
|
||||
}
|
||||
|
||||
return $tableName;
|
||||
@@ -56,10 +56,10 @@ final class AlwaysQuoteStrategy implements QuoteStrategy
|
||||
$schema = '';
|
||||
|
||||
if (isset($association->joinTable->schema)) {
|
||||
$schema = $platform->quoteSingleIdentifier($association->joinTable->schema) . '.';
|
||||
$schema = $platform->quoteSingleIdentifier($association->joinTable->schema).'.';
|
||||
}
|
||||
|
||||
return $schema . $platform->quoteSingleIdentifier($association->joinTable->name);
|
||||
return $schema.$platform->quoteSingleIdentifier($association->joinTable->name);
|
||||
}
|
||||
|
||||
public function getJoinColumnName(JoinColumnMapping $joinColumn, ClassMetadata $class, AbstractPlatform $platform): string
|
||||
@@ -82,12 +82,13 @@ final class AlwaysQuoteStrategy implements QuoteStrategy
|
||||
foreach ($class->identifier as $fieldName) {
|
||||
if (isset($class->fieldMappings[$fieldName])) {
|
||||
$quotedColumnNames[] = $this->getColumnName($fieldName, $class, $platform);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$assoc = $class->associationMappings[$fieldName];
|
||||
assert($assoc->isToOneOwningSide());
|
||||
$joinColumns = $assoc->joinColumns;
|
||||
$joinColumns = $assoc->joinColumns;
|
||||
$assocQuotedColumnNames = array_map(
|
||||
static fn (JoinColumnMapping $joinColumn) => $platform->quoteSingleIdentifier($joinColumn->name),
|
||||
$joinColumns,
|
||||
@@ -103,8 +104,8 @@ final class AlwaysQuoteStrategy implements QuoteStrategy
|
||||
string $columnName,
|
||||
int $counter,
|
||||
AbstractPlatform $platform,
|
||||
ClassMetadata|null $class = null,
|
||||
?ClassMetadata $class = null,
|
||||
): string {
|
||||
return $this->getSQLResultCasing($platform, $columnName . '_' . $counter);
|
||||
return $this->getSQLResultCasing($platform, $columnName.'_'.$counter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,11 +49,11 @@ class AuditLog
|
||||
?array $snapshot = null,
|
||||
?string $actorProfileId = null,
|
||||
) {
|
||||
$this->entityType = $entityType;
|
||||
$this->entityId = $entityId;
|
||||
$this->action = $action;
|
||||
$this->diff = $diff;
|
||||
$this->snapshot = $snapshot;
|
||||
$this->entityType = $entityType;
|
||||
$this->entityId = $entityId;
|
||||
$this->action = $action;
|
||||
$this->diff = $diff;
|
||||
$this->snapshot = $snapshot;
|
||||
$this->actorProfileId = $actorProfileId;
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ class AuditLog
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
if ($this->id === null) {
|
||||
if (null === $this->id) {
|
||||
$this->id = $this->generateCuid();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,17 +24,17 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
#[ApiResource(
|
||||
normalizationContext: ['groups' => ['composant:read']],
|
||||
paginationClientItemsPerPage: true,
|
||||
paginationMaximumItemsPerPage: 500
|
||||
paginationMaximumItemsPerPage: 200
|
||||
)]
|
||||
class Composant
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
#[Groups(['composant:read'])]
|
||||
#[Groups(['composant:read', 'document:list'])]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
|
||||
#[Groups(['composant:read'])]
|
||||
#[Groups(['composant:read', 'document:list'])]
|
||||
private string $name;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
||||
@@ -144,7 +144,7 @@ class Composant
|
||||
|
||||
public function setName(string $name): static
|
||||
{
|
||||
$this->name = $name;
|
||||
$this->name = mb_strtoupper(mb_substr($name, 0, 1)).mb_substr($name, 1);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ use Doctrine\ORM\Mapping as ORM;
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiResource(
|
||||
paginationClientItemsPerPage: true,
|
||||
paginationMaximumItemsPerPage: 500
|
||||
paginationMaximumItemsPerPage: 200
|
||||
)]
|
||||
class Constructeur
|
||||
{
|
||||
|
||||
@@ -6,10 +6,12 @@ namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use App\Repository\CustomFieldRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ORM\Entity(repositoryClass: CustomFieldRepository::class)]
|
||||
#[ORM\Table(name: 'custom_fields')]
|
||||
@@ -19,24 +21,30 @@ class CustomField
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255)]
|
||||
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
|
||||
private string $name;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 50)]
|
||||
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
|
||||
private string $type;
|
||||
|
||||
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => false])]
|
||||
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
|
||||
private bool $required = false;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'defaultValue')]
|
||||
private ?string $defaultValue = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON, nullable: true)]
|
||||
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
|
||||
private ?array $options = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0], name: 'orderIndex')]
|
||||
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
|
||||
private int $orderIndex = 0;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: TypeMachine::class, inversedBy: 'customFields')]
|
||||
@@ -62,10 +70,10 @@ class CustomField
|
||||
private Collection $customFieldValues;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private \DateTimeImmutable $updatedAt;
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -75,11 +83,11 @@ class CustomField
|
||||
#[ORM\PrePersist]
|
||||
public function setCreatedAtValue(): void
|
||||
{
|
||||
$now = new \DateTimeImmutable();
|
||||
$now = new DateTimeImmutable();
|
||||
$this->createdAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
|
||||
if ($this->id === null) {
|
||||
if (null === $this->id) {
|
||||
$this->id = $this->generateCuid();
|
||||
}
|
||||
}
|
||||
@@ -87,12 +95,7 @@ class CustomField
|
||||
#[ORM\PreUpdate]
|
||||
public function setUpdatedAtValue(): void
|
||||
{
|
||||
$this->updatedAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl' . bin2hex(random_bytes(12));
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?string
|
||||
@@ -191,13 +194,18 @@ class CustomField
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTimeImmutable
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): \DateTimeImmutable
|
||||
public function getUpdatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl'.bin2hex(random_bytes(12));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use App\Repository\CustomFieldValueRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ORM\Entity(repositoryClass: CustomFieldValueRepository::class)]
|
||||
#[ORM\Table(name: 'custom_field_values')]
|
||||
@@ -17,13 +19,16 @@ class CustomFieldValue
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255)]
|
||||
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
|
||||
private string $value;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: CustomField::class, inversedBy: 'customFieldValues')]
|
||||
#[ORM\JoinColumn(name: 'customFieldId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
|
||||
private CustomField $customField;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Machine::class, inversedBy: 'customFieldValues')]
|
||||
@@ -43,19 +48,21 @@ class CustomFieldValue
|
||||
private ?Product $product = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private \DateTimeImmutable $updatedAt;
|
||||
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
#[ORM\PrePersist]
|
||||
public function setCreatedAtValue(): void
|
||||
{
|
||||
$now = new \DateTimeImmutable();
|
||||
$now = new DateTimeImmutable();
|
||||
$this->createdAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
|
||||
if ($this->id === null) {
|
||||
if (null === $this->id) {
|
||||
$this->id = $this->generateCuid();
|
||||
}
|
||||
}
|
||||
@@ -63,12 +70,7 @@ class CustomFieldValue
|
||||
#[ORM\PreUpdate]
|
||||
public function setUpdatedAtValue(): void
|
||||
{
|
||||
$this->updatedAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl' . bin2hex(random_bytes(12));
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?string
|
||||
@@ -155,13 +157,18 @@ class CustomFieldValue
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTimeImmutable
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): \DateTimeImmutable
|
||||
public function getUpdatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl'.bin2hex(random_bytes(12));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,13 @@ declare(strict_types=1);
|
||||
namespace App\Entity;
|
||||
|
||||
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 DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
@@ -13,68 +19,84 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
#[ORM\Entity(repositoryClass: DocumentRepository::class)]
|
||||
#[ORM\Table(name: 'documents')]
|
||||
#[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
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[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;
|
||||
|
||||
#[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;
|
||||
|
||||
#[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;
|
||||
|
||||
#[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;
|
||||
|
||||
#[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;
|
||||
|
||||
#[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;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Machine::class, inversedBy: 'documents')]
|
||||
#[ORM\JoinColumn(name: 'machineId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
#[Groups(['document:list'])]
|
||||
private ?Machine $machine = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Composant::class, inversedBy: 'documents')]
|
||||
#[ORM\JoinColumn(name: 'composantId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
#[Groups(['document:list'])]
|
||||
private ?Composant $composant = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Piece::class, inversedBy: 'documents')]
|
||||
#[ORM\JoinColumn(name: 'pieceId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
#[Groups(['document:list'])]
|
||||
private ?Piece $piece = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Product::class, inversedBy: 'documents')]
|
||||
#[ORM\JoinColumn(name: 'productId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
#[Groups(['document:list'])]
|
||||
private ?Product $product = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Site::class, inversedBy: 'documents')]
|
||||
#[ORM\JoinColumn(name: 'siteId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
#[Groups(['document:list'])]
|
||||
private ?Site $site = null;
|
||||
|
||||
#[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')]
|
||||
private \DateTimeImmutable $updatedAt;
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
#[ORM\PrePersist]
|
||||
public function setCreatedAtValue(): void
|
||||
{
|
||||
$now = new \DateTimeImmutable();
|
||||
$now = new DateTimeImmutable();
|
||||
$this->createdAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
|
||||
if ($this->id === null) {
|
||||
if (null === $this->id) {
|
||||
$this->id = $this->generateCuid();
|
||||
}
|
||||
}
|
||||
@@ -82,12 +104,7 @@ class Document
|
||||
#[ORM\PreUpdate]
|
||||
public function setUpdatedAtValue(): void
|
||||
{
|
||||
$this->updatedAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl' . bin2hex(random_bytes(12));
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?string
|
||||
@@ -222,13 +239,18 @@ class Document
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTimeImmutable
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): \DateTimeImmutable
|
||||
public function getUpdatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl'.bin2hex(random_bytes(12));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use App\Repository\MachineRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ORM\Entity(repositoryClass: MachineRepository::class)]
|
||||
#[ORM\Table(name: 'machines')]
|
||||
@@ -19,9 +21,11 @@ class Machine
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
#[Groups(['document:list'])]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
|
||||
#[Groups(['document:list'])]
|
||||
private string $name;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
||||
@@ -80,29 +84,29 @@ class Machine
|
||||
private Collection $customFieldValues;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private \DateTimeImmutable $updatedAt;
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->constructeurs = new ArrayCollection();
|
||||
$this->componentLinks = new ArrayCollection();
|
||||
$this->pieceLinks = new ArrayCollection();
|
||||
$this->productLinks = new ArrayCollection();
|
||||
$this->documents = new ArrayCollection();
|
||||
$this->constructeurs = new ArrayCollection();
|
||||
$this->componentLinks = new ArrayCollection();
|
||||
$this->pieceLinks = new ArrayCollection();
|
||||
$this->productLinks = new ArrayCollection();
|
||||
$this->documents = new ArrayCollection();
|
||||
$this->customFieldValues = new ArrayCollection();
|
||||
}
|
||||
|
||||
#[ORM\PrePersist]
|
||||
public function setCreatedAtValue(): void
|
||||
{
|
||||
$now = new \DateTimeImmutable();
|
||||
$now = new DateTimeImmutable();
|
||||
$this->createdAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
|
||||
if ($this->id === null) {
|
||||
if (null === $this->id) {
|
||||
$this->id = $this->generateCuid();
|
||||
}
|
||||
}
|
||||
@@ -110,12 +114,7 @@ class Machine
|
||||
#[ORM\PreUpdate]
|
||||
public function setUpdatedAtValue(): void
|
||||
{
|
||||
$this->updatedAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl' . bin2hex(random_bytes(12));
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?string
|
||||
@@ -238,13 +237,18 @@ class Machine
|
||||
return $this->customFieldValues;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTimeImmutable
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): \DateTimeImmutable
|
||||
public function getUpdatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl'.bin2hex(random_bytes(12));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use App\Repository\MachineComponentLinkRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
@@ -65,26 +66,26 @@ class MachineComponentLink
|
||||
private ?string $prixOverride = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private \DateTimeImmutable $updatedAt;
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->childLinks = new ArrayCollection();
|
||||
$this->pieceLinks = new ArrayCollection();
|
||||
$this->childLinks = new ArrayCollection();
|
||||
$this->pieceLinks = new ArrayCollection();
|
||||
$this->productLinks = new ArrayCollection();
|
||||
}
|
||||
|
||||
#[ORM\PrePersist]
|
||||
public function setCreatedAtValue(): void
|
||||
{
|
||||
$now = new \DateTimeImmutable();
|
||||
$now = new DateTimeImmutable();
|
||||
$this->createdAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
|
||||
if ($this->id === null) {
|
||||
if (null === $this->id) {
|
||||
$this->id = $this->generateCuid();
|
||||
}
|
||||
}
|
||||
@@ -92,12 +93,7 @@ class MachineComponentLink
|
||||
#[ORM\PreUpdate]
|
||||
public function setUpdatedAtValue(): void
|
||||
{
|
||||
$this->updatedAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl' . bin2hex(random_bytes(12));
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?string
|
||||
@@ -195,4 +191,9 @@ class MachineComponentLink
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl'.bin2hex(random_bytes(12));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use App\Repository\MachinePieceLinkRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
@@ -53,10 +54,10 @@ class MachinePieceLink
|
||||
private ?string $prixOverride = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private \DateTimeImmutable $updatedAt;
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -66,11 +67,11 @@ class MachinePieceLink
|
||||
#[ORM\PrePersist]
|
||||
public function setCreatedAtValue(): void
|
||||
{
|
||||
$now = new \DateTimeImmutable();
|
||||
$now = new DateTimeImmutable();
|
||||
$this->createdAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
|
||||
if ($this->id === null) {
|
||||
if (null === $this->id) {
|
||||
$this->id = $this->generateCuid();
|
||||
}
|
||||
}
|
||||
@@ -78,12 +79,7 @@ class MachinePieceLink
|
||||
#[ORM\PreUpdate]
|
||||
public function setUpdatedAtValue(): void
|
||||
{
|
||||
$this->updatedAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl' . bin2hex(random_bytes(12));
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?string
|
||||
@@ -181,4 +177,9 @@ class MachinePieceLink
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl'.bin2hex(random_bytes(12));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use App\Repository\MachineProductLinkRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
@@ -52,10 +53,10 @@ class MachineProductLink
|
||||
private ?MachinePieceLink $parentPieceLink = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private \DateTimeImmutable $updatedAt;
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -65,11 +66,11 @@ class MachineProductLink
|
||||
#[ORM\PrePersist]
|
||||
public function setCreatedAtValue(): void
|
||||
{
|
||||
$now = new \DateTimeImmutable();
|
||||
$now = new DateTimeImmutable();
|
||||
$this->createdAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
|
||||
if ($this->id === null) {
|
||||
if (null === $this->id) {
|
||||
$this->id = $this->generateCuid();
|
||||
}
|
||||
}
|
||||
@@ -77,12 +78,7 @@ class MachineProductLink
|
||||
#[ORM\PreUpdate]
|
||||
public function setUpdatedAtValue(): void
|
||||
{
|
||||
$this->updatedAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl' . bin2hex(random_bytes(12));
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?string
|
||||
@@ -168,4 +164,9 @@ class MachineProductLink
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl'.bin2hex(random_bytes(12));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
@@ -21,9 +22,10 @@ use Symfony\Component\Serializer\Annotation\Groups;
|
||||
#[ORM\UniqueConstraint(name: 'unique_category_name', columns: ['category', 'name'])]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['category' => 'exact', 'name' => 'ipartial'])]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])]
|
||||
#[ApiResource(
|
||||
paginationClientItemsPerPage: true,
|
||||
paginationMaximumItemsPerPage: 500
|
||||
paginationMaximumItemsPerPage: 200
|
||||
)]
|
||||
class ModelType
|
||||
{
|
||||
@@ -178,7 +180,7 @@ class ModelType
|
||||
|
||||
public function setName(string $name): static
|
||||
{
|
||||
$this->name = $name;
|
||||
$this->name = mb_strtoupper(mb_substr($name, 0, 1)).mb_substr($name, 1);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -24,17 +24,17 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
#[ApiResource(
|
||||
normalizationContext: ['groups' => ['piece:read']],
|
||||
paginationClientItemsPerPage: true,
|
||||
paginationMaximumItemsPerPage: 500
|
||||
paginationMaximumItemsPerPage: 200
|
||||
)]
|
||||
class Piece
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
#[Groups(['piece:read'])]
|
||||
#[Groups(['piece:read', 'document:list'])]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
|
||||
#[Groups(['piece:read'])]
|
||||
#[Groups(['piece:read', 'document:list'])]
|
||||
private string $name;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
||||
|
||||
@@ -24,17 +24,17 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
#[ApiResource(
|
||||
normalizationContext: ['groups' => ['product:read']],
|
||||
paginationClientItemsPerPage: true,
|
||||
paginationMaximumItemsPerPage: 500
|
||||
paginationMaximumItemsPerPage: 200
|
||||
)]
|
||||
class Product
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
#[Groups(['product:read'])]
|
||||
#[Groups(['product:read', 'document:list'])]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
|
||||
#[Groups(['product:read'])]
|
||||
#[Groups(['product:read', 'document:list'])]
|
||||
private string $name;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
||||
|
||||
@@ -16,6 +16,7 @@ use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity(repositoryClass: SiteRepository::class)]
|
||||
@@ -30,16 +31,18 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
new Delete(),
|
||||
],
|
||||
paginationClientItemsPerPage: true,
|
||||
paginationMaximumItemsPerPage: 500
|
||||
paginationMaximumItemsPerPage: 200
|
||||
)]
|
||||
class Site
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
#[Groups(['document:list'])]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255)]
|
||||
#[Assert\NotBlank]
|
||||
#[Groups(['document:list'])]
|
||||
private string $name;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, options: ['default' => ''], name: 'contactName')]
|
||||
|
||||
@@ -34,7 +34,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
new Delete(),
|
||||
],
|
||||
paginationClientItemsPerPage: true,
|
||||
paginationMaximumItemsPerPage: 500
|
||||
paginationMaximumItemsPerPage: 200
|
||||
)]
|
||||
class TypeMachine
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Entity;
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use App\Repository\TypeMachineComponentRequirementRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
@@ -65,10 +66,10 @@ class TypeMachineComponentRequirement
|
||||
private Collection $machineComponentLinks;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private \DateTimeImmutable $updatedAt;
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -78,11 +79,11 @@ class TypeMachineComponentRequirement
|
||||
#[ORM\PrePersist]
|
||||
public function setCreatedAtValue(): void
|
||||
{
|
||||
$now = new \DateTimeImmutable();
|
||||
$now = new DateTimeImmutable();
|
||||
$this->createdAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
|
||||
if ($this->id === null) {
|
||||
if (null === $this->id) {
|
||||
$this->id = $this->generateCuid();
|
||||
}
|
||||
}
|
||||
@@ -90,12 +91,7 @@ class TypeMachineComponentRequirement
|
||||
#[ORM\PreUpdate]
|
||||
public function setUpdatedAtValue(): void
|
||||
{
|
||||
$this->updatedAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl' . bin2hex(random_bytes(12));
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?string
|
||||
@@ -205,4 +201,9 @@ class TypeMachineComponentRequirement
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl'.bin2hex(random_bytes(12));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Entity;
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use App\Repository\TypeMachinePieceRequirementRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
@@ -65,10 +66,10 @@ class TypeMachinePieceRequirement
|
||||
private Collection $machinePieceLinks;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private \DateTimeImmutable $updatedAt;
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -78,11 +79,11 @@ class TypeMachinePieceRequirement
|
||||
#[ORM\PrePersist]
|
||||
public function setCreatedAtValue(): void
|
||||
{
|
||||
$now = new \DateTimeImmutable();
|
||||
$now = new DateTimeImmutable();
|
||||
$this->createdAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
|
||||
if ($this->id === null) {
|
||||
if (null === $this->id) {
|
||||
$this->id = $this->generateCuid();
|
||||
}
|
||||
}
|
||||
@@ -90,12 +91,7 @@ class TypeMachinePieceRequirement
|
||||
#[ORM\PreUpdate]
|
||||
public function setUpdatedAtValue(): void
|
||||
{
|
||||
$this->updatedAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl' . bin2hex(random_bytes(12));
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?string
|
||||
@@ -205,4 +201,9 @@ class TypeMachinePieceRequirement
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl'.bin2hex(random_bytes(12));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Entity;
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use App\Repository\TypeMachineProductRequirementRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
@@ -65,10 +66,10 @@ class TypeMachineProductRequirement
|
||||
private Collection $machineProductLinks;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private \DateTimeImmutable $updatedAt;
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -78,11 +79,11 @@ class TypeMachineProductRequirement
|
||||
#[ORM\PrePersist]
|
||||
public function setCreatedAtValue(): void
|
||||
{
|
||||
$now = new \DateTimeImmutable();
|
||||
$now = new DateTimeImmutable();
|
||||
$this->createdAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
|
||||
if ($this->id === null) {
|
||||
if (null === $this->id) {
|
||||
$this->id = $this->generateCuid();
|
||||
}
|
||||
}
|
||||
@@ -90,12 +91,7 @@ class TypeMachineProductRequirement
|
||||
#[ORM\PreUpdate]
|
||||
public function setUpdatedAtValue(): void
|
||||
{
|
||||
$this->updatedAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl' . bin2hex(random_bytes(12));
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?string
|
||||
@@ -205,4 +201,9 @@ class TypeMachineProductRequirement
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl'.bin2hex(random_bytes(12));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,6 @@ namespace App\Enum;
|
||||
enum ModelCategory: string
|
||||
{
|
||||
case COMPONENT = 'COMPONENT';
|
||||
case PIECE = 'PIECE';
|
||||
case PRODUCT = 'PRODUCT';
|
||||
case PIECE = 'PIECE';
|
||||
case PRODUCT = 'PRODUCT';
|
||||
}
|
||||
|
||||
@@ -6,8 +6,11 @@ namespace App\EventSubscriber;
|
||||
|
||||
use App\Entity\AuditLog;
|
||||
use App\Entity\Composant;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\ModelType;
|
||||
use App\Entity\Product;
|
||||
use App\Entity\Profile;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\Common\EventSubscriber;
|
||||
@@ -15,15 +18,24 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Event\OnFlushEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
use Doctrine\ORM\PersistentCollection;
|
||||
use Doctrine\ORM\UnitOfWork;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
use Throwable;
|
||||
|
||||
use function is_array;
|
||||
use function is_object;
|
||||
use function is_scalar;
|
||||
use function method_exists;
|
||||
|
||||
#[AsDoctrineListener(event: Events::onFlush)]
|
||||
final class ComposantAuditSubscriber implements EventSubscriber
|
||||
{
|
||||
public function __construct(private readonly RequestStack $requestStack)
|
||||
{
|
||||
}
|
||||
public function __construct(
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function getSubscribedEvents(): array
|
||||
{
|
||||
@@ -39,10 +51,10 @@ final class ComposantAuditSubscriber implements EventSubscriber
|
||||
return;
|
||||
}
|
||||
|
||||
$uow = $em->getUnitOfWork();
|
||||
$actorProfileId = $this->resolveActorProfileId();
|
||||
$pendingUpdates = [];
|
||||
$pendingSnapshots = [];
|
||||
$uow = $em->getUnitOfWork();
|
||||
$actorProfileId = $this->resolveActorProfileId();
|
||||
$pendingUpdates = [];
|
||||
$pendingSnapshots = [];
|
||||
$pendingComponents = [];
|
||||
|
||||
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
||||
@@ -50,7 +62,7 @@ final class ComposantAuditSubscriber implements EventSubscriber
|
||||
continue;
|
||||
}
|
||||
|
||||
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
||||
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
||||
$snapshot = $this->snapshotComposant($entity);
|
||||
$this->persistAuditLog($em, new AuditLog('composant', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
|
||||
}
|
||||
@@ -61,14 +73,14 @@ final class ComposantAuditSubscriber implements EventSubscriber
|
||||
}
|
||||
|
||||
$componentId = (string) $entity->getId();
|
||||
if ($componentId === '') {
|
||||
if ('' === $componentId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
||||
if ($diff !== []) {
|
||||
$pendingUpdates[$componentId] = $this->mergeDiffs($pendingUpdates[$componentId] ?? [], $diff);
|
||||
$pendingSnapshots[$componentId] = $this->snapshotComposant($entity);
|
||||
if ([] !== $diff) {
|
||||
$pendingUpdates[$componentId] = $this->mergeDiffs($pendingUpdates[$componentId] ?? [], $diff);
|
||||
$pendingSnapshots[$componentId] = $this->snapshotComposant($entity);
|
||||
$pendingComponents[$componentId] = $entity;
|
||||
}
|
||||
}
|
||||
@@ -89,8 +101,10 @@ final class ComposantAuditSubscriber implements EventSubscriber
|
||||
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingComponents);
|
||||
}
|
||||
|
||||
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingComponents);
|
||||
|
||||
foreach ($pendingUpdates as $componentId => $diff) {
|
||||
if ($diff === []) {
|
||||
if ([] === $diff) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -106,8 +120,8 @@ final class ComposantAuditSubscriber implements EventSubscriber
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
|
||||
* @param array<string, array<string, mixed>> $pendingSnapshots
|
||||
* @param array<string, Composant> $pendingComponents
|
||||
* @param array<string, array<string, mixed>> $pendingSnapshots
|
||||
* @param array<string, Composant> $pendingComponents
|
||||
*/
|
||||
private function collectCollectionUpdate(
|
||||
object $collection,
|
||||
@@ -125,18 +139,18 @@ final class ComposantAuditSubscriber implements EventSubscriber
|
||||
}
|
||||
|
||||
$componentId = (string) $owner->getId();
|
||||
if ($componentId === '') {
|
||||
if ('' === $componentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$mapping = $collection->getMapping();
|
||||
$mapping = $collection->getMapping();
|
||||
$fieldName = $mapping['fieldName'] ?? null;
|
||||
if ($fieldName !== 'constructeurs') {
|
||||
if ('constructeurs' !== $fieldName) {
|
||||
return;
|
||||
}
|
||||
|
||||
$before = $this->normalizeCollection($collection->getSnapshot());
|
||||
$after = $this->normalizeCollection($collection->toArray());
|
||||
$after = $this->normalizeCollection($collection->toArray());
|
||||
|
||||
if ($before === $after) {
|
||||
return;
|
||||
@@ -145,15 +159,84 @@ final class ComposantAuditSubscriber implements EventSubscriber
|
||||
$diff = [
|
||||
'constructeurIds' => [
|
||||
'from' => $before,
|
||||
'to' => $after,
|
||||
'to' => $after,
|
||||
],
|
||||
];
|
||||
|
||||
$pendingUpdates[$componentId] = $this->mergeDiffs($pendingUpdates[$componentId] ?? [], $diff);
|
||||
$pendingSnapshots[$componentId] = $this->snapshotComposant($owner);
|
||||
$pendingUpdates[$componentId] = $this->mergeDiffs($pendingUpdates[$componentId] ?? [], $diff);
|
||||
$pendingSnapshots[$componentId] = $this->snapshotComposant($owner);
|
||||
$pendingComponents[$componentId] = $owner;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
|
||||
* @param array<string, array<string, mixed>> $pendingSnapshots
|
||||
* @param array<string, Composant> $pendingComponents
|
||||
*/
|
||||
private function collectCustomFieldValueChanges(
|
||||
UnitOfWork $uow,
|
||||
array &$pendingUpdates,
|
||||
array &$pendingSnapshots,
|
||||
array &$pendingComponents,
|
||||
): void {
|
||||
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
||||
if ($entity instanceof CustomFieldValue) {
|
||||
$this->trackCustomFieldValueChange($entity, null, $entity->getValue(), $pendingUpdates, $pendingSnapshots, $pendingComponents);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
||||
if (!$entity instanceof CustomFieldValue) {
|
||||
continue;
|
||||
}
|
||||
$changeSet = $uow->getEntityChangeSet($entity);
|
||||
if (!isset($changeSet['value'])) {
|
||||
continue;
|
||||
}
|
||||
[$oldVal, $newVal] = $changeSet['value'];
|
||||
if ($oldVal !== $newVal) {
|
||||
$this->trackCustomFieldValueChange($entity, $oldVal, $newVal, $pendingUpdates, $pendingSnapshots, $pendingComponents);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityDeletions() as $entity) {
|
||||
if ($entity instanceof CustomFieldValue) {
|
||||
$this->trackCustomFieldValueChange($entity, $entity->getValue(), null, $pendingUpdates, $pendingSnapshots, $pendingComponents);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
|
||||
* @param array<string, array<string, mixed>> $pendingSnapshots
|
||||
* @param array<string, Composant> $pendingComponents
|
||||
*/
|
||||
private function trackCustomFieldValueChange(
|
||||
CustomFieldValue $cfv,
|
||||
mixed $from,
|
||||
mixed $to,
|
||||
array &$pendingUpdates,
|
||||
array &$pendingSnapshots,
|
||||
array &$pendingComponents,
|
||||
): void {
|
||||
$owner = $cfv->getComposant();
|
||||
if (!$owner instanceof Composant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ownerId = (string) $owner->getId();
|
||||
if ('' === $ownerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$fieldName = 'customField:'.$cfv->getCustomField()->getName();
|
||||
$diff = [$fieldName => ['from' => $from, 'to' => $to]];
|
||||
|
||||
$pendingUpdates[$ownerId] = $this->mergeDiffs($pendingUpdates[$ownerId] ?? [], $diff);
|
||||
$pendingSnapshots[$ownerId] = $this->snapshotComposant($owner);
|
||||
$pendingComponents[$ownerId] = $owner;
|
||||
}
|
||||
|
||||
private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
|
||||
{
|
||||
$uow = $em->getUnitOfWork();
|
||||
@@ -166,13 +249,14 @@ final class ComposantAuditSubscriber implements EventSubscriber
|
||||
|
||||
/**
|
||||
* @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 ($field === 'updatedAt' || $field === 'createdAt') {
|
||||
if ('updatedAt' === $field || 'createdAt' === $field) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -185,7 +269,7 @@ final class ComposantAuditSubscriber implements EventSubscriber
|
||||
|
||||
$diff[$field] = [
|
||||
'from' => $normalizedOld,
|
||||
'to' => $normalizedNew,
|
||||
'to' => $normalizedNew,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -195,51 +279,57 @@ final class ComposantAuditSubscriber implements EventSubscriber
|
||||
private function snapshotComposant(Composant $component): array
|
||||
{
|
||||
return [
|
||||
'id' => $component->getId(),
|
||||
'name' => $component->getName(),
|
||||
'reference' => $component->getReference(),
|
||||
'prix' => $component->getPrix(),
|
||||
'structure' => $component->getStructure(),
|
||||
'typeComposant' => $this->normalizeValue($component->getTypeComposant()),
|
||||
'product' => $this->normalizeValue($component->getProduct()),
|
||||
'id' => $component->getId(),
|
||||
'name' => $component->getName(),
|
||||
'reference' => $component->getReference(),
|
||||
'prix' => $component->getPrix(),
|
||||
'structure' => $component->getStructure(),
|
||||
'typeComposant' => $this->normalizeValue($component->getTypeComposant()),
|
||||
'product' => $this->normalizeValue($component->getProduct()),
|
||||
'constructeurIds' => $this->normalizeCollection($component->getConstructeurs()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<mixed> $items
|
||||
* @return list<string>
|
||||
*
|
||||
* @return list<array{id: string, name: string}|string>
|
||||
*/
|
||||
private function normalizeCollection(iterable $items): array
|
||||
{
|
||||
$ids = [];
|
||||
$entries = [];
|
||||
$seen = [];
|
||||
foreach ($items as $item) {
|
||||
if (\is_object($item) && \method_exists($item, 'getId')) {
|
||||
if (is_object($item) && method_exists($item, 'getId')) {
|
||||
$id = $item->getId();
|
||||
if ($id !== null && $id !== '') {
|
||||
$ids[] = (string) $id;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort($ids);
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
return $entries;
|
||||
}
|
||||
|
||||
private function normalizeValue(mixed $value): mixed
|
||||
{
|
||||
if ($value === null || \is_scalar($value)) {
|
||||
if (null === $value || is_scalar($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return $value->format(\DateTimeInterface::ATOM);
|
||||
if ($value instanceof DateTimeInterface) {
|
||||
return $value->format(DateTimeInterface::ATOM);
|
||||
}
|
||||
|
||||
if ($value instanceof ModelType) {
|
||||
return [
|
||||
'id' => $value->getId(),
|
||||
'id' => $value->getId(),
|
||||
'name' => $value->getName(),
|
||||
'code' => $value->getCode(),
|
||||
];
|
||||
@@ -247,8 +337,8 @@ final class ComposantAuditSubscriber implements EventSubscriber
|
||||
|
||||
if ($value instanceof Product) {
|
||||
return [
|
||||
'id' => $value->getId(),
|
||||
'name' => $value->getName(),
|
||||
'id' => $value->getId(),
|
||||
'name' => $value->getName(),
|
||||
'reference' => $value->getReference(),
|
||||
];
|
||||
}
|
||||
@@ -257,11 +347,11 @@ final class ComposantAuditSubscriber implements EventSubscriber
|
||||
return $this->normalizeCollection($value);
|
||||
}
|
||||
|
||||
if (\is_object($value) && \method_exists($value, 'getId')) {
|
||||
if (is_object($value) && method_exists($value, 'getId')) {
|
||||
return (string) $value->getId();
|
||||
}
|
||||
|
||||
if (\is_array($value)) {
|
||||
if (is_array($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}> $extra
|
||||
*
|
||||
* @return array<string, array{from:mixed, to:mixed}>
|
||||
*/
|
||||
private function mergeDiffs(array $base, array $extra): array
|
||||
@@ -284,17 +375,23 @@ final class ComposantAuditSubscriber implements EventSubscriber
|
||||
|
||||
private function resolveActorProfileId(): ?string
|
||||
{
|
||||
$session = $this->requestStack->getSession();
|
||||
if (!$session instanceof SessionInterface) {
|
||||
return null;
|
||||
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.)
|
||||
}
|
||||
|
||||
$profileId = $session->get('profileId');
|
||||
if (!$profileId) {
|
||||
return null;
|
||||
$user = $this->security->getUser();
|
||||
if ($user instanceof Profile) {
|
||||
return $user->getId();
|
||||
}
|
||||
|
||||
return (string) $profileId;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
168
src/EventSubscriber/ConstructeurAuditSubscriber.php
Normal file
168
src/EventSubscriber/ConstructeurAuditSubscriber.php
Normal 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;
|
||||
}
|
||||
}
|
||||
192
src/EventSubscriber/DocumentAuditSubscriber.php
Normal file
192
src/EventSubscriber/DocumentAuditSubscriber.php
Normal 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;
|
||||
}
|
||||
}
|
||||
412
src/EventSubscriber/MachineAuditSubscriber.php
Normal file
412
src/EventSubscriber/MachineAuditSubscriber.php
Normal 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;
|
||||
}
|
||||
}
|
||||
180
src/EventSubscriber/ModelTypeAuditSubscriber.php
Normal file
180
src/EventSubscriber/ModelTypeAuditSubscriber.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,12 @@ declare(strict_types=1);
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Entity\AuditLog;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\ModelType;
|
||||
use App\Entity\Piece;
|
||||
use App\Entity\Product;
|
||||
use App\Entity\Profile;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\Common\EventSubscriber;
|
||||
@@ -15,15 +18,24 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Event\OnFlushEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
use Doctrine\ORM\PersistentCollection;
|
||||
use Doctrine\ORM\UnitOfWork;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
use Throwable;
|
||||
|
||||
use function is_array;
|
||||
use function is_object;
|
||||
use function is_scalar;
|
||||
use function method_exists;
|
||||
|
||||
#[AsDoctrineListener(event: Events::onFlush)]
|
||||
final class PieceAuditSubscriber implements EventSubscriber
|
||||
{
|
||||
public function __construct(private readonly RequestStack $requestStack)
|
||||
{
|
||||
}
|
||||
public function __construct(
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function getSubscribedEvents(): array
|
||||
{
|
||||
@@ -39,18 +51,18 @@ final class PieceAuditSubscriber implements EventSubscriber
|
||||
return;
|
||||
}
|
||||
|
||||
$uow = $em->getUnitOfWork();
|
||||
$actorProfileId = $this->resolveActorProfileId();
|
||||
$pendingUpdates = [];
|
||||
$uow = $em->getUnitOfWork();
|
||||
$actorProfileId = $this->resolveActorProfileId();
|
||||
$pendingUpdates = [];
|
||||
$pendingSnapshots = [];
|
||||
$pendingPieces = [];
|
||||
$pendingPieces = [];
|
||||
|
||||
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
||||
if (!$entity instanceof Piece) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
||||
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
||||
$snapshot = $this->snapshotPiece($entity);
|
||||
$this->persistAuditLog($em, new AuditLog('piece', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
|
||||
}
|
||||
@@ -61,15 +73,15 @@ final class PieceAuditSubscriber implements EventSubscriber
|
||||
}
|
||||
|
||||
$pieceId = (string) $entity->getId();
|
||||
if ($pieceId === '') {
|
||||
if ('' === $pieceId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
||||
if ($diff !== []) {
|
||||
$pendingUpdates[$pieceId] = $this->mergeDiffs($pendingUpdates[$pieceId] ?? [], $diff);
|
||||
if ([] !== $diff) {
|
||||
$pendingUpdates[$pieceId] = $this->mergeDiffs($pendingUpdates[$pieceId] ?? [], $diff);
|
||||
$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->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingPieces);
|
||||
|
||||
foreach ($pendingUpdates as $pieceId => $diff) {
|
||||
if ($diff === []) {
|
||||
if ([] === $diff) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -106,8 +120,8 @@ final class PieceAuditSubscriber implements EventSubscriber
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
|
||||
* @param array<string, array<string, mixed>> $pendingSnapshots
|
||||
* @param array<string, Piece> $pendingPieces
|
||||
* @param array<string, array<string, mixed>> $pendingSnapshots
|
||||
* @param array<string, Piece> $pendingPieces
|
||||
*/
|
||||
private function collectCollectionUpdate(
|
||||
object $collection,
|
||||
@@ -125,18 +139,18 @@ final class PieceAuditSubscriber implements EventSubscriber
|
||||
}
|
||||
|
||||
$pieceId = (string) $owner->getId();
|
||||
if ($pieceId === '') {
|
||||
if ('' === $pieceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$mapping = $collection->getMapping();
|
||||
$mapping = $collection->getMapping();
|
||||
$fieldName = $mapping['fieldName'] ?? null;
|
||||
if ($fieldName !== 'constructeurs') {
|
||||
if ('constructeurs' !== $fieldName) {
|
||||
return;
|
||||
}
|
||||
|
||||
$before = $this->normalizeCollection($collection->getSnapshot());
|
||||
$after = $this->normalizeCollection($collection->toArray());
|
||||
$after = $this->normalizeCollection($collection->toArray());
|
||||
|
||||
if ($before === $after) {
|
||||
return;
|
||||
@@ -145,13 +159,82 @@ final class PieceAuditSubscriber implements EventSubscriber
|
||||
$diff = [
|
||||
'constructeurIds' => [
|
||||
'from' => $before,
|
||||
'to' => $after,
|
||||
'to' => $after,
|
||||
],
|
||||
];
|
||||
|
||||
$pendingUpdates[$pieceId] = $this->mergeDiffs($pendingUpdates[$pieceId] ?? [], $diff);
|
||||
$pendingUpdates[$pieceId] = $this->mergeDiffs($pendingUpdates[$pieceId] ?? [], $diff);
|
||||
$pendingSnapshots[$pieceId] = $this->snapshotPiece($owner);
|
||||
$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
|
||||
@@ -166,13 +249,14 @@ final class PieceAuditSubscriber implements EventSubscriber
|
||||
|
||||
/**
|
||||
* @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 ($field === 'updatedAt' || $field === 'createdAt') {
|
||||
if ('updatedAt' === $field || 'createdAt' === $field) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -185,7 +269,7 @@ final class PieceAuditSubscriber implements EventSubscriber
|
||||
|
||||
$diff[$field] = [
|
||||
'from' => $normalizedOld,
|
||||
'to' => $normalizedNew,
|
||||
'to' => $normalizedNew,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -195,51 +279,57 @@ final class PieceAuditSubscriber implements EventSubscriber
|
||||
private function snapshotPiece(Piece $piece): array
|
||||
{
|
||||
return [
|
||||
'id' => $piece->getId(),
|
||||
'name' => $piece->getName(),
|
||||
'reference' => $piece->getReference(),
|
||||
'prix' => $piece->getPrix(),
|
||||
'typePiece' => $this->normalizeValue($piece->getTypePiece()),
|
||||
'product' => $this->normalizeValue($piece->getProduct()),
|
||||
'productIds' => $piece->getProductIds(),
|
||||
'id' => $piece->getId(),
|
||||
'name' => $piece->getName(),
|
||||
'reference' => $piece->getReference(),
|
||||
'prix' => $piece->getPrix(),
|
||||
'typePiece' => $this->normalizeValue($piece->getTypePiece()),
|
||||
'product' => $this->normalizeValue($piece->getProduct()),
|
||||
'productIds' => $piece->getProductIds(),
|
||||
'constructeurIds' => $this->normalizeCollection($piece->getConstructeurs()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<mixed> $items
|
||||
* @return list<string>
|
||||
*
|
||||
* @return list<array{id: string, name: string}|string>
|
||||
*/
|
||||
private function normalizeCollection(iterable $items): array
|
||||
{
|
||||
$ids = [];
|
||||
$entries = [];
|
||||
$seen = [];
|
||||
foreach ($items as $item) {
|
||||
if (\is_object($item) && \method_exists($item, 'getId')) {
|
||||
if (is_object($item) && method_exists($item, 'getId')) {
|
||||
$id = $item->getId();
|
||||
if ($id !== null && $id !== '') {
|
||||
$ids[] = (string) $id;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort($ids);
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
return $entries;
|
||||
}
|
||||
|
||||
private function normalizeValue(mixed $value): mixed
|
||||
{
|
||||
if ($value === null || \is_scalar($value)) {
|
||||
if (null === $value || is_scalar($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return $value->format(\DateTimeInterface::ATOM);
|
||||
if ($value instanceof DateTimeInterface) {
|
||||
return $value->format(DateTimeInterface::ATOM);
|
||||
}
|
||||
|
||||
if ($value instanceof ModelType) {
|
||||
return [
|
||||
'id' => $value->getId(),
|
||||
'id' => $value->getId(),
|
||||
'name' => $value->getName(),
|
||||
'code' => $value->getCode(),
|
||||
];
|
||||
@@ -247,8 +337,8 @@ final class PieceAuditSubscriber implements EventSubscriber
|
||||
|
||||
if ($value instanceof Product) {
|
||||
return [
|
||||
'id' => $value->getId(),
|
||||
'name' => $value->getName(),
|
||||
'id' => $value->getId(),
|
||||
'name' => $value->getName(),
|
||||
'reference' => $value->getReference(),
|
||||
];
|
||||
}
|
||||
@@ -257,11 +347,11 @@ final class PieceAuditSubscriber implements EventSubscriber
|
||||
return $this->normalizeCollection($value);
|
||||
}
|
||||
|
||||
if (\is_object($value) && \method_exists($value, 'getId')) {
|
||||
if (is_object($value) && method_exists($value, 'getId')) {
|
||||
return (string) $value->getId();
|
||||
}
|
||||
|
||||
if (\is_array($value)) {
|
||||
if (is_array($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}> $extra
|
||||
*
|
||||
* @return array<string, array{from:mixed, to:mixed}>
|
||||
*/
|
||||
private function mergeDiffs(array $base, array $extra): array
|
||||
@@ -284,17 +375,23 @@ final class PieceAuditSubscriber implements EventSubscriber
|
||||
|
||||
private function resolveActorProfileId(): ?string
|
||||
{
|
||||
$session = $this->requestStack->getSession();
|
||||
if (!$session instanceof SessionInterface) {
|
||||
return null;
|
||||
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.)
|
||||
}
|
||||
|
||||
$profileId = $session->get('profileId');
|
||||
if (!$profileId) {
|
||||
return null;
|
||||
$user = $this->security->getUser();
|
||||
if ($user instanceof Profile) {
|
||||
return $user->getId();
|
||||
}
|
||||
|
||||
return (string) $profileId;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,9 +16,7 @@ use Doctrine\ORM\Events;
|
||||
*/
|
||||
final class PieceProductSyncSubscriber implements EventSubscriber
|
||||
{
|
||||
public function __construct(private readonly ProductRepository $productRepository)
|
||||
{
|
||||
}
|
||||
public function __construct(private readonly ProductRepository $productRepository) {}
|
||||
|
||||
public function getSubscribedEvents(): array
|
||||
{
|
||||
@@ -47,7 +45,7 @@ final class PieceProductSyncSubscriber implements EventSubscriber
|
||||
|
||||
$this->syncPrimaryProduct($entity);
|
||||
|
||||
$em = $args->getObjectManager();
|
||||
$em = $args->getObjectManager();
|
||||
$meta = $em->getClassMetadata(Piece::class);
|
||||
$em->getUnitOfWork()->recomputeSingleEntityChangeSet($meta, $entity);
|
||||
}
|
||||
@@ -56,7 +54,7 @@ final class PieceProductSyncSubscriber implements EventSubscriber
|
||||
{
|
||||
$productIds = $piece->getProductIds();
|
||||
|
||||
if ($productIds === []) {
|
||||
if ([] === $productIds) {
|
||||
// If no explicit list is provided, keep the legacy relation as-is.
|
||||
return;
|
||||
}
|
||||
@@ -77,4 +75,3 @@ final class PieceProductSyncSubscriber implements EventSubscriber
|
||||
$piece->setProduct($primaryProduct);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,11 @@ declare(strict_types=1);
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Entity\AuditLog;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\ModelType;
|
||||
use App\Entity\Product;
|
||||
use App\Entity\Profile;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\Common\EventSubscriber;
|
||||
@@ -14,8 +17,16 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Event\OnFlushEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
use Doctrine\ORM\PersistentCollection;
|
||||
use Doctrine\ORM\UnitOfWork;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
use Throwable;
|
||||
|
||||
use function is_array;
|
||||
use function is_object;
|
||||
use function is_scalar;
|
||||
use function method_exists;
|
||||
|
||||
/**
|
||||
* Record a lightweight, per-product audit trail.
|
||||
@@ -27,9 +38,10 @@ use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
#[AsDoctrineListener(event: Events::onFlush)]
|
||||
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
|
||||
{
|
||||
@@ -45,18 +57,18 @@ final class ProductAuditSubscriber implements EventSubscriber
|
||||
return;
|
||||
}
|
||||
|
||||
$uow = $em->getUnitOfWork();
|
||||
$actorProfileId = $this->resolveActorProfileId();
|
||||
$pendingUpdates = [];
|
||||
$uow = $em->getUnitOfWork();
|
||||
$actorProfileId = $this->resolveActorProfileId();
|
||||
$pendingUpdates = [];
|
||||
$pendingSnapshots = [];
|
||||
$pendingProducts = [];
|
||||
$pendingProducts = [];
|
||||
|
||||
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
||||
if (!$entity instanceof Product) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
||||
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
||||
$snapshot = $this->snapshotProduct($entity);
|
||||
$this->persistAuditLog($em, new AuditLog('product', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
|
||||
}
|
||||
@@ -67,15 +79,15 @@ final class ProductAuditSubscriber implements EventSubscriber
|
||||
}
|
||||
|
||||
$productId = (string) $entity->getId();
|
||||
if ($productId === '') {
|
||||
if ('' === $productId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
||||
if ($diff !== []) {
|
||||
$pendingUpdates[$productId] = $this->mergeDiffs($pendingUpdates[$productId] ?? [], $diff);
|
||||
if ([] !== $diff) {
|
||||
$pendingUpdates[$productId] = $this->mergeDiffs($pendingUpdates[$productId] ?? [], $diff);
|
||||
$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->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingProducts);
|
||||
|
||||
foreach ($pendingUpdates as $productId => $diff) {
|
||||
if ($diff === []) {
|
||||
if ([] === $diff) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -113,8 +127,8 @@ final class ProductAuditSubscriber implements EventSubscriber
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
|
||||
* @param array<string, array<string, mixed>> $pendingSnapshots
|
||||
* @param array<string, Product> $pendingProducts
|
||||
* @param array<string, array<string, mixed>> $pendingSnapshots
|
||||
* @param array<string, Product> $pendingProducts
|
||||
*/
|
||||
private function collectCollectionUpdate(
|
||||
object $collection,
|
||||
@@ -132,18 +146,18 @@ final class ProductAuditSubscriber implements EventSubscriber
|
||||
}
|
||||
|
||||
$productId = (string) $owner->getId();
|
||||
if ($productId === '') {
|
||||
if ('' === $productId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$mapping = $collection->getMapping();
|
||||
$mapping = $collection->getMapping();
|
||||
$fieldName = $mapping['fieldName'] ?? null;
|
||||
if ($fieldName !== 'constructeurs') {
|
||||
if ('constructeurs' !== $fieldName) {
|
||||
return;
|
||||
}
|
||||
|
||||
$before = $this->normalizeCollection($collection->getSnapshot());
|
||||
$after = $this->normalizeCollection($collection->toArray());
|
||||
$after = $this->normalizeCollection($collection->toArray());
|
||||
|
||||
if ($before === $after) {
|
||||
return;
|
||||
@@ -152,13 +166,82 @@ final class ProductAuditSubscriber implements EventSubscriber
|
||||
$diff = [
|
||||
'constructeurIds' => [
|
||||
'from' => $before,
|
||||
'to' => $after,
|
||||
'to' => $after,
|
||||
],
|
||||
];
|
||||
|
||||
$pendingUpdates[$productId] = $this->mergeDiffs($pendingUpdates[$productId] ?? [], $diff);
|
||||
$pendingUpdates[$productId] = $this->mergeDiffs($pendingUpdates[$productId] ?? [], $diff);
|
||||
$pendingSnapshots[$productId] = $this->snapshotProduct($owner);
|
||||
$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
|
||||
@@ -174,6 +257,7 @@ final class ProductAuditSubscriber implements EventSubscriber
|
||||
|
||||
/**
|
||||
* @param array<string, array{0:mixed, 1:mixed}> $changeSet
|
||||
*
|
||||
* @return array<string, array{from:mixed, to:mixed}>
|
||||
*/
|
||||
private function buildDiffFromChangeSet(array $changeSet): array
|
||||
@@ -181,7 +265,7 @@ final class ProductAuditSubscriber implements EventSubscriber
|
||||
$diff = [];
|
||||
foreach ($changeSet as $field => [$oldValue, $newValue]) {
|
||||
// Skip noisy timestamps managed automatically.
|
||||
if ($field === 'updatedAt' || $field === 'createdAt') {
|
||||
if ('updatedAt' === $field || 'createdAt' === $field) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -194,7 +278,7 @@ final class ProductAuditSubscriber implements EventSubscriber
|
||||
|
||||
$diff[$field] = [
|
||||
'from' => $normalizedOld,
|
||||
'to' => $normalizedNew,
|
||||
'to' => $normalizedNew,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -204,11 +288,11 @@ final class ProductAuditSubscriber implements EventSubscriber
|
||||
private function snapshotProduct(Product $product): array
|
||||
{
|
||||
return [
|
||||
'id' => $product->getId(),
|
||||
'name' => $product->getName(),
|
||||
'reference' => $product->getReference(),
|
||||
'supplierPrice' => $product->getSupplierPrice(),
|
||||
'typeProduct' => $this->normalizeValue($product->getTypeProduct()),
|
||||
'id' => $product->getId(),
|
||||
'name' => $product->getName(),
|
||||
'reference' => $product->getReference(),
|
||||
'supplierPrice' => $product->getSupplierPrice(),
|
||||
'typeProduct' => $this->normalizeValue($product->getTypeProduct()),
|
||||
'constructeurIds' => $this->normalizeCollection($product->getConstructeurs()),
|
||||
];
|
||||
}
|
||||
@@ -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}> $extra
|
||||
*
|
||||
* @return array<string, array{from:mixed, to:mixed}>
|
||||
*/
|
||||
private function mergeDiffs(array $base, array $extra): array
|
||||
@@ -229,38 +314,44 @@ final class ProductAuditSubscriber implements EventSubscriber
|
||||
|
||||
/**
|
||||
* @param iterable<mixed> $items
|
||||
* @return list<string>
|
||||
*
|
||||
* @return list<array{id: string, name: string}|string>
|
||||
*/
|
||||
private function normalizeCollection(iterable $items): array
|
||||
{
|
||||
$ids = [];
|
||||
$entries = [];
|
||||
$seen = [];
|
||||
foreach ($items as $item) {
|
||||
if (\is_object($item) && \method_exists($item, 'getId')) {
|
||||
if (is_object($item) && method_exists($item, 'getId')) {
|
||||
$id = $item->getId();
|
||||
if ($id !== null && $id !== '') {
|
||||
$ids[] = (string) $id;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort($ids);
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
return $entries;
|
||||
}
|
||||
|
||||
private function normalizeValue(mixed $value): mixed
|
||||
{
|
||||
if ($value === null || \is_scalar($value)) {
|
||||
if (null === $value || is_scalar($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return $value->format(\DateTimeInterface::ATOM);
|
||||
if ($value instanceof DateTimeInterface) {
|
||||
return $value->format(DateTimeInterface::ATOM);
|
||||
}
|
||||
|
||||
if ($value instanceof ModelType) {
|
||||
return [
|
||||
'id' => $value->getId(),
|
||||
'id' => $value->getId(),
|
||||
'name' => $value->getName(),
|
||||
'code' => $value->getCode(),
|
||||
];
|
||||
@@ -270,11 +361,11 @@ final class ProductAuditSubscriber implements EventSubscriber
|
||||
return $this->normalizeCollection($value);
|
||||
}
|
||||
|
||||
if (\is_object($value) && \method_exists($value, 'getId')) {
|
||||
if (is_object($value) && method_exists($value, 'getId')) {
|
||||
return (string) $value->getId();
|
||||
}
|
||||
|
||||
if (\is_array($value)) {
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
@@ -283,16 +374,23 @@ final class ProductAuditSubscriber implements EventSubscriber
|
||||
|
||||
private function resolveActorProfileId(): ?string
|
||||
{
|
||||
$session = $this->requestStack->getSession();
|
||||
if (!$session instanceof SessionInterface) {
|
||||
return null;
|
||||
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.)
|
||||
}
|
||||
|
||||
$profileId = $session->get('profileId');
|
||||
if (!$profileId) {
|
||||
return null;
|
||||
$user = $this->security->getUser();
|
||||
if ($user instanceof Profile) {
|
||||
return $user->getId();
|
||||
}
|
||||
|
||||
return (string) $profileId;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Throwable;
|
||||
|
||||
final class UniqueConstraintSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
@@ -30,15 +31,15 @@ final class UniqueConstraintSubscriber implements EventSubscriberInterface
|
||||
$event->setResponse(new JsonResponse(
|
||||
[
|
||||
'success' => false,
|
||||
'error' => 'nom duplique',
|
||||
'error' => 'nom duplique',
|
||||
],
|
||||
JsonResponse::HTTP_CONFLICT
|
||||
));
|
||||
}
|
||||
|
||||
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) {
|
||||
return $current;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,46 @@ final class AuditLogRepository extends ServiceEntityRepository
|
||||
->orderBy('a.createdAt', 'DESC')
|
||||
->setMaxResults($limit)
|
||||
->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
494
src/Service/ModelTypeCategoryConversionService.php
Normal file
494
src/Service/ModelTypeCategoryConversionService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user