Compare commits

...

28 Commits

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 17:34:43 +01:00
8d83076be0 chore(release) : v1.2.0 2026-01-29 19:55:29 +01:00
55 changed files with 2319 additions and 12656 deletions

786
REFACTORING_PLAN.md Normal file
View File

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

View File

@@ -1 +1 @@
1.1.2
1.6.0

View File

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

View File

@@ -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:

View File

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

24
frontend/.gitignore vendored
View File

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

View File

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

View File

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

View File

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

11892
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ namespace App\Controller;
use App\Repository\AuditLogRepository;
use App\Repository\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
]);
}
}

View File

@@ -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),
];
}
}

View File

@@ -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);
}

View File

@@ -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

View File

@@ -47,8 +47,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 +58,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 +83,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 +116,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 +167,7 @@ class MachineSkeletonController extends AbstractController
$this->entityManager->persist($link);
$links[$linkId] = $link;
$keepIds[] = $linkId;
$keepIds[] = $linkId;
}
foreach ($pendingParents as $linkId => $parentId) {
@@ -190,15 +190,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 +236,7 @@ class MachineSkeletonController extends AbstractController
$this->entityManager->persist($link);
$links[$linkId] = $link;
$keepIds[] = $linkId;
$keepIds[] = $linkId;
}
foreach ($pendingParents as $linkId => $parentId) {
@@ -263,16 +263,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 +302,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,8 +338,8 @@ 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) {
@@ -354,10 +354,10 @@ class MachineSkeletonController extends AbstractController
$this->attachPiecesToComponents($componentIndex, $normalizedPieceLinks);
return [
'machine' => $this->normalizeMachine($machine),
'machine' => $this->normalizeMachine($machine),
'componentLinks' => array_values($componentIndex),
'pieceLinks' => $normalizedPieceLinks,
'productLinks' => $this->normalizeProductLinks($productLinks),
'pieceLinks' => $normalizedPieceLinks,
'productLinks' => $this->normalizeProductLinks($productLinks),
];
}
@@ -400,26 +400,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 +430,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 +444,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 +460,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 +487,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 +512,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 +533,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 +586,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 +596,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 +637,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 +649,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 +683,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 +698,10 @@ class MachineSkeletonController extends AbstractController
continue;
}
$value = $entry[$key];
if ($value === null || $value === '') {
if (null === $value || '' === $value) {
continue;
}
return (string) $value;
}
@@ -709,6 +710,7 @@ class MachineSkeletonController extends AbstractController
/**
* @param array<array-key, object> $links
*
* @return array<string, object>
*/
private function indexLinksById(array $links): array
@@ -751,6 +753,6 @@ class MachineSkeletonController extends AbstractController
private function generateCuid(): string
{
return 'cl' . bin2hex(random_bytes(12));
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

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

View File

@@ -7,6 +7,7 @@ namespace App\Controller;
use App\Repository\AuditLogRepository;
use App\Repository\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
]);
}
}

View File

@@ -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
]);
}
}

View File

@@ -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(),
]);
}

View File

@@ -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(),
];
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

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

View File

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

View File

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

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

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

View File

@@ -24,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)]

View File

@@ -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)]

View File

@@ -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')]

View File

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

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

@@ -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';
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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,
];
}
}

View File

@@ -0,0 +1,418 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Enum\ModelCategory;
use App\Repository\ModelTypeRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Throwable;
final class ModelTypeCategoryConversionService
{
public function __construct(
private readonly Connection $connection,
private readonly ModelTypeRepository $modelTypes,
) {}
/**
* @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();
$this->connection->beginTransaction();
try {
if (ModelCategory::PIECE === $category) {
$count = $this->convertPieceToComponent($modelTypeId);
} else {
$count = $this->convertComponentToPiece($modelTypeId);
}
$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;
}
}