Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51248b7854 | ||
|
|
0e11f4ad2d | ||
|
|
f2539099bc | ||
|
|
e5dc60467e | ||
|
|
fbc0372bd6 | ||
|
|
1483b0075b | ||
|
|
74e88923dc | ||
|
|
ef61d1a0d3 | ||
|
|
3f0fb0d5c2 | ||
|
|
dd1497beac | ||
|
|
7cd8772617 | ||
|
|
d89c97f0a0 | ||
|
|
7a5dd0b555 | ||
|
|
44d69db560 | ||
|
|
453065c9f0 | ||
|
|
eb85323116 | ||
|
|
2dfa501a65 | ||
|
|
c22f9dbf2b | ||
|
|
27a1b09d62 | ||
|
|
7bbb693924 | ||
|
|
9661fd5d91 | ||
|
|
d9ab583879 | ||
|
|
5d41bda997 | ||
|
|
3d037083c6 | ||
|
|
a3e440c254 | ||
|
|
adc44b99d3 | ||
|
|
60afeb4cfd | ||
|
|
02ff8b1a96 | ||
|
|
2156df22c6 | ||
|
|
cd2a3fac55 | ||
|
|
6300a3588a | ||
|
|
45213103e4 | ||
|
|
91b8b424d6 | ||
|
|
0d1c9277e5 | ||
|
|
db16d26103 | ||
|
|
0eb64d0975 | ||
|
|
39e503ae18 | ||
|
|
70ed354c42 | ||
|
|
ba98ae37f4 | ||
|
|
906d39793f | ||
|
|
f970c1928d | ||
|
|
2a1d966b87 | ||
|
|
a393b62e9f | ||
|
|
1247f72af6 | ||
|
|
6735bf252c | ||
|
|
508066d39f | ||
|
|
70956c204e | ||
|
|
16a7eac0c6 | ||
|
|
37ac08b182 | ||
|
|
5ef80b362e | ||
|
|
78f19daf76 | ||
|
|
6caa4a61df | ||
|
|
bf55034b2e | ||
|
|
ba1114e78b | ||
|
|
5ccc3b30f0 | ||
| 8d83076be0 | |||
|
|
997a3ae822 | ||
|
|
034c193e4b |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -32,6 +32,12 @@ docker/.env.docker.local
|
||||
/_archives/
|
||||
###< migration archives ###
|
||||
|
||||
###> temp files ###
|
||||
*.sql
|
||||
*.har
|
||||
FEATURE_IDEAS.md
|
||||
###< temp files ###
|
||||
|
||||
###> frontend ###
|
||||
/frontend/node_modules/
|
||||
/frontend/.nuxt/
|
||||
|
||||
@@ -1,425 +0,0 @@
|
||||
# 📔 Carnet de Bord - Migration Inventory → Symfony
|
||||
|
||||
**Projet** : Migration backend NestJS/Prisma → Symfony/API Platform
|
||||
**Début** : 2026-01-10
|
||||
**Objectif** : Migrer vers Symfony + JWT + API Platform propre et maintenable
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Convention de liaison des commits (INV)
|
||||
|
||||
- **Format** : `[INV-YYYYMMDD-XX]`
|
||||
- **Usage** : même code dans les commits du backend **et** du frontend + ajout ici pour retrouver le duo rapidement.
|
||||
|
||||
## 🧾 Journal des liaisons INV
|
||||
|
||||
- INV-20260111-01 : ajout du lien submodule `Inventory_frontend` (commit backend : `987aa5c`, commit frontend : `936a73f`)
|
||||
- INV-20260111-02 : alignement front API Platform + sessions (commit backend : `f7fc1bd`, commit frontend : `e99f053`)
|
||||
|
||||
## 🎯 Contexte
|
||||
|
||||
- **Situation initiale** :
|
||||
- `Inventory_backend/` : NestJS + Prisma (fonctionnel, ~11k lignes)
|
||||
- `Inventory_frontend/` : Nuxt 3 (fonctionnel, 105 fichiers)
|
||||
- Base de données PostgreSQL avec données en production
|
||||
|
||||
- **Objectif** :
|
||||
- Backend Symfony 8 + API Platform + JWT
|
||||
- Garder les données existantes (migration Prisma → Doctrine)
|
||||
- Frontend Nuxt connecté au nouveau backend
|
||||
- Docker : 2 backends en parallèle pendant transition
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 1 : Préparation (TERMINÉE - 10/01/2026)
|
||||
|
||||
### Ce qui a été fait
|
||||
|
||||
#### 1. Docker & Infrastructure ✅
|
||||
- **pgAdmin ajouté** au docker-compose.yml
|
||||
- Port : 5050
|
||||
- Login : admin@admin.com / admin
|
||||
- Container : `pgadmin-inventory`
|
||||
- Volume persistant : `pgadmin_data`
|
||||
- **Serveur PostgreSQL pré-configuré** :
|
||||
- Fichier `docker/pgadmin/servers.json` monté automatiquement
|
||||
- Fichier `docker/pgadmin/pgpass` pour authentification sans mot de passe
|
||||
- Connexion automatique à `db:5432/inventory` au démarrage
|
||||
- Nom du serveur : "Inventory PostgreSQL"
|
||||
|
||||
#### 2. Bundles Symfony installés ✅
|
||||
```bash
|
||||
# Versions installées
|
||||
- lexik/jwt-authentication-bundle: v3.2.0
|
||||
- vich/uploader-bundle: v2.9.1
|
||||
- symfony/uid: 8.0.*
|
||||
```
|
||||
|
||||
#### 3. JWT Configuration ✅
|
||||
- **Clés RSA générées** : `config/jwt/private.pem` + `public.pem`
|
||||
- **security.yaml configuré** :
|
||||
- Firewall `login` : pattern `^/api/login_check` avec `json_login`
|
||||
- Firewall `api` : pattern `^/api` avec `jwt` authenticator
|
||||
- Provider : `app_user_provider` (entité Profile via email)
|
||||
- Password hasher : bcrypt auto
|
||||
|
||||
#### 4. Entité Profile créée ✅
|
||||
**Fichier** : `src/Entity/Profile.php`
|
||||
|
||||
**Caractéristiques** :
|
||||
- Implémente `UserInterface` + `PasswordAuthenticatedUserInterface`
|
||||
- Champs :
|
||||
- `id` : string (30 chars, CUID-compatible pour Prisma)
|
||||
- `email` : string unique (username pour JWT)
|
||||
- `password` : string (hashed)
|
||||
- `roles` : array JSON (ROLE_USER par défaut)
|
||||
- `firstName`, `lastName` : string
|
||||
- `isActive` : boolean
|
||||
- `createdAt`, `updatedAt` : DateTimeImmutable
|
||||
- Repository : `ProfileRepository` avec `PasswordUpgraderInterface`
|
||||
- API Platform : endpoints CRUD auto-générés
|
||||
|
||||
#### 5. Base de Données ✅
|
||||
- **Migration créée** : `Version20260110175413`
|
||||
- **Table** : `profiles` créée avec succès
|
||||
- **Utilisateur test créé** :
|
||||
```
|
||||
Email: admin@admin.com
|
||||
Password: admin123
|
||||
Roles: ['ROLE_USER', 'ROLE_ADMIN']
|
||||
```
|
||||
|
||||
#### 6. API Platform ✅
|
||||
- **Endpoint racine** : http://localhost:8081/api/
|
||||
- **Réponse** :
|
||||
```json
|
||||
{
|
||||
"@context": "/api/contexts/Entrypoint",
|
||||
"@id": "/api/",
|
||||
"@type": "Entrypoint",
|
||||
"profile": "/api/profiles"
|
||||
}
|
||||
```
|
||||
- **OpenAPI Docs** : Configurées (à tester)
|
||||
|
||||
#### 7. Configuration Apache ✅
|
||||
- **VirtualHost** : `docker/php/config/vhost.conf`
|
||||
- **DocumentRoot** : `/var/www/html/public`
|
||||
- **AllowOverride** : All (pour `.htaccess`)
|
||||
- **Port** : 8081 (Apache) → accessible depuis l'hôte
|
||||
|
||||
#### 8. Routing Symfony ✅
|
||||
- **Routes définies** :
|
||||
- `/api/login_check` : Login JWT
|
||||
- `/api/test` : Test endpoint (TestController)
|
||||
- `/api/*` : API Platform auto-routes
|
||||
- **Vérification** :
|
||||
```bash
|
||||
php bin/console debug:router api_test
|
||||
php bin/console router:match /api/test --method=GET
|
||||
# ✅ Route found and matches
|
||||
```
|
||||
|
||||
#### 9. .htaccess créé ✅
|
||||
**Fichier** : `public/.htaccess`
|
||||
|
||||
**Contenu** : Symfony standard avec mod_rewrite
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ Problèmes identifiés
|
||||
|
||||
#### 1. Routes inaccessibles via Apache (404)
|
||||
|
||||
**Symptôme** :
|
||||
```bash
|
||||
curl http://localhost:8081/api/test
|
||||
# → 404 Not Found
|
||||
```
|
||||
|
||||
**Tests effectués** :
|
||||
- ✅ Route existe : `php bin/console debug:router api_test`
|
||||
- ✅ Route match : `php bin/console router:match /api/test`
|
||||
- ✅ Symfony fonctionne : `curl http://localhost:8081/api/` → JSON OK
|
||||
- ✅ PHP built-in server OK :
|
||||
```bash
|
||||
php -S localhost:9000
|
||||
curl http://localhost:9000/api/test
|
||||
# → {"status":"ok","message":"Test endpoint works!"}
|
||||
```
|
||||
- ❌ Apache 404 : Depuis l'hôte via port 8081
|
||||
|
||||
**Diagnostic** :
|
||||
- Le problème est **Apache-spécifique**
|
||||
- Symfony/PHP fonctionnent correctement
|
||||
- Le `.htaccess` n'est probablement **PAS lu par Apache**
|
||||
- Hypothèses :
|
||||
1. `AllowOverride All` non pris en compte
|
||||
2. `mod_rewrite` mal configuré
|
||||
3. Ordre des directives Apache incorrect
|
||||
4. Problème de permissions sur `.htaccess`
|
||||
|
||||
**Actions à faire** :
|
||||
- [ ] Vérifier permissions `.htaccess` dans container
|
||||
- [ ] Tester `apache2ctl configtest`
|
||||
- [ ] Activer logs de rewrite : `LogLevel alert rewrite:trace3`
|
||||
- [ ] Tester FallbackResource dans vhost au lieu de `.htaccess`
|
||||
|
||||
#### 2. JWT Login non testé
|
||||
|
||||
**Raison** : Bloqué par problème #1 (routes inaccessibles)
|
||||
|
||||
**Actions à faire** :
|
||||
- [ ] Résoudre problème Apache
|
||||
- [ ] Tester `POST /api/login_check` avec credentials
|
||||
- [ ] Vérifier génération du token JWT
|
||||
- [ ] Tester route protégée avec token
|
||||
|
||||
---
|
||||
|
||||
## 📝 Configuration Actuelle
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```yaml
|
||||
services:
|
||||
web:
|
||||
ports:
|
||||
- "8081:80" # Symfony API
|
||||
- "3001:3000" # (prévu pour Nuxt)
|
||||
|
||||
db:
|
||||
ports:
|
||||
- "5433:5432" # PostgreSQL
|
||||
|
||||
pgadmin:
|
||||
ports:
|
||||
- "5050:80" # pgAdmin
|
||||
```
|
||||
|
||||
### Variables d'Environnement
|
||||
|
||||
**Fichier** : `docker/.env.docker.local`
|
||||
|
||||
```env
|
||||
# PostgreSQL
|
||||
POSTGRES_DB=inventory
|
||||
POSTGRES_USER=root
|
||||
POSTGRES_PASSWORD=root
|
||||
POSTGRES_PORT=5433
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 2 : Migration DB + Frontend (TERMINÉE - 10/01/2026)
|
||||
|
||||
### Ce qui a été fait
|
||||
|
||||
#### 1. Entités Doctrine alignées Prisma ✅
|
||||
- **Toutes les entités manquantes** créées (Machine, ModelType, Composant, Piece, Product, Links, Requirements, CustomField, Document, etc.)
|
||||
- **IDs en string(36)** pour compatibilité CUID/UUID
|
||||
- **Colonnes Prisma en camelCase** conservées via `name="..."` (ex: `machineId`, `createdAt`, `supplierPrice`)
|
||||
- **Corrections** :
|
||||
- `Document.path` passé en `TEXT`
|
||||
- `CustomField.options` nullable
|
||||
- `TypeMachineComponentRequirement.required` corrigé
|
||||
|
||||
#### 2. Migration DB inventory-data → inventory ✅
|
||||
- **Dump data-only + normalisation** (conversion des identifiants quoted vers lowercase)
|
||||
- **Mapping table Prisma** : `"ModelType"` → `model_types`
|
||||
- **Exclusions** : `profiles`, `_prisma_migrations`
|
||||
- **Import validé** : `Counts match for all tables.`
|
||||
|
||||
Scripts utiles :
|
||||
```bash
|
||||
scripts/normalize-dump.py
|
||||
scripts/validate-migration.php
|
||||
```
|
||||
|
||||
#### 3. Frontend basculé sur Inventory_frontend ✅
|
||||
- `make dev-nuxt` pointe vers `Inventory_frontend/`
|
||||
- `README.md` mis à jour
|
||||
- **Base API** ajustée : `http://localhost:8081/api`
|
||||
|
||||
Fichiers modifiés :
|
||||
```
|
||||
makefile
|
||||
README.md
|
||||
Inventory_frontend/.env
|
||||
Inventory_frontend/nuxt.config.ts
|
||||
Inventory_frontend/app/services/modelTypes.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# pgAdmin
|
||||
PGADMIN_EMAIL=admin@admin.com
|
||||
PGADMIN_PASSWORD=admin
|
||||
PGADMIN_PORT=5050
|
||||
|
||||
# Symfony
|
||||
APP_ENV=dev
|
||||
APP_SECRET=changeme_super_secret_key_123456789
|
||||
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||
JWT_PASSPHRASE=your_jwt_passphrase_change_me
|
||||
|
||||
# NestJS (pour futur parallèle)
|
||||
NESTJS_PORT=3000
|
||||
SESSION_SECRET=changeme_session_secret
|
||||
CORS_ORIGIN=http://localhost:3001
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚧 Phase 2 : Debugging & Tests (EN COURS)
|
||||
|
||||
### Objectifs
|
||||
- [x] Résoudre problème Apache `.htaccess`
|
||||
- [ ] Tester authentification JWT complète
|
||||
- [ ] Créer endpoint de test public fonctionnel
|
||||
- [ ] Documenter la solution Apache
|
||||
|
||||
### Prochaines étapes
|
||||
1. **Fix Apache** : Logs de debug + test FallbackResource
|
||||
2. **Test JWT** : Login + génération token + route protégée
|
||||
3. **Documentation** : Documenter la config Apache qui fonctionne
|
||||
|
||||
---
|
||||
|
||||
## 📊 Métriques
|
||||
|
||||
### Temps passé
|
||||
- **Phase 1** : ~3h (exploration + setup + debugging)
|
||||
- **Problème Apache** : ~1h30 (en cours)
|
||||
|
||||
### Fichiers créés/modifiés
|
||||
|
||||
**Nouveaux fichiers** :
|
||||
- `src/Entity/Profile.php`
|
||||
- `src/Repository/ProfileRepository.php`
|
||||
- `src/Controller/TestController.php`
|
||||
- `public/.htaccess`
|
||||
- `config/routes/routing.controllers.yaml`
|
||||
- `create_test_user.php` (script utilitaire)
|
||||
- `migrations/Version20260110175413.php`
|
||||
- `docker/pgadmin/servers.json` (config serveur PostgreSQL)
|
||||
- `docker/pgadmin/pgpass` (credentials PostgreSQL)
|
||||
- `CARNET_DE_BORD.md` (ce fichier)
|
||||
|
||||
**Fichiers modifiés** :
|
||||
- `docker-compose.yml` (+ pgAdmin)
|
||||
- `docker/.env.docker.local` (+ variables Symfony/JWT/pgAdmin)
|
||||
- `docker/php/config/vhost.conf` (DocumentRoot → public/)
|
||||
- `config/packages/security.yaml` (JWT firewalls)
|
||||
- `config/routes.yaml` (+ api_login_check)
|
||||
- `composer.json` (+ lexik JWT, vich uploader)
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Leçons Apprises
|
||||
|
||||
### 1. Symfony 8 + API Platform
|
||||
- **Attributs PHP 8** : `use Symfony\Component\Routing\Attribute\Route;` (pas `Annotation`)
|
||||
- **Routes controllers** : Nécessite `config/routes/routing.controllers.yaml` avec `type: attribute`
|
||||
- **API Platform** : Auto-génère les endpoints CRUD avec `#[ApiResource]`
|
||||
|
||||
### 2. JWT Authentication
|
||||
- **3 composants** :
|
||||
1. Firewall `login` : `json_login` intercepte `/api/login_check`
|
||||
2. Firewall `api` : `jwt` vérifie le token sur `/api/*`
|
||||
3. Access control : `PUBLIC_ACCESS` vs `IS_AUTHENTICATED_FULLY`
|
||||
- **username_path** : Permet de mapper `email` au lieu de `username`
|
||||
- **Provider** : Doit être défini dans le firewall `login`
|
||||
|
||||
### 3. Doctrine Migrations
|
||||
- **ID Prisma CUID** : Garder en `string(30)` pour compatibilité
|
||||
- **Lifecycle callbacks** : `#[ORM\PrePersist]` pour `createdAt`/`updatedAt`
|
||||
- **UserInterface** : Nécessite `getUserIdentifier()`, `getRoles()`, `eraseCredentials()`
|
||||
|
||||
### 4. Docker & Apache
|
||||
- **`.htaccess` vs VirtualHost** : Le vhost peut override le `.htaccess`
|
||||
- **AllowOverride All** : Indispensable pour que `.htaccess` fonctionne
|
||||
- **FallbackResource** : Alternative au mod_rewrite dans `.htaccess`
|
||||
- **Debugging** : Tester avec PHP built-in server pour isoler le problème
|
||||
|
||||
---
|
||||
|
||||
## 📚 Ressources Utiles
|
||||
|
||||
### Accès aux Services
|
||||
|
||||
```
|
||||
🌐 pgAdmin: http://localhost:5050
|
||||
└─ Login: admin@admin.com / admin
|
||||
└─ Serveur: "Inventory PostgreSQL" (pré-configuré)
|
||||
└─ Database: inventory
|
||||
└─ Note: Le serveur PostgreSQL est automatiquement connecté au démarrage
|
||||
|
||||
🌐 API Platform: http://localhost:8081/api/
|
||||
└─ Docs: http://localhost:8081/api/docs (à venir)
|
||||
|
||||
🗄️ PostgreSQL: localhost:5433
|
||||
└─ User: root / root
|
||||
└─ Database: inventory
|
||||
```
|
||||
|
||||
### Commandes fréquentes
|
||||
|
||||
```bash
|
||||
# Symfony
|
||||
make shell # Entrer dans le container
|
||||
php bin/console cache:clear # Clear cache
|
||||
make cache-clear-full # Clear cache + purge var/cache
|
||||
php bin/console debug:router # Lister routes
|
||||
php bin/console debug:firewall # Lister firewalls
|
||||
php bin/console doctrine:migrations:migrate # Exécuter migrations
|
||||
|
||||
# Docker
|
||||
make start # Démarrer containers
|
||||
make stop # Arrêter containers
|
||||
docker logs -f php-inventory-apache # Logs Apache
|
||||
docker logs -f pgadmin-inventory # Logs pgAdmin
|
||||
docker exec php-inventory-apache bash # Shell root
|
||||
|
||||
# Tests API
|
||||
curl http://localhost:8081/api/ # Test API Platform
|
||||
curl -X POST http://localhost:8081/api/login_check \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"admin@admin.com","password":"admin123"}'
|
||||
```
|
||||
|
||||
### Documentation
|
||||
- [Lexik JWT Bundle](https://github.com/lexik/LexikJWTAuthenticationBundle/blob/2.x/Resources/doc/index.rst)
|
||||
- [API Platform Security](https://api-platform.com/docs/core/security/)
|
||||
- [Symfony Security](https://symfony.com/doc/current/security.html)
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Historique des Changements
|
||||
|
||||
### 2026-01-15 - Session 3
|
||||
- ✅ Filtre API Platform `category` sur `ModelType`
|
||||
- ✅ Normalisation des structures `ModelType` (structure ↔ skeleton)
|
||||
- ✅ Migration `custom_fields.options` en JSON
|
||||
- ✅ Ajout commande `make cache-clear-full`
|
||||
- ✅ Correctifs frontend: headers API Platform, pagination par catégorie, persistance tri
|
||||
|
||||
### 2026-01-10 - Session 2 (20h30)
|
||||
- ✅ Problème Apache résolu (routes fonctionnelles)
|
||||
- ✅ Phase 2 complète (JWT 100% opérationnel)
|
||||
- ✅ Authentification testée avec succès
|
||||
- ✅ Réorganisation projet (frontend/ + _archives/)
|
||||
- ✅ État des lieux dans MIGRATION_PLAN.md
|
||||
- ✅ 5 commits conventionnels créés
|
||||
- 📊 Base inventory-data analysée (673 lignes)
|
||||
|
||||
### 2026-01-10 - Session 1 (19h00)
|
||||
- ✅ Création projet migration
|
||||
- ✅ Phase 1 complète (pgAdmin, JWT, Profile, migrations)
|
||||
- ⚠️ Problème Apache identifié (routes 404)
|
||||
- 📝 Carnet de bord créé
|
||||
|
||||
---
|
||||
|
||||
**Dernière mise à jour** : 2026-01-15 13:45
|
||||
**Statut** : Phase 3 EN COURS ⚠️ - Migrations et intégration frontend
|
||||
71
CHANGELOG.md
71
CHANGELOG.md
@@ -1,15 +1,68 @@
|
||||
# Changelog
|
||||
|
||||
Liste des évolutions du projet inventory
|
||||
## [1.8.1] - 2026-03-05
|
||||
|
||||
## [0.0.0]
|
||||
### Parameters
|
||||
Ajouter dans le fichier .env
|
||||
- DEFAULT_URI
|
||||
- DATABASE_URL
|
||||
### Refactoring
|
||||
- **Suppression du systeme TypeMachine (squelettes machines)** : les entites `TypeMachine`, `TypeMachineComponentRequirement`, `TypeMachinePieceRequirement`, `TypeMachineProductRequirement` sont supprimees. Les champs personnalises machines sont desormais lies directement a chaque machine (relation `CustomField → Machine`).
|
||||
- **Suppression des pages squelettes machines** : pages `/machine-skeleton`, `/type/[id]`, `/type/edit/[id]` et tous les composants associes (`TypeEditForm`, `MachineSkeletonSummary`, `MachineCreatePreview`, selectors de requirements).
|
||||
- **Simplification de la creation de machines** : plus besoin de selectionner un squelette, ajout direct de composants/pieces/produits.
|
||||
|
||||
### Added
|
||||
### Corrections
|
||||
- **Fix affichage categorie sur les pages edit** : les categories (produit, composant, piece) s'affichent correctement sur les pages d'edition au lieu de "Categorie inconnue". Cause : import `Serializer\Annotation\Groups` obsolete dans `ModelType` (remplace par `Attribute\Groups` pour Symfony 8) + groupes de serialisation manquants (`product:read`, `composant:read`, `piece:read`).
|
||||
- Fix import `Serializer\Annotation\Groups` → `Attribute\Groups` dans `Profile`.
|
||||
- Fix filtre `SearchFilter` : `partial` → `ipartial` sur `Comment.entityName` et `Document.name`/`Document.filename` pour recherche insensible a la casse.
|
||||
|
||||
### Changed
|
||||
### Migration requise
|
||||
```bash
|
||||
docker compose exec web php bin/console doctrine:migrations:migrate
|
||||
```
|
||||
|
||||
### Fixed
|
||||
## [1.8.0] - 2026-03-03
|
||||
|
||||
### Ajouts
|
||||
- **Stockage documents sur disque** : les documents sont desormais stockes en fichiers sur le systeme de fichiers au lieu de Base64 en base de donnees. Les endpoints `/api/documents/{id}/file` et `/api/documents/{id}/download` servent les fichiers directement.
|
||||
- **Commande de migration** `app:migrate-documents-to-filesystem` : migre les documents existants (Base64 → fichiers) avec dry-run, batch-size et limit.
|
||||
- **Pagination serveur sur la page Documents** : recherche, tri (date/nom/taille), filtre par rattachement (site/machine/composant/piece/produit), selecteur par page (20/50/100).
|
||||
- **Compression PDF automatique** : les documents PDF uploades sont compresses automatiquement via Ghostscript. Commande `app:compress-pdf` pour compresser les PDFs existants.
|
||||
- **Nettoyage automatique des fichiers** : suppression du fichier sur disque lors de la suppression d'un document.
|
||||
- **Champ description** sur les entites Piece et Composant, visible dans les catalogues avec popover au survol.
|
||||
|
||||
### Corrections
|
||||
- Fix normalisation des documents : `fileUrl` et `downloadUrl` toujours exposes dans l'API (meme sans `path` dans le groupe de serialisation).
|
||||
- Fix recursion infinie dans `DocumentNormalizer` (`getSupportedTypes` retourne `false` pour desactiver le cache).
|
||||
- Fix edition de squelettes machines : `deserialize: false` + `validate: false` sur le PUT pour eviter le conflit UniqueEntity et l'interference du deserialiseur avec les collections writableLink.
|
||||
- Fix sites : ajout operation PATCH et correction migration contrainte.
|
||||
- Retrocompatibilite : le controleur de service gere transparentement les anciens documents Base64 et les nouveaux fichiers.
|
||||
|
||||
### Migration requise
|
||||
```bash
|
||||
docker compose exec php php bin/console doctrine:migrations:migrate
|
||||
docker compose exec php php bin/console app:migrate-documents-to-filesystem
|
||||
```
|
||||
|
||||
## [1.7.0] - 2026-03-02
|
||||
|
||||
### Ajouts
|
||||
- **Systeme de commentaires / tickets** : les utilisateurs peuvent laisser des commentaires sur les fiches (machines, pieces, composants, produits, categories, squelettes). Les gestionnaires peuvent les resoudre.
|
||||
- **Page commentaires** (`/comments`) : vue centralisee avec filtres (statut, type d'entite), pagination et liens cliquables vers les fiches.
|
||||
- **Badge notifications** : compteur de commentaires ouverts sur l'avatar utilisateur et dans le menu profil (polling 60s).
|
||||
- **Controle d'acces par roles** : ROLE_ADMIN, ROLE_GESTIONNAIRE, ROLE_VIEWER avec permissions granulaires sur toutes les pages.
|
||||
- **Badge de role** dans le dropdown du profil utilisateur.
|
||||
- **Journal d'audit etendu** : audit logging sur machines, constructeurs, types de modeles, documents et conversions.
|
||||
- **Commande `app:init-profile-passwords`** : initialisation en masse des mots de passe et roles.
|
||||
|
||||
### Corrections
|
||||
- Toggle switch pour les champs personnalises booleens (remplace les checkboxes).
|
||||
- Recherche constructeur : filtrage cote client au lieu d'appels API debounce.
|
||||
- Prevention des doublons de noms de constructeurs et de references de pieces (contraintes unique).
|
||||
- Fix creation de squelettes machines : pagination, duplication, champs personnalises.
|
||||
|
||||
### Migration requise
|
||||
```bash
|
||||
docker compose exec php php bin/console doctrine:migrations:migrate
|
||||
docker compose exec php php bin/console app:init-profile-passwords
|
||||
```
|
||||
|
||||
## [1.6.0] - 2026-02-12
|
||||
|
||||
- Version initiale avec gestion du parc machines, pieces, composants, produits et categories.
|
||||
|
||||
168
CLAUDE.md
Normal file
168
CLAUDE.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# CLAUDE.md — Inventory Project
|
||||
|
||||
## Project Overview
|
||||
|
||||
Application de gestion d'inventaire industriel (machines, pièces, composants, produits).
|
||||
Mono-repo avec backend Symfony et frontend Nuxt en submodule git.
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Tech | Version |
|
||||
|-------|------|---------|
|
||||
| Backend | Symfony + API Platform | 8.0 / ^4.2 |
|
||||
| PHP | PHP | >=8.4 |
|
||||
| Database | PostgreSQL | 16 |
|
||||
| Frontend | Nuxt (SPA, SSR off) | 4 |
|
||||
| UI | Vue 3 Composition API + TypeScript | 3.5 / 5.7 |
|
||||
| CSS | TailwindCSS 4 + DaisyUI 5 | |
|
||||
| Auth | Session-based (cookies, pas JWT) | |
|
||||
| Containers | Docker Compose | |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Inventory/ # Backend Symfony (repo principal)
|
||||
├── src/Entity/ # Entités Doctrine (annotations PHP 8 attributes)
|
||||
├── src/Controller/ # Controllers custom (session, comments, audit…)
|
||||
├── src/EventSubscriber/ # Audit subscribers (onFlush)
|
||||
├── config/ # Config Symfony
|
||||
├── migrations/ # Migrations Doctrine (raw SQL PostgreSQL)
|
||||
├── docker/ # Dockerfile + .env.docker
|
||||
├── scripts/ # release.sh, normalize-dump.py
|
||||
├── fixtures/ # SQL fixtures
|
||||
├── tests/ # PHPUnit
|
||||
├── pre-commit, commit-msg # Git hooks
|
||||
├── makefile # Commandes Docker/dev
|
||||
├── VERSION # Source unique de version (semver)
|
||||
├── Inventory_frontend/ # ← SUBMODULE GIT (repo séparé)
|
||||
│ ├── app/pages/ # Pages Nuxt (file-based routing)
|
||||
│ ├── app/components/ # Composants Vue (auto-imported)
|
||||
│ ├── app/composables/ # Composables Vue
|
||||
│ ├── app/shared/ # Types, utils, validation
|
||||
│ ├── app/middleware/ # Auth middleware global
|
||||
│ └── app/services/ # Service layer (wrappers useApi)
|
||||
```
|
||||
|
||||
## Key Commands
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
make start # Démarrer les containers
|
||||
make stop # Arrêter
|
||||
make shell # Shell dans le container PHP
|
||||
make install # Install complet (composer + npm + build)
|
||||
|
||||
# Backend
|
||||
make test # PHPUnit
|
||||
docker compose exec php vendor/bin/php-cs-fixer fix # Linter PHP
|
||||
docker compose exec php php bin/console doctrine:migrations:migrate
|
||||
|
||||
# Frontend (dans Inventory_frontend/)
|
||||
npm run dev # Dev server (port 3001)
|
||||
npm run build # Build production
|
||||
npm run lint:fix # ESLint fix
|
||||
npx nuxi typecheck # TypeScript check (0 errors attendu)
|
||||
|
||||
# Release
|
||||
./scripts/release.sh patch # Bump patch version (ou minor/major)
|
||||
```
|
||||
|
||||
## Git Conventions
|
||||
|
||||
### Branches
|
||||
- `master` — production
|
||||
- `develop` — branche principale de dev (cible des PR)
|
||||
- `feat/xxx`, `fix/xxx`, `refactor/xxx` — branches de travail
|
||||
|
||||
### Commit Message Format (enforced by hook)
|
||||
```
|
||||
<type>(<scope optionnel>) : <message>
|
||||
```
|
||||
**Espace obligatoire autour du `:`**. Types autorisés (minuscules) :
|
||||
`build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`, `wip`
|
||||
|
||||
Exemples :
|
||||
- `feat(auth) : add login page`
|
||||
- `fix(machines) : prevent null crash on skeleton creation`
|
||||
|
||||
### Pre-commit Hook
|
||||
1. php-cs-fixer sur les fichiers PHP stagés
|
||||
2. PHPUnit — bloque le commit si tests échouent
|
||||
|
||||
### Submodule Workflow
|
||||
Le frontend est un submodule git. Lors d'un commit frontend :
|
||||
1. Commit dans `Inventory_frontend/` d'abord
|
||||
2. Commit dans le repo principal pour mettre à jour le pointeur submodule
|
||||
3. Push les deux repos
|
||||
|
||||
## Architecture Backend
|
||||
|
||||
### Entités Principales
|
||||
`Machine`, `Piece`, `Composant`, `Product`, `Constructeur`, `Site`, `TypeMachine`, `ModelType`, `CustomField`, `CustomFieldValue`, `Document`, `AuditLog`, `Comment`, `Profile`
|
||||
|
||||
### Patterns
|
||||
- **IDs** : CUID-like strings (`'cl' + bin2hex(random_bytes(12))`), pas d'auto-increment
|
||||
- **ORM** : Attributs PHP 8 (`#[ORM\Column(...)]`, `#[Groups([...])]`)
|
||||
- **Lifecycle** : `#[ORM\HasLifecycleCallbacks]` avec `PrePersist`/`PreUpdate` pour `createdAt`/`updatedAt`
|
||||
- **Sécurité** : `security: "is_granted('ROLE_...')"` sur chaque opération API Platform
|
||||
- **Audit** : Subscribers Doctrine `onFlush` capturent diff + snapshot complet
|
||||
- **Migrations** : Raw SQL PostgreSQL avec `IF NOT EXISTS`/`IF EXISTS` pour idempotence
|
||||
|
||||
### Rôles (hiérarchie)
|
||||
```
|
||||
ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
|
||||
```
|
||||
|
||||
### PostgreSQL — ATTENTION
|
||||
- Les noms de colonnes sont **TOUJOURS EN MINUSCULES** dans PG
|
||||
- Doctrine utilise camelCase (`typePieceId`) mais PG stocke `typepieceid`
|
||||
- Le SQL brut doit utiliser les noms lowercase
|
||||
- Tables de jointure many-to-many : colonnes `a` et `b` (ex: `_piececonstructeurs`)
|
||||
|
||||
## Architecture Frontend
|
||||
|
||||
### Patterns
|
||||
- **Composables** : `interface Deps { ... }` + `export function useXxx(deps: Deps)`
|
||||
- **Communication composants** : Props + Events uniquement (pas de provide/inject)
|
||||
- **API** : `useApi.ts` wraps fetch avec `credentials: 'include'` pour les cookies session
|
||||
- **Content-Type** : `application/ld+json` pour POST/PUT, `application/merge-patch+json` pour PATCH
|
||||
- **Auth** : `useProfileSession` + middleware global `profile.global.ts`
|
||||
- **Permissions** : `usePermissions.ts` miroir de la hiérarchie backend côté client
|
||||
- **Auto-imports** : Nuxt auto-importe composants (`components/`) et composables (`composables/`)
|
||||
|
||||
### DaisyUI Classes
|
||||
- Input : `input input-bordered input-sm md:input-md`
|
||||
- Textarea : `textarea textarea-bordered textarea-sm md:textarea-md`
|
||||
- Select : `select select-bordered select-sm md:select-md`
|
||||
- Button : `btn btn-sm md:btn-md btn-primary`
|
||||
|
||||
## Règles Importantes
|
||||
|
||||
### Toujours faire AVANT de modifier du code
|
||||
1. **Lire le fichier** avant de l'éditer — ne jamais proposer de changements sur du code non lu
|
||||
2. **Comprendre le pattern existant** — reproduire le style du fichier (noms, indentation, structure)
|
||||
3. **Vérifier les deux repos** — un changement peut impacter backend ET frontend
|
||||
|
||||
### Après chaque modification
|
||||
1. Backend PHP : `docker compose exec php vendor/bin/php-cs-fixer fix`
|
||||
2. Frontend : `npm run lint:fix` puis `npx nuxi typecheck` si fichiers TS modifiés
|
||||
|
||||
### Ne jamais faire
|
||||
- Ajouter des features non demandées, du code mort, ou des abstractions prématurées
|
||||
- Utiliser `provide/inject` — le codebase utilise Props + Events
|
||||
- Utiliser JWT/tokens — l'auth est session-based
|
||||
- Écrire du SQL avec des noms camelCase — PostgreSQL = lowercase
|
||||
- Committer sans que l'utilisateur le demande explicitement
|
||||
- Force push sans confirmation explicite
|
||||
- Modifier la config git
|
||||
|
||||
### Submodule — Synchronisation
|
||||
Quand les branches `master` et `develop` divergent sur l'un des deux repos, **toujours les synchroniser** :
|
||||
- Main repo : `git checkout master && git merge develop && git push`
|
||||
- Frontend : `git checkout develop && git merge master && git push` (ou l'inverse selon le cas)
|
||||
|
||||
## URLs Locales
|
||||
- API Symfony : `http://localhost:8081/api`
|
||||
- Nuxt dev : `http://localhost:3001`
|
||||
- Adminer (PG) : `http://localhost:5050`
|
||||
- PG direct : `localhost:5433` (user: root, pass: root, db: inventory)
|
||||
Submodule Inventory_frontend updated: adccfa9b46...32d03b480d
1419
MIGRATION_PLAN.md
1419
MIGRATION_PLAN.md
File diff suppressed because it is too large
Load Diff
264
README.md
264
README.md
@@ -1,92 +1,220 @@
|
||||
# Projet Inventory
|
||||
# Inventory
|
||||
|
||||
## Installation du projet
|
||||
### Windows
|
||||
Pour windows, il faut installer le WSL2, Ubuntu, docker et nvm.
|
||||
Il suffit de suivre cette [doc](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/windows)
|
||||
Application de gestion d'inventaire industriel pour **Malio**. Gestion complète du parc machines, des pièces, composants, produits, fournisseurs et documents associés, avec traçabilité et contrôle d'accès par rôles.
|
||||
|
||||
### Linux
|
||||
Pour linux, il faut installer docker et nvm.
|
||||
Il suffit de suivre cette [doc](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/linux)
|
||||
## Stack technique
|
||||
|
||||
| Couche | Technologie | Version |
|
||||
|--------|-------------|---------|
|
||||
| Backend | Symfony + API Platform | 8.0 / 4.2 |
|
||||
| PHP | PHP | >= 8.4 |
|
||||
| Base de données | PostgreSQL | 16 |
|
||||
| Frontend | Nuxt (SPA, SSR off) | 4 |
|
||||
| UI | Vue 3 Composition API + TypeScript | 3.5 / 5.7 |
|
||||
| CSS | TailwindCSS + DaisyUI | 4 / 5 |
|
||||
| Conteneurs | Docker Compose | |
|
||||
|
||||
## Prérequis
|
||||
|
||||
- **Docker** et **Docker Compose**
|
||||
- **Node.js** >= 20 (via nvm)
|
||||
- **make**
|
||||
|
||||
### Installation de l'environnement
|
||||
|
||||
| OS | Documentation |
|
||||
|----|---------------|
|
||||
| Windows | [WSL2 + Ubuntu + Docker](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/windows) |
|
||||
| Linux | [Docker + nvm](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/linux) |
|
||||
|
||||
## Installation
|
||||
|
||||
### Installation du projet
|
||||
Une fois les prérequis installés, il suffit de cloner le projet et de lancer les commandes suivantes
|
||||
```bash
|
||||
sudo apt install make -y
|
||||
git clone --recurse-submodules <url-du-repo>
|
||||
cd Inventory
|
||||
make start
|
||||
make install
|
||||
```
|
||||
Dans le cas ou le `make start` plante à cause du port de la bdd, il faut modifier **POSTGRES_PORT** dans le fichier .env.docker.local, remplacer le par un port disponible.
|
||||
|
||||
### Configuration xdebug
|
||||
Pour configurer xdebug, il faut ajouter un serveur sur phpstorm. <br>
|
||||
Pour cela, il faut aller dans **Settings > PHP > Servers** <br>
|
||||
* Name : inventory-docker
|
||||
* Host : localhost
|
||||
* Port : 8080
|
||||
* Path : File/Directory -> l'endroit où est stocké votre projet et le path -> /var/www/html
|
||||
> Si `make start` échoue sur le port de la BDD, modifier `POSTGRES_PORT` dans `docker/.env.docker.local`.
|
||||
|
||||
Pour que xdebug fonctionne sur windows, il faut modifier la variable **XDEBUG_CLIENT_HOST** par votre ip local
|
||||
## URLs locales
|
||||
|
||||
| Service | URL |
|
||||
|---------|-----|
|
||||
| API Symfony | http://localhost:8081/api |
|
||||
| Frontend Nuxt | http://localhost:3001 |
|
||||
| Adminer (BDD) | http://localhost:5050 |
|
||||
| PostgreSQL | `localhost:5433` (user: root, pass: root, db: inventory) |
|
||||
|
||||
## Commandes
|
||||
|
||||
### Docker
|
||||
|
||||
| Commande | Description |
|
||||
|----------|-------------|
|
||||
| `make start` | Démarrer les conteneurs |
|
||||
| `make stop` | Arrêter les conteneurs |
|
||||
| `make restart` | Redémarrer les conteneurs |
|
||||
| `make shell` | Shell bash dans le conteneur PHP |
|
||||
| `make reset` | Reset complet (supprime volumes, réinstalle) |
|
||||
|
||||
## Utilisation du projet
|
||||
### Backend
|
||||
L'api est disponible sur http://localhost:8080/api
|
||||
Pour la bdd toutes les infos sont dans le fichier **docker/.env.docker.local**
|
||||
Vous pouvez modifier le port si nécessaire.
|
||||
|
||||
La bdd est déja pré-configuré dans PhpStorm, il suffit de rentrer les infos du .env.docker.local pour se connecter.
|
||||
C'est un bdd local dans le docker.
|
||||
| Commande | Description |
|
||||
|----------|-------------|
|
||||
| `make test` | Lancer les tests PHPUnit |
|
||||
| `make php-cs-fixer-allow-risky` | Formatter le code PHP |
|
||||
| `make cache-clear` | Vider le cache Symfony |
|
||||
| `make db-reset` | Reset de la BDD (supprime les données) |
|
||||
| `make fixtures-load` | Charger les fixtures SQL |
|
||||
| `make fixtures-dump` | Dumper la BDD dans fixtures/data.sql |
|
||||
|
||||
### Frontend
|
||||
Le frontend utilise le dossier `Inventory_frontend/`.
|
||||
Pour le frontend, il suffit de taper la commande suivante qui va lancer le serveur de dev
|
||||
|
||||
| Commande | Description |
|
||||
|----------|-------------|
|
||||
| `make dev-nuxt` | Serveur de dev Nuxt |
|
||||
| `make build-nuxtJS` | Build de production |
|
||||
|
||||
### Release
|
||||
|
||||
```bash
|
||||
make dev-nuxt
|
||||
```
|
||||
Le front sera accessible sur http://localhost:3000
|
||||
|
||||
## Compression automatique des PDFs
|
||||
|
||||
Les documents PDF uploadés sont automatiquement compressés sans perte de qualité grâce à **qpdf**.
|
||||
|
||||
### Prérequis
|
||||
```bash
|
||||
# Installation de qpdf (outil système)
|
||||
sudo apt install qpdf
|
||||
|
||||
# Ou dans Docker
|
||||
docker exec -it php-inventory-apache apt update && apt install -y qpdf
|
||||
./scripts/release.sh patch # Bump patch (ou minor / major)
|
||||
```
|
||||
|
||||
### Fonctionnement
|
||||
- À chaque upload de PDF, le système compresse automatiquement le fichier
|
||||
- Compression lossless (sans perte de qualité)
|
||||
- Le PDF est compressé uniquement si la taille diminue
|
||||
- Si qpdf n'est pas installé, le système fonctionne normalement sans compression
|
||||
Synchronise automatiquement la version dans `VERSION`, `api_platform.yaml` et `nuxt.config.ts`, crée le tag git et pousse les deux repos.
|
||||
|
||||
### Compresser les PDFs existants
|
||||
Pour compresser tous les PDFs déjà en base :
|
||||
```bash
|
||||
# Voir ce qui serait compressé (dry-run)
|
||||
php bin/console app:compress-pdf --dry-run
|
||||
## Architecture
|
||||
|
||||
# Compresser tous les PDFs
|
||||
php bin/console app:compress-pdf
|
||||
### Structure du projet
|
||||
|
||||
```
|
||||
Inventory/ # Backend Symfony (repo principal)
|
||||
├── src/
|
||||
│ ├── Entity/ # 20 entités Doctrine (attributs PHP 8)
|
||||
│ ├── Controller/ # 16 contrôleurs custom
|
||||
│ ├── EventSubscriber/ # 9 subscribers (audit onFlush)
|
||||
│ ├── EventListener/ # Listeners documents (cleanup, compression)
|
||||
│ ├── Command/ # 3 commandes CLI
|
||||
│ ├── Service/ # 3 services (stockage, conversion, PDF)
|
||||
│ ├── State/ # 3 processeurs API Platform
|
||||
│ ├── Repository/ # 19 repositories Doctrine
|
||||
│ ├── Security/ # Authenticateur session
|
||||
│ └── Serializer/ # Normalizer custom (Document)
|
||||
├── config/ # Configuration Symfony
|
||||
├── migrations/ # 4 migrations Doctrine (SQL PostgreSQL)
|
||||
├── fixtures/ # Données de test (SQL)
|
||||
├── scripts/ # Utilitaires (release, migration, normalisation)
|
||||
├── docker/ # Dockerfile + config Docker
|
||||
├── makefile # Commandes de dev
|
||||
├── VERSION # Version courante (semver)
|
||||
└── Inventory_frontend/ # Submodule git (repo séparé)
|
||||
├── app/pages/ # 36 pages Nuxt (file-based routing)
|
||||
├── app/components/ # 57 composants Vue
|
||||
├── app/composables/ # 45 composables
|
||||
└── app/shared/ # Types, utils, validation
|
||||
```
|
||||
|
||||
## Commandes utiles
|
||||
Pour restart le container
|
||||
```bash
|
||||
make restart
|
||||
### Entités principales
|
||||
|
||||
| Entité | Description |
|
||||
|--------|-------------|
|
||||
| `Machine` | Machines du parc industriel |
|
||||
| `Composant` | Composants rattachés aux machines |
|
||||
| `Piece` | Pièces détachées |
|
||||
| `Product` | Produits (consommables, outillage) |
|
||||
| `Site` | Sites physiques / usines |
|
||||
| `Constructeur` | Fournisseurs / fabricants |
|
||||
| `TypeMachine` | Types de machines avec squelettes de structure |
|
||||
| `ModelType` | Catégories (pièce, composant, produit) avec champs personnalisés |
|
||||
| `CustomField` / `CustomFieldValue` | Champs personnalisés extensibles |
|
||||
| `Document` | Documents uploadés (stockage fichier + compression PDF) |
|
||||
| `AuditLog` | Journal d'audit (diff + snapshot) |
|
||||
| `Comment` | Commentaires / tickets sur les fiches |
|
||||
| `Profile` | Utilisateurs avec rôles |
|
||||
|
||||
### Commandes Symfony
|
||||
|
||||
| Commande | Description |
|
||||
|----------|-------------|
|
||||
| `app:compress-pdf` | Compresser les PDFs existants (supporte `--dry-run`) |
|
||||
| `app:migrate-documents-to-filesystem` | Migrer les documents Base64 vers le système de fichiers |
|
||||
| `app:init-profile-passwords` | Initialiser mots de passe et rôles en masse |
|
||||
|
||||
### Rôles et permissions
|
||||
|
||||
```
|
||||
Pour lancer les TU
|
||||
```bash
|
||||
make test
|
||||
ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
|
||||
```
|
||||
Pour accéder au container et lance des commandes
|
||||
```bash
|
||||
make shell
|
||||
|
||||
- **ADMIN** : accès complet, gestion des profils
|
||||
- **GESTIONNAIRE** : CRUD sur toutes les entités, résolution des commentaires
|
||||
- **VIEWER** : lecture seule sur toutes les entités
|
||||
- **USER** : accès de base
|
||||
|
||||
### Authentification
|
||||
|
||||
Authentification par **session (cookies)**, pas de JWT. Le profil actif est stocké en session côté serveur.
|
||||
|
||||
### Base de données
|
||||
|
||||
PostgreSQL 16 avec les particularités suivantes :
|
||||
- **IDs** : chaînes CUID (`'cl' + bin2hex(random_bytes(12))`), pas d'auto-increment
|
||||
- **Noms de colonnes** : toujours en **minuscules** dans PostgreSQL (Doctrine map `typePieceId` → `typepieceid`)
|
||||
- **Audit** : les subscribers Doctrine `onFlush` capturent le diff + snapshot complet de chaque modification
|
||||
- **Migrations** : SQL brut avec `IF NOT EXISTS` / `IF EXISTS` pour l'idempotence
|
||||
|
||||
## Services Docker
|
||||
|
||||
| Service | Image | Port | Rôle |
|
||||
|---------|-------|------|------|
|
||||
| `web` | PHP 8.4 + Apache + Node | 8081, 3001 | API Symfony + Nuxt dev |
|
||||
| `db` | PostgreSQL 16 Alpine | 5433 | Base de données |
|
||||
| `adminer` | Adminer | 5050 | Interface web BDD |
|
||||
|
||||
## Xdebug
|
||||
|
||||
Configuration PhpStorm / VSCode :
|
||||
- **Serveur** : `inventory-docker`
|
||||
- **Host** : `localhost`
|
||||
- **Port** : `8081`
|
||||
- **Path mapping** : racine du projet → `/var/www/html`
|
||||
|
||||
> Sous WSL, modifier `XDEBUG_CLIENT_HOST` dans `docker/.env.docker.local` avec votre IP locale.
|
||||
|
||||
## Git
|
||||
|
||||
### Branches
|
||||
|
||||
- `master` : production
|
||||
- `develop` : branche principale de dev (cible des PR)
|
||||
- `feat/xxx`, `fix/xxx`, `refactor/xxx` : branches de travail
|
||||
|
||||
### Convention de commit
|
||||
|
||||
```
|
||||
Pour clear le cache Symfony
|
||||
```bash
|
||||
make cache-clear
|
||||
<type>(<scope>) : <message>
|
||||
```
|
||||
|
||||
**Espace obligatoire autour du `:`**. Types : `feat`, `fix`, `perf`, `refactor`, `chore`, `docs`, `test`, `style`, `build`, `ci`, `revert`, `wip`.
|
||||
|
||||
### Pre-commit hook
|
||||
|
||||
1. php-cs-fixer sur les fichiers PHP stagés
|
||||
2. PHPUnit — bloque le commit si les tests échouent
|
||||
|
||||
### Submodule frontend
|
||||
|
||||
Le frontend est un **submodule git** dans `Inventory_frontend/`. Workflow :
|
||||
|
||||
1. Commiter dans `Inventory_frontend/` d'abord
|
||||
2. Commiter dans le repo principal pour mettre à jour le pointeur
|
||||
3. Pousser les deux repos
|
||||
|
||||
## Documentation complémentaire
|
||||
|
||||
- [DEPLOY.md](DEPLOY.md) : guide de déploiement serveur (Nginx, PHP-FPM, PostgreSQL)
|
||||
- [RELEASE.md](RELEASE.md) : processus de release et versioning
|
||||
- [CHANGELOG.md](CHANGELOG.md) : historique des versions
|
||||
- [Frontend README](Inventory_frontend/README.md) : documentation du frontend Nuxt
|
||||
|
||||
2
TODO.md
2
TODO.md
@@ -1,2 +0,0 @@
|
||||
- Doc: ne pas oublier de mettre `make` dans la documentation.
|
||||
- Note: le probleme d'IP sous WSL, a ajouter dans la doc.
|
||||
@@ -1,7 +1,9 @@
|
||||
api_platform:
|
||||
title: Hello API Platform
|
||||
version: 1.1.1
|
||||
version: 1.8.1
|
||||
defaults:
|
||||
stateless: false
|
||||
cache_headers:
|
||||
vary: ['Content-Type', 'Authorization', 'Origin']
|
||||
pagination_items_per_page: 30
|
||||
pagination_maximum_items_per_page: 200
|
||||
|
||||
@@ -29,33 +29,36 @@ security:
|
||||
success_handler: lexik_jwt_authentication.handler.authentication_success
|
||||
failure_handler: lexik_jwt_authentication.handler.authentication_failure
|
||||
|
||||
session_profile:
|
||||
pattern: ^/api/session
|
||||
stateless: false
|
||||
|
||||
session_api:
|
||||
pattern: ^/api/(sites|machines|documents|profiles)
|
||||
stateless: false
|
||||
session_public:
|
||||
pattern: ^/api/session/profiles?$
|
||||
security: false
|
||||
|
||||
api:
|
||||
pattern: ^/api
|
||||
stateless: false
|
||||
stateless: true
|
||||
custom_authenticators:
|
||||
- App\Security\SessionProfileAuthenticator
|
||||
|
||||
main:
|
||||
lazy: true
|
||||
provider: app_user_provider
|
||||
|
||||
role_hierarchy:
|
||||
ROLE_ADMIN: ROLE_GESTIONNAIRE
|
||||
ROLE_GESTIONNAIRE: ROLE_VIEWER
|
||||
ROLE_VIEWER: ROLE_USER
|
||||
|
||||
# Note: Only the *first* matching rule is applied
|
||||
access_control:
|
||||
- { path: ^/api/session/profile, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/session/profiles, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/session/profile$, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/session/profiles, roles: PUBLIC_ACCESS, methods: [GET] }
|
||||
- { path: ^/api/admin, roles: ROLE_ADMIN }
|
||||
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/test, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/docs, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/contexts, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/\.well-known, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
||||
- { path: ^/api, roles: ROLE_VIEWER }
|
||||
|
||||
when@test:
|
||||
security:
|
||||
|
||||
@@ -21,3 +21,15 @@ services:
|
||||
|
||||
# add more service definitions when explicit configuration is needed
|
||||
# please note that last definitions always *replace* previous ones
|
||||
|
||||
App\EventSubscriber\ProductAuditSubscriber:
|
||||
tags:
|
||||
- { name: doctrine.event_subscriber }
|
||||
|
||||
App\EventSubscriber\PieceAuditSubscriber:
|
||||
tags:
|
||||
- { name: doctrine.event_subscriber }
|
||||
|
||||
App\EventSubscriber\ComposantAuditSubscriber:
|
||||
tags:
|
||||
- { name: doctrine.event_subscriber }
|
||||
|
||||
@@ -45,34 +45,17 @@ services:
|
||||
- "${POSTGRES_PORT:-5433}:5432"
|
||||
restart: unless-stopped
|
||||
|
||||
pgadmin:
|
||||
container_name: pgadmin-${DOCKER_APP_NAME}
|
||||
image: dpage/pgadmin4:latest
|
||||
user: root
|
||||
adminer:
|
||||
container_name: adminer-${DOCKER_APP_NAME}
|
||||
image: adminer:latest
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@admin.com}
|
||||
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-admin}
|
||||
PGADMIN_CONFIG_SERVER_MODE: 'False'
|
||||
PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False'
|
||||
PGADMIN_SERVER_JSON_FILE: '/pgadmin4/servers.json'
|
||||
volumes:
|
||||
- pgadmin_data:/var/lib/pgadmin
|
||||
- ./docker/pgadmin/servers.json:/pgadmin4/servers.json:ro
|
||||
- ./docker/pgadmin/pgpass:/pgadmin4/pgpass:ro
|
||||
ADMINER_DEFAULT_SERVER: db
|
||||
ADMINER_DESIGN: dracula
|
||||
ports:
|
||||
- "${PGADMIN_PORT:-5050}:80"
|
||||
- "${ADMINER_PORT:-5050}:8080"
|
||||
depends_on:
|
||||
- db
|
||||
restart: unless-stopped
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
mkdir -p /var/lib/pgadmin &&
|
||||
cp /pgadmin4/pgpass /var/lib/pgadmin/pgpass &&
|
||||
chmod 600 /var/lib/pgadmin/pgpass &&
|
||||
chown 5050:5050 /var/lib/pgadmin/pgpass &&
|
||||
/entrypoint.sh
|
||||
"
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
pgadmin_data:
|
||||
|
||||
@@ -6,4 +6,4 @@ POSTGRES_DB=inventory
|
||||
POSTGRES_USER=root
|
||||
POSTGRES_PASSWORD=root
|
||||
POSTGRES_PORT=5432
|
||||
XDEBUG_CLIENT_HOST=host.docker.internal
|
||||
XDEBUG_CLIENT_HOST=host.docker.internal
|
||||
24
frontend/.gitignore
vendored
24
frontend/.gitignore
vendored
@@ -1,24 +0,0 @@
|
||||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -1,75 +0,0 @@
|
||||
# Nuxt Minimal Starter
|
||||
|
||||
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||
|
||||
## Setup
|
||||
|
||||
Make sure to install dependencies:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm install
|
||||
|
||||
# pnpm
|
||||
pnpm install
|
||||
|
||||
# yarn
|
||||
yarn install
|
||||
|
||||
# bun
|
||||
bun install
|
||||
```
|
||||
|
||||
## Development Server
|
||||
|
||||
Start the development server on `http://localhost:3000`:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run dev
|
||||
|
||||
# pnpm
|
||||
pnpm dev
|
||||
|
||||
# yarn
|
||||
yarn dev
|
||||
|
||||
# bun
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Production
|
||||
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run build
|
||||
|
||||
# pnpm
|
||||
pnpm build
|
||||
|
||||
# yarn
|
||||
yarn build
|
||||
|
||||
# bun
|
||||
bun run build
|
||||
```
|
||||
|
||||
Locally preview production build:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run preview
|
||||
|
||||
# pnpm
|
||||
pnpm preview
|
||||
|
||||
# yarn
|
||||
yarn preview
|
||||
|
||||
# bun
|
||||
bun run preview
|
||||
```
|
||||
|
||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||
@@ -1,3 +0,0 @@
|
||||
<template>
|
||||
<NuxtPage/>
|
||||
</template>
|
||||
@@ -1,9 +0,0 @@
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: true },
|
||||
ssr: false,
|
||||
modules: ['@nuxtjs/tailwindcss'],
|
||||
typescript: {
|
||||
strict: true
|
||||
}
|
||||
})
|
||||
11892
frontend/package-lock.json
generated
11892
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare",
|
||||
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"nuxt": "^4.2.2",
|
||||
"vue": "^3.5.26",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center">
|
||||
<h1 class="text-3xl font-bold">Nuxt OK ✅</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
@@ -1,2 +0,0 @@
|
||||
User-Agent: *
|
||||
Disallow:
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
// https://nuxt.com/docs/guide/concepts/typescript
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.server.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.shared.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
# Migration DB (manuel)
|
||||
|
||||
Ce guide explique comment importer un dump SQL venant de pgAdmin dans la base Docker.
|
||||
|
||||
## 1) Export pgAdmin
|
||||
|
||||
Dans pgAdmin:
|
||||
|
||||
- Format: Plain
|
||||
- Options: Use INSERT commands + Use column inserts
|
||||
- Fichier: `data.sql`
|
||||
|
||||
## 2) Normaliser le dump
|
||||
|
||||
Convertit les colonnes camelCase en lowercase compact.
|
||||
|
||||
```bash
|
||||
python3 scripts/normalize-dump.py data.sql data_norm.sql --lower
|
||||
```
|
||||
|
||||
## 3) Importer dans la base Docker
|
||||
|
||||
Utilise `session_replication_role` pour eviter les erreurs de contraintes circulaires.
|
||||
|
||||
```bash
|
||||
docker compose --env-file docker/.env.docker.local exec -T db psql -U root -d inventory -v ON_ERROR_STOP=1 -c "SET session_replication_role = replica;"
|
||||
docker compose --env-file docker/.env.docker.local exec -T db psql -U root -d inventory -v ON_ERROR_STOP=1 < data_norm.sql
|
||||
docker compose --env-file docker/.env.docker.local exec -T db psql -U root -d inventory -v ON_ERROR_STOP=1 -c "SET session_replication_role = DEFAULT;"
|
||||
```
|
||||
|
||||
## 4) Verifier
|
||||
|
||||
```bash
|
||||
docker compose --env-file docker/.env.docker.local exec -T db psql -U $POSTGRES_USER -d $POSTGRES_DB -c "\\dt"
|
||||
```
|
||||
@@ -20,165 +20,792 @@ final class Version20260125143939 extends AbstractMigration
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER INDEX idx_f95a3199df92e79b RENAME TO IDX_F95A3199CC8A4CEE');
|
||||
$this->addSql('ALTER INDEX idx_f95a3199a3fdb2a7 RENAME TO IDX_F95A319936799605');
|
||||
$this->addSql('ALTER TABLE _composantconstructeurs DROP CONSTRAINT "_ComposantConstructeurs_A_fkey"');
|
||||
$this->addSql('ALTER TABLE _composantconstructeurs DROP CONSTRAINT "_ComposantConstructeurs_B_fkey"');
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_f95a3199df92e79b') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_f95a3199df92e79b RENAME TO IDX_F95A3199CC8A4CEE';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_f95a3199a3fdb2a7') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_f95a3199a3fdb2a7 RENAME TO IDX_F95A319936799605';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql('ALTER TABLE _composantconstructeurs DROP CONSTRAINT IF EXISTS "_ComposantConstructeurs_A_fkey"');
|
||||
$this->addSql('ALTER TABLE _composantconstructeurs DROP CONSTRAINT IF EXISTS "_ComposantConstructeurs_B_fkey"');
|
||||
$this->addSql('ALTER TABLE _composantconstructeurs ALTER A TYPE VARCHAR(36)');
|
||||
$this->addSql('ALTER TABLE _composantconstructeurs ALTER B TYPE VARCHAR(36)');
|
||||
$this->addSql('ALTER TABLE _composantconstructeurs ADD CONSTRAINT FK_60760125D3D99E8B FOREIGN KEY (A) REFERENCES composants (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE _composantconstructeurs ADD CONSTRAINT FK_607601254AD0CF31 FOREIGN KEY (B) REFERENCES constructeurs (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE _composantconstructeurs ADD PRIMARY KEY (A, B)');
|
||||
$this->addSql('ALTER INDEX idx_5b97d813e8b7be43 RENAME TO IDX_60760125D3D99E8B');
|
||||
$this->addSql('ALTER INDEX _composantconstructeurs_b_index RENAME TO IDX_607601254AD0CF31');
|
||||
$this->addSql('ALTER INDEX idx_6b64d7ff6736d61 RENAME TO IDX_6B64D7FF5C4A705F');
|
||||
$this->addSql('ALTER INDEX idx_6b64d7fff6bae05f RENAME TO IDX_6B64D7FF633EC4FD');
|
||||
$this->addSql('ALTER INDEX idx_6b64d7ffa1dac1c6 RENAME TO IDX_6B64D7FF345EE564');
|
||||
$this->addSql('ALTER INDEX idx_6b64d7ff96428d73 RENAME TO IDX_6B64D7FF3C6A9D1');
|
||||
$this->addSql('ALTER INDEX idx_6b64d7ffa3fdb2a7 RENAME TO IDX_6B64D7FF36799605');
|
||||
$this->addSql('ALTER INDEX idx_4a48378c158582c3 RENAME TO IDX_4A48378C2F024C2');
|
||||
$this->addSql('ALTER INDEX idx_4a48378cdf92e79b RENAME TO IDX_4A48378CCC8A4CEE');
|
||||
$this->addSql('ALTER INDEX idx_4a48378c4ca601c8 RENAME TO IDX_4A48378C169F1CF6');
|
||||
$this->addSql('ALTER INDEX idx_4a48378c40c2d03b RENAME TO IDX_4A48378C57B7763A');
|
||||
$this->addSql('ALTER INDEX idx_a2b07288f6bae05f RENAME TO IDX_A2B07288633EC4FD');
|
||||
$this->addSql('ALTER INDEX idx_a2b07288a1dac1c6 RENAME TO IDX_A2B07288345EE564');
|
||||
$this->addSql('ALTER INDEX idx_a2b0728896428d73 RENAME TO IDX_A2B072883C6A9D1');
|
||||
$this->addSql('ALTER INDEX idx_a2b07288a3fdb2a7 RENAME TO IDX_A2B0728836799605');
|
||||
$this->addSql('ALTER INDEX idx_a2b07288fcf7805f RENAME TO IDX_A2B072886973A4FD');
|
||||
$this->addSql('ALTER INDEX idx_528efe19f6bae05f RENAME TO IDX_528EFE19633EC4FD');
|
||||
$this->addSql('ALTER INDEX idx_528efe19a1dac1c6 RENAME TO IDX_528EFE19345EE564');
|
||||
$this->addSql('ALTER INDEX idx_528efe197d44d2df RENAME TO IDX_528EFE19EF6CF34B');
|
||||
$this->addSql('ALTER INDEX idx_528efe19bcced9e3 RENAME TO IDX_528EFE19C44B383C');
|
||||
$this->addSql('ALTER INDEX idx_62941615f6bae05f RENAME TO IDX_62941615633EC4FD');
|
||||
$this->addSql('ALTER INDEX idx_6294161596428d73 RENAME TO IDX_629416153C6A9D1');
|
||||
$this->addSql('ALTER INDEX idx_629416157d44d2df RENAME TO IDX_62941615EF6CF34B');
|
||||
$this->addSql('ALTER INDEX idx_6294161532c54aaf RENAME TO IDX_62941615F957D314');
|
||||
$this->addSql('ALTER INDEX machine_product_links_machineid_idx RENAME TO IDX_8CC32259633EC4FD');
|
||||
$this->addSql('ALTER INDEX machine_product_links_productid_idx RENAME TO IDX_8CC3225936799605');
|
||||
$this->addSql('ALTER INDEX idx_8cc32259357fdbff RENAME TO IDX_8CC32259B590B209');
|
||||
$this->addSql('ALTER INDEX idx_8cc322597d44d2df RENAME TO IDX_8CC32259EF6CF34B');
|
||||
$this->addSql('ALTER INDEX idx_8cc32259bcd7dad6 RENAME TO IDX_8CC32259A63AC5DC');
|
||||
$this->addSql('ALTER INDEX idx_8cc3225987ceb33f RENAME TO IDX_8CC32259937A1D7C');
|
||||
$this->addSql('ALTER INDEX idx_f1ce8dedfcf7805f RENAME TO IDX_F1CE8DED6973A4FD');
|
||||
$this->addSql('ALTER INDEX idx_f1ce8ded158582c3 RENAME TO IDX_F1CE8DED2F024C2');
|
||||
$this->addSql('ALTER TABLE _machineconstructeurs DROP CONSTRAINT "_MachineConstructeurs_B_fkey"');
|
||||
$this->addSql('ALTER TABLE _machineconstructeurs DROP CONSTRAINT "_MachineConstructeurs_A_fkey"');
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_5b97d813e8b7be43') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_5b97d813e8b7be43 RENAME TO IDX_60760125D3D99E8B';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('_composantconstructeurs_b_index') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX _composantconstructeurs_b_index RENAME TO IDX_607601254AD0CF31';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_6b64d7ff6736d61') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_6b64d7ff6736d61 RENAME TO IDX_6B64D7FF5C4A705F';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_6b64d7fff6bae05f') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_6b64d7fff6bae05f RENAME TO IDX_6B64D7FF633EC4FD';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_6b64d7ffa1dac1c6') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_6b64d7ffa1dac1c6 RENAME TO IDX_6B64D7FF345EE564';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_6b64d7ff96428d73') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_6b64d7ff96428d73 RENAME TO IDX_6B64D7FF3C6A9D1';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_6b64d7ffa3fdb2a7') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_6b64d7ffa3fdb2a7 RENAME TO IDX_6B64D7FF36799605';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_4a48378c158582c3') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_4a48378c158582c3 RENAME TO IDX_4A48378C2F024C2';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_4a48378cdf92e79b') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_4a48378cdf92e79b RENAME TO IDX_4A48378CCC8A4CEE';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_4a48378c4ca601c8') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_4a48378c4ca601c8 RENAME TO IDX_4A48378C169F1CF6';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_4a48378c40c2d03b') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_4a48378c40c2d03b RENAME TO IDX_4A48378C57B7763A';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_a2b07288f6bae05f') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_a2b07288f6bae05f RENAME TO IDX_A2B07288633EC4FD';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_a2b07288a1dac1c6') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_a2b07288a1dac1c6 RENAME TO IDX_A2B07288345EE564';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_a2b0728896428d73') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_a2b0728896428d73 RENAME TO IDX_A2B072883C6A9D1';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_a2b07288a3fdb2a7') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_a2b07288a3fdb2a7 RENAME TO IDX_A2B0728836799605';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_a2b07288fcf7805f') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_a2b07288fcf7805f RENAME TO IDX_A2B072886973A4FD';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_528efe19f6bae05f') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_528efe19f6bae05f RENAME TO IDX_528EFE19633EC4FD';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_528efe19a1dac1c6') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_528efe19a1dac1c6 RENAME TO IDX_528EFE19345EE564';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_528efe197d44d2df') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_528efe197d44d2df RENAME TO IDX_528EFE19EF6CF34B';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_528efe19bcced9e3') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_528efe19bcced9e3 RENAME TO IDX_528EFE19C44B383C';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_62941615f6bae05f') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_62941615f6bae05f RENAME TO IDX_62941615633EC4FD';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_6294161596428d73') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_6294161596428d73 RENAME TO IDX_629416153C6A9D1';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_629416157d44d2df') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_629416157d44d2df RENAME TO IDX_62941615EF6CF34B';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_6294161532c54aaf') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_6294161532c54aaf RENAME TO IDX_62941615F957D314';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('machine_product_links_machineid_idx') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX machine_product_links_machineid_idx RENAME TO IDX_8CC32259633EC4FD';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('machine_product_links_productid_idx') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX machine_product_links_productid_idx RENAME TO IDX_8CC3225936799605';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_8cc32259357fdbff') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_8cc32259357fdbff RENAME TO IDX_8CC32259B590B209';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_8cc322597d44d2df') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_8cc322597d44d2df RENAME TO IDX_8CC32259EF6CF34B';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_8cc32259bcd7dad6') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_8cc32259bcd7dad6 RENAME TO IDX_8CC32259A63AC5DC';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_8cc3225987ceb33f') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_8cc3225987ceb33f RENAME TO IDX_8CC32259937A1D7C';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_f1ce8dedfcf7805f') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_f1ce8dedfcf7805f RENAME TO IDX_F1CE8DED6973A4FD';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_f1ce8ded158582c3') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_f1ce8ded158582c3 RENAME TO IDX_F1CE8DED2F024C2';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql('ALTER TABLE _machineconstructeurs DROP CONSTRAINT IF EXISTS "_MachineConstructeurs_B_fkey"');
|
||||
$this->addSql('ALTER TABLE _machineconstructeurs DROP CONSTRAINT IF EXISTS "_MachineConstructeurs_A_fkey"');
|
||||
$this->addSql('ALTER TABLE _machineconstructeurs ALTER A TYPE VARCHAR(36)');
|
||||
$this->addSql('ALTER TABLE _machineconstructeurs ALTER B TYPE VARCHAR(36)');
|
||||
$this->addSql('ALTER TABLE _machineconstructeurs ADD CONSTRAINT FK_E6A040CCD3D99E8B FOREIGN KEY (A) REFERENCES machines (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE _machineconstructeurs ADD CONSTRAINT FK_E6A040CC4AD0CF31 FOREIGN KEY (B) REFERENCES constructeurs (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE _machineconstructeurs ADD PRIMARY KEY (A, B)');
|
||||
$this->addSql('ALTER INDEX idx_4f225b32e8b7be43 RENAME TO IDX_E6A040CCD3D99E8B');
|
||||
$this->addSql('ALTER INDEX _machineconstructeurs_b_index RENAME TO IDX_E6A040CC4AD0CF31');
|
||||
$this->addSql('DROP INDEX "ModelType_category_name_key"');
|
||||
$this->addSql('DROP INDEX "ModelType_code_key"');
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_4f225b32e8b7be43') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_4f225b32e8b7be43 RENAME TO IDX_E6A040CCD3D99E8B';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('_machineconstructeurs_b_index') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX _machineconstructeurs_b_index RENAME TO IDX_E6A040CC4AD0CF31';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql('ALTER TABLE model_types DROP CONSTRAINT IF EXISTS "ModelType_category_name_key"');
|
||||
$this->addSql('ALTER TABLE model_types DROP CONSTRAINT IF EXISTS "ModelType_code_key"');
|
||||
$this->addSql('ALTER TABLE model_types ALTER id TYPE VARCHAR(36)');
|
||||
$this->addSql('ALTER TABLE model_types ALTER category TYPE VARCHAR(255)');
|
||||
$this->addSql('ALTER TABLE model_types ALTER createdAt DROP DEFAULT');
|
||||
$this->addSql('ALTER TABLE model_types ALTER componentSkeleton TYPE JSON');
|
||||
$this->addSql('ALTER TABLE model_types ALTER pieceSkeleton TYPE JSON');
|
||||
$this->addSql('ALTER TABLE model_types ALTER productSkeleton TYPE JSON');
|
||||
$this->addSql('ALTER INDEX idx_b92d74724ca601c8 RENAME TO IDX_B92D7472169F1CF6');
|
||||
$this->addSql('ALTER INDEX idx_b92d7472a3fdb2a7 RENAME TO IDX_B92D747236799605');
|
||||
$this->addSql('ALTER TABLE _piececonstructeurs DROP CONSTRAINT "_PieceConstructeurs_A_fkey"');
|
||||
$this->addSql('ALTER TABLE _piececonstructeurs DROP CONSTRAINT "_PieceConstructeurs_B_fkey"');
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_b92d74724ca601c8') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_b92d74724ca601c8 RENAME TO IDX_B92D7472169F1CF6';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_b92d7472a3fdb2a7') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_b92d7472a3fdb2a7 RENAME TO IDX_B92D747236799605';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql('ALTER TABLE _piececonstructeurs DROP CONSTRAINT IF EXISTS "_PieceConstructeurs_A_fkey"');
|
||||
$this->addSql('ALTER TABLE _piececonstructeurs DROP CONSTRAINT IF EXISTS "_PieceConstructeurs_B_fkey"');
|
||||
$this->addSql('ALTER TABLE _piececonstructeurs ALTER A TYPE VARCHAR(36)');
|
||||
$this->addSql('ALTER TABLE _piececonstructeurs ALTER B TYPE VARCHAR(36)');
|
||||
$this->addSql('ALTER TABLE _piececonstructeurs ADD CONSTRAINT FK_E94732E5D3D99E8B FOREIGN KEY (A) REFERENCES pieces (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE _piececonstructeurs ADD CONSTRAINT FK_E94732E54AD0CF31 FOREIGN KEY (B) REFERENCES constructeurs (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE _piececonstructeurs ADD PRIMARY KEY (A, B)');
|
||||
$this->addSql('ALTER INDEX idx_77fc120e8b7be43 RENAME TO IDX_E94732E5D3D99E8B');
|
||||
$this->addSql('ALTER INDEX _piececonstructeurs_b_index RENAME TO IDX_E94732E54AD0CF31');
|
||||
$this->addSql('ALTER INDEX idx_b3ba5a5a40c2d03b RENAME TO IDX_B3BA5A5A57B7763A');
|
||||
$this->addSql('ALTER TABLE _productconstructeurs DROP CONSTRAINT "_ProductConstructeurs_B_fkey"');
|
||||
$this->addSql('ALTER TABLE _productconstructeurs DROP CONSTRAINT "_ProductConstructeurs_A_fkey"');
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_77fc120e8b7be43') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_77fc120e8b7be43 RENAME TO IDX_E94732E5D3D99E8B';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('_piececonstructeurs_b_index') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX _piececonstructeurs_b_index RENAME TO IDX_E94732E54AD0CF31';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_b3ba5a5a40c2d03b') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_b3ba5a5a40c2d03b RENAME TO IDX_B3BA5A5A57B7763A';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql('ALTER TABLE _productconstructeurs DROP CONSTRAINT IF EXISTS "_ProductConstructeurs_B_fkey"');
|
||||
$this->addSql('ALTER TABLE _productconstructeurs DROP CONSTRAINT IF EXISTS "_ProductConstructeurs_A_fkey"');
|
||||
$this->addSql('ALTER TABLE _productconstructeurs ALTER A TYPE VARCHAR(36)');
|
||||
$this->addSql('ALTER TABLE _productconstructeurs ALTER B TYPE VARCHAR(36)');
|
||||
// Clean orphaned relations before re-adding foreign keys.
|
||||
$this->addSql('DELETE FROM _productconstructeurs WHERE A IS NULL OR B IS NULL');
|
||||
$this->addSql('DELETE FROM _productconstructeurs pc WHERE NOT EXISTS (SELECT 1 FROM products p WHERE p.id = pc.A)');
|
||||
$this->addSql('DELETE FROM _productconstructeurs pc WHERE NOT EXISTS (SELECT 1 FROM constructeurs c WHERE c.id = pc.B)');
|
||||
$this->addSql('ALTER TABLE _productconstructeurs ADD CONSTRAINT FK_CF7403FCD3D99E8B FOREIGN KEY (A) REFERENCES products (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE _productconstructeurs ADD CONSTRAINT FK_CF7403FC4AD0CF31 FOREIGN KEY (B) REFERENCES constructeurs (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE _productconstructeurs ADD PRIMARY KEY (A, B)');
|
||||
$this->addSql('ALTER INDEX idx_66f61802e8b7be43 RENAME TO IDX_CF7403FCD3D99E8B');
|
||||
$this->addSql('ALTER INDEX _productconstructeurs_b_index RENAME TO IDX_CF7403FC4AD0CF31');
|
||||
$this->addSql('DROP INDEX uniq_profiles_email');
|
||||
$this->addSql('ALTER INDEX idx_96958790158582c3 RENAME TO IDX_969587902F024C2');
|
||||
$this->addSql('ALTER INDEX idx_96958790df92e79b RENAME TO IDX_96958790CC8A4CEE');
|
||||
$this->addSql('ALTER INDEX idx_f609e59e158582c3 RENAME TO IDX_F609E59E2F024C2');
|
||||
$this->addSql('ALTER INDEX idx_f609e59e4ca601c8 RENAME TO IDX_F609E59E169F1CF6');
|
||||
$this->addSql('ALTER INDEX idx_29a51f98158582c3 RENAME TO IDX_29A51F982F024C2');
|
||||
$this->addSql('ALTER INDEX idx_29a51f9840c2d03b RENAME TO IDX_29A51F9857B7763A');
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_66f61802e8b7be43') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_66f61802e8b7be43 RENAME TO IDX_CF7403FCD3D99E8B';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('_productconstructeurs_b_index') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX _productconstructeurs_b_index RENAME TO IDX_CF7403FC4AD0CF31';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql('DROP INDEX IF EXISTS uniq_profiles_email');
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_96958790158582c3') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_96958790158582c3 RENAME TO IDX_969587902F024C2';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_96958790df92e79b') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_96958790df92e79b RENAME TO IDX_96958790CC8A4CEE';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_f609e59e158582c3') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_f609e59e158582c3 RENAME TO IDX_F609E59E2F024C2';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_f609e59e4ca601c8') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_f609e59e4ca601c8 RENAME TO IDX_F609E59E169F1CF6';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_29a51f98158582c3') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_29a51f98158582c3 RENAME TO IDX_29A51F982F024C2';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_29a51f9840c2d03b') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_29a51f9840c2d03b RENAME TO IDX_29A51F9857B7763A';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE _ComposantConstructeurs DROP CONSTRAINT FK_60760125D3D99E8B');
|
||||
$this->addSql('ALTER TABLE _ComposantConstructeurs DROP CONSTRAINT FK_607601254AD0CF31');
|
||||
$this->addSql('ALTER TABLE _ComposantConstructeurs DROP CONSTRAINT _ComposantConstructeurs_pkey');
|
||||
$this->addSql('ALTER TABLE _ComposantConstructeurs DROP CONSTRAINT IF EXISTS FK_60760125D3D99E8B');
|
||||
$this->addSql('ALTER TABLE _ComposantConstructeurs DROP CONSTRAINT IF EXISTS FK_607601254AD0CF31');
|
||||
$this->addSql('ALTER TABLE _ComposantConstructeurs DROP CONSTRAINT IF EXISTS _ComposantConstructeurs_pkey');
|
||||
$this->addSql('ALTER TABLE _ComposantConstructeurs ALTER a TYPE TEXT');
|
||||
$this->addSql('ALTER TABLE _ComposantConstructeurs ALTER b TYPE TEXT');
|
||||
$this->addSql('ALTER TABLE _ComposantConstructeurs ADD CONSTRAINT "_ComposantConstructeurs_A_fkey" FOREIGN KEY (a) REFERENCES composants (id) ON UPDATE CASCADE ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||
$this->addSql('ALTER TABLE _ComposantConstructeurs ADD CONSTRAINT "_ComposantConstructeurs_B_fkey" FOREIGN KEY (b) REFERENCES constructeurs (id) ON UPDATE CASCADE ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||
$this->addSql('ALTER INDEX idx_607601254ad0cf31 RENAME TO "_ComposantConstructeurs_B_index"');
|
||||
$this->addSql('ALTER INDEX idx_60760125d3d99e8b RENAME TO IDX_5B97D813E8B7BE43');
|
||||
$this->addSql('ALTER TABLE _MachineConstructeurs DROP CONSTRAINT FK_E6A040CCD3D99E8B');
|
||||
$this->addSql('ALTER TABLE _MachineConstructeurs DROP CONSTRAINT FK_E6A040CC4AD0CF31');
|
||||
$this->addSql('ALTER TABLE _MachineConstructeurs DROP CONSTRAINT _MachineConstructeurs_pkey');
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_607601254ad0cf31') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_607601254ad0cf31 RENAME TO "_ComposantConstructeurs_B_index"';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_60760125d3d99e8b') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_60760125d3d99e8b RENAME TO IDX_5B97D813E8B7BE43';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql('ALTER TABLE _MachineConstructeurs DROP CONSTRAINT IF EXISTS FK_E6A040CCD3D99E8B');
|
||||
$this->addSql('ALTER TABLE _MachineConstructeurs DROP CONSTRAINT IF EXISTS FK_E6A040CC4AD0CF31');
|
||||
$this->addSql('ALTER TABLE _MachineConstructeurs DROP CONSTRAINT IF EXISTS _MachineConstructeurs_pkey');
|
||||
$this->addSql('ALTER TABLE _MachineConstructeurs ALTER a TYPE TEXT');
|
||||
$this->addSql('ALTER TABLE _MachineConstructeurs ALTER b TYPE TEXT');
|
||||
$this->addSql('ALTER TABLE _MachineConstructeurs ADD CONSTRAINT "_MachineConstructeurs_B_fkey" FOREIGN KEY (b) REFERENCES constructeurs (id) ON UPDATE CASCADE ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||
$this->addSql('ALTER TABLE _MachineConstructeurs ADD CONSTRAINT "_MachineConstructeurs_A_fkey" FOREIGN KEY (a) REFERENCES machines (id) ON UPDATE CASCADE ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||
$this->addSql('ALTER INDEX idx_e6a040cc4ad0cf31 RENAME TO "_MachineConstructeurs_B_index"');
|
||||
$this->addSql('ALTER INDEX idx_e6a040ccd3d99e8b RENAME TO IDX_4F225B32E8B7BE43');
|
||||
$this->addSql('ALTER TABLE _PieceConstructeurs DROP CONSTRAINT FK_E94732E5D3D99E8B');
|
||||
$this->addSql('ALTER TABLE _PieceConstructeurs DROP CONSTRAINT FK_E94732E54AD0CF31');
|
||||
$this->addSql('ALTER TABLE _PieceConstructeurs DROP CONSTRAINT _PieceConstructeurs_pkey');
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_e6a040cc4ad0cf31') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_e6a040cc4ad0cf31 RENAME TO "_MachineConstructeurs_B_index"';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_e6a040ccd3d99e8b') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_e6a040ccd3d99e8b RENAME TO IDX_4F225B32E8B7BE43';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql('ALTER TABLE _PieceConstructeurs DROP CONSTRAINT IF EXISTS FK_E94732E5D3D99E8B');
|
||||
$this->addSql('ALTER TABLE _PieceConstructeurs DROP CONSTRAINT IF EXISTS FK_E94732E54AD0CF31');
|
||||
$this->addSql('ALTER TABLE _PieceConstructeurs DROP CONSTRAINT IF EXISTS _PieceConstructeurs_pkey');
|
||||
$this->addSql('ALTER TABLE _PieceConstructeurs ALTER a TYPE TEXT');
|
||||
$this->addSql('ALTER TABLE _PieceConstructeurs ALTER b TYPE TEXT');
|
||||
$this->addSql('ALTER TABLE _PieceConstructeurs ADD CONSTRAINT "_PieceConstructeurs_A_fkey" FOREIGN KEY (a) REFERENCES pieces (id) ON UPDATE CASCADE ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||
$this->addSql('ALTER TABLE _PieceConstructeurs ADD CONSTRAINT "_PieceConstructeurs_B_fkey" FOREIGN KEY (b) REFERENCES constructeurs (id) ON UPDATE CASCADE ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||
$this->addSql('ALTER INDEX idx_e94732e54ad0cf31 RENAME TO "_PieceConstructeurs_B_index"');
|
||||
$this->addSql('ALTER INDEX idx_e94732e5d3d99e8b RENAME TO IDX_77FC120E8B7BE43');
|
||||
$this->addSql('ALTER TABLE _ProductConstructeurs DROP CONSTRAINT FK_CF7403FCD3D99E8B');
|
||||
$this->addSql('ALTER TABLE _ProductConstructeurs DROP CONSTRAINT FK_CF7403FC4AD0CF31');
|
||||
$this->addSql('ALTER TABLE _ProductConstructeurs DROP CONSTRAINT _ProductConstructeurs_pkey');
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_e94732e54ad0cf31') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_e94732e54ad0cf31 RENAME TO "_PieceConstructeurs_B_index"';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_e94732e5d3d99e8b') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_e94732e5d3d99e8b RENAME TO IDX_77FC120E8B7BE43';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql('ALTER TABLE _ProductConstructeurs DROP CONSTRAINT IF EXISTS FK_CF7403FCD3D99E8B');
|
||||
$this->addSql('ALTER TABLE _ProductConstructeurs DROP CONSTRAINT IF EXISTS FK_CF7403FC4AD0CF31');
|
||||
$this->addSql('ALTER TABLE _ProductConstructeurs DROP CONSTRAINT IF EXISTS _ProductConstructeurs_pkey');
|
||||
$this->addSql('ALTER TABLE _ProductConstructeurs ALTER a TYPE TEXT');
|
||||
$this->addSql('ALTER TABLE _ProductConstructeurs ALTER b TYPE TEXT');
|
||||
$this->addSql('ALTER TABLE _ProductConstructeurs ADD CONSTRAINT "_ProductConstructeurs_B_fkey" FOREIGN KEY (b) REFERENCES products (id) ON UPDATE CASCADE ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||
$this->addSql('ALTER TABLE _ProductConstructeurs ADD CONSTRAINT "_ProductConstructeurs_A_fkey" FOREIGN KEY (a) REFERENCES constructeurs (id) ON UPDATE CASCADE ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||
$this->addSql('ALTER INDEX idx_cf7403fc4ad0cf31 RENAME TO "_ProductConstructeurs_B_index"');
|
||||
$this->addSql('ALTER INDEX idx_cf7403fcd3d99e8b RENAME TO IDX_66F61802E8B7BE43');
|
||||
$this->addSql('ALTER INDEX idx_f95a319936799605 RENAME TO IDX_F95A3199A3FDB2A7');
|
||||
$this->addSql('ALTER INDEX idx_f95a3199cc8a4cee RENAME TO IDX_F95A3199DF92E79B');
|
||||
$this->addSql('ALTER INDEX idx_6b64d7ff345ee564 RENAME TO IDX_6B64D7FFA1DAC1C6');
|
||||
$this->addSql('ALTER INDEX idx_6b64d7ff5c4a705f RENAME TO IDX_6B64D7FF6736D61');
|
||||
$this->addSql('ALTER INDEX idx_6b64d7ff633ec4fd RENAME TO IDX_6B64D7FFF6BAE05F');
|
||||
$this->addSql('ALTER INDEX idx_6b64d7ff3c6a9d1 RENAME TO IDX_6B64D7FF96428D73');
|
||||
$this->addSql('ALTER INDEX idx_6b64d7ff36799605 RENAME TO IDX_6B64D7FFA3FDB2A7');
|
||||
$this->addSql('ALTER INDEX idx_4a48378c57b7763a RENAME TO IDX_4A48378C40C2D03B');
|
||||
$this->addSql('ALTER INDEX idx_4a48378c2f024c2 RENAME TO IDX_4A48378C158582C3');
|
||||
$this->addSql('ALTER INDEX idx_4a48378c169f1cf6 RENAME TO IDX_4A48378C4CA601C8');
|
||||
$this->addSql('ALTER INDEX idx_4a48378ccc8a4cee RENAME TO IDX_4A48378CDF92E79B');
|
||||
$this->addSql('ALTER INDEX idx_a2b07288345ee564 RENAME TO IDX_A2B07288A1DAC1C6');
|
||||
$this->addSql('ALTER INDEX idx_a2b07288633ec4fd RENAME TO IDX_A2B07288F6BAE05F');
|
||||
$this->addSql('ALTER INDEX idx_a2b072886973a4fd RENAME TO IDX_A2B07288FCF7805F');
|
||||
$this->addSql('ALTER INDEX idx_a2b072883c6a9d1 RENAME TO IDX_A2B0728896428D73');
|
||||
$this->addSql('ALTER INDEX idx_a2b0728836799605 RENAME TO IDX_A2B07288A3FDB2A7');
|
||||
$this->addSql('ALTER INDEX idx_528efe19345ee564 RENAME TO IDX_528EFE19A1DAC1C6');
|
||||
$this->addSql('ALTER INDEX idx_528efe19633ec4fd RENAME TO IDX_528EFE19F6BAE05F');
|
||||
$this->addSql('ALTER INDEX idx_528efe19ef6cf34b RENAME TO IDX_528EFE197D44D2DF');
|
||||
$this->addSql('ALTER INDEX idx_528efe19c44b383c RENAME TO IDX_528EFE19BCCED9E3');
|
||||
$this->addSql('ALTER INDEX idx_62941615ef6cf34b RENAME TO IDX_629416157D44D2DF');
|
||||
$this->addSql('ALTER INDEX idx_62941615633ec4fd RENAME TO IDX_62941615F6BAE05F');
|
||||
$this->addSql('ALTER INDEX idx_629416153c6a9d1 RENAME TO IDX_6294161596428D73');
|
||||
$this->addSql('ALTER INDEX idx_62941615f957d314 RENAME TO IDX_6294161532C54AAF');
|
||||
$this->addSql('ALTER INDEX idx_8cc32259633ec4fd RENAME TO "machine_product_links_machineId_idx"');
|
||||
$this->addSql('ALTER INDEX idx_8cc3225936799605 RENAME TO "machine_product_links_productId_idx"');
|
||||
$this->addSql('ALTER INDEX idx_8cc32259ef6cf34b RENAME TO IDX_8CC322597D44D2DF');
|
||||
$this->addSql('ALTER INDEX idx_8cc32259b590b209 RENAME TO IDX_8CC32259357FDBFF');
|
||||
$this->addSql('ALTER INDEX idx_8cc32259a63ac5dc RENAME TO IDX_8CC32259BCD7DAD6');
|
||||
$this->addSql('ALTER INDEX idx_8cc32259937a1d7c RENAME TO IDX_8CC3225987CEB33F');
|
||||
$this->addSql('ALTER INDEX idx_f1ce8ded2f024c2 RENAME TO IDX_F1CE8DED158582C3');
|
||||
$this->addSql('ALTER INDEX idx_f1ce8ded6973a4fd RENAME TO IDX_F1CE8DEDFCF7805F');
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_cf7403fc4ad0cf31') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_cf7403fc4ad0cf31 RENAME TO "_ProductConstructeurs_B_index"';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_cf7403fcd3d99e8b') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_cf7403fcd3d99e8b RENAME TO IDX_66F61802E8B7BE43';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_f95a319936799605') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_f95a319936799605 RENAME TO IDX_F95A3199A3FDB2A7';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_f95a3199cc8a4cee') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_f95a3199cc8a4cee RENAME TO IDX_F95A3199DF92E79B';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_6b64d7ff345ee564') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_6b64d7ff345ee564 RENAME TO IDX_6B64D7FFA1DAC1C6';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_6b64d7ff5c4a705f') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_6b64d7ff5c4a705f RENAME TO IDX_6B64D7FF6736D61';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_6b64d7ff633ec4fd') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_6b64d7ff633ec4fd RENAME TO IDX_6B64D7FFF6BAE05F';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_6b64d7ff3c6a9d1') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_6b64d7ff3c6a9d1 RENAME TO IDX_6B64D7FF96428D73';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_6b64d7ff36799605') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_6b64d7ff36799605 RENAME TO IDX_6B64D7FFA3FDB2A7';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_4a48378c57b7763a') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_4a48378c57b7763a RENAME TO IDX_4A48378C40C2D03B';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_4a48378c2f024c2') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_4a48378c2f024c2 RENAME TO IDX_4A48378C158582C3';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_4a48378c169f1cf6') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_4a48378c169f1cf6 RENAME TO IDX_4A48378C4CA601C8';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_4a48378ccc8a4cee') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_4a48378ccc8a4cee RENAME TO IDX_4A48378CDF92E79B';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_a2b07288345ee564') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_a2b07288345ee564 RENAME TO IDX_A2B07288A1DAC1C6';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_a2b07288633ec4fd') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_a2b07288633ec4fd RENAME TO IDX_A2B07288F6BAE05F';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_a2b072886973a4fd') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_a2b072886973a4fd RENAME TO IDX_A2B07288FCF7805F';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_a2b072883c6a9d1') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_a2b072883c6a9d1 RENAME TO IDX_A2B0728896428D73';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_a2b0728836799605') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_a2b0728836799605 RENAME TO IDX_A2B07288A3FDB2A7';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_528efe19345ee564') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_528efe19345ee564 RENAME TO IDX_528EFE19A1DAC1C6';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_528efe19633ec4fd') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_528efe19633ec4fd RENAME TO IDX_528EFE19F6BAE05F';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_528efe19ef6cf34b') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_528efe19ef6cf34b RENAME TO IDX_528EFE197D44D2DF';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_528efe19c44b383c') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_528efe19c44b383c RENAME TO IDX_528EFE19BCCED9E3';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_62941615ef6cf34b') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_62941615ef6cf34b RENAME TO IDX_629416157D44D2DF';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_62941615633ec4fd') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_62941615633ec4fd RENAME TO IDX_62941615F6BAE05F';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_629416153c6a9d1') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_629416153c6a9d1 RENAME TO IDX_6294161596428D73';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_62941615f957d314') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_62941615f957d314 RENAME TO IDX_6294161532C54AAF';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_8cc32259633ec4fd') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_8cc32259633ec4fd RENAME TO "machine_product_links_machineId_idx"';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_8cc3225936799605') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_8cc3225936799605 RENAME TO "machine_product_links_productId_idx"';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_8cc32259ef6cf34b') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_8cc32259ef6cf34b RENAME TO IDX_8CC322597D44D2DF';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_8cc32259b590b209') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_8cc32259b590b209 RENAME TO IDX_8CC32259357FDBFF';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_8cc32259a63ac5dc') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_8cc32259a63ac5dc RENAME TO IDX_8CC32259BCD7DAD6';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_8cc32259937a1d7c') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_8cc32259937a1d7c RENAME TO IDX_8CC3225987CEB33F';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_f1ce8ded2f024c2') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_f1ce8ded2f024c2 RENAME TO IDX_F1CE8DED158582C3';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_f1ce8ded6973a4fd') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_f1ce8ded6973a4fd RENAME TO IDX_F1CE8DEDFCF7805F';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql('ALTER TABLE model_types ALTER id TYPE TEXT');
|
||||
$this->addSql('ALTER TABLE model_types ALTER category TYPE VARCHAR');
|
||||
$this->addSql('ALTER TABLE model_types ALTER componentskeleton TYPE JSONB');
|
||||
@@ -187,15 +814,78 @@ final class Version20260125143939 extends AbstractMigration
|
||||
$this->addSql('ALTER TABLE model_types ALTER createdat SET DEFAULT CURRENT_TIMESTAMP');
|
||||
$this->addSql('CREATE UNIQUE INDEX "ModelType_category_name_key" ON model_types (category, name)');
|
||||
$this->addSql('CREATE UNIQUE INDEX "ModelType_code_key" ON model_types (code)');
|
||||
$this->addSql('ALTER INDEX idx_b92d7472169f1cf6 RENAME TO IDX_B92D74724CA601C8');
|
||||
$this->addSql('ALTER INDEX idx_b92d747236799605 RENAME TO IDX_B92D7472A3FDB2A7');
|
||||
$this->addSql('ALTER INDEX idx_b3ba5a5a57b7763a RENAME TO IDX_B3BA5A5A40C2D03B');
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_b92d7472169f1cf6') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_b92d7472169f1cf6 RENAME TO IDX_B92D74724CA601C8';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_b92d747236799605') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_b92d747236799605 RENAME TO IDX_B92D7472A3FDB2A7';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_b3ba5a5a57b7763a') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_b3ba5a5a57b7763a RENAME TO IDX_B3BA5A5A40C2D03B';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_profiles_email ON profiles (email)');
|
||||
$this->addSql('ALTER INDEX idx_969587902f024c2 RENAME TO IDX_96958790158582C3');
|
||||
$this->addSql('ALTER INDEX idx_96958790cc8a4cee RENAME TO IDX_96958790DF92E79B');
|
||||
$this->addSql('ALTER INDEX idx_f609e59e169f1cf6 RENAME TO IDX_F609E59E4CA601C8');
|
||||
$this->addSql('ALTER INDEX idx_f609e59e2f024c2 RENAME TO IDX_F609E59E158582C3');
|
||||
$this->addSql('ALTER INDEX idx_29a51f9857b7763a RENAME TO IDX_29A51F9840C2D03B');
|
||||
$this->addSql('ALTER INDEX idx_29a51f982f024c2 RENAME TO IDX_29A51F98158582C3');
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_969587902f024c2') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_969587902f024c2 RENAME TO IDX_96958790158582C3';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_96958790cc8a4cee') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_96958790cc8a4cee RENAME TO IDX_96958790DF92E79B';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_f609e59e169f1cf6') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_f609e59e169f1cf6 RENAME TO IDX_F609E59E4CA601C8';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_f609e59e2f024c2') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_f609e59e2f024c2 RENAME TO IDX_F609E59E158582C3';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_29a51f9857b7763a') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_29a51f9857b7763a RENAME TO IDX_29A51F9840C2D03B';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF to_regclass('idx_29a51f982f024c2') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER INDEX idx_29a51f982f024c2 RENAME TO IDX_29A51F98158582C3';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
41
migrations/Version20260125170000.php
Normal file
41
migrations/Version20260125170000.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260125170000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add audit_logs table to store per-entity history entries.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE audit_logs (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
entityType VARCHAR(50) NOT NULL,
|
||||
entityId VARCHAR(36) NOT NULL,
|
||||
action VARCHAR(20) NOT NULL,
|
||||
diff JSON DEFAULT NULL,
|
||||
snapshot JSON DEFAULT NULL,
|
||||
actorProfileId VARCHAR(36) DEFAULT NULL,
|
||||
createdAt TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
PRIMARY KEY(id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE INDEX idx_audit_entity ON audit_logs (entityType, entityId)');
|
||||
$this->addSql('CREATE INDEX idx_audit_created_at ON audit_logs (createdAt)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE audit_logs');
|
||||
}
|
||||
}
|
||||
51
migrations/Version20260302103003.php
Normal file
51
migrations/Version20260302103003.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260302103003 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create comments table + make piece reference unique instead of name';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Comments table (IF NOT EXISTS in case first attempt partially succeeded)
|
||||
$this->addSql('CREATE TABLE IF NOT EXISTS comments (id VARCHAR(36) NOT NULL, content TEXT NOT NULL, entity_type VARCHAR(50) NOT NULL, entity_id VARCHAR(36) NOT NULL, entity_name VARCHAR(255) DEFAULT NULL, author_id VARCHAR(36) NOT NULL, author_name VARCHAR(255) NOT NULL, status VARCHAR(20) NOT NULL, resolved_by_id VARCHAR(36) DEFAULT NULL, resolved_by_name VARCHAR(255) DEFAULT NULL, resolved_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_comment_entity_status ON comments (entity_type, entity_id, status)');
|
||||
$this->addSql('COMMENT ON COLUMN comments.resolved_at IS \'(DC2Type:datetime_immutable)\'');
|
||||
$this->addSql('COMMENT ON COLUMN comments.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||
$this->addSql('COMMENT ON COLUMN comments.updated_at IS \'(DC2Type:datetime_immutable)\'');
|
||||
|
||||
// Piece: remove unique constraint on name (it's a constraint, not just an index)
|
||||
$this->addSql('ALTER TABLE pieces DROP CONSTRAINT IF EXISTS uniq_b92d74725e237e06');
|
||||
|
||||
// Deduplicate piece references before adding unique constraint
|
||||
$this->addSql("
|
||||
UPDATE pieces p
|
||||
SET reference = p.reference || '-' || LEFT(p.id, 6)
|
||||
FROM (
|
||||
SELECT id, reference,
|
||||
ROW_NUMBER() OVER (PARTITION BY reference ORDER BY createdat) AS rn
|
||||
FROM pieces
|
||||
WHERE reference IS NOT NULL AND reference != ''
|
||||
) dup
|
||||
WHERE p.id = dup.id AND dup.rn > 1
|
||||
");
|
||||
|
||||
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uniq_pieces_reference ON pieces (reference)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE IF EXISTS comments');
|
||||
$this->addSql('DROP INDEX IF EXISTS uniq_pieces_reference');
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_b92d74725e237e06 ON pieces (name)');
|
||||
}
|
||||
}
|
||||
28
migrations/Version20260302120000.php
Normal file
28
migrations/Version20260302120000.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260302120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add description column to pieces and composants tables';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE pieces ADD COLUMN IF NOT EXISTS description TEXT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE composants ADD COLUMN IF NOT EXISTS description TEXT DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE pieces DROP COLUMN IF EXISTS description');
|
||||
$this->addSql('ALTER TABLE composants DROP COLUMN IF EXISTS description');
|
||||
}
|
||||
}
|
||||
158
migrations/Version20260304120000.php
Normal file
158
migrations/Version20260304120000.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260304120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Remove TypeMachine skeleton system, link custom fields directly to machines';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// 1. Drop requirement FK columns on link tables
|
||||
$this->addSql('ALTER TABLE machine_component_links DROP COLUMN IF EXISTS typemachinecomponentrequirementid');
|
||||
$this->addSql('ALTER TABLE machine_piece_links DROP COLUMN IF EXISTS typemachinepiecerequirementid');
|
||||
$this->addSql('ALTER TABLE machine_product_links DROP COLUMN IF EXISTS typemachineproductrequirementid');
|
||||
|
||||
// 2. Add machineid column to custom_fields (new direct FK to machines)
|
||||
$this->addSql('ALTER TABLE custom_fields ADD COLUMN IF NOT EXISTS machineid VARCHAR(36) DEFAULT NULL');
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'fk_custom_fields_machine' AND table_name = 'custom_fields'
|
||||
) THEN
|
||||
ALTER TABLE custom_fields ADD CONSTRAINT fk_custom_fields_machine
|
||||
FOREIGN KEY (machineid) REFERENCES machines(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
SQL);
|
||||
|
||||
// 3. Enable pgcrypto for gen_random_bytes (needed for CUID generation)
|
||||
$this->addSql('CREATE EXTENSION IF NOT EXISTS pgcrypto');
|
||||
|
||||
// 4. Migrate existing custom fields: copy from TypeMachine to each linked Machine
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO custom_fields (id, name, type, required, defaultvalue, options, orderindex, machineid, createdat, updatedat)
|
||||
SELECT
|
||||
'cl' || encode(gen_random_bytes(12), 'hex'),
|
||||
cf.name, cf.type, cf.required, cf.defaultvalue, cf.options, cf.orderindex,
|
||||
m.id,
|
||||
NOW(), NOW()
|
||||
FROM custom_fields cf
|
||||
JOIN machines m ON m.typemachineid = cf.typemachineid
|
||||
WHERE cf.typemachineid IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM custom_fields existing
|
||||
WHERE existing.machineid = m.id AND existing.name = cf.name
|
||||
)
|
||||
SQL);
|
||||
|
||||
// 4. Delete original TypeMachine-linked custom fields (now migrated)
|
||||
$this->addSql('DELETE FROM custom_fields WHERE typemachineid IS NOT NULL');
|
||||
|
||||
// 5. Drop typemachineid column from custom_fields
|
||||
$this->addSql('ALTER TABLE custom_fields DROP COLUMN IF EXISTS typemachineid');
|
||||
|
||||
// 6. Drop typemachineid column from machines
|
||||
$this->addSql('ALTER TABLE machines DROP COLUMN IF EXISTS typemachineid');
|
||||
|
||||
// 7. Drop requirement tables (order matters: these reference type_machines)
|
||||
$this->addSql('DROP TABLE IF EXISTS type_machine_component_requirements');
|
||||
$this->addSql('DROP TABLE IF EXISTS type_machine_piece_requirements');
|
||||
$this->addSql('DROP TABLE IF EXISTS type_machine_product_requirements');
|
||||
|
||||
// 8. Drop type_machines table
|
||||
$this->addSql('DROP TABLE IF EXISTS type_machines');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Recreate type_machines table
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS type_machines (
|
||||
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
description TEXT DEFAULT NULL,
|
||||
category VARCHAR(255) DEFAULT NULL,
|
||||
maintenancefrequency VARCHAR(255) DEFAULT NULL,
|
||||
components JSON DEFAULT NULL,
|
||||
criticalparts JSON DEFAULT NULL,
|
||||
machinepieces JSON DEFAULT NULL,
|
||||
specifications JSON DEFAULT NULL,
|
||||
createdat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updatedat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
|
||||
)
|
||||
SQL);
|
||||
|
||||
// Recreate requirement tables
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS type_machine_component_requirements (
|
||||
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
typemachineid VARCHAR(36) NOT NULL REFERENCES type_machines(id) ON DELETE CASCADE,
|
||||
typecomposantid VARCHAR(36) NOT NULL REFERENCES model_types(id),
|
||||
label VARCHAR(255) DEFAULT NULL,
|
||||
mincount INTEGER DEFAULT 1,
|
||||
maxcount INTEGER DEFAULT NULL,
|
||||
required BOOLEAN DEFAULT true,
|
||||
allownewmodels BOOLEAN DEFAULT true,
|
||||
orderindex INTEGER DEFAULT 0,
|
||||
createdat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updatedat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS type_machine_piece_requirements (
|
||||
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
typemachineid VARCHAR(36) NOT NULL REFERENCES type_machines(id) ON DELETE CASCADE,
|
||||
typepieceid VARCHAR(36) NOT NULL REFERENCES model_types(id),
|
||||
label VARCHAR(255) DEFAULT NULL,
|
||||
mincount INTEGER DEFAULT 0,
|
||||
maxcount INTEGER DEFAULT NULL,
|
||||
required BOOLEAN DEFAULT false,
|
||||
allownewmodels BOOLEAN DEFAULT true,
|
||||
orderindex INTEGER DEFAULT 0,
|
||||
createdat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updatedat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS type_machine_product_requirements (
|
||||
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
typemachineid VARCHAR(36) NOT NULL REFERENCES type_machines(id) ON DELETE CASCADE,
|
||||
typeproductid VARCHAR(36) NOT NULL REFERENCES model_types(id),
|
||||
label VARCHAR(255) DEFAULT NULL,
|
||||
mincount INTEGER DEFAULT 0,
|
||||
maxcount INTEGER DEFAULT NULL,
|
||||
required BOOLEAN DEFAULT false,
|
||||
allownewmodels BOOLEAN DEFAULT true,
|
||||
orderindex INTEGER DEFAULT 0,
|
||||
createdat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updatedat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
|
||||
)
|
||||
SQL);
|
||||
|
||||
// Re-add typemachineid to machines
|
||||
$this->addSql('ALTER TABLE machines ADD COLUMN IF NOT EXISTS typemachineid VARCHAR(36) DEFAULT NULL');
|
||||
|
||||
// Re-add typemachineid to custom_fields
|
||||
$this->addSql('ALTER TABLE custom_fields ADD COLUMN IF NOT EXISTS typemachineid VARCHAR(36) DEFAULT NULL');
|
||||
|
||||
// Re-add requirement FK columns to link tables
|
||||
$this->addSql('ALTER TABLE machine_component_links ADD COLUMN IF NOT EXISTS typemachinecomponentrequirementid VARCHAR(36) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE machine_piece_links ADD COLUMN IF NOT EXISTS typemachinepiecerequirementid VARCHAR(36) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE machine_product_links ADD COLUMN IF NOT EXISTS typemachineproductrequirementid VARCHAR(36) DEFAULT NULL');
|
||||
|
||||
// Drop machine FK on custom_fields
|
||||
$this->addSql('ALTER TABLE custom_fields DROP COLUMN IF EXISTS machineid');
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Document;
|
||||
use App\Repository\DocumentRepository;
|
||||
use App\Service\DocumentStorageService;
|
||||
use App\Service\PdfCompressorService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
@@ -13,15 +16,20 @@ use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function count;
|
||||
use function strlen;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:compress-pdf',
|
||||
description: 'Compress all PDF documents stored in database without quality loss',
|
||||
description: 'Compress all PDF documents without quality loss',
|
||||
)]
|
||||
class CompressPdfCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DocumentRepository $documentRepository,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly PdfCompressorService $pdfCompressor,
|
||||
private readonly DocumentStorageService $storageService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -61,87 +69,13 @@ class CompressPdfCommand extends Command
|
||||
$compressed = 0;
|
||||
|
||||
foreach ($documents as $document) {
|
||||
$base64Data = $document->getPath();
|
||||
$path = $document->getPath();
|
||||
|
||||
// Remove data URI prefix if present
|
||||
if (str_contains($base64Data, ',')) {
|
||||
$base64Data = explode(',', $base64Data, 2)[1];
|
||||
}
|
||||
|
||||
$pdfContent = base64_decode($base64Data, true);
|
||||
if (false === $pdfContent) {
|
||||
$io->warning(sprintf('Failed to decode document: %s', $document->getName()));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$originalSize = strlen($pdfContent);
|
||||
|
||||
if ($dryRun) {
|
||||
$io->text(sprintf(
|
||||
' [DRY-RUN] Would compress: %s (%s)',
|
||||
$document->getName(),
|
||||
$this->formatBytes($originalSize)
|
||||
));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create temp files
|
||||
$tempInput = tempnam(sys_get_temp_dir(), 'pdf_in_');
|
||||
$tempOutput = tempnam(sys_get_temp_dir(), 'pdf_out_');
|
||||
|
||||
file_put_contents($tempInput, $pdfContent);
|
||||
|
||||
// Compress with qpdf (lossless)
|
||||
$command = sprintf(
|
||||
'qpdf --linearize --object-streams=generate %s %s 2>&1',
|
||||
escapeshellarg($tempInput),
|
||||
escapeshellarg($tempOutput)
|
||||
);
|
||||
|
||||
exec($command, $cmdOutput, $returnCode);
|
||||
|
||||
if (0 !== $returnCode || !file_exists($tempOutput)) {
|
||||
$io->warning(sprintf('Failed to compress: %s', $document->getName()));
|
||||
@unlink($tempInput);
|
||||
@unlink($tempOutput);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$compressedContent = file_get_contents($tempOutput);
|
||||
$compressedSize = strlen($compressedContent);
|
||||
|
||||
// Only update if we actually saved space
|
||||
if ($compressedSize < $originalSize) {
|
||||
$saved = $originalSize - $compressedSize;
|
||||
$totalSaved += $saved;
|
||||
++$compressed;
|
||||
|
||||
// Rebuild base64 with data URI prefix
|
||||
$newBase64 = 'data:application/pdf;base64,'.base64_encode($compressedContent);
|
||||
$document->setPath($newBase64);
|
||||
$document->setSize($compressedSize);
|
||||
|
||||
$io->text(sprintf(
|
||||
' ✓ %s: %s → %s (-%s, -%.1f%%)',
|
||||
$document->getName(),
|
||||
$this->formatBytes($originalSize),
|
||||
$this->formatBytes($compressedSize),
|
||||
$this->formatBytes($saved),
|
||||
($saved / $originalSize) * 100
|
||||
));
|
||||
if ($this->storageService->isBase64DataUri($path)) {
|
||||
$this->compressBase64Document($document, $path, $dryRun, $io, $totalSaved, $compressed);
|
||||
} else {
|
||||
$io->text(sprintf(
|
||||
' - %s: Already optimal (%s)',
|
||||
$document->getName(),
|
||||
$this->formatBytes($originalSize)
|
||||
));
|
||||
$this->compressFileDocument($document, $path, $dryRun, $io, $totalSaved, $compressed);
|
||||
}
|
||||
|
||||
@unlink($tempInput);
|
||||
@unlink($tempOutput);
|
||||
}
|
||||
|
||||
if (!$dryRun && $compressed > 0) {
|
||||
@@ -161,6 +95,115 @@ class CompressPdfCommand extends Command
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function compressBase64Document(
|
||||
Document $document,
|
||||
string $path,
|
||||
bool $dryRun,
|
||||
SymfonyStyle $io,
|
||||
int &$totalSaved,
|
||||
int &$compressed,
|
||||
): void {
|
||||
$base64Data = $path;
|
||||
if (str_contains($base64Data, ',')) {
|
||||
$base64Data = explode(',', $base64Data, 2)[1];
|
||||
}
|
||||
|
||||
$pdfContent = base64_decode($base64Data, true);
|
||||
if (false === $pdfContent) {
|
||||
$io->warning(sprintf('Failed to decode document: %s', $document->getName()));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$originalSize = strlen($pdfContent);
|
||||
|
||||
if ($dryRun) {
|
||||
$io->text(sprintf(
|
||||
' [DRY-RUN] Would compress (base64): %s (%s)',
|
||||
$document->getName(),
|
||||
$this->formatBytes($originalSize)
|
||||
));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $this->pdfCompressor->compressBase64Pdf($path);
|
||||
if (null !== $result) {
|
||||
$document->setPath($result['path']);
|
||||
$document->setSize($result['size']);
|
||||
$totalSaved += $result['saved'];
|
||||
++$compressed;
|
||||
|
||||
$io->text(sprintf(
|
||||
' OK %s: %s → %s (-%s, -%.1f%%)',
|
||||
$document->getName(),
|
||||
$this->formatBytes($result['originalSize']),
|
||||
$this->formatBytes($result['size']),
|
||||
$this->formatBytes($result['saved']),
|
||||
($result['saved'] / $result['originalSize']) * 100
|
||||
));
|
||||
} else {
|
||||
$io->text(sprintf(
|
||||
' - %s: Already optimal (%s)',
|
||||
$document->getName(),
|
||||
$this->formatBytes($originalSize)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private function compressFileDocument(
|
||||
Document $document,
|
||||
string $path,
|
||||
bool $dryRun,
|
||||
SymfonyStyle $io,
|
||||
int &$totalSaved,
|
||||
int &$compressed,
|
||||
): void {
|
||||
$absolutePath = $this->storageService->getAbsolutePath($path);
|
||||
if (!file_exists($absolutePath)) {
|
||||
$io->warning(sprintf('File not found: %s (%s)', $document->getName(), $path));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$originalSize = filesize($absolutePath);
|
||||
if (false === $originalSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$io->text(sprintf(
|
||||
' [DRY-RUN] Would compress (file): %s (%s)',
|
||||
$document->getName(),
|
||||
$this->formatBytes($originalSize)
|
||||
));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $this->pdfCompressor->compressFile($absolutePath);
|
||||
if (null !== $result) {
|
||||
$document->setSize($result['size']);
|
||||
$totalSaved += $result['saved'];
|
||||
++$compressed;
|
||||
|
||||
$io->text(sprintf(
|
||||
' OK %s: %s → %s (-%s, -%.1f%%)',
|
||||
$document->getName(),
|
||||
$this->formatBytes($result['originalSize']),
|
||||
$this->formatBytes($result['size']),
|
||||
$this->formatBytes($result['saved']),
|
||||
($result['saved'] / $result['originalSize']) * 100
|
||||
));
|
||||
} else {
|
||||
$io->text(sprintf(
|
||||
' - %s: Already optimal (%s)',
|
||||
$document->getName(),
|
||||
$this->formatBytes($originalSize)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private function formatBytes(int $bytes): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
|
||||
85
src/Command/InitProfilePasswordsCommand.php
Normal file
85
src/Command/InitProfilePasswordsCommand.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Repository\ProfileRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
use function count;
|
||||
use function in_array;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:init-profile-passwords',
|
||||
description: 'Initialize all profile passwords to first letter of firstName + "123"',
|
||||
)]
|
||||
class InitProfilePasswordsCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProfileRepository $profiles,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$all = $this->profiles->findAll();
|
||||
|
||||
if (0 === count($all)) {
|
||||
$io->warning('Aucun profil trouvé.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// Promote first profile to ROLE_ADMIN if none exists
|
||||
$hasAdmin = false;
|
||||
foreach ($all as $profile) {
|
||||
if (in_array('ROLE_ADMIN', $profile->getRoles(), true)) {
|
||||
$hasAdmin = true;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$isFirst = true;
|
||||
$count = 0;
|
||||
foreach ($all as $profile) {
|
||||
// Set password: first letter of firstName + "123"
|
||||
$firstLetter = mb_strtoupper(mb_substr($profile->getFirstName(), 0, 1));
|
||||
$plain = $firstLetter.'123';
|
||||
$hashed = $this->passwordHasher->hashPassword($profile, $plain);
|
||||
$profile->setPassword($hashed);
|
||||
|
||||
// Set roles: first profile → ADMIN, others → VIEWER (minimum to use the app)
|
||||
if (!$hasAdmin && $isFirst) {
|
||||
$profile->setRoles(['ROLE_ADMIN']);
|
||||
$io->writeln(sprintf(' %s %s → mdp: %s — ROLE_ADMIN', $profile->getFirstName(), $profile->getLastName(), $plain));
|
||||
$isFirst = false;
|
||||
} elseif (in_array('ROLE_USER', $profile->getRoles(), true) && !in_array('ROLE_VIEWER', $profile->getRoles(), true) && !in_array('ROLE_GESTIONNAIRE', $profile->getRoles(), true) && !in_array('ROLE_ADMIN', $profile->getRoles(), true)) {
|
||||
$profile->setRoles(['ROLE_VIEWER']);
|
||||
$io->writeln(sprintf(' %s %s → mdp: %s — ROLE_VIEWER', $profile->getFirstName(), $profile->getLastName(), $plain));
|
||||
} else {
|
||||
$io->writeln(sprintf(' %s %s → mdp: %s — %s', $profile->getFirstName(), $profile->getLastName(), $plain, implode(', ', $profile->getRoles())));
|
||||
}
|
||||
|
||||
++$count;
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
$io->success(sprintf('%d mot(s) de passe initialisé(s).', $count));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
218
src/Command/MigrateDocumentsToFilesystemCommand.php
Normal file
218
src/Command/MigrateDocumentsToFilesystemCommand.php
Normal file
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Repository\DocumentRepository;
|
||||
use App\Service\DocumentStorageService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Throwable;
|
||||
|
||||
use function count;
|
||||
use function strlen;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:migrate-documents-to-filesystem',
|
||||
description: 'Migrate document storage from Base64 in DB to filesystem',
|
||||
)]
|
||||
class MigrateDocumentsToFilesystemCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DocumentRepository $documentRepository,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly DocumentStorageService $storageService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be migrated without making changes')
|
||||
->addOption('batch-size', null, InputOption::VALUE_REQUIRED, 'Number of documents to process before flushing', '50')
|
||||
->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Max documents to migrate (for testing)', '0')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$dryRun = $input->getOption('dry-run');
|
||||
$batchSize = (int) $input->getOption('batch-size');
|
||||
$limit = (int) $input->getOption('limit');
|
||||
|
||||
$io->title('Document Storage Migration: Base64 → Filesystem');
|
||||
|
||||
// Verify storage directory is writable
|
||||
$storageDir = $this->storageService->getStorageDir();
|
||||
if (!$dryRun) {
|
||||
if (!is_dir($storageDir)) {
|
||||
mkdir($storageDir, 0o775, true);
|
||||
}
|
||||
if (!is_writable($storageDir)) {
|
||||
$io->error("Storage directory is not writable: {$storageDir}");
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$io->text("Storage directory: {$storageDir}");
|
||||
}
|
||||
|
||||
// Step 1: fetch only IDs of Base64 documents (no heavy path column loaded)
|
||||
$conn = $this->em->getConnection();
|
||||
$ids = $conn->fetchFirstColumn("SELECT id FROM documents WHERE path LIKE 'data:%'");
|
||||
$total = count($ids);
|
||||
$migrated = 0;
|
||||
$skipped = 0;
|
||||
$errors = 0;
|
||||
$totalBytes = 0;
|
||||
|
||||
$io->text(sprintf('Found %d documents with Base64 data to migrate', $total));
|
||||
|
||||
if (0 === $total) {
|
||||
$io->success('Nothing to migrate — all documents are already file-based.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// Step 2: process one document at a time to avoid memory exhaustion
|
||||
foreach ($ids as $index => $docId) {
|
||||
if ($limit > 0 && $migrated >= $limit) {
|
||||
$io->text("Reached limit of {$limit} documents.");
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// Fetch single row with raw SQL to keep memory flat
|
||||
$row = $conn->fetchAssociative(
|
||||
'SELECT id, name, filename, path, mimetype, size FROM documents WHERE id = ?',
|
||||
[$docId]
|
||||
);
|
||||
|
||||
if (!$row) {
|
||||
++$skipped;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $row['path'];
|
||||
if (!$this->storageService->isBase64DataUri($path)) {
|
||||
++$skipped;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$docName = $row['name'] ?: $row['filename'];
|
||||
$filename = $row['filename'] ?: $row['name'];
|
||||
$mimeType = $row['mimetype'] ?? 'application/octet-stream';
|
||||
|
||||
// Extract binary content from data URI
|
||||
$parts = explode(',', $path, 2);
|
||||
$base64 = $parts[1] ?? '';
|
||||
$content = base64_decode($base64, true);
|
||||
|
||||
// Free the raw row immediately
|
||||
unset($row, $path, $base64, $parts);
|
||||
|
||||
if (false === $content || '' === $content) {
|
||||
$io->warning(sprintf('[%d/%d] Cannot decode: %s (id: %s)', $index + 1, $total, $docName, $docId));
|
||||
++$errors;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$fileSize = strlen($content);
|
||||
$extension = $this->storageService->extensionFromFilename(
|
||||
$filename ?: ('file.'.$this->storageService->extensionFromMimeType($mimeType))
|
||||
);
|
||||
|
||||
if ($dryRun) {
|
||||
$io->text(sprintf(
|
||||
' [DRY-RUN] Would migrate: %s (%s)',
|
||||
$docName,
|
||||
$this->formatBytes($fileSize)
|
||||
));
|
||||
++$migrated;
|
||||
$totalBytes += $fileSize;
|
||||
unset($content);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$relativePath = $this->storageService->store($content, $docId, $extension);
|
||||
unset($content);
|
||||
|
||||
// Update DB directly — avoid loading entity with huge path
|
||||
$conn->executeStatement(
|
||||
'UPDATE documents SET path = ?, size = ? WHERE id = ?',
|
||||
[$relativePath, $fileSize, $docId]
|
||||
);
|
||||
|
||||
++$migrated;
|
||||
$totalBytes += $fileSize;
|
||||
|
||||
$io->text(sprintf(
|
||||
' [OK] %s → %s (%s)',
|
||||
$docName,
|
||||
$relativePath,
|
||||
$this->formatBytes($fileSize)
|
||||
));
|
||||
} catch (Throwable $e) {
|
||||
unset($content);
|
||||
$io->error(sprintf(
|
||||
' [FAIL] %s: %s',
|
||||
$docName,
|
||||
$e->getMessage()
|
||||
));
|
||||
++$errors;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (0 === $migrated % $batchSize) {
|
||||
$io->text(sprintf(' ... %d migrated so far', $migrated));
|
||||
}
|
||||
}
|
||||
|
||||
$io->newLine();
|
||||
$io->table(
|
||||
['Metric', 'Count'],
|
||||
[
|
||||
['Total documents', (string) $total],
|
||||
['Migrated', (string) $migrated],
|
||||
['Skipped (already file-based)', (string) $skipped],
|
||||
['Errors', (string) $errors],
|
||||
['Total bytes written', $this->formatBytes($totalBytes)],
|
||||
]
|
||||
);
|
||||
|
||||
if ($dryRun) {
|
||||
$io->info('Dry run completed. No changes were made.');
|
||||
} elseif ($errors > 0) {
|
||||
$io->warning(sprintf('Migration completed with %d errors.', $errors));
|
||||
} else {
|
||||
$io->success('Migration completed successfully.');
|
||||
}
|
||||
|
||||
return $errors > 0 ? Command::FAILURE : Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function formatBytes(int $bytes): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
$i = 0;
|
||||
while ($bytes >= 1024 && $i < count($units) - 1) {
|
||||
$bytes /= 1024;
|
||||
++$i;
|
||||
}
|
||||
|
||||
return round($bytes, 2).' '.$units[$i];
|
||||
}
|
||||
}
|
||||
90
src/Controller/ActivityLogController.php
Normal file
90
src/Controller/ActivityLogController.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Repository\AuditLogRepository;
|
||||
use App\Repository\ProfileRepository;
|
||||
use DateTimeInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class ActivityLogController extends AbstractController
|
||||
{
|
||||
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
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
193
src/Controller/AdminProfileController.php
Normal file
193
src/Controller/AdminProfileController.php
Normal file
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Profile;
|
||||
use App\Repository\ProfileRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
use function count;
|
||||
use function in_array;
|
||||
|
||||
#[Route('/api/admin/profiles')]
|
||||
final class AdminProfileController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProfileRepository $profiles,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
) {}
|
||||
|
||||
#[Route('', name: 'admin_profiles_list', methods: ['GET'])]
|
||||
public function list(): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
|
||||
$items = $this->profiles->findBy([], ['firstName' => 'ASC']);
|
||||
|
||||
return new JsonResponse(array_map([$this, 'serializeProfile'], $items));
|
||||
}
|
||||
|
||||
#[Route('', name: 'admin_profiles_create', methods: ['POST'])]
|
||||
public function create(Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
|
||||
$payload = $request->toArray();
|
||||
$firstName = trim((string) ($payload['firstName'] ?? ''));
|
||||
$lastName = trim((string) ($payload['lastName'] ?? ''));
|
||||
|
||||
if ('' === $firstName || '' === $lastName) {
|
||||
return new JsonResponse(['message' => 'firstName et lastName sont requis.'], JsonResponse::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$email = trim((string) ($payload['email'] ?? ''));
|
||||
$password = $payload['password'] ?? null;
|
||||
$role = $payload['role'] ?? 'ROLE_VIEWER';
|
||||
|
||||
$allowedRoles = ['ROLE_ADMIN', 'ROLE_GESTIONNAIRE', 'ROLE_VIEWER', 'ROLE_USER'];
|
||||
if (!in_array($role, $allowedRoles, true)) {
|
||||
return new JsonResponse(['message' => 'Role invalide.'], JsonResponse::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$profile = new Profile();
|
||||
$profile->setFirstName($firstName);
|
||||
$profile->setLastName($lastName);
|
||||
$profile->setIsActive(true);
|
||||
$profile->setRoles([$role]);
|
||||
|
||||
if ('' !== $email) {
|
||||
$profile->setEmail($email);
|
||||
}
|
||||
|
||||
if (null !== $password && '' !== $password) {
|
||||
$profile->setPassword(
|
||||
$this->passwordHasher->hashPassword($profile, $password)
|
||||
);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($profile);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new JsonResponse($this->serializeProfile($profile), JsonResponse::HTTP_CREATED);
|
||||
}
|
||||
|
||||
#[Route('/{id}/role', name: 'admin_profiles_update_role', methods: ['PUT'])]
|
||||
public function updateRole(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
|
||||
$profile = $this->profiles->find($id);
|
||||
if (!$profile) {
|
||||
return new JsonResponse(['message' => 'Profil introuvable.'], JsonResponse::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$payload = $request->toArray();
|
||||
$role = $payload['role'] ?? null;
|
||||
|
||||
$allowedRoles = ['ROLE_ADMIN', 'ROLE_GESTIONNAIRE', 'ROLE_VIEWER', 'ROLE_USER'];
|
||||
if (!$role || !in_array($role, $allowedRoles, true)) {
|
||||
return new JsonResponse(['message' => 'Role invalide.'], JsonResponse::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Prevent removing the last admin
|
||||
if (in_array('ROLE_ADMIN', $profile->getRoles(), true) && 'ROLE_ADMIN' !== $role) {
|
||||
$adminCount = $this->countAdmins();
|
||||
if ($adminCount <= 1) {
|
||||
return new JsonResponse(
|
||||
['message' => 'Impossible de retirer le dernier administrateur.'],
|
||||
JsonResponse::HTTP_CONFLICT
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$profile->setRoles([$role]);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new JsonResponse($this->serializeProfile($profile));
|
||||
}
|
||||
|
||||
#[Route('/{id}/password', name: 'admin_profiles_update_password', methods: ['PUT'])]
|
||||
public function updatePassword(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
|
||||
$profile = $this->profiles->find($id);
|
||||
if (!$profile) {
|
||||
return new JsonResponse(['message' => 'Profil introuvable.'], JsonResponse::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$payload = $request->toArray();
|
||||
$password = $payload['password'] ?? '';
|
||||
|
||||
if ('' === $password) {
|
||||
return new JsonResponse(['message' => 'Le mot de passe est requis.'], JsonResponse::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$profile->setPassword(
|
||||
$this->passwordHasher->hashPassword($profile, $password)
|
||||
);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new JsonResponse($this->serializeProfile($profile));
|
||||
}
|
||||
|
||||
#[Route('/{id}/deactivate', name: 'admin_profiles_deactivate', methods: ['PUT'])]
|
||||
public function deactivate(string $id): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
|
||||
$profile = $this->profiles->find($id);
|
||||
if (!$profile) {
|
||||
return new JsonResponse(['message' => 'Profil introuvable.'], JsonResponse::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Prevent deactivating the last admin
|
||||
if (in_array('ROLE_ADMIN', $profile->getRoles(), true)) {
|
||||
$adminCount = $this->countAdmins();
|
||||
if ($adminCount <= 1) {
|
||||
return new JsonResponse(
|
||||
['message' => 'Impossible de desactiver le dernier administrateur.'],
|
||||
JsonResponse::HTTP_CONFLICT
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$profile->setIsActive(false);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new JsonResponse($this->serializeProfile($profile));
|
||||
}
|
||||
|
||||
private function serializeProfile(Profile $profile): array
|
||||
{
|
||||
return [
|
||||
'id' => $profile->getId(),
|
||||
'firstName' => $profile->getFirstName(),
|
||||
'lastName' => $profile->getLastName(),
|
||||
'email' => $profile->getEmail(),
|
||||
'isActive' => $profile->isActive(),
|
||||
'hasPassword' => null !== $profile->getPassword() && '' !== $profile->getPassword(),
|
||||
'roles' => $profile->getRoles(),
|
||||
'createdAt' => $profile->getCreatedAt()->format('c'),
|
||||
'updatedAt' => $profile->getUpdatedAt()->format('c'),
|
||||
];
|
||||
}
|
||||
|
||||
private function countAdmins(): int
|
||||
{
|
||||
$all = $this->profiles->findBy(['isActive' => true]);
|
||||
|
||||
return count(array_filter(
|
||||
$all,
|
||||
static fn (Profile $p) => in_array('ROLE_ADMIN', $p->getRoles(), true)
|
||||
));
|
||||
}
|
||||
}
|
||||
145
src/Controller/CommentController.php
Normal file
145
src/Controller/CommentController.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Comment;
|
||||
use App\Repository\ProfileRepository;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/api/comments')]
|
||||
final class CommentController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly ProfileRepository $profiles,
|
||||
) {}
|
||||
|
||||
#[Route('', name: 'api_comments_create', methods: ['POST'])]
|
||||
public function create(Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$session = $request->getSession();
|
||||
$profileId = $session->get('profileId');
|
||||
if (!$profileId) {
|
||||
return $this->json(['message' => 'Aucun profil actif.'], 401);
|
||||
}
|
||||
|
||||
$profile = $this->profiles->find($profileId);
|
||||
if (!$profile) {
|
||||
return $this->json(['message' => 'Profil introuvable.'], 401);
|
||||
}
|
||||
|
||||
$payload = json_decode($request->getContent(), true);
|
||||
if (!is_array($payload)) {
|
||||
return $this->json(['message' => 'Payload JSON invalide.'], 400);
|
||||
}
|
||||
|
||||
$content = trim((string) ($payload['content'] ?? ''));
|
||||
$entityType = trim((string) ($payload['entityType'] ?? ''));
|
||||
$entityId = trim((string) ($payload['entityId'] ?? ''));
|
||||
$entityName = isset($payload['entityName']) ? trim((string) $payload['entityName']) : null;
|
||||
|
||||
if ('' === $content) {
|
||||
return $this->json(['message' => 'Le contenu est requis.'], 400);
|
||||
}
|
||||
|
||||
$allowedTypes = ['machine', 'piece', 'composant', 'product', 'piece_category', 'component_category', 'product_category', 'machine_skeleton'];
|
||||
if (!in_array($entityType, $allowedTypes, true)) {
|
||||
return $this->json(['message' => 'Type d\'entité invalide.'], 400);
|
||||
}
|
||||
|
||||
if ('' === $entityId) {
|
||||
return $this->json(['message' => 'L\'identifiant de l\'entité est requis.'], 400);
|
||||
}
|
||||
|
||||
$authorName = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
|
||||
if ('' === $authorName) {
|
||||
$authorName = $profile->getEmail() ?? 'Inconnu';
|
||||
}
|
||||
|
||||
$comment = new Comment();
|
||||
$comment->setContent($content);
|
||||
$comment->setEntityType($entityType);
|
||||
$comment->setEntityId($entityId);
|
||||
$comment->setEntityName($entityName);
|
||||
$comment->setAuthorId($profileId);
|
||||
$comment->setAuthorName($authorName);
|
||||
|
||||
$this->entityManager->persist($comment);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json($this->normalize($comment), 201);
|
||||
}
|
||||
|
||||
#[Route('/{id}/resolve', name: 'api_comments_resolve', methods: ['PATCH'])]
|
||||
public function resolve(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$comment = $this->entityManager->getRepository(Comment::class)->find($id);
|
||||
if (!$comment) {
|
||||
return $this->json(['message' => 'Commentaire introuvable.'], 404);
|
||||
}
|
||||
|
||||
$session = $request->getSession();
|
||||
$profileId = $session->get('profileId');
|
||||
$profile = $profileId ? $this->profiles->find($profileId) : null;
|
||||
|
||||
$resolverName = 'Inconnu';
|
||||
if ($profile) {
|
||||
$resolverName = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
|
||||
if ('' === $resolverName) {
|
||||
$resolverName = $profile->getEmail() ?? 'Inconnu';
|
||||
}
|
||||
}
|
||||
|
||||
$comment->setStatus('resolved');
|
||||
$comment->setResolvedById($profileId);
|
||||
$comment->setResolvedByName($resolverName);
|
||||
$comment->setResolvedAt(new DateTimeImmutable());
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json($this->normalize($comment));
|
||||
}
|
||||
|
||||
#[Route('/stats/unresolved-count', name: 'api_comments_unresolved_count', methods: ['GET'])]
|
||||
public function unresolvedCount(): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$count = $this->entityManager->getRepository(Comment::class)
|
||||
->count(['status' => 'open'])
|
||||
;
|
||||
|
||||
return $this->json(['count' => $count]);
|
||||
}
|
||||
|
||||
private function normalize(Comment $comment): array
|
||||
{
|
||||
return [
|
||||
'id' => $comment->getId(),
|
||||
'content' => $comment->getContent(),
|
||||
'entityType' => $comment->getEntityType(),
|
||||
'entityId' => $comment->getEntityId(),
|
||||
'entityName' => $comment->getEntityName(),
|
||||
'authorId' => $comment->getAuthorId(),
|
||||
'authorName' => $comment->getAuthorName(),
|
||||
'status' => $comment->getStatus(),
|
||||
'resolvedById' => $comment->getResolvedById(),
|
||||
'resolvedByName' => $comment->getResolvedByName(),
|
||||
'resolvedAt' => $comment->getResolvedAt()?->format(DateTimeInterface::ATOM),
|
||||
'createdAt' => $comment->getCreatedAt()->format(DateTimeInterface::ATOM),
|
||||
'updatedAt' => $comment->getUpdatedAt()->format(DateTimeInterface::ATOM),
|
||||
];
|
||||
}
|
||||
}
|
||||
82
src/Controller/ComposantHistoryController.php
Normal file
82
src/Controller/ComposantHistoryController.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Repository\AuditLogRepository;
|
||||
use App\Repository\ComposantRepository;
|
||||
use App\Repository\ProfileRepository;
|
||||
use DateTimeInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class ComposantHistoryController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ComposantRepository $components,
|
||||
private readonly AuditLogRepository $auditLogs,
|
||||
private readonly ProfileRepository $profiles,
|
||||
) {}
|
||||
|
||||
#[Route('/api/composants/{id}/history', name: 'api_composant_history', methods: ['GET'])]
|
||||
public function __invoke(string $id): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$component = $this->components->find($id);
|
||||
if (!$component) {
|
||||
return new JsonResponse(
|
||||
['message' => 'Composant introuvable.'],
|
||||
Response::HTTP_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
$logs = $this->auditLogs->findEntityHistory('composant', $id, 200);
|
||||
|
||||
$actorIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn ($log) => $log->getActorProfileId(),
|
||||
$logs,
|
||||
))));
|
||||
|
||||
$actorMap = [];
|
||||
if ([] !== $actorIds) {
|
||||
$profiles = $this->profiles->findBy(['id' => $actorIds]);
|
||||
foreach ($profiles as $profile) {
|
||||
$label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
|
||||
if ('' === $label) {
|
||||
$label = $profile->getEmail() ?? $profile->getId();
|
||||
}
|
||||
$actorMap[$profile->getId()] = $label;
|
||||
}
|
||||
}
|
||||
|
||||
$items = array_map(
|
||||
static function ($log) use ($actorMap) {
|
||||
$actorId = $log->getActorProfileId();
|
||||
|
||||
return [
|
||||
'id' => $log->getId(),
|
||||
'action' => $log->getAction(),
|
||||
'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
|
||||
'actor' => $actorId
|
||||
? [
|
||||
'id' => $actorId,
|
||||
'label' => $actorMap[$actorId] ?? $actorId,
|
||||
]
|
||||
: null,
|
||||
'diff' => $log->getDiff(),
|
||||
'snapshot' => $log->getSnapshot(),
|
||||
];
|
||||
},
|
||||
$logs,
|
||||
);
|
||||
|
||||
return new JsonResponse([
|
||||
'items' => array_values($items),
|
||||
'total' => count($items),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -29,12 +29,13 @@ 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
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$payload = $this->decodePayload($request);
|
||||
if ($payload instanceof JsonResponse) {
|
||||
return $payload;
|
||||
@@ -64,6 +65,8 @@ class CustomFieldValueController extends AbstractController
|
||||
#[Route('/upsert', name: 'custom_field_values_upsert', methods: ['POST'])]
|
||||
public function upsert(Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$payload = $this->decodePayload($request);
|
||||
if ($payload instanceof JsonResponse) {
|
||||
return $payload;
|
||||
@@ -80,7 +83,7 @@ class CustomFieldValueController extends AbstractController
|
||||
}
|
||||
|
||||
$existing = $this->customFieldValueRepository->findOneBy([
|
||||
'customField' => $customField,
|
||||
'customField' => $customField,
|
||||
$target['type'] => $target['entity'],
|
||||
]);
|
||||
|
||||
@@ -105,9 +108,11 @@ class CustomFieldValueController extends AbstractController
|
||||
#[Route('/{entityType}/{entityId}', name: 'custom_field_values_list', methods: ['GET'])]
|
||||
public function listByEntity(string $entityType, string $entityId): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$target = $this->resolveTarget([
|
||||
'entityType' => $entityType,
|
||||
'entityId' => $entityId,
|
||||
'entityId' => $entityId,
|
||||
]);
|
||||
|
||||
if ($target instanceof JsonResponse) {
|
||||
@@ -127,6 +132,8 @@ class CustomFieldValueController extends AbstractController
|
||||
#[Route('/{id}', name: 'custom_field_values_update', methods: ['PATCH'])]
|
||||
public function update(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$value = $this->customFieldValueRepository->find($id);
|
||||
if (!$value instanceof CustomFieldValue) {
|
||||
return $this->json(['success' => false, 'error' => 'Custom field value not found.'], 404);
|
||||
@@ -149,6 +156,8 @@ class CustomFieldValueController extends AbstractController
|
||||
#[Route('/{id}', name: 'custom_field_values_delete', methods: ['DELETE'])]
|
||||
public function delete(string $id): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$value = $this->customFieldValueRepository->find($id);
|
||||
if (!$value instanceof CustomFieldValue) {
|
||||
return $this->json(['success' => false, 'error' => 'Custom field value not found.'], 404);
|
||||
@@ -173,7 +182,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 +192,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 +214,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 +257,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 +282,23 @@ class CustomFieldValueController extends AbstractController
|
||||
$customField = $value->getCustomField();
|
||||
|
||||
return [
|
||||
'id' => $value->getId(),
|
||||
'value' => $value->getValue(),
|
||||
'id' => $value->getId(),
|
||||
'value' => $value->getValue(),
|
||||
'customFieldId' => $customField->getId(),
|
||||
'customField' => [
|
||||
'id' => $customField->getId(),
|
||||
'name' => $customField->getName(),
|
||||
'type' => $customField->getType(),
|
||||
'required' => $customField->isRequired(),
|
||||
'options' => $customField->getOptions(),
|
||||
'customField' => [
|
||||
'id' => $customField->getId(),
|
||||
'name' => $customField->getName(),
|
||||
'type' => $customField->getType(),
|
||||
'required' => $customField->isRequired(),
|
||||
'options' => $customField->getOptions(),
|
||||
'orderIndex' => $customField->getOrderIndex(),
|
||||
],
|
||||
'machineId' => $value->getMachine()?->getId(),
|
||||
'machineId' => $value->getMachine()?->getId(),
|
||||
'composantId' => $value->getComposant()?->getId(),
|
||||
'pieceId' => $value->getPiece()?->getId(),
|
||||
'productId' => $value->getProduct()?->getId(),
|
||||
'createdAt' => $value->getCreatedAt()->format(DATE_ATOM),
|
||||
'updatedAt' => $value->getUpdatedAt()->format(DATE_ATOM),
|
||||
'pieceId' => $value->getPiece()?->getId(),
|
||||
'productId' => $value->getProduct()?->getId(),
|
||||
'createdAt' => $value->getCreatedAt()->format(DATE_ATOM),
|
||||
'updatedAt' => $value->getUpdatedAt()->format(DATE_ATOM),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,12 +25,13 @@ 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
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$site = $this->siteRepository->find($id);
|
||||
if (!$site) {
|
||||
return $this->json(['success' => false, 'error' => 'Site not found.'], 404);
|
||||
@@ -44,6 +45,8 @@ class DocumentQueryController extends AbstractController
|
||||
#[Route('/machine/{id}', name: 'documents_by_machine', methods: ['GET'])]
|
||||
public function listByMachine(string $id): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$machine = $this->machineRepository->find($id);
|
||||
if (!$machine) {
|
||||
return $this->json(['success' => false, 'error' => 'Machine not found.'], 404);
|
||||
@@ -57,6 +60,8 @@ class DocumentQueryController extends AbstractController
|
||||
#[Route('/composant/{id}', name: 'documents_by_composant', methods: ['GET'])]
|
||||
public function listByComposant(string $id): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$composant = $this->composantRepository->find($id);
|
||||
if (!$composant) {
|
||||
return $this->json(['success' => false, 'error' => 'Composant not found.'], 404);
|
||||
@@ -70,6 +75,8 @@ class DocumentQueryController extends AbstractController
|
||||
#[Route('/piece/{id}', name: 'documents_by_piece', methods: ['GET'])]
|
||||
public function listByPiece(string $id): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$piece = $this->pieceRepository->find($id);
|
||||
if (!$piece) {
|
||||
return $this->json(['success' => false, 'error' => 'Piece not found.'], 404);
|
||||
@@ -83,6 +90,8 @@ class DocumentQueryController extends AbstractController
|
||||
#[Route('/product/{id}', name: 'documents_by_product', methods: ['GET'])]
|
||||
public function listByProduct(string $id): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$product = $this->productRepository->find($id);
|
||||
if (!$product) {
|
||||
return $this->json(['success' => false, 'error' => 'Product not found.'], 404);
|
||||
@@ -100,19 +109,20 @@ 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(),
|
||||
'fileUrl' => '/api/documents/'.$document->getId().'/file',
|
||||
'downloadUrl' => '/api/documents/'.$document->getId().'/download',
|
||||
'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);
|
||||
}
|
||||
|
||||
109
src/Controller/DocumentServeController.php
Normal file
109
src/Controller/DocumentServeController.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Repository\DocumentRepository;
|
||||
use App\Service\DocumentStorageService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
use function strlen;
|
||||
|
||||
#[Route('/api/documents')]
|
||||
class DocumentServeController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DocumentRepository $documentRepository,
|
||||
private readonly DocumentStorageService $storageService,
|
||||
) {}
|
||||
|
||||
#[Route('/{id}/file', name: 'document_serve_file', methods: ['GET'])]
|
||||
public function serve(string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$document = $this->documentRepository->find($id);
|
||||
if (!$document) {
|
||||
return $this->json(['error' => 'Document not found.'], 404);
|
||||
}
|
||||
|
||||
$path = $document->getPath();
|
||||
|
||||
// Backward compatibility: serve Base64 data URIs from DB
|
||||
if ($this->storageService->isBase64DataUri($path)) {
|
||||
$parts = explode(',', $path, 2);
|
||||
$content = base64_decode($parts[1] ?? '', true);
|
||||
if (false === $content) {
|
||||
return $this->json(['error' => 'Invalid document data.'], 500);
|
||||
}
|
||||
|
||||
return new Response($content, 200, [
|
||||
'Content-Type' => $document->getMimeType(),
|
||||
'Content-Disposition' => ResponseHeaderBag::DISPOSITION_INLINE.'; filename="'.$document->getFilename().'"',
|
||||
'Content-Length' => (string) strlen($content),
|
||||
'Cache-Control' => 'private, max-age=3600',
|
||||
]);
|
||||
}
|
||||
|
||||
// File-based path: serve from disk
|
||||
$absolutePath = $this->storageService->getAbsolutePath($path);
|
||||
if (!file_exists($absolutePath)) {
|
||||
return $this->json(['error' => 'File not found on disk.'], 404);
|
||||
}
|
||||
|
||||
$response = new BinaryFileResponse($absolutePath);
|
||||
$response->headers->set('Content-Type', $document->getMimeType());
|
||||
$response->setContentDisposition(
|
||||
ResponseHeaderBag::DISPOSITION_INLINE,
|
||||
$document->getFilename()
|
||||
);
|
||||
$response->headers->set('Cache-Control', 'private, max-age=3600');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
#[Route('/{id}/download', name: 'document_download_file', methods: ['GET'])]
|
||||
public function download(string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$document = $this->documentRepository->find($id);
|
||||
if (!$document) {
|
||||
return $this->json(['error' => 'Document not found.'], 404);
|
||||
}
|
||||
|
||||
$path = $document->getPath();
|
||||
|
||||
if ($this->storageService->isBase64DataUri($path)) {
|
||||
$parts = explode(',', $path, 2);
|
||||
$content = base64_decode($parts[1] ?? '', true);
|
||||
if (false === $content) {
|
||||
return $this->json(['error' => 'Invalid document data.'], 500);
|
||||
}
|
||||
|
||||
return new Response($content, 200, [
|
||||
'Content-Type' => 'application/octet-stream',
|
||||
'Content-Disposition' => ResponseHeaderBag::DISPOSITION_ATTACHMENT.'; filename="'.$document->getFilename().'"',
|
||||
'Content-Length' => (string) strlen($content),
|
||||
]);
|
||||
}
|
||||
|
||||
$absolutePath = $this->storageService->getAbsolutePath($path);
|
||||
if (!file_exists($absolutePath)) {
|
||||
return $this->json(['error' => 'File not found on disk.'], 404);
|
||||
}
|
||||
|
||||
$response = new BinaryFileResponse($absolutePath);
|
||||
$response->setContentDisposition(
|
||||
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
|
||||
$document->getFilename()
|
||||
);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -21,28 +21,24 @@ 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
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$machine = $this->machineRepository->find($id);
|
||||
if (!$machine instanceof Machine) {
|
||||
return $this->json(['success' => false, 'error' => 'Machine not found.'], 404);
|
||||
}
|
||||
|
||||
$typeMachine = $machine->getTypeMachine();
|
||||
if (!$typeMachine) {
|
||||
return $this->json(['success' => true, 'machineId' => $machine->getId(), 'customFieldValues' => []]);
|
||||
}
|
||||
|
||||
foreach ($typeMachine->getCustomFields() as $customField) {
|
||||
foreach ($machine->getCustomFields() as $customField) {
|
||||
if (!$customField instanceof CustomField) {
|
||||
continue;
|
||||
}
|
||||
$existing = $this->customFieldValueRepository->findOneBy([
|
||||
'machine' => $machine,
|
||||
'machine' => $machine,
|
||||
'customField' => $customField,
|
||||
]);
|
||||
if ($existing instanceof CustomFieldValue) {
|
||||
@@ -61,12 +57,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
|
||||
|
||||
82
src/Controller/MachineHistoryController.php
Normal file
82
src/Controller/MachineHistoryController.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Repository\AuditLogRepository;
|
||||
use App\Repository\MachineRepository;
|
||||
use App\Repository\ProfileRepository;
|
||||
use DateTimeInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class MachineHistoryController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MachineRepository $machines,
|
||||
private readonly AuditLogRepository $auditLogs,
|
||||
private readonly ProfileRepository $profiles,
|
||||
) {}
|
||||
|
||||
#[Route('/api/machines/{id}/history', name: 'api_machine_history', methods: ['GET'])]
|
||||
public function __invoke(string $id): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$machine = $this->machines->find($id);
|
||||
if (!$machine) {
|
||||
return new JsonResponse(
|
||||
['message' => 'Machine introuvable.'],
|
||||
Response::HTTP_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
$logs = $this->auditLogs->findEntityHistory('machine', $id, 200);
|
||||
|
||||
$actorIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn ($log) => $log->getActorProfileId(),
|
||||
$logs,
|
||||
))));
|
||||
|
||||
$actorMap = [];
|
||||
if ([] !== $actorIds) {
|
||||
$profiles = $this->profiles->findBy(['id' => $actorIds]);
|
||||
foreach ($profiles as $profile) {
|
||||
$label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
|
||||
if ('' === $label) {
|
||||
$label = $profile->getEmail() ?? $profile->getId();
|
||||
}
|
||||
$actorMap[$profile->getId()] = $label;
|
||||
}
|
||||
}
|
||||
|
||||
$items = array_map(
|
||||
static function ($log) use ($actorMap) {
|
||||
$actorId = $log->getActorProfileId();
|
||||
|
||||
return [
|
||||
'id' => $log->getId(),
|
||||
'action' => $log->getAction(),
|
||||
'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
|
||||
'actor' => $actorId
|
||||
? [
|
||||
'id' => $actorId,
|
||||
'label' => $actorMap[$actorId] ?? $actorId,
|
||||
]
|
||||
: null,
|
||||
'diff' => $log->getDiff(),
|
||||
'snapshot' => $log->getSnapshot(),
|
||||
];
|
||||
},
|
||||
$logs,
|
||||
);
|
||||
|
||||
return new JsonResponse([
|
||||
'items' => array_values($items),
|
||||
'total' => count($items),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,756 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Composant;
|
||||
use App\Entity\Machine;
|
||||
use App\Entity\MachineComponentLink;
|
||||
use App\Entity\MachinePieceLink;
|
||||
use App\Entity\MachineProductLink;
|
||||
use App\Entity\ModelType;
|
||||
use App\Entity\Piece;
|
||||
use App\Entity\Product;
|
||||
use App\Entity\TypeMachineComponentRequirement;
|
||||
use App\Entity\TypeMachinePieceRequirement;
|
||||
use App\Entity\TypeMachineProductRequirement;
|
||||
use App\Repository\ComposantRepository;
|
||||
use App\Repository\MachineComponentLinkRepository;
|
||||
use App\Repository\MachinePieceLinkRepository;
|
||||
use App\Repository\MachineProductLinkRepository;
|
||||
use App\Repository\MachineRepository;
|
||||
use App\Repository\PieceRepository;
|
||||
use App\Repository\ProductRepository;
|
||||
use App\Repository\TypeMachineComponentRequirementRepository;
|
||||
use App\Repository\TypeMachinePieceRequirementRepository;
|
||||
use App\Repository\TypeMachineProductRequirementRepository;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/api/machines')]
|
||||
class MachineSkeletonController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly MachineRepository $machineRepository,
|
||||
private readonly MachineComponentLinkRepository $machineComponentLinkRepository,
|
||||
private readonly MachinePieceLinkRepository $machinePieceLinkRepository,
|
||||
private readonly MachineProductLinkRepository $machineProductLinkRepository,
|
||||
private readonly ComposantRepository $composantRepository,
|
||||
private readonly PieceRepository $pieceRepository,
|
||||
private readonly ProductRepository $productRepository,
|
||||
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
|
||||
{
|
||||
$machine = $this->machineRepository->find($id);
|
||||
if (!$machine instanceof Machine) {
|
||||
return $this->json(['success' => false, 'error' => 'Machine not found.'], 404);
|
||||
}
|
||||
|
||||
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $machine]);
|
||||
$pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $machine]);
|
||||
$productLinks = $this->machineProductLinkRepository->findBy(['machine' => $machine]);
|
||||
|
||||
return $this->json($this->normalizeMachineSkeletonResponse(
|
||||
$machine,
|
||||
$componentLinks,
|
||||
$pieceLinks,
|
||||
$productLinks
|
||||
));
|
||||
}
|
||||
|
||||
#[Route('/{id}/skeleton', name: 'machine_skeleton_update', methods: ['PATCH'])]
|
||||
public function updateSkeleton(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$machine = $this->machineRepository->find($id);
|
||||
if (!$machine instanceof Machine) {
|
||||
return $this->json(['success' => false, 'error' => 'Machine not found.'], 404);
|
||||
}
|
||||
|
||||
$payload = json_decode($request->getContent(), true);
|
||||
if (!is_array($payload)) {
|
||||
return $this->json(['success' => false, 'error' => 'Invalid JSON payload.'], 400);
|
||||
}
|
||||
|
||||
$componentLinksPayload = $this->normalizePayloadList($payload['componentLinks'] ?? []);
|
||||
$pieceLinksPayload = $this->normalizePayloadList($payload['pieceLinks'] ?? []);
|
||||
$productLinksPayload = $this->normalizePayloadList($payload['productLinks'] ?? []);
|
||||
|
||||
$componentLinks = $this->applyComponentLinks($machine, $componentLinksPayload);
|
||||
if ($componentLinks instanceof JsonResponse) {
|
||||
return $componentLinks;
|
||||
}
|
||||
|
||||
$pieceLinks = $this->applyPieceLinks($machine, $pieceLinksPayload, $componentLinks);
|
||||
if ($pieceLinks instanceof JsonResponse) {
|
||||
return $pieceLinks;
|
||||
}
|
||||
|
||||
$productLinks = $this->applyProductLinks($machine, $productLinksPayload, $componentLinks, $pieceLinks);
|
||||
if ($productLinks instanceof JsonResponse) {
|
||||
return $productLinks;
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json($this->normalizeMachineSkeletonResponse(
|
||||
$machine,
|
||||
$componentLinks,
|
||||
$pieceLinks,
|
||||
$productLinks
|
||||
));
|
||||
}
|
||||
|
||||
private function normalizePayloadList(mixed $value): array
|
||||
{
|
||||
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 = [];
|
||||
$pendingParents = [];
|
||||
$links = [];
|
||||
|
||||
foreach ($payload as $entry) {
|
||||
$linkId = $this->resolveIdentifier($entry, ['id', 'linkId']);
|
||||
$link = $linkId && isset($existing[$linkId]) ? $existing[$linkId] : new MachineComponentLink();
|
||||
if (!$linkId) {
|
||||
$linkId = $this->generateCuid();
|
||||
}
|
||||
if (!$link->getId()) {
|
||||
$link->setId($linkId);
|
||||
}
|
||||
|
||||
$composantId = $this->resolveIdentifier($entry, ['composantId', 'componentId', 'idComposant']);
|
||||
if (!$composantId) {
|
||||
return $this->json(['success' => false, 'error' => 'Composant requis pour le squelette.'], 400);
|
||||
}
|
||||
$composant = $this->composantRepository->find($composantId);
|
||||
if (!$composant instanceof Composant) {
|
||||
return $this->json(['success' => false, 'error' => 'Composant introuvable.'], 404);
|
||||
}
|
||||
|
||||
$link->setMachine($machine);
|
||||
$link->setComposant($composant);
|
||||
|
||||
$requirementId = $this->resolveIdentifier($entry, ['requirementId', 'typeMachineComponentRequirementId']);
|
||||
if ($requirementId) {
|
||||
$requirement = $this->componentRequirementRepository->find($requirementId);
|
||||
if ($requirement instanceof TypeMachineComponentRequirement) {
|
||||
$link->setTypeMachineComponentRequirement($requirement);
|
||||
}
|
||||
}
|
||||
|
||||
$this->applyOverrides($link, $entry['overrides'] ?? null);
|
||||
|
||||
$pendingParents[$linkId] = $this->resolveIdentifier($entry, [
|
||||
'parentComponentLinkId',
|
||||
'parentLinkId',
|
||||
'parentMachineComponentLinkId',
|
||||
]);
|
||||
|
||||
$this->entityManager->persist($link);
|
||||
$links[$linkId] = $link;
|
||||
$keepIds[] = $linkId;
|
||||
}
|
||||
|
||||
foreach ($pendingParents as $linkId => $parentId) {
|
||||
if (!$parentId) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($links[$linkId])) {
|
||||
continue;
|
||||
}
|
||||
$parent = $links[$parentId] ?? $existing[$parentId] ?? null;
|
||||
if ($parent instanceof MachineComponentLink) {
|
||||
$links[$linkId]->setParentLink($parent);
|
||||
}
|
||||
}
|
||||
|
||||
$this->removeMissingLinks($existing, $keepIds);
|
||||
|
||||
return array_values($links);
|
||||
}
|
||||
|
||||
private function applyPieceLinks(Machine $machine, array $payload, array $componentLinks): array|JsonResponse
|
||||
{
|
||||
$existing = $this->indexLinksById($this->machinePieceLinkRepository->findBy(['machine' => $machine]));
|
||||
$componentIndex = $this->indexLinksById($componentLinks);
|
||||
$keepIds = [];
|
||||
$pendingParents = [];
|
||||
$links = [];
|
||||
|
||||
foreach ($payload as $entry) {
|
||||
$linkId = $this->resolveIdentifier($entry, ['id', 'linkId']);
|
||||
$link = $linkId && isset($existing[$linkId]) ? $existing[$linkId] : new MachinePieceLink();
|
||||
if (!$linkId) {
|
||||
$linkId = $this->generateCuid();
|
||||
}
|
||||
if (!$link->getId()) {
|
||||
$link->setId($linkId);
|
||||
}
|
||||
|
||||
$pieceId = $this->resolveIdentifier($entry, ['pieceId']);
|
||||
if (!$pieceId) {
|
||||
return $this->json(['success' => false, 'error' => 'Pièce requise pour le squelette.'], 400);
|
||||
}
|
||||
$piece = $this->pieceRepository->find($pieceId);
|
||||
if (!$piece instanceof Piece) {
|
||||
return $this->json(['success' => false, 'error' => 'Pièce introuvable.'], 404);
|
||||
}
|
||||
|
||||
$link->setMachine($machine);
|
||||
$link->setPiece($piece);
|
||||
|
||||
$requirementId = $this->resolveIdentifier($entry, ['requirementId', 'typeMachinePieceRequirementId']);
|
||||
if ($requirementId) {
|
||||
$requirement = $this->pieceRequirementRepository->find($requirementId);
|
||||
if ($requirement instanceof TypeMachinePieceRequirement) {
|
||||
$link->setTypeMachinePieceRequirement($requirement);
|
||||
}
|
||||
}
|
||||
|
||||
$this->applyOverrides($link, $entry['overrides'] ?? null);
|
||||
|
||||
$pendingParents[$linkId] = $this->resolveIdentifier($entry, [
|
||||
'parentComponentLinkId',
|
||||
'parentLinkId',
|
||||
'parentMachineComponentLinkId',
|
||||
]);
|
||||
|
||||
$this->entityManager->persist($link);
|
||||
$links[$linkId] = $link;
|
||||
$keepIds[] = $linkId;
|
||||
}
|
||||
|
||||
foreach ($pendingParents as $linkId => $parentId) {
|
||||
if (!$parentId) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($links[$linkId])) {
|
||||
continue;
|
||||
}
|
||||
$parent = $componentIndex[$parentId] ?? null;
|
||||
if ($parent instanceof MachineComponentLink) {
|
||||
$links[$linkId]->setParentLink($parent);
|
||||
}
|
||||
}
|
||||
|
||||
$this->removeMissingLinks($existing, $keepIds);
|
||||
|
||||
return array_values($links);
|
||||
}
|
||||
|
||||
private function applyProductLinks(
|
||||
Machine $machine,
|
||||
array $payload,
|
||||
array $componentLinks,
|
||||
array $pieceLinks,
|
||||
): array|JsonResponse {
|
||||
$existing = $this->indexLinksById($this->machineProductLinkRepository->findBy(['machine' => $machine]));
|
||||
$componentIndex = $this->indexLinksById($componentLinks);
|
||||
$pieceIndex = $this->indexLinksById($pieceLinks);
|
||||
$keepIds = [];
|
||||
$pendingParents = [];
|
||||
$links = [];
|
||||
|
||||
foreach ($payload as $entry) {
|
||||
$linkId = $this->resolveIdentifier($entry, ['id', 'linkId']);
|
||||
$link = $linkId && isset($existing[$linkId]) ? $existing[$linkId] : new MachineProductLink();
|
||||
if (!$linkId) {
|
||||
$linkId = $this->generateCuid();
|
||||
}
|
||||
if (!$link->getId()) {
|
||||
$link->setId($linkId);
|
||||
}
|
||||
|
||||
$productId = $this->resolveIdentifier($entry, ['productId']);
|
||||
if (!$productId) {
|
||||
return $this->json(['success' => false, 'error' => 'Produit requis pour le squelette.'], 400);
|
||||
}
|
||||
$product = $this->productRepository->find($productId);
|
||||
if (!$product instanceof Product) {
|
||||
return $this->json(['success' => false, 'error' => 'Produit introuvable.'], 404);
|
||||
}
|
||||
|
||||
$link->setMachine($machine);
|
||||
$link->setProduct($product);
|
||||
|
||||
$requirementId = $this->resolveIdentifier($entry, ['requirementId', 'typeMachineProductRequirementId']);
|
||||
if ($requirementId) {
|
||||
$requirement = $this->productRequirementRepository->find($requirementId);
|
||||
if ($requirement instanceof TypeMachineProductRequirement) {
|
||||
$link->setTypeMachineProductRequirement($requirement);
|
||||
}
|
||||
}
|
||||
|
||||
$pendingParents[$linkId] = [
|
||||
'parentComponentLinkId' => $this->resolveIdentifier($entry, ['parentComponentLinkId']),
|
||||
'parentPieceLinkId' => $this->resolveIdentifier($entry, ['parentPieceLinkId']),
|
||||
'parentLinkId' => $this->resolveIdentifier($entry, ['parentLinkId']),
|
||||
];
|
||||
|
||||
$this->entityManager->persist($link);
|
||||
$links[$linkId] = $link;
|
||||
$keepIds[] = $linkId;
|
||||
}
|
||||
|
||||
foreach ($pendingParents as $linkId => $parentIds) {
|
||||
if (!isset($links[$linkId])) {
|
||||
continue;
|
||||
}
|
||||
if (!empty($parentIds['parentComponentLinkId']) && isset($componentIndex[$parentIds['parentComponentLinkId']])) {
|
||||
$links[$linkId]->setParentComponentLink($componentIndex[$parentIds['parentComponentLinkId']]);
|
||||
}
|
||||
if (!empty($parentIds['parentPieceLinkId']) && isset($pieceIndex[$parentIds['parentPieceLinkId']])) {
|
||||
$links[$linkId]->setParentPieceLink($pieceIndex[$parentIds['parentPieceLinkId']]);
|
||||
}
|
||||
if (!empty($parentIds['parentLinkId']) && isset($links[$parentIds['parentLinkId']])) {
|
||||
$links[$linkId]->setParentLink($links[$parentIds['parentLinkId']]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->removeMissingLinks($existing, $keepIds);
|
||||
|
||||
return array_values($links);
|
||||
}
|
||||
|
||||
private function normalizeMachineSkeletonResponse(
|
||||
Machine $machine,
|
||||
array $componentLinks,
|
||||
array $pieceLinks,
|
||||
array $productLinks,
|
||||
): array {
|
||||
$normalizedComponentLinks = $this->normalizeComponentLinks($componentLinks);
|
||||
$componentIndex = $this->indexNormalizedLinks($normalizedComponentLinks);
|
||||
$normalizedPieceLinks = $this->normalizePieceLinks($pieceLinks);
|
||||
|
||||
// Build component hierarchy
|
||||
foreach ($normalizedComponentLinks as &$link) {
|
||||
$parentId = $link['parentComponentLinkId'] ?? null;
|
||||
if ($parentId && isset($componentIndex[$parentId])) {
|
||||
$componentIndex[$parentId]['childLinks'][] = &$link;
|
||||
}
|
||||
}
|
||||
unset($link);
|
||||
|
||||
// Add pieces to components recursively
|
||||
$this->attachPiecesToComponents($componentIndex, $normalizedPieceLinks);
|
||||
|
||||
return [
|
||||
'machine' => $this->normalizeMachine($machine),
|
||||
'componentLinks' => array_values($componentIndex),
|
||||
'pieceLinks' => $normalizedPieceLinks,
|
||||
'productLinks' => $this->normalizeProductLinks($productLinks),
|
||||
];
|
||||
}
|
||||
|
||||
private function attachPiecesToComponents(array &$componentIndex, array $pieceLinks): void
|
||||
{
|
||||
foreach ($pieceLinks as $pieceLink) {
|
||||
$parentId = $pieceLink['parentComponentLinkId'] ?? null;
|
||||
if ($parentId && isset($componentIndex[$parentId])) {
|
||||
$componentIndex[$parentId]['pieceLinks'][] = $pieceLink;
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively attach to child components
|
||||
foreach ($componentIndex as &$component) {
|
||||
if (!empty($component['childLinks'])) {
|
||||
$this->attachPiecesToChildComponents($component['childLinks'], $pieceLinks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function attachPiecesToChildComponents(array &$childLinks, array $pieceLinks): void
|
||||
{
|
||||
foreach ($childLinks as &$child) {
|
||||
$childId = $child['id'] ?? $child['linkId'] ?? null;
|
||||
if ($childId) {
|
||||
foreach ($pieceLinks as $pieceLink) {
|
||||
$parentId = $pieceLink['parentComponentLinkId'] ?? null;
|
||||
if ($parentId === $childId) {
|
||||
$child['pieceLinks'][] = $pieceLink;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively process nested children
|
||||
if (!empty($child['childLinks'])) {
|
||||
$this->attachPiecesToChildComponents($child['childLinks'], $pieceLinks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeMachine(Machine $machine): array
|
||||
{
|
||||
$site = $machine->getSite();
|
||||
$typeMachine = $machine->getTypeMachine();
|
||||
|
||||
return [
|
||||
'id' => $machine->getId(),
|
||||
'name' => $machine->getName(),
|
||||
'reference' => $machine->getReference(),
|
||||
'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()),
|
||||
'componentRequirements' => $typeMachine->getComponentRequirements()
|
||||
->map(fn (TypeMachineComponentRequirement $req) => $this->normalizeComponentRequirement($req))
|
||||
->toArray(),
|
||||
'pieceRequirements' => $typeMachine->getPieceRequirements()
|
||||
->map(fn (TypeMachinePieceRequirement $req) => $this->normalizePieceRequirement($req))
|
||||
->toArray(),
|
||||
'productRequirements' => $typeMachine->getProductRequirements()
|
||||
->map(fn (TypeMachineProductRequirement $req) => $this->normalizeProductRequirement($req))
|
||||
->toArray(),
|
||||
] : null,
|
||||
'constructeurs' => $this->normalizeConstructeurs($machine->getConstructeurs()),
|
||||
'documents' => null,
|
||||
'customFieldValues' => null,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeCustomFields(Collection $customFields): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($customFields as $customField) {
|
||||
if (!$customField instanceof CustomField) {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'id' => $customField->getId(),
|
||||
'name' => $customField->getName(),
|
||||
'type' => $customField->getType(),
|
||||
'required' => $customField->isRequired(),
|
||||
'options' => $customField->getOptions(),
|
||||
'defaultValue' => $customField->getDefaultValue(),
|
||||
'orderIndex' => $customField->getOrderIndex(),
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function normalizeComponentLinks(array $links): array
|
||||
{
|
||||
return array_map(function (MachineComponentLink $link): array {
|
||||
$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(),
|
||||
'parentMachineComponentRequirementId' => $parentRequirementId,
|
||||
'overrides' => $this->normalizeOverrides($link),
|
||||
'childLinks' => [],
|
||||
'pieceLinks' => [],
|
||||
];
|
||||
}, $links);
|
||||
}
|
||||
|
||||
private function normalizePieceLinks(array $links): array
|
||||
{
|
||||
return array_map(function (MachinePieceLink $link): array {
|
||||
$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(),
|
||||
'parentMachineComponentRequirementId' => $parentRequirementId,
|
||||
'overrides' => $this->normalizeOverrides($link),
|
||||
];
|
||||
}, $links);
|
||||
}
|
||||
|
||||
private function normalizeProductLinks(array $links): array
|
||||
{
|
||||
return array_map(function (MachineProductLink $link): array {
|
||||
$product = $link->getProduct();
|
||||
$requirement = $link->getTypeMachineProductRequirement();
|
||||
|
||||
return [
|
||||
'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(),
|
||||
];
|
||||
}, $links);
|
||||
}
|
||||
|
||||
private function normalizeComposant(Composant $composant): array
|
||||
{
|
||||
return [
|
||||
'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' => [],
|
||||
];
|
||||
}
|
||||
|
||||
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,
|
||||
'constructeurs' => $this->normalizeConstructeurs($piece->getConstructeurs()),
|
||||
'documents' => [],
|
||||
'customFields' => [],
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeProduct(Product $product): array
|
||||
{
|
||||
return [
|
||||
'id' => $product->getId(),
|
||||
'name' => $product->getName(),
|
||||
'reference' => $product->getReference(),
|
||||
'supplierPrice' => $product->getSupplierPrice(),
|
||||
'typeProductId' => $product->getTypeProduct()?->getId(),
|
||||
'typeProduct' => $this->normalizeModelType($product->getTypeProduct()),
|
||||
'constructeurs' => $this->normalizeConstructeurs($product->getConstructeurs()),
|
||||
'documents' => [],
|
||||
'customFields' => [],
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeModelType(?ModelType $type): ?array
|
||||
{
|
||||
if (!$type instanceof ModelType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $type->getId(),
|
||||
'name' => $type->getName(),
|
||||
'code' => $type->getCode(),
|
||||
'category' => $type->getCategory()->value,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeComponentRequirement(TypeMachineComponentRequirement $requirement): array
|
||||
{
|
||||
return [
|
||||
'id' => $requirement->getId(),
|
||||
'label' => $requirement->getLabel(),
|
||||
'minCount' => $requirement->getMinCount(),
|
||||
'maxCount' => $requirement->getMaxCount(),
|
||||
'required' => $requirement->isRequired(),
|
||||
'typeComposantId' => $requirement->getTypeComposant()->getId(),
|
||||
'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(),
|
||||
'typePieceId' => $requirement->getTypePiece()->getId(),
|
||||
'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(),
|
||||
'typeProductId' => $requirement->getTypeProduct()->getId(),
|
||||
'typeProduct' => $this->normalizeModelType($requirement->getTypeProduct()),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeConstructeurs(Collection $constructeurs): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($constructeurs as $constructeur) {
|
||||
$items[] = [
|
||||
'id' => $constructeur->getId(),
|
||||
'name' => $constructeur->getName(),
|
||||
'email' => $constructeur->getEmail(),
|
||||
'phone' => $constructeur->getPhone(),
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function normalizeOverrides(object $link): ?array
|
||||
{
|
||||
$name = method_exists($link, 'getNameOverride') ? $link->getNameOverride() : null;
|
||||
$reference = method_exists($link, 'getReferenceOverride') ? $link->getReferenceOverride() : null;
|
||||
$prix = method_exists($link, 'getPrixOverride') ? $link->getPrixOverride() : null;
|
||||
|
||||
if ($name === null && $reference === null && $prix === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'reference' => $reference,
|
||||
'prix' => $prix,
|
||||
];
|
||||
}
|
||||
|
||||
private function applyOverrides(object $link, mixed $overrides): void
|
||||
{
|
||||
if (!is_array($overrides)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (array_key_exists('name', $overrides) && method_exists($link, 'setNameOverride')) {
|
||||
$link->setNameOverride($this->stringOrNull($overrides['name']));
|
||||
}
|
||||
if (array_key_exists('reference', $overrides) && method_exists($link, 'setReferenceOverride')) {
|
||||
$link->setReferenceOverride($this->stringOrNull($overrides['reference']));
|
||||
}
|
||||
if (array_key_exists('prix', $overrides) && method_exists($link, 'setPrixOverride')) {
|
||||
$link->setPrixOverride($this->stringOrNull($overrides['prix']));
|
||||
}
|
||||
}
|
||||
|
||||
private function stringOrNull(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
$string = trim((string) $value);
|
||||
|
||||
return $string === '' ? null : $string;
|
||||
}
|
||||
|
||||
private function resolveIdentifier(array $entry, array $keys): ?string
|
||||
{
|
||||
foreach ($keys as $key) {
|
||||
if (!array_key_exists($key, $entry)) {
|
||||
continue;
|
||||
}
|
||||
$value = $entry[$key];
|
||||
if ($value === null || $value === '') {
|
||||
continue;
|
||||
}
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array-key, object> $links
|
||||
* @return array<string, object>
|
||||
*/
|
||||
private function indexLinksById(array $links): array
|
||||
{
|
||||
$indexed = [];
|
||||
foreach ($links as $link) {
|
||||
if (method_exists($link, 'getId') && $link->getId()) {
|
||||
$indexed[$link->getId()] = $link;
|
||||
}
|
||||
}
|
||||
|
||||
return $indexed;
|
||||
}
|
||||
|
||||
private function indexNormalizedLinks(array $links): array
|
||||
{
|
||||
$indexed = [];
|
||||
foreach ($links as $link) {
|
||||
if (is_array($link) && isset($link['id'])) {
|
||||
$indexed[$link['id']] = $link;
|
||||
}
|
||||
}
|
||||
|
||||
return $indexed;
|
||||
}
|
||||
|
||||
private function removeMissingLinks(array $existing, array $keepIds): void
|
||||
{
|
||||
$keep = array_flip($keepIds);
|
||||
foreach ($existing as $link) {
|
||||
if (!method_exists($link, 'getId')) {
|
||||
continue;
|
||||
}
|
||||
$id = $link->getId();
|
||||
if ($id && !isset($keep[$id])) {
|
||||
$this->entityManager->remove($link);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl' . bin2hex(random_bytes(12));
|
||||
}
|
||||
}
|
||||
902
src/Controller/MachineStructureController.php
Normal file
902
src/Controller/MachineStructureController.php
Normal file
@@ -0,0 +1,902 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Composant;
|
||||
use App\Entity\CustomField;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\Machine;
|
||||
use App\Entity\MachineComponentLink;
|
||||
use App\Entity\MachinePieceLink;
|
||||
use App\Entity\MachineProductLink;
|
||||
use App\Entity\ModelType;
|
||||
use App\Entity\Piece;
|
||||
use App\Entity\Product;
|
||||
use App\Repository\ComposantRepository;
|
||||
use App\Repository\MachineComponentLinkRepository;
|
||||
use App\Repository\MachinePieceLinkRepository;
|
||||
use App\Repository\MachineProductLinkRepository;
|
||||
use App\Repository\MachineRepository;
|
||||
use App\Repository\PieceRepository;
|
||||
use App\Repository\ProductRepository;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/api/machines')]
|
||||
class MachineStructureController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly MachineRepository $machineRepository,
|
||||
private readonly MachineComponentLinkRepository $machineComponentLinkRepository,
|
||||
private readonly MachinePieceLinkRepository $machinePieceLinkRepository,
|
||||
private readonly MachineProductLinkRepository $machineProductLinkRepository,
|
||||
private readonly ComposantRepository $composantRepository,
|
||||
private readonly PieceRepository $pieceRepository,
|
||||
private readonly ProductRepository $productRepository,
|
||||
) {}
|
||||
|
||||
#[Route('/{id}/structure', name: 'machine_structure_get', methods: ['GET'])]
|
||||
public function getStructure(string $id): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$machine = $this->machineRepository->find($id);
|
||||
if (!$machine instanceof Machine) {
|
||||
return $this->json(['success' => false, 'error' => 'Machine not found.'], 404);
|
||||
}
|
||||
|
||||
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $machine]);
|
||||
$pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $machine]);
|
||||
$productLinks = $this->machineProductLinkRepository->findBy(['machine' => $machine]);
|
||||
|
||||
return $this->json($this->normalizeStructureResponse(
|
||||
$machine,
|
||||
$componentLinks,
|
||||
$pieceLinks,
|
||||
$productLinks
|
||||
));
|
||||
}
|
||||
|
||||
#[Route('/{id}/structure', name: 'machine_structure_update', methods: ['PATCH'])]
|
||||
public function updateStructure(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$machine = $this->machineRepository->find($id);
|
||||
if (!$machine instanceof Machine) {
|
||||
return $this->json(['success' => false, 'error' => 'Machine not found.'], 404);
|
||||
}
|
||||
|
||||
$payload = json_decode($request->getContent(), true);
|
||||
if (!is_array($payload)) {
|
||||
return $this->json(['success' => false, 'error' => 'Invalid JSON payload.'], 400);
|
||||
}
|
||||
|
||||
$componentLinksPayload = $this->normalizePayloadList($payload['componentLinks'] ?? []);
|
||||
$pieceLinksPayload = $this->normalizePayloadList($payload['pieceLinks'] ?? []);
|
||||
$productLinksPayload = $this->normalizePayloadList($payload['productLinks'] ?? []);
|
||||
|
||||
$componentLinks = $this->applyComponentLinks($machine, $componentLinksPayload);
|
||||
if ($componentLinks instanceof JsonResponse) {
|
||||
return $componentLinks;
|
||||
}
|
||||
|
||||
$pieceLinks = $this->applyPieceLinks($machine, $pieceLinksPayload, $componentLinks);
|
||||
if ($pieceLinks instanceof JsonResponse) {
|
||||
return $pieceLinks;
|
||||
}
|
||||
|
||||
$productLinks = $this->applyProductLinks($machine, $productLinksPayload, $componentLinks, $pieceLinks);
|
||||
if ($productLinks instanceof JsonResponse) {
|
||||
return $productLinks;
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json($this->normalizeStructureResponse(
|
||||
$machine,
|
||||
$componentLinks,
|
||||
$pieceLinks,
|
||||
$productLinks
|
||||
));
|
||||
}
|
||||
|
||||
#[Route('/{id}/clone', name: 'machine_clone', methods: ['POST'])]
|
||||
public function cloneMachine(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$source = $this->machineRepository->find($id);
|
||||
if (!$source instanceof Machine) {
|
||||
return $this->json(['success' => false, 'error' => 'Machine source introuvable.'], 404);
|
||||
}
|
||||
|
||||
$payload = json_decode($request->getContent(), true);
|
||||
if (!is_array($payload) || empty($payload['name']) || empty($payload['siteId'])) {
|
||||
return $this->json(['success' => false, 'error' => 'name et siteId sont requis.'], 400);
|
||||
}
|
||||
|
||||
$site = $this->entityManager->getRepository(\App\Entity\Site::class)->find($payload['siteId']);
|
||||
if (!$site) {
|
||||
return $this->json(['success' => false, 'error' => 'Site introuvable.'], 404);
|
||||
}
|
||||
|
||||
// Create new machine
|
||||
$newMachine = new Machine();
|
||||
$newMachine->setName($payload['name']);
|
||||
$newMachine->setSite($site);
|
||||
if (!empty($payload['reference'])) {
|
||||
$newMachine->setReference($payload['reference']);
|
||||
}
|
||||
$newMachine->setPrix($source->getPrix());
|
||||
|
||||
// Copy constructeurs
|
||||
foreach ($source->getConstructeurs() as $constructeur) {
|
||||
$newMachine->getConstructeurs()->add($constructeur);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($newMachine);
|
||||
|
||||
// Copy custom fields and values
|
||||
$this->cloneCustomFields($source, $newMachine);
|
||||
|
||||
// Copy component links (preserving hierarchy)
|
||||
$componentLinkMap = $this->cloneComponentLinks($source, $newMachine);
|
||||
|
||||
// Copy piece links
|
||||
$pieceLinkMap = $this->clonePieceLinks($source, $newMachine, $componentLinkMap);
|
||||
|
||||
// Copy product links
|
||||
$this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $newMachine]);
|
||||
$pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $newMachine]);
|
||||
$productLinks = $this->machineProductLinkRepository->findBy(['machine' => $newMachine]);
|
||||
|
||||
return $this->json($this->normalizeStructureResponse(
|
||||
$newMachine,
|
||||
$componentLinks,
|
||||
$pieceLinks,
|
||||
$productLinks
|
||||
), 201);
|
||||
}
|
||||
|
||||
private function cloneCustomFields(Machine $source, Machine $target): void
|
||||
{
|
||||
foreach ($source->getCustomFields() as $cf) {
|
||||
$newCf = new CustomField();
|
||||
$newCf->setName($cf->getName());
|
||||
$newCf->setType($cf->getType());
|
||||
$newCf->setRequired($cf->isRequired());
|
||||
$newCf->setDefaultValue($cf->getDefaultValue());
|
||||
$newCf->setOptions($cf->getOptions());
|
||||
$newCf->setOrderIndex($cf->getOrderIndex());
|
||||
$newCf->setMachine($target);
|
||||
$this->entityManager->persist($newCf);
|
||||
}
|
||||
|
||||
foreach ($source->getCustomFieldValues() as $cfv) {
|
||||
$newValue = new CustomFieldValue();
|
||||
$newValue->setMachine($target);
|
||||
$newValue->setCustomField($cfv->getCustomField());
|
||||
$newValue->setValue($cfv->getValue());
|
||||
$this->entityManager->persist($newValue);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, MachineComponentLink> Map of old link ID → new link
|
||||
*/
|
||||
private function cloneComponentLinks(Machine $source, Machine $target): array
|
||||
{
|
||||
$sourceLinks = $this->machineComponentLinkRepository->findBy(['machine' => $source]);
|
||||
$linkMap = [];
|
||||
|
||||
// First pass: create all links without parent relationships
|
||||
foreach ($sourceLinks as $link) {
|
||||
$newLink = new MachineComponentLink();
|
||||
$newLink->setMachine($target);
|
||||
$newLink->setComposant($link->getComposant());
|
||||
$newLink->setNameOverride($link->getNameOverride());
|
||||
$newLink->setReferenceOverride($link->getReferenceOverride());
|
||||
$newLink->setPrixOverride($link->getPrixOverride());
|
||||
$this->entityManager->persist($newLink);
|
||||
$linkMap[$link->getId()] = $newLink;
|
||||
}
|
||||
|
||||
// Second pass: set parent relationships
|
||||
foreach ($sourceLinks as $link) {
|
||||
$parent = $link->getParentLink();
|
||||
if ($parent && isset($linkMap[$parent->getId()])) {
|
||||
$linkMap[$link->getId()]->setParentLink($linkMap[$parent->getId()]);
|
||||
}
|
||||
}
|
||||
|
||||
return $linkMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, MachineComponentLink> $componentLinkMap
|
||||
*
|
||||
* @return array<string, MachinePieceLink> Map of old link ID → new link
|
||||
*/
|
||||
private function clonePieceLinks(Machine $source, Machine $target, array $componentLinkMap): array
|
||||
{
|
||||
$sourceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $source]);
|
||||
$linkMap = [];
|
||||
|
||||
foreach ($sourceLinks as $link) {
|
||||
$newLink = new MachinePieceLink();
|
||||
$newLink->setMachine($target);
|
||||
$newLink->setPiece($link->getPiece());
|
||||
$newLink->setNameOverride($link->getNameOverride());
|
||||
$newLink->setReferenceOverride($link->getReferenceOverride());
|
||||
$newLink->setPrixOverride($link->getPrixOverride());
|
||||
|
||||
$parent = $link->getParentLink();
|
||||
if ($parent && isset($componentLinkMap[$parent->getId()])) {
|
||||
$newLink->setParentLink($componentLinkMap[$parent->getId()]);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($newLink);
|
||||
$linkMap[$link->getId()] = $newLink;
|
||||
}
|
||||
|
||||
return $linkMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, MachineComponentLink> $componentLinkMap
|
||||
* @param array<string, MachinePieceLink> $pieceLinkMap
|
||||
*/
|
||||
private function cloneProductLinks(
|
||||
Machine $source,
|
||||
Machine $target,
|
||||
array $componentLinkMap,
|
||||
array $pieceLinkMap,
|
||||
): void {
|
||||
$sourceLinks = $this->machineProductLinkRepository->findBy(['machine' => $source]);
|
||||
$linkMap = [];
|
||||
|
||||
// First pass: create all links
|
||||
foreach ($sourceLinks as $link) {
|
||||
$newLink = new MachineProductLink();
|
||||
$newLink->setMachine($target);
|
||||
$newLink->setProduct($link->getProduct());
|
||||
|
||||
$parentComponent = $link->getParentComponentLink();
|
||||
if ($parentComponent && isset($componentLinkMap[$parentComponent->getId()])) {
|
||||
$newLink->setParentComponentLink($componentLinkMap[$parentComponent->getId()]);
|
||||
}
|
||||
|
||||
$parentPiece = $link->getParentPieceLink();
|
||||
if ($parentPiece && isset($pieceLinkMap[$parentPiece->getId()])) {
|
||||
$newLink->setParentPieceLink($pieceLinkMap[$parentPiece->getId()]);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($newLink);
|
||||
$linkMap[$link->getId()] = $newLink;
|
||||
}
|
||||
|
||||
// Second pass: set parent product link relationships
|
||||
foreach ($sourceLinks as $link) {
|
||||
$parent = $link->getParentLink();
|
||||
if ($parent && isset($linkMap[$parent->getId()])) {
|
||||
$linkMap[$link->getId()]->setParentLink($linkMap[$parent->getId()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizePayloadList(mixed $value): array
|
||||
{
|
||||
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 = [];
|
||||
$pendingParents = [];
|
||||
$links = [];
|
||||
|
||||
foreach ($payload as $entry) {
|
||||
$linkId = $this->resolveIdentifier($entry, ['id', 'linkId']);
|
||||
$link = $linkId && isset($existing[$linkId]) ? $existing[$linkId] : new MachineComponentLink();
|
||||
if (!$linkId) {
|
||||
$linkId = $this->generateCuid();
|
||||
}
|
||||
if (!$link->getId()) {
|
||||
$link->setId($linkId);
|
||||
}
|
||||
|
||||
$composantId = $this->resolveIdentifier($entry, ['composantId', 'componentId', 'idComposant']);
|
||||
if (!$composantId) {
|
||||
return $this->json(['success' => false, 'error' => 'Composant requis.'], 400);
|
||||
}
|
||||
$composant = $this->composantRepository->find($composantId);
|
||||
if (!$composant instanceof Composant) {
|
||||
return $this->json(['success' => false, 'error' => 'Composant introuvable.'], 404);
|
||||
}
|
||||
|
||||
$link->setMachine($machine);
|
||||
$link->setComposant($composant);
|
||||
|
||||
$this->applyOverrides($link, $entry['overrides'] ?? null);
|
||||
|
||||
$pendingParents[$linkId] = $this->resolveIdentifier($entry, [
|
||||
'parentComponentLinkId',
|
||||
'parentLinkId',
|
||||
'parentMachineComponentLinkId',
|
||||
]);
|
||||
|
||||
$this->entityManager->persist($link);
|
||||
$links[$linkId] = $link;
|
||||
$keepIds[] = $linkId;
|
||||
}
|
||||
|
||||
foreach ($pendingParents as $linkId => $parentId) {
|
||||
if (!$parentId || !isset($links[$linkId])) {
|
||||
continue;
|
||||
}
|
||||
$parent = $links[$parentId] ?? $existing[$parentId] ?? null;
|
||||
if ($parent instanceof MachineComponentLink) {
|
||||
$links[$linkId]->setParentLink($parent);
|
||||
}
|
||||
}
|
||||
|
||||
$this->removeMissingLinks($existing, $keepIds);
|
||||
|
||||
return array_values($links);
|
||||
}
|
||||
|
||||
private function applyPieceLinks(Machine $machine, array $payload, array $componentLinks): array|JsonResponse
|
||||
{
|
||||
$existing = $this->indexLinksById($this->machinePieceLinkRepository->findBy(['machine' => $machine]));
|
||||
$componentIndex = $this->indexLinksById($componentLinks);
|
||||
$keepIds = [];
|
||||
$pendingParents = [];
|
||||
$links = [];
|
||||
|
||||
foreach ($payload as $entry) {
|
||||
$linkId = $this->resolveIdentifier($entry, ['id', 'linkId']);
|
||||
$link = $linkId && isset($existing[$linkId]) ? $existing[$linkId] : new MachinePieceLink();
|
||||
if (!$linkId) {
|
||||
$linkId = $this->generateCuid();
|
||||
}
|
||||
if (!$link->getId()) {
|
||||
$link->setId($linkId);
|
||||
}
|
||||
|
||||
$pieceId = $this->resolveIdentifier($entry, ['pieceId']);
|
||||
if (!$pieceId) {
|
||||
return $this->json(['success' => false, 'error' => 'Pièce requise.'], 400);
|
||||
}
|
||||
$piece = $this->pieceRepository->find($pieceId);
|
||||
if (!$piece instanceof Piece) {
|
||||
return $this->json(['success' => false, 'error' => 'Pièce introuvable.'], 404);
|
||||
}
|
||||
|
||||
$link->setMachine($machine);
|
||||
$link->setPiece($piece);
|
||||
|
||||
$this->applyOverrides($link, $entry['overrides'] ?? null);
|
||||
|
||||
$pendingParents[$linkId] = $this->resolveIdentifier($entry, [
|
||||
'parentComponentLinkId',
|
||||
'parentLinkId',
|
||||
'parentMachineComponentLinkId',
|
||||
]);
|
||||
|
||||
$this->entityManager->persist($link);
|
||||
$links[$linkId] = $link;
|
||||
$keepIds[] = $linkId;
|
||||
}
|
||||
|
||||
foreach ($pendingParents as $linkId => $parentId) {
|
||||
if (!$parentId || !isset($links[$linkId])) {
|
||||
continue;
|
||||
}
|
||||
$parent = $componentIndex[$parentId] ?? null;
|
||||
if ($parent instanceof MachineComponentLink) {
|
||||
$links[$linkId]->setParentLink($parent);
|
||||
}
|
||||
}
|
||||
|
||||
$this->removeMissingLinks($existing, $keepIds);
|
||||
|
||||
return array_values($links);
|
||||
}
|
||||
|
||||
private function applyProductLinks(
|
||||
Machine $machine,
|
||||
array $payload,
|
||||
array $componentLinks,
|
||||
array $pieceLinks,
|
||||
): array|JsonResponse {
|
||||
$existing = $this->indexLinksById($this->machineProductLinkRepository->findBy(['machine' => $machine]));
|
||||
$componentIndex = $this->indexLinksById($componentLinks);
|
||||
$pieceIndex = $this->indexLinksById($pieceLinks);
|
||||
$keepIds = [];
|
||||
$pendingParents = [];
|
||||
$links = [];
|
||||
|
||||
foreach ($payload as $entry) {
|
||||
$linkId = $this->resolveIdentifier($entry, ['id', 'linkId']);
|
||||
$link = $linkId && isset($existing[$linkId]) ? $existing[$linkId] : new MachineProductLink();
|
||||
if (!$linkId) {
|
||||
$linkId = $this->generateCuid();
|
||||
}
|
||||
if (!$link->getId()) {
|
||||
$link->setId($linkId);
|
||||
}
|
||||
|
||||
$productId = $this->resolveIdentifier($entry, ['productId']);
|
||||
if (!$productId) {
|
||||
return $this->json(['success' => false, 'error' => 'Produit requis.'], 400);
|
||||
}
|
||||
$product = $this->productRepository->find($productId);
|
||||
if (!$product instanceof Product) {
|
||||
return $this->json(['success' => false, 'error' => 'Produit introuvable.'], 404);
|
||||
}
|
||||
|
||||
$link->setMachine($machine);
|
||||
$link->setProduct($product);
|
||||
|
||||
$pendingParents[$linkId] = [
|
||||
'parentComponentLinkId' => $this->resolveIdentifier($entry, ['parentComponentLinkId']),
|
||||
'parentPieceLinkId' => $this->resolveIdentifier($entry, ['parentPieceLinkId']),
|
||||
'parentLinkId' => $this->resolveIdentifier($entry, ['parentLinkId']),
|
||||
];
|
||||
|
||||
$this->entityManager->persist($link);
|
||||
$links[$linkId] = $link;
|
||||
$keepIds[] = $linkId;
|
||||
}
|
||||
|
||||
foreach ($pendingParents as $linkId => $parentIds) {
|
||||
if (!isset($links[$linkId])) {
|
||||
continue;
|
||||
}
|
||||
if (!empty($parentIds['parentComponentLinkId']) && isset($componentIndex[$parentIds['parentComponentLinkId']])) {
|
||||
$links[$linkId]->setParentComponentLink($componentIndex[$parentIds['parentComponentLinkId']]);
|
||||
}
|
||||
if (!empty($parentIds['parentPieceLinkId']) && isset($pieceIndex[$parentIds['parentPieceLinkId']])) {
|
||||
$links[$linkId]->setParentPieceLink($pieceIndex[$parentIds['parentPieceLinkId']]);
|
||||
}
|
||||
if (!empty($parentIds['parentLinkId']) && isset($links[$parentIds['parentLinkId']])) {
|
||||
$links[$linkId]->setParentLink($links[$parentIds['parentLinkId']]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->removeMissingLinks($existing, $keepIds);
|
||||
|
||||
return array_values($links);
|
||||
}
|
||||
|
||||
private function normalizeStructureResponse(
|
||||
Machine $machine,
|
||||
array $componentLinks,
|
||||
array $pieceLinks,
|
||||
array $productLinks,
|
||||
): array {
|
||||
$normalizedComponentLinks = $this->normalizeComponentLinks($componentLinks);
|
||||
$componentIndex = $this->indexNormalizedLinks($normalizedComponentLinks);
|
||||
$normalizedPieceLinks = $this->normalizePieceLinks($pieceLinks);
|
||||
|
||||
$childIds = [];
|
||||
foreach ($normalizedComponentLinks as $link) {
|
||||
$parentId = $link['parentComponentLinkId'] ?? null;
|
||||
if ($parentId && isset($componentIndex[$parentId])) {
|
||||
$componentIndex[$parentId]['childLinks'][] = $link;
|
||||
$childIds[$link['id']] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$this->attachPiecesToComponents($componentIndex, $normalizedPieceLinks);
|
||||
|
||||
$rootComponents = array_filter(
|
||||
$componentIndex,
|
||||
static fn (array $link) => !isset($childIds[$link['id']]),
|
||||
);
|
||||
|
||||
return [
|
||||
'machine' => $this->normalizeMachine($machine),
|
||||
'componentLinks' => array_values($rootComponents),
|
||||
'pieceLinks' => $normalizedPieceLinks,
|
||||
'productLinks' => $this->normalizeProductLinks($productLinks),
|
||||
];
|
||||
}
|
||||
|
||||
private function attachPiecesToComponents(array &$componentIndex, array $pieceLinks): void
|
||||
{
|
||||
foreach ($pieceLinks as $pieceLink) {
|
||||
$parentId = $pieceLink['parentComponentLinkId'] ?? null;
|
||||
if ($parentId && isset($componentIndex[$parentId])) {
|
||||
$componentIndex[$parentId]['pieceLinks'][] = $pieceLink;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($componentIndex as &$component) {
|
||||
if (!empty($component['childLinks'])) {
|
||||
$this->attachPiecesToChildComponents($component['childLinks'], $pieceLinks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function attachPiecesToChildComponents(array &$childLinks, array $pieceLinks): void
|
||||
{
|
||||
foreach ($childLinks as &$child) {
|
||||
$childId = $child['id'] ?? $child['linkId'] ?? null;
|
||||
if ($childId) {
|
||||
foreach ($pieceLinks as $pieceLink) {
|
||||
$parentId = $pieceLink['parentComponentLinkId'] ?? null;
|
||||
if ($parentId === $childId) {
|
||||
$child['pieceLinks'][] = $pieceLink;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($child['childLinks'])) {
|
||||
$this->attachPiecesToChildComponents($child['childLinks'], $pieceLinks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeMachine(Machine $machine): array
|
||||
{
|
||||
$site = $machine->getSite();
|
||||
|
||||
return [
|
||||
'id' => $machine->getId(),
|
||||
'name' => $machine->getName(),
|
||||
'reference' => $machine->getReference(),
|
||||
'prix' => $machine->getPrix(),
|
||||
'siteId' => $site->getId(),
|
||||
'site' => [
|
||||
'id' => $site->getId(),
|
||||
'name' => $site->getName(),
|
||||
],
|
||||
'constructeurs' => $this->normalizeConstructeurs($machine->getConstructeurs()),
|
||||
'customFields' => $this->normalizeCustomFields($machine->getCustomFields()),
|
||||
'documents' => null,
|
||||
'customFieldValues' => null,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeCustomFields(Collection $customFields): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($customFields as $customField) {
|
||||
if (!$customField instanceof CustomField) {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'id' => $customField->getId(),
|
||||
'name' => $customField->getName(),
|
||||
'type' => $customField->getType(),
|
||||
'required' => $customField->isRequired(),
|
||||
'options' => $customField->getOptions(),
|
||||
'defaultValue' => $customField->getDefaultValue(),
|
||||
'orderIndex' => $customField->getOrderIndex(),
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function normalizeComponentLinks(array $links): array
|
||||
{
|
||||
return array_map(function (MachineComponentLink $link): array {
|
||||
$composant = $link->getComposant();
|
||||
$parentLink = $link->getParentLink();
|
||||
|
||||
return [
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'composantId' => $composant->getId(),
|
||||
'composant' => $this->normalizeComposant($composant),
|
||||
'parentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentId' => $parentLink?->getComposant()->getId(),
|
||||
'overrides' => $this->normalizeOverrides($link),
|
||||
'childLinks' => [],
|
||||
'pieceLinks' => [],
|
||||
];
|
||||
}, $links);
|
||||
}
|
||||
|
||||
private function normalizePieceLinks(array $links): array
|
||||
{
|
||||
return array_map(function (MachinePieceLink $link): array {
|
||||
$piece = $link->getPiece();
|
||||
$parentLink = $link->getParentLink();
|
||||
|
||||
return [
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'pieceId' => $piece->getId(),
|
||||
'piece' => $this->normalizePiece($piece),
|
||||
'parentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentId' => $parentLink?->getComposant()->getId(),
|
||||
'overrides' => $this->normalizeOverrides($link),
|
||||
];
|
||||
}, $links);
|
||||
}
|
||||
|
||||
private function normalizeProductLinks(array $links): array
|
||||
{
|
||||
return array_map(function (MachineProductLink $link): array {
|
||||
$product = $link->getProduct();
|
||||
|
||||
return [
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'productId' => $product->getId(),
|
||||
'product' => $this->normalizeProduct($product),
|
||||
'parentLinkId' => $link->getParentLink()?->getId(),
|
||||
'parentComponentLinkId' => $link->getParentComponentLink()?->getId(),
|
||||
'parentPieceLinkId' => $link->getParentPieceLink()?->getId(),
|
||||
];
|
||||
}, $links);
|
||||
}
|
||||
|
||||
private function normalizeComposant(Composant $composant): array
|
||||
{
|
||||
$type = $composant->getTypeComposant();
|
||||
|
||||
return [
|
||||
'id' => $composant->getId(),
|
||||
'name' => $composant->getName(),
|
||||
'reference' => $composant->getReference(),
|
||||
'prix' => $composant->getPrix(),
|
||||
'typeComposantId' => $type?->getId(),
|
||||
'typeComposant' => $this->normalizeModelType($type),
|
||||
'productId' => $composant->getProduct()?->getId(),
|
||||
'product' => $composant->getProduct() ? $this->normalizeProduct($composant->getProduct()) : null,
|
||||
'constructeurs' => $this->normalizeConstructeurs($composant->getConstructeurs()),
|
||||
'documents' => [],
|
||||
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getComponentCustomFields()) : [],
|
||||
'customFieldValues' => $this->normalizeCustomFieldValues($composant->getCustomFieldValues()),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizePiece(Piece $piece): array
|
||||
{
|
||||
$type = $piece->getTypePiece();
|
||||
|
||||
return [
|
||||
'id' => $piece->getId(),
|
||||
'name' => $piece->getName(),
|
||||
'reference' => $piece->getReference(),
|
||||
'prix' => $piece->getPrix(),
|
||||
'typePieceId' => $type?->getId(),
|
||||
'typePiece' => $this->normalizeModelType($type),
|
||||
'productId' => $piece->getProduct()?->getId(),
|
||||
'product' => $piece->getProduct() ? $this->normalizeProduct($piece->getProduct()) : null,
|
||||
'constructeurs' => $this->normalizeConstructeurs($piece->getConstructeurs()),
|
||||
'documents' => [],
|
||||
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getPieceCustomFields()) : [],
|
||||
'customFieldValues' => $this->normalizeCustomFieldValues($piece->getCustomFieldValues()),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeProduct(Product $product): array
|
||||
{
|
||||
return [
|
||||
'id' => $product->getId(),
|
||||
'name' => $product->getName(),
|
||||
'reference' => $product->getReference(),
|
||||
'supplierPrice' => $product->getSupplierPrice(),
|
||||
'typeProductId' => $product->getTypeProduct()?->getId(),
|
||||
'typeProduct' => $this->normalizeModelType($product->getTypeProduct()),
|
||||
'constructeurs' => $this->normalizeConstructeurs($product->getConstructeurs()),
|
||||
'documents' => [],
|
||||
'customFields' => [],
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeModelType(?ModelType $type): ?array
|
||||
{
|
||||
if (!$type instanceof ModelType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $type->getId(),
|
||||
'name' => $type->getName(),
|
||||
'code' => $type->getCode(),
|
||||
'category' => $type->getCategory()->value,
|
||||
'structure' => $type->getStructure(),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeConstructeurs(Collection $constructeurs): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($constructeurs as $constructeur) {
|
||||
$items[] = [
|
||||
'id' => $constructeur->getId(),
|
||||
'name' => $constructeur->getName(),
|
||||
'email' => $constructeur->getEmail(),
|
||||
'phone' => $constructeur->getPhone(),
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function normalizeCustomFieldDefinitions(Collection $customFields): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($customFields as $cf) {
|
||||
if (!$cf instanceof CustomField) {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'id' => $cf->getId(),
|
||||
'name' => $cf->getName(),
|
||||
'type' => $cf->getType(),
|
||||
'required' => $cf->isRequired(),
|
||||
'options' => $cf->getOptions(),
|
||||
'defaultValue' => $cf->getDefaultValue(),
|
||||
'orderIndex' => $cf->getOrderIndex(),
|
||||
];
|
||||
}
|
||||
|
||||
usort($items, static fn (array $a, array $b) => $a['orderIndex'] <=> $b['orderIndex']);
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function normalizeCustomFieldValues(Collection $customFieldValues): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($customFieldValues as $cfv) {
|
||||
if (!$cfv instanceof CustomFieldValue) {
|
||||
continue;
|
||||
}
|
||||
$cf = $cfv->getCustomField();
|
||||
$items[] = [
|
||||
'id' => $cfv->getId(),
|
||||
'value' => $cfv->getValue(),
|
||||
'customField' => [
|
||||
'id' => $cf->getId(),
|
||||
'name' => $cf->getName(),
|
||||
'type' => $cf->getType(),
|
||||
'required' => $cf->isRequired(),
|
||||
'options' => $cf->getOptions(),
|
||||
'defaultValue' => $cf->getDefaultValue(),
|
||||
'orderIndex' => $cf->getOrderIndex(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function normalizeOverrides(object $link): ?array
|
||||
{
|
||||
$name = method_exists($link, 'getNameOverride') ? $link->getNameOverride() : null;
|
||||
$reference = method_exists($link, 'getReferenceOverride') ? $link->getReferenceOverride() : null;
|
||||
$prix = method_exists($link, 'getPrixOverride') ? $link->getPrixOverride() : null;
|
||||
|
||||
if (null === $name && null === $reference && null === $prix) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'reference' => $reference,
|
||||
'prix' => $prix,
|
||||
];
|
||||
}
|
||||
|
||||
private function applyOverrides(object $link, mixed $overrides): void
|
||||
{
|
||||
if (!is_array($overrides)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (array_key_exists('name', $overrides) && method_exists($link, 'setNameOverride')) {
|
||||
$link->setNameOverride($this->stringOrNull($overrides['name']));
|
||||
}
|
||||
if (array_key_exists('reference', $overrides) && method_exists($link, 'setReferenceOverride')) {
|
||||
$link->setReferenceOverride($this->stringOrNull($overrides['reference']));
|
||||
}
|
||||
if (array_key_exists('prix', $overrides) && method_exists($link, 'setPrixOverride')) {
|
||||
$link->setPrixOverride($this->stringOrNull($overrides['prix']));
|
||||
}
|
||||
}
|
||||
|
||||
private function stringOrNull(mixed $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
$string = trim((string) $value);
|
||||
|
||||
return '' === $string ? null : $string;
|
||||
}
|
||||
|
||||
private function resolveIdentifier(array $entry, array $keys): ?string
|
||||
{
|
||||
foreach ($keys as $key) {
|
||||
if (!array_key_exists($key, $entry)) {
|
||||
continue;
|
||||
}
|
||||
$value = $entry[$key];
|
||||
if (null === $value || '' === $value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array-key, object> $links
|
||||
*
|
||||
* @return array<string, object>
|
||||
*/
|
||||
private function indexLinksById(array $links): array
|
||||
{
|
||||
$indexed = [];
|
||||
foreach ($links as $link) {
|
||||
if (method_exists($link, 'getId') && $link->getId()) {
|
||||
$indexed[$link->getId()] = $link;
|
||||
}
|
||||
}
|
||||
|
||||
return $indexed;
|
||||
}
|
||||
|
||||
private function indexNormalizedLinks(array $links): array
|
||||
{
|
||||
$indexed = [];
|
||||
foreach ($links as $link) {
|
||||
if (is_array($link) && isset($link['id'])) {
|
||||
$indexed[$link['id']] = $link;
|
||||
}
|
||||
}
|
||||
|
||||
return $indexed;
|
||||
}
|
||||
|
||||
private function removeMissingLinks(array $existing, array $keepIds): void
|
||||
{
|
||||
$keep = array_flip($keepIds);
|
||||
foreach ($existing as $link) {
|
||||
if (!method_exists($link, 'getId')) {
|
||||
continue;
|
||||
}
|
||||
$id = $link->getId();
|
||||
if ($id && !isset($keep[$id])) {
|
||||
$this->entityManager->remove($link);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl'.bin2hex(random_bytes(12));
|
||||
}
|
||||
}
|
||||
60
src/Controller/ModelTypeConversionController.php
Normal file
60
src/Controller/ModelTypeConversionController.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Repository\ModelTypeRepository;
|
||||
use App\Service\ModelTypeCategoryConversionService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class ModelTypeConversionController extends AbstractController
|
||||
{
|
||||
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
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$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
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$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);
|
||||
}
|
||||
}
|
||||
82
src/Controller/PieceHistoryController.php
Normal file
82
src/Controller/PieceHistoryController.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Repository\AuditLogRepository;
|
||||
use App\Repository\PieceRepository;
|
||||
use App\Repository\ProfileRepository;
|
||||
use DateTimeInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class PieceHistoryController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PieceRepository $pieces,
|
||||
private readonly AuditLogRepository $auditLogs,
|
||||
private readonly ProfileRepository $profiles,
|
||||
) {}
|
||||
|
||||
#[Route('/api/pieces/{id}/history', name: 'api_piece_history', methods: ['GET'])]
|
||||
public function __invoke(string $id): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$piece = $this->pieces->find($id);
|
||||
if (!$piece) {
|
||||
return new JsonResponse(
|
||||
['message' => 'Pièce introuvable.'],
|
||||
Response::HTTP_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
$logs = $this->auditLogs->findEntityHistory('piece', $id, 200);
|
||||
|
||||
$actorIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn ($log) => $log->getActorProfileId(),
|
||||
$logs,
|
||||
))));
|
||||
|
||||
$actorMap = [];
|
||||
if ([] !== $actorIds) {
|
||||
$profiles = $this->profiles->findBy(['id' => $actorIds]);
|
||||
foreach ($profiles as $profile) {
|
||||
$label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
|
||||
if ('' === $label) {
|
||||
$label = $profile->getEmail() ?? $profile->getId();
|
||||
}
|
||||
$actorMap[$profile->getId()] = $label;
|
||||
}
|
||||
}
|
||||
|
||||
$items = array_map(
|
||||
static function ($log) use ($actorMap) {
|
||||
$actorId = $log->getActorProfileId();
|
||||
|
||||
return [
|
||||
'id' => $log->getId(),
|
||||
'action' => $log->getAction(),
|
||||
'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
|
||||
'actor' => $actorId
|
||||
? [
|
||||
'id' => $actorId,
|
||||
'label' => $actorMap[$actorId] ?? $actorId,
|
||||
]
|
||||
: null,
|
||||
'diff' => $log->getDiff(),
|
||||
'snapshot' => $log->getSnapshot(),
|
||||
];
|
||||
},
|
||||
$logs,
|
||||
);
|
||||
|
||||
return new JsonResponse([
|
||||
'items' => array_values($items),
|
||||
'total' => count($items),
|
||||
]);
|
||||
}
|
||||
}
|
||||
82
src/Controller/ProductHistoryController.php
Normal file
82
src/Controller/ProductHistoryController.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Repository\AuditLogRepository;
|
||||
use App\Repository\ProductRepository;
|
||||
use App\Repository\ProfileRepository;
|
||||
use DateTimeInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class ProductHistoryController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProductRepository $products,
|
||||
private readonly AuditLogRepository $auditLogs,
|
||||
private readonly ProfileRepository $profiles,
|
||||
) {}
|
||||
|
||||
#[Route('/api/products/{id}/history', name: 'api_product_history', methods: ['GET'])]
|
||||
public function __invoke(string $id): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$product = $this->products->find($id);
|
||||
if (!$product) {
|
||||
return new JsonResponse(
|
||||
['message' => 'Produit introuvable.'],
|
||||
Response::HTTP_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
$logs = $this->auditLogs->findEntityHistory('product', $id, 200);
|
||||
|
||||
$actorIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn ($log) => $log->getActorProfileId(),
|
||||
$logs,
|
||||
))));
|
||||
|
||||
$actorMap = [];
|
||||
if ([] !== $actorIds) {
|
||||
$profiles = $this->profiles->findBy(['id' => $actorIds]);
|
||||
foreach ($profiles as $profile) {
|
||||
$label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
|
||||
if ('' === $label) {
|
||||
$label = $profile->getEmail() ?? $profile->getId();
|
||||
}
|
||||
$actorMap[$profile->getId()] = $label;
|
||||
}
|
||||
}
|
||||
|
||||
$items = array_map(
|
||||
static function ($log) use ($actorMap) {
|
||||
$actorId = $log->getActorProfileId();
|
||||
|
||||
return [
|
||||
'id' => $log->getId(),
|
||||
'action' => $log->getAction(),
|
||||
'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
|
||||
'actor' => $actorId
|
||||
? [
|
||||
'id' => $actorId,
|
||||
'label' => $actorMap[$actorId] ?? $actorId,
|
||||
]
|
||||
: null,
|
||||
'diff' => $log->getDiff(),
|
||||
'snapshot' => $log->getSnapshot(),
|
||||
];
|
||||
},
|
||||
$logs,
|
||||
);
|
||||
|
||||
return new JsonResponse([
|
||||
'items' => array_values($items),
|
||||
'total' => count($items),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,15 @@ use App\Repository\ProfileRepository;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class SessionProfileController
|
||||
{
|
||||
public function __construct(private readonly ProfileRepository $profiles)
|
||||
{
|
||||
}
|
||||
public function __construct(
|
||||
private readonly ProfileRepository $profiles,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
) {}
|
||||
|
||||
#[Route('/api/session/profile', name: 'api_session_profile_get', methods: ['GET'])]
|
||||
public function getActiveProfile(Request $request): JsonResponse
|
||||
@@ -32,16 +34,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 +56,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) {
|
||||
@@ -65,15 +68,32 @@ final class SessionProfileController
|
||||
return new JsonResponse(['message' => 'Profil introuvable ou inactif.'], JsonResponse::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$password = $payload['password'] ?? '';
|
||||
if ('' === $password) {
|
||||
return new JsonResponse(['message' => 'Mot de passe requis.'], JsonResponse::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (!$profile->getPassword()) {
|
||||
return new JsonResponse(
|
||||
['message' => 'Ce profil n\'a pas de mot de passe. Contactez un administrateur.'],
|
||||
JsonResponse::HTTP_FORBIDDEN,
|
||||
);
|
||||
}
|
||||
|
||||
if (!$this->passwordHasher->isPasswordValid($profile, $password)) {
|
||||
return new JsonResponse(['message' => 'Mot de passe incorrect.'], JsonResponse::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$session->set('profileId', $profile->getId());
|
||||
$session->set('profileRoles', $profile->getRoles());
|
||||
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,20 +4,15 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Profile;
|
||||
use App\Repository\ProfileRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
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,54 +22,16 @@ final class SessionProfilesController
|
||||
->setParameter('active', true)
|
||||
->orderBy('p.firstName', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
->getResult()
|
||||
;
|
||||
|
||||
return new JsonResponse(array_map([$this, 'serializeProfile'], $items));
|
||||
}
|
||||
|
||||
#[Route('/api/session/profiles', name: 'api_session_profiles_create', methods: ['POST'])]
|
||||
public function create(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->toArray();
|
||||
$firstName = trim((string) ($payload['firstName'] ?? ''));
|
||||
$lastName = trim((string) ($payload['lastName'] ?? ''));
|
||||
|
||||
if ($firstName === '' || $lastName === '') {
|
||||
return new JsonResponse(['message' => 'firstName et lastName sont requis.'], JsonResponse::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$profile = new Profile();
|
||||
$profile->setFirstName($firstName);
|
||||
$profile->setLastName($lastName);
|
||||
$profile->setIsActive(true);
|
||||
|
||||
$this->entityManager->persist($profile);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new JsonResponse($this->serializeProfile($profile), JsonResponse::HTTP_CREATED);
|
||||
}
|
||||
|
||||
#[Route('/api/session/profiles/{id}', name: 'api_session_profiles_delete', methods: ['DELETE'])]
|
||||
public function delete(string $id): JsonResponse
|
||||
{
|
||||
$profile = $this->profiles->find($id);
|
||||
if (!$profile) {
|
||||
return new JsonResponse(['message' => 'Profil introuvable.'], JsonResponse::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$profile->setIsActive(false);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new JsonResponse(['success' => true]);
|
||||
}
|
||||
|
||||
private function serializeProfile(Profile $profile): array
|
||||
{
|
||||
return [
|
||||
'id' => $profile->getId(),
|
||||
'firstName' => $profile->getFirstName(),
|
||||
'lastName' => $profile->getLastName(),
|
||||
'isActive' => $profile->isActive(),
|
||||
];
|
||||
return new JsonResponse(array_map(static function ($profile): array {
|
||||
return [
|
||||
'id' => $profile->getId(),
|
||||
'firstName' => $profile->getFirstName(),
|
||||
'lastName' => $profile->getLastName(),
|
||||
'hasPassword' => null !== $profile->getPassword() && '' !== $profile->getPassword(),
|
||||
];
|
||||
}, $items));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
117
src/Entity/AuditLog.php
Normal file
117
src/Entity/AuditLog.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\AuditLogRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: AuditLogRepository::class)]
|
||||
#[ORM\Table(name: 'audit_logs')]
|
||||
#[ORM\Index(name: 'idx_audit_entity', columns: ['entityType', 'entityId'])]
|
||||
#[ORM\Index(name: 'idx_audit_created_at', columns: ['createdAt'])]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class AuditLog
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 50)]
|
||||
private string $entityType;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
private string $entityId;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 20)]
|
||||
private string $action;
|
||||
|
||||
#[ORM\Column(type: Types::JSON, nullable: true)]
|
||||
private ?array $diff = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON, nullable: true)]
|
||||
private ?array $snapshot = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 36, nullable: true)]
|
||||
private ?string $actorProfileId = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
public function __construct(
|
||||
string $entityType,
|
||||
string $entityId,
|
||||
string $action,
|
||||
?array $diff = null,
|
||||
?array $snapshot = null,
|
||||
?string $actorProfileId = null,
|
||||
) {
|
||||
$this->entityType = $entityType;
|
||||
$this->entityId = $entityId;
|
||||
$this->action = $action;
|
||||
$this->diff = $diff;
|
||||
$this->snapshot = $snapshot;
|
||||
$this->actorProfileId = $actorProfileId;
|
||||
}
|
||||
|
||||
#[ORM\PrePersist]
|
||||
public function initializeAuditLog(): void
|
||||
{
|
||||
if (!isset($this->createdAt)) {
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
if (null === $this->id) {
|
||||
$this->id = $this->generateCuid();
|
||||
}
|
||||
}
|
||||
|
||||
public function getId(): ?string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getEntityType(): string
|
||||
{
|
||||
return $this->entityType;
|
||||
}
|
||||
|
||||
public function getEntityId(): string
|
||||
{
|
||||
return $this->entityId;
|
||||
}
|
||||
|
||||
public function getAction(): string
|
||||
{
|
||||
return $this->action;
|
||||
}
|
||||
|
||||
public function getDiff(): ?array
|
||||
{
|
||||
return $this->diff;
|
||||
}
|
||||
|
||||
public function getSnapshot(): ?array
|
||||
{
|
||||
return $this->snapshot;
|
||||
}
|
||||
|
||||
public function getActorProfileId(): ?string
|
||||
{
|
||||
return $this->actorProfileId;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
// Keep the same lightweight CUID-like strategy used across the project.
|
||||
return 'cl'.substr(strtolower(base_convert(bin2hex(random_bytes(12)), 16, 36)), 0, 24);
|
||||
}
|
||||
}
|
||||
235
src/Entity/Comment.php
Normal file
235
src/Entity/Comment.php
Normal file
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'comments')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'status'], name: 'idx_comment_entity_status')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['entityType' => 'exact', 'entityId' => 'exact', 'status' => 'exact', 'entityName' => 'ipartial'])]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['createdAt', 'authorName', 'status'])]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
],
|
||||
order: ['createdAt' => 'DESC'],
|
||||
paginationClientItemsPerPage: true,
|
||||
paginationMaximumItemsPerPage: 200
|
||||
)]
|
||||
class Comment
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT)]
|
||||
private string $content;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 50, name: 'entity_type')]
|
||||
private string $entityType;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 36, name: 'entity_id')]
|
||||
private string $entityId;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'entity_name')]
|
||||
private ?string $entityName = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 36, name: 'author_id')]
|
||||
private string $authorId;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, name: 'author_name')]
|
||||
private string $authorName;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 20)]
|
||||
private string $status = 'open';
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 36, nullable: true, name: 'resolved_by_id')]
|
||||
private ?string $resolvedById = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'resolved_by_name')]
|
||||
private ?string $resolvedByName = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true, name: 'resolved_at')]
|
||||
private ?DateTimeImmutable $resolvedAt = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'created_at')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updated_at')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
#[ORM\PrePersist]
|
||||
public function setCreatedAtValue(): void
|
||||
{
|
||||
$now = new DateTimeImmutable();
|
||||
$this->createdAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
|
||||
if (null === $this->id) {
|
||||
$this->id = $this->generateCuid();
|
||||
}
|
||||
}
|
||||
|
||||
#[ORM\PreUpdate]
|
||||
public function setUpdatedAtValue(): void
|
||||
{
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getContent(): string
|
||||
{
|
||||
return $this->content;
|
||||
}
|
||||
|
||||
public function setContent(string $content): static
|
||||
{
|
||||
$this->content = $content;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEntityType(): string
|
||||
{
|
||||
return $this->entityType;
|
||||
}
|
||||
|
||||
public function setEntityType(string $entityType): static
|
||||
{
|
||||
$this->entityType = $entityType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEntityId(): string
|
||||
{
|
||||
return $this->entityId;
|
||||
}
|
||||
|
||||
public function setEntityId(string $entityId): static
|
||||
{
|
||||
$this->entityId = $entityId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEntityName(): ?string
|
||||
{
|
||||
return $this->entityName;
|
||||
}
|
||||
|
||||
public function setEntityName(?string $entityName): static
|
||||
{
|
||||
$this->entityName = $entityName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAuthorId(): string
|
||||
{
|
||||
return $this->authorId;
|
||||
}
|
||||
|
||||
public function setAuthorId(string $authorId): static
|
||||
{
|
||||
$this->authorId = $authorId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAuthorName(): string
|
||||
{
|
||||
return $this->authorName;
|
||||
}
|
||||
|
||||
public function setAuthorName(string $authorName): static
|
||||
{
|
||||
$this->authorName = $authorName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStatus(): string
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function setStatus(string $status): static
|
||||
{
|
||||
$this->status = $status;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getResolvedById(): ?string
|
||||
{
|
||||
return $this->resolvedById;
|
||||
}
|
||||
|
||||
public function setResolvedById(?string $resolvedById): static
|
||||
{
|
||||
$this->resolvedById = $resolvedById;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getResolvedByName(): ?string
|
||||
{
|
||||
return $this->resolvedByName;
|
||||
}
|
||||
|
||||
public function setResolvedByName(?string $resolvedByName): static
|
||||
{
|
||||
$this->resolvedByName = $resolvedByName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getResolvedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->resolvedAt;
|
||||
}
|
||||
|
||||
public function setResolvedAt(?DateTimeImmutable $resolvedAt): static
|
||||
{
|
||||
$this->resolvedAt = $resolvedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl'.bin2hex(random_bytes(12));
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,12 @@ use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Repository\ComposantRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
@@ -19,28 +25,40 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
#[ORM\Entity(repositoryClass: ComposantRepository::class)]
|
||||
#[ORM\Table(name: 'composants')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typeComposant' => 'exact'])]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typeComposant' => 'exact', 'typeComposant.name' => 'ipartial'])]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
],
|
||||
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)]
|
||||
#[Groups(['composant:read'])]
|
||||
private ?string $reference = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
#[Groups(['composant:read'])]
|
||||
private ?string $description = null;
|
||||
|
||||
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2, nullable: true)]
|
||||
#[Groups(['composant:read'])]
|
||||
private ?string $prix = null;
|
||||
@@ -144,7 +162,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;
|
||||
}
|
||||
@@ -161,6 +179,18 @@ class Composant
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function setDescription(?string $description): static
|
||||
{
|
||||
$this->description = $description;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPrix(): ?string
|
||||
{
|
||||
return $this->prix;
|
||||
|
||||
@@ -5,19 +5,35 @@ 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\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Repository\ConstructeurRepository;
|
||||
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\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
|
||||
#[UniqueEntity(fields: ['name'], message: 'Un fournisseur avec ce nom existe déjà.')]
|
||||
#[ORM\Entity(repositoryClass: ConstructeurRepository::class)]
|
||||
#[ORM\Table(name: 'constructeurs')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
],
|
||||
paginationClientItemsPerPage: true,
|
||||
paginationMaximumItemsPerPage: 500
|
||||
paginationMaximumItemsPerPage: 200
|
||||
)]
|
||||
class Constructeur
|
||||
{
|
||||
|
||||
@@ -5,43 +5,66 @@ 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\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
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')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiResource]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
]
|
||||
)]
|
||||
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')]
|
||||
#[ORM\JoinColumn(name: 'typeMachineId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?TypeMachine $typeMachine = null;
|
||||
#[ORM\ManyToOne(targetEntity: Machine::class, inversedBy: 'customFields')]
|
||||
#[ORM\JoinColumn(name: 'machineId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?Machine $machine = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'customFields')]
|
||||
#[ORM\JoinColumn(name: 'typeComposantId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
@@ -62,10 +85,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 +98,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 +110,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
|
||||
@@ -179,25 +197,30 @@ class CustomField
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeMachine(): ?TypeMachine
|
||||
public function getMachine(): ?Machine
|
||||
{
|
||||
return $this->typeMachine;
|
||||
return $this->machine;
|
||||
}
|
||||
|
||||
public function setTypeMachine(?TypeMachine $typeMachine): static
|
||||
public function setMachine(?Machine $machine): static
|
||||
{
|
||||
$this->typeMachine = $typeMachine;
|
||||
$this->machine = $machine;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTimeImmutable
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): \DateTimeImmutable
|
||||
public function getUpdatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl'.bin2hex(random_bytes(12));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,25 +5,45 @@ 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\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
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')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiResource]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
]
|
||||
)]
|
||||
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 +63,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 +85,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 +172,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,19 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Repository\DocumentRepository;
|
||||
use App\State\DocumentUploadProcessor;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
@@ -13,68 +24,99 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
#[ORM\Entity(repositoryClass: DocumentRepository::class)]
|
||||
#[ORM\Table(name: 'documents')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiResource]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'filename' => 'ipartial'])]
|
||||
#[ApiFilter(ExistsFilter::class, properties: ['site', 'machine', 'composant', 'piece', 'product'])]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['createdAt', 'name', 'size'])]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
security: "is_granted('ROLE_VIEWER')",
|
||||
normalizationContext: ['groups' => ['document:list']],
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('ROLE_VIEWER')",
|
||||
normalizationContext: ['groups' => ['document:list', 'document:detail']],
|
||||
),
|
||||
new Post(
|
||||
security: "is_granted('ROLE_GESTIONNAIRE')",
|
||||
processor: DocumentUploadProcessor::class,
|
||||
deserialize: false,
|
||||
inputFormats: ['multipart' => ['multipart/form-data']],
|
||||
),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
],
|
||||
paginationClientItemsPerPage: true,
|
||||
paginationMaximumItemsPerPage: 500,
|
||||
order: ['createdAt' => 'DESC']
|
||||
)]
|
||||
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 +124,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 +259,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,23 +5,42 @@ 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\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
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')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiResource]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
]
|
||||
)]
|
||||
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)]
|
||||
@@ -34,10 +53,6 @@ class Machine
|
||||
#[ORM\JoinColumn(name: 'siteId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Site $site;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: TypeMachine::class, inversedBy: 'machines')]
|
||||
#[ORM\JoinColumn(name: 'typeMachineId', referencedColumnName: 'id', nullable: true)]
|
||||
private ?TypeMachine $typeMachine = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, Constructeur>
|
||||
*/
|
||||
@@ -73,6 +88,12 @@ class Machine
|
||||
#[ORM\OneToMany(mappedBy: 'machine', targetEntity: Document::class)]
|
||||
private Collection $documents;
|
||||
|
||||
/**
|
||||
* @var Collection<int, CustomField>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'machine', targetEntity: CustomField::class, cascade: ['persist', 'remove'])]
|
||||
private Collection $customFields;
|
||||
|
||||
/**
|
||||
* @var Collection<int, CustomFieldValue>
|
||||
*/
|
||||
@@ -80,29 +101,30 @@ 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->customFields = 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 +132,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
|
||||
@@ -178,14 +195,31 @@ class Machine
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeMachine(): ?TypeMachine
|
||||
/**
|
||||
* @return Collection<int, CustomField>
|
||||
*/
|
||||
public function getCustomFields(): Collection
|
||||
{
|
||||
return $this->typeMachine;
|
||||
return $this->customFields;
|
||||
}
|
||||
|
||||
public function setTypeMachine(?TypeMachine $typeMachine): static
|
||||
public function addCustomField(CustomField $customField): static
|
||||
{
|
||||
$this->typeMachine = $typeMachine;
|
||||
if (!$this->customFields->contains($customField)) {
|
||||
$this->customFields->add($customField);
|
||||
$customField->setMachine($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeCustomField(CustomField $customField): static
|
||||
{
|
||||
if ($this->customFields->removeElement($customField)) {
|
||||
if ($customField->getMachine() === $this) {
|
||||
$customField->setMachine(null);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
@@ -238,13 +272,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,14 @@ 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\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Repository\MachineComponentLinkRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
@@ -14,7 +21,16 @@ use Doctrine\ORM\Mapping as ORM;
|
||||
#[ORM\Entity(repositoryClass: MachineComponentLinkRepository::class)]
|
||||
#[ORM\Table(name: 'machine_component_links')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiResource]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
]
|
||||
)]
|
||||
class MachineComponentLink
|
||||
{
|
||||
#[ORM\Id]
|
||||
@@ -39,10 +55,6 @@ class MachineComponentLink
|
||||
#[ORM\OneToMany(mappedBy: 'parentLink', targetEntity: MachineComponentLink::class)]
|
||||
private Collection $childLinks;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: TypeMachineComponentRequirement::class, inversedBy: 'machineComponentLinks')]
|
||||
#[ORM\JoinColumn(name: 'typeMachineComponentRequirementId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?TypeMachineComponentRequirement $typeMachineComponentRequirement = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, MachinePieceLink>
|
||||
*/
|
||||
@@ -65,26 +77,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 +104,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
|
||||
@@ -148,18 +155,6 @@ class MachineComponentLink
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeMachineComponentRequirement(): ?TypeMachineComponentRequirement
|
||||
{
|
||||
return $this->typeMachineComponentRequirement;
|
||||
}
|
||||
|
||||
public function setTypeMachineComponentRequirement(?TypeMachineComponentRequirement $requirement): static
|
||||
{
|
||||
$this->typeMachineComponentRequirement = $requirement;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getNameOverride(): ?string
|
||||
{
|
||||
return $this->nameOverride;
|
||||
@@ -195,4 +190,9 @@ class MachineComponentLink
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl'.bin2hex(random_bytes(12));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,14 @@ 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\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Repository\MachinePieceLinkRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
@@ -14,7 +21,16 @@ use Doctrine\ORM\Mapping as ORM;
|
||||
#[ORM\Entity(repositoryClass: MachinePieceLinkRepository::class)]
|
||||
#[ORM\Table(name: 'machine_piece_links')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiResource]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
]
|
||||
)]
|
||||
class MachinePieceLink
|
||||
{
|
||||
#[ORM\Id]
|
||||
@@ -33,10 +49,6 @@ class MachinePieceLink
|
||||
#[ORM\JoinColumn(name: 'parentLinkId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?MachineComponentLink $parentLink = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: TypeMachinePieceRequirement::class, inversedBy: 'machinePieceLinks')]
|
||||
#[ORM\JoinColumn(name: 'typeMachinePieceRequirementId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?TypeMachinePieceRequirement $typeMachinePieceRequirement = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, MachineProductLink>
|
||||
*/
|
||||
@@ -53,10 +65,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 +78,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 +90,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
|
||||
@@ -134,18 +141,6 @@ class MachinePieceLink
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeMachinePieceRequirement(): ?TypeMachinePieceRequirement
|
||||
{
|
||||
return $this->typeMachinePieceRequirement;
|
||||
}
|
||||
|
||||
public function setTypeMachinePieceRequirement(?TypeMachinePieceRequirement $requirement): static
|
||||
{
|
||||
$this->typeMachinePieceRequirement = $requirement;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getNameOverride(): ?string
|
||||
{
|
||||
return $this->nameOverride;
|
||||
@@ -181,4 +176,9 @@ class MachinePieceLink
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl'.bin2hex(random_bytes(12));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,14 @@ 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\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Repository\MachineProductLinkRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
@@ -14,7 +21,16 @@ use Doctrine\ORM\Mapping as ORM;
|
||||
#[ORM\Entity(repositoryClass: MachineProductLinkRepository::class)]
|
||||
#[ORM\Table(name: 'machine_product_links')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiResource]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
]
|
||||
)]
|
||||
class MachineProductLink
|
||||
{
|
||||
#[ORM\Id]
|
||||
@@ -29,10 +45,6 @@ class MachineProductLink
|
||||
#[ORM\JoinColumn(name: 'productId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Product $product;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: TypeMachineProductRequirement::class, inversedBy: 'machineProductLinks')]
|
||||
#[ORM\JoinColumn(name: 'typeMachineProductRequirementId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?TypeMachineProductRequirement $typeMachineProductRequirement = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: MachineProductLink::class, inversedBy: 'childLinks')]
|
||||
#[ORM\JoinColumn(name: 'parentLinkId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?MachineProductLink $parentLink = null;
|
||||
@@ -52,10 +64,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 +77,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 +89,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
|
||||
@@ -121,18 +128,6 @@ class MachineProductLink
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeMachineProductRequirement(): ?TypeMachineProductRequirement
|
||||
{
|
||||
return $this->typeMachineProductRequirement;
|
||||
}
|
||||
|
||||
public function setTypeMachineProductRequirement(?TypeMachineProductRequirement $requirement): static
|
||||
{
|
||||
$this->typeMachineProductRequirement = $requirement;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getParentLink(): ?MachineProductLink
|
||||
{
|
||||
return $this->parentLink;
|
||||
@@ -168,4 +163,9 @@ class MachineProductLink
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl'.bin2hex(random_bytes(12));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,16 @@ 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;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Enum\ModelCategory;
|
||||
use App\Repository\ModelTypeRepository;
|
||||
use DateTimeImmutable;
|
||||
@@ -14,26 +21,35 @@ 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\Annotation\Groups;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ORM\Entity(repositoryClass: ModelTypeRepository::class)]
|
||||
#[ORM\Table(name: 'model_types')]
|
||||
#[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(
|
||||
operations: [
|
||||
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
],
|
||||
paginationClientItemsPerPage: true,
|
||||
paginationMaximumItemsPerPage: 500
|
||||
paginationMaximumItemsPerPage: 200
|
||||
)]
|
||||
class ModelType
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
#[Groups(['type_machine:read', 'model_type:read'])]
|
||||
#[Groups(['type_machine:read', 'model_type:read', 'product:read', 'composant:read', 'piece:read'])]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 120)]
|
||||
#[Groups(['type_machine:read', 'model_type:read', 'model_type:write'])]
|
||||
#[Groups(['type_machine:read', 'model_type:read', 'model_type:write', 'product:read', 'composant:read', 'piece:read'])]
|
||||
private string $name;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 60, unique: true)]
|
||||
@@ -53,15 +69,15 @@ class ModelType
|
||||
private ?string $description = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON, nullable: true, name: 'componentSkeleton')]
|
||||
#[Groups(['model_type:read'])]
|
||||
#[Groups(['model_type:read', 'composant:read'])]
|
||||
private ?array $componentSkeleton = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON, nullable: true, name: 'pieceSkeleton')]
|
||||
#[Groups(['model_type:read'])]
|
||||
#[Groups(['model_type:read', 'piece:read'])]
|
||||
private ?array $pieceSkeleton = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON, nullable: true, name: 'productSkeleton')]
|
||||
#[Groups(['model_type:read'])]
|
||||
#[Groups(['model_type:read', 'product:read'])]
|
||||
private ?array $productSkeleton = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
@@ -92,24 +108,6 @@ class ModelType
|
||||
#[ORM\OneToMany(mappedBy: 'typeProduct', targetEntity: Product::class)]
|
||||
private Collection $products;
|
||||
|
||||
/**
|
||||
* @var Collection<int, TypeMachineComponentRequirement>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'typeComposant', targetEntity: TypeMachineComponentRequirement::class)]
|
||||
private Collection $componentRequirements;
|
||||
|
||||
/**
|
||||
* @var Collection<int, TypeMachinePieceRequirement>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'typePiece', targetEntity: TypeMachinePieceRequirement::class)]
|
||||
private Collection $pieceRequirements;
|
||||
|
||||
/**
|
||||
* @var Collection<int, TypeMachineProductRequirement>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'typeProduct', targetEntity: TypeMachineProductRequirement::class)]
|
||||
private Collection $productRequirements;
|
||||
|
||||
/**
|
||||
* @var Collection<int, CustomField>
|
||||
*/
|
||||
@@ -130,15 +128,12 @@ class ModelType
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->composants = new ArrayCollection();
|
||||
$this->pieces = new ArrayCollection();
|
||||
$this->products = new ArrayCollection();
|
||||
$this->componentRequirements = new ArrayCollection();
|
||||
$this->pieceRequirements = new ArrayCollection();
|
||||
$this->productRequirements = new ArrayCollection();
|
||||
$this->customFields = new ArrayCollection();
|
||||
$this->pieceCustomFields = new ArrayCollection();
|
||||
$this->productCustomFields = new ArrayCollection();
|
||||
$this->composants = new ArrayCollection();
|
||||
$this->pieces = new ArrayCollection();
|
||||
$this->products = new ArrayCollection();
|
||||
$this->customFields = new ArrayCollection();
|
||||
$this->pieceCustomFields = new ArrayCollection();
|
||||
$this->productCustomFields = new ArrayCollection();
|
||||
}
|
||||
|
||||
#[ORM\PrePersist]
|
||||
@@ -178,7 +173,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;
|
||||
}
|
||||
@@ -272,7 +267,7 @@ class ModelType
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Groups(['model_type:read'])]
|
||||
#[Groups(['model_type:read', 'product:read', 'composant:read', 'piece:read'])]
|
||||
public function getStructure(): ?array
|
||||
{
|
||||
return match ($this->category) {
|
||||
@@ -296,6 +291,30 @@ class ModelType
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, CustomField>
|
||||
*/
|
||||
public function getComponentCustomFields(): Collection
|
||||
{
|
||||
return $this->customFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, CustomField>
|
||||
*/
|
||||
public function getPieceCustomFields(): Collection
|
||||
{
|
||||
return $this->pieceCustomFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, CustomField>
|
||||
*/
|
||||
public function getProductCustomFields(): Collection
|
||||
{
|
||||
return $this->productCustomFields;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
|
||||
@@ -8,39 +8,59 @@ use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Repository\PieceRepository;
|
||||
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\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[UniqueEntity(fields: ['reference'], message: 'Une pièce avec cette référence existe déjà.')]
|
||||
#[ORM\Entity(repositoryClass: PieceRepository::class)]
|
||||
#[ORM\Table(name: 'pieces')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typePiece' => 'exact'])]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typePiece' => 'exact', 'typePiece.name' => 'ipartial'])]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
],
|
||||
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'])]
|
||||
#[ORM\Column(type: Types::STRING, length: 255)]
|
||||
#[Groups(['piece:read', 'document:list'])]
|
||||
private string $name;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
||||
#[ORM\Column(type: Types::STRING, length: 255, unique: true, nullable: true)]
|
||||
#[Groups(['piece:read'])]
|
||||
private ?string $reference = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
#[Groups(['piece:read'])]
|
||||
private ?string $description = null;
|
||||
|
||||
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2, nullable: true)]
|
||||
#[Groups(['piece:read'])]
|
||||
private ?string $prix = null;
|
||||
@@ -161,6 +181,18 @@ class Piece
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function setDescription(?string $description): static
|
||||
{
|
||||
$this->description = $description;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPrix(): ?string
|
||||
{
|
||||
return $this->prix;
|
||||
|
||||
@@ -8,6 +8,12 @@ use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Repository\ProductRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
@@ -19,22 +25,30 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
#[ORM\Entity(repositoryClass: ProductRepository::class)]
|
||||
#[ORM\Table(name: 'products')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typeProduct' => 'exact'])]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typeProduct' => 'exact', 'typeProduct.name' => 'ipartial'])]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt', 'supplierPrice'])]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
],
|
||||
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)]
|
||||
|
||||
@@ -8,14 +8,16 @@ use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Repository\ProfileRepository;
|
||||
use App\State\ProfilePasswordHasher;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Serializer\Annotation\Groups;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity(repositoryClass: ProfileRepository::class)]
|
||||
@@ -24,11 +26,24 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(),
|
||||
new GetCollection(),
|
||||
new Post(),
|
||||
new Put(),
|
||||
new Delete(),
|
||||
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||
new GetCollection(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Post(
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
denormalizationContext: ['groups' => ['profile:write', 'profile:admin:write']],
|
||||
processor: ProfilePasswordHasher::class,
|
||||
),
|
||||
new Put(
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
denormalizationContext: ['groups' => ['profile:write', 'profile:admin:write']],
|
||||
processor: ProfilePasswordHasher::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
denormalizationContext: ['groups' => ['profile:write', 'profile:admin:write']],
|
||||
processor: ProfilePasswordHasher::class,
|
||||
),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
],
|
||||
normalizationContext: ['groups' => ['profile:read']],
|
||||
denormalizationContext: ['groups' => ['profile:write']]
|
||||
@@ -63,16 +78,21 @@ class Profile implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
* @var list<string> The user roles
|
||||
*/
|
||||
#[ORM\Column(type: 'json', options: ['default' => '["ROLE_USER"]'])]
|
||||
#[Groups(['profile:read', 'profile:write'])]
|
||||
#[Groups(['profile:read', 'profile:admin:write'])]
|
||||
private array $roles = ['ROLE_USER'];
|
||||
|
||||
/**
|
||||
* @var string The hashed password
|
||||
* @var null|string The hashed password
|
||||
*/
|
||||
#[ORM\Column(type: 'string', nullable: true)]
|
||||
#[Groups(['profile:write'])]
|
||||
private ?string $password = null;
|
||||
|
||||
/**
|
||||
* Non-persisted field used for password hashing via ProfilePasswordHasher.
|
||||
*/
|
||||
#[Groups(['profile:write'])]
|
||||
private ?string $plainPassword = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable', name: 'createdat')]
|
||||
#[Groups(['profile:read'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
@@ -83,7 +103,6 @@ class Profile implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Générer un CUID-like ID pour compatibilité avec Prisma
|
||||
$this->id = 'cl'.substr(strtolower(base_convert(random_bytes(12), 2, 36)), 0, 24);
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
@@ -157,11 +176,10 @@ class Profile implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
*/
|
||||
public function getRoles(): array
|
||||
{
|
||||
$roles = $this->roles;
|
||||
// guarantee every user at least has ROLE_USER
|
||||
$roles = $this->roles;
|
||||
$roles[] = 'ROLE_USER';
|
||||
|
||||
return array_unique($roles);
|
||||
return array_values(array_unique($roles));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,20 +200,37 @@ class Profile implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
return $this->password;
|
||||
}
|
||||
|
||||
public function setPassword(string $password): static
|
||||
public function setPassword(?string $password): static
|
||||
{
|
||||
$this->password = $password;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPlainPassword(): ?string
|
||||
{
|
||||
return $this->plainPassword;
|
||||
}
|
||||
|
||||
public function setPlainPassword(?string $plainPassword): static
|
||||
{
|
||||
$this->plainPassword = $plainPassword;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Groups(['profile:read'])]
|
||||
public function getHasPassword(): bool
|
||||
{
|
||||
return null !== $this->password && '' !== $this->password;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see UserInterface
|
||||
*/
|
||||
public function eraseCredentials(): void
|
||||
{
|
||||
// If you store any temporary, sensitive data on the user, clear it here
|
||||
// $this->plainPassword = null;
|
||||
$this->plainPassword = null;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
|
||||
@@ -8,6 +8,7 @@ use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Repository\SiteRepository;
|
||||
@@ -16,6 +17,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)]
|
||||
@@ -23,23 +25,26 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(),
|
||||
new GetCollection(),
|
||||
new Post(),
|
||||
new Put(),
|
||||
new Delete(),
|
||||
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
],
|
||||
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')]
|
||||
|
||||
@@ -1,401 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
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\TypeMachineRepository;
|
||||
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\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Serializer\Annotation\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity(repositoryClass: TypeMachineRepository::class)]
|
||||
#[ORM\Table(name: 'type_machines')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[UniqueEntity(fields: ['name'], message: 'Ce nom de type de machine existe déjà.')]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(),
|
||||
new GetCollection(),
|
||||
new Post(),
|
||||
new Put(),
|
||||
new Delete(),
|
||||
],
|
||||
paginationClientItemsPerPage: true,
|
||||
paginationMaximumItemsPerPage: 500
|
||||
)]
|
||||
class TypeMachine
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
#[Groups(['type_machine:read'])]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
|
||||
#[Assert\NotBlank]
|
||||
#[Groups(['type_machine:read', 'type_machine:write', 'machine:read'])]
|
||||
private string $name;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
#[Groups(['type_machine:read', 'type_machine:write'])]
|
||||
private ?string $description = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
||||
#[Groups(['type_machine:read', 'type_machine:write'])]
|
||||
private ?string $category = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
||||
#[Groups(['type_machine:read', 'type_machine:write'])]
|
||||
private ?string $maintenanceFrequency = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON, nullable: true)]
|
||||
#[Groups(['type_machine:read', 'type_machine:write'])]
|
||||
private ?array $components = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON, nullable: true)]
|
||||
#[Groups(['type_machine:read', 'type_machine:write'])]
|
||||
private ?array $criticalParts = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON, nullable: true)]
|
||||
#[Groups(['type_machine:read', 'type_machine:write'])]
|
||||
private ?array $machinePieces = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON, nullable: true)]
|
||||
#[Groups(['type_machine:read', 'type_machine:write'])]
|
||||
private ?array $specifications = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
#[Groups(['type_machine:read'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
#[Groups(['type_machine:read'])]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
/**
|
||||
* @var Collection<int, Machine>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: Machine::class, mappedBy: 'typeMachine')]
|
||||
private Collection $machines;
|
||||
|
||||
/**
|
||||
* @var Collection<int, CustomField>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: CustomField::class, mappedBy: 'typeMachine', cascade: ['persist', 'remove'])]
|
||||
#[ApiProperty(readableLink: true, writableLink: true)]
|
||||
private Collection $customFields;
|
||||
|
||||
/**
|
||||
* @var Collection<int, TypeMachineComponentRequirement>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: TypeMachineComponentRequirement::class, mappedBy: 'typeMachine', cascade: ['persist', 'remove'])]
|
||||
#[ApiProperty(readableLink: true, writableLink: true)]
|
||||
private Collection $componentRequirements;
|
||||
|
||||
/**
|
||||
* @var Collection<int, TypeMachinePieceRequirement>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: TypeMachinePieceRequirement::class, mappedBy: 'typeMachine', cascade: ['persist', 'remove'])]
|
||||
#[ApiProperty(readableLink: true, writableLink: true)]
|
||||
private Collection $pieceRequirements;
|
||||
|
||||
/**
|
||||
* @var Collection<int, TypeMachineProductRequirement>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: TypeMachineProductRequirement::class, mappedBy: 'typeMachine', cascade: ['persist', 'remove'])]
|
||||
#[ApiProperty(readableLink: true, writableLink: true)]
|
||||
private Collection $productRequirements;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->id = 'cl'.bin2hex(random_bytes(12));
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
$this->machines = new ArrayCollection();
|
||||
$this->customFields = new ArrayCollection();
|
||||
$this->componentRequirements = new ArrayCollection();
|
||||
$this->pieceRequirements = new ArrayCollection();
|
||||
$this->productRequirements = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(string $name): static
|
||||
{
|
||||
$this->name = $name;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function setDescription(?string $description): static
|
||||
{
|
||||
$this->description = $description;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCategory(): ?string
|
||||
{
|
||||
return $this->category;
|
||||
}
|
||||
|
||||
public function setCategory(?string $category): static
|
||||
{
|
||||
$this->category = $category;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMaintenanceFrequency(): ?string
|
||||
{
|
||||
return $this->maintenanceFrequency;
|
||||
}
|
||||
|
||||
public function setMaintenanceFrequency(?string $maintenanceFrequency): static
|
||||
{
|
||||
$this->maintenanceFrequency = $maintenanceFrequency;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getComponents(): ?array
|
||||
{
|
||||
return $this->components;
|
||||
}
|
||||
|
||||
public function setComponents(?array $components): static
|
||||
{
|
||||
$this->components = $components;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCriticalParts(): ?array
|
||||
{
|
||||
return $this->criticalParts;
|
||||
}
|
||||
|
||||
public function setCriticalParts(?array $criticalParts): static
|
||||
{
|
||||
$this->criticalParts = $criticalParts;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMachinePieces(): ?array
|
||||
{
|
||||
return $this->machinePieces;
|
||||
}
|
||||
|
||||
public function setMachinePieces(?array $machinePieces): static
|
||||
{
|
||||
$this->machinePieces = $machinePieces;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSpecifications(): ?array
|
||||
{
|
||||
return $this->specifications;
|
||||
}
|
||||
|
||||
public function setSpecifications(?array $specifications): static
|
||||
{
|
||||
$this->specifications = $specifications;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Machine>
|
||||
*/
|
||||
public function getMachines(): Collection
|
||||
{
|
||||
return $this->machines;
|
||||
}
|
||||
|
||||
public function addMachine(Machine $machine): static
|
||||
{
|
||||
if (!$this->machines->contains($machine)) {
|
||||
$this->machines->add($machine);
|
||||
$machine->setTypeMachine($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeMachine(Machine $machine): static
|
||||
{
|
||||
if ($this->machines->removeElement($machine)) {
|
||||
if ($machine->getTypeMachine() === $this) {
|
||||
$machine->setTypeMachine(null);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, CustomField>
|
||||
*/
|
||||
public function getCustomFields(): Collection
|
||||
{
|
||||
return $this->customFields;
|
||||
}
|
||||
|
||||
public function addCustomField(CustomField $customField): static
|
||||
{
|
||||
if (!$this->customFields->contains($customField)) {
|
||||
$this->customFields->add($customField);
|
||||
$customField->setTypeMachine($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeCustomField(CustomField $customField): static
|
||||
{
|
||||
if ($this->customFields->removeElement($customField)) {
|
||||
if ($customField->getTypeMachine() === $this) {
|
||||
$customField->setTypeMachine(null);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, TypeMachineComponentRequirement>
|
||||
*/
|
||||
public function getComponentRequirements(): Collection
|
||||
{
|
||||
return $this->componentRequirements;
|
||||
}
|
||||
|
||||
public function addComponentRequirement(TypeMachineComponentRequirement $componentRequirement): static
|
||||
{
|
||||
if (!$this->componentRequirements->contains($componentRequirement)) {
|
||||
$this->componentRequirements->add($componentRequirement);
|
||||
$componentRequirement->setTypeMachine($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeComponentRequirement(TypeMachineComponentRequirement $componentRequirement): static
|
||||
{
|
||||
if ($this->componentRequirements->removeElement($componentRequirement)) {
|
||||
if ($componentRequirement->getTypeMachine() === $this) {
|
||||
$componentRequirement->setTypeMachine(null);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, TypeMachinePieceRequirement>
|
||||
*/
|
||||
public function getPieceRequirements(): Collection
|
||||
{
|
||||
return $this->pieceRequirements;
|
||||
}
|
||||
|
||||
public function addPieceRequirement(TypeMachinePieceRequirement $pieceRequirement): static
|
||||
{
|
||||
if (!$this->pieceRequirements->contains($pieceRequirement)) {
|
||||
$this->pieceRequirements->add($pieceRequirement);
|
||||
$pieceRequirement->setTypeMachine($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removePieceRequirement(TypeMachinePieceRequirement $pieceRequirement): static
|
||||
{
|
||||
if ($this->pieceRequirements->removeElement($pieceRequirement)) {
|
||||
if ($pieceRequirement->getTypeMachine() === $this) {
|
||||
$pieceRequirement->setTypeMachine(null);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, TypeMachineProductRequirement>
|
||||
*/
|
||||
public function getProductRequirements(): Collection
|
||||
{
|
||||
return $this->productRequirements;
|
||||
}
|
||||
|
||||
public function addProductRequirement(TypeMachineProductRequirement $productRequirement): static
|
||||
{
|
||||
if (!$this->productRequirements->contains($productRequirement)) {
|
||||
$this->productRequirements->add($productRequirement);
|
||||
$productRequirement->setTypeMachine($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeProductRequirement(TypeMachineProductRequirement $productRequirement): static
|
||||
{
|
||||
if ($this->productRequirements->removeElement($productRequirement)) {
|
||||
if ($productRequirement->getTypeMachine() === $this) {
|
||||
$productRequirement->setTypeMachine(null);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[ORM\PrePersist]
|
||||
public function setCreatedAtValue(): void
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
#[ORM\PreUpdate]
|
||||
public function setUpdatedAtValue(): void
|
||||
{
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use App\Repository\TypeMachineComponentRequirementRepository;
|
||||
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\Annotation\Groups;
|
||||
|
||||
#[ORM\Entity(repositoryClass: TypeMachineComponentRequirementRepository::class)]
|
||||
#[ORM\Table(name: 'type_machine_component_requirements')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiResource]
|
||||
class TypeMachineComponentRequirement
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
#[Groups(['type_machine:read'])]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
||||
#[Groups(['type_machine:read'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1], name: 'minCount')]
|
||||
#[Groups(['type_machine:read'])]
|
||||
private int $minCount = 1;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, nullable: true, name: 'maxCount')]
|
||||
#[Groups(['type_machine:read'])]
|
||||
private ?int $maxCount = null;
|
||||
|
||||
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => true], name: 'required')]
|
||||
#[Groups(['type_machine:read'])]
|
||||
private bool $required = true;
|
||||
|
||||
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => true], name: 'allowNewModels')]
|
||||
#[Groups(['type_machine:read'])]
|
||||
private bool $allowNewModels = true;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0], name: 'orderIndex')]
|
||||
#[Groups(['type_machine:read'])]
|
||||
private int $orderIndex = 0;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: TypeMachine::class, inversedBy: 'componentRequirements')]
|
||||
#[ORM\JoinColumn(name: 'typeMachineId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private TypeMachine $typeMachine;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'componentRequirements')]
|
||||
#[ORM\JoinColumn(name: 'typeComposantId', referencedColumnName: 'id', nullable: false)]
|
||||
#[ApiProperty(readableLink: true)]
|
||||
#[Groups(['type_machine:read'])]
|
||||
private ModelType $typeComposant;
|
||||
|
||||
/**
|
||||
* @var Collection<int, MachineComponentLink>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'typeMachineComponentRequirement', targetEntity: MachineComponentLink::class)]
|
||||
private Collection $machineComponentLinks;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private \DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->machineComponentLinks = new ArrayCollection();
|
||||
}
|
||||
|
||||
#[ORM\PrePersist]
|
||||
public function setCreatedAtValue(): void
|
||||
{
|
||||
$now = new \DateTimeImmutable();
|
||||
$this->createdAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
|
||||
if ($this->id === null) {
|
||||
$this->id = $this->generateCuid();
|
||||
}
|
||||
}
|
||||
|
||||
#[ORM\PreUpdate]
|
||||
public function setUpdatedAtValue(): void
|
||||
{
|
||||
$this->updatedAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl' . bin2hex(random_bytes(12));
|
||||
}
|
||||
|
||||
public function getId(): ?string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setId(string $id): static
|
||||
{
|
||||
$this->id = $id;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel(?string $label): static
|
||||
{
|
||||
$this->label = $label;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMinCount(): int
|
||||
{
|
||||
return $this->minCount;
|
||||
}
|
||||
|
||||
public function setMinCount(int $minCount): static
|
||||
{
|
||||
$this->minCount = $minCount;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMaxCount(): ?int
|
||||
{
|
||||
return $this->maxCount;
|
||||
}
|
||||
|
||||
public function setMaxCount(?int $maxCount): static
|
||||
{
|
||||
$this->maxCount = $maxCount;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isRequired(): bool
|
||||
{
|
||||
return $this->required;
|
||||
}
|
||||
|
||||
public function setRequired(bool $required): static
|
||||
{
|
||||
$this->required = $required;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isAllowNewModels(): bool
|
||||
{
|
||||
return $this->allowNewModels;
|
||||
}
|
||||
|
||||
public function setAllowNewModels(bool $allowNewModels): static
|
||||
{
|
||||
$this->allowNewModels = $allowNewModels;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getOrderIndex(): int
|
||||
{
|
||||
return $this->orderIndex;
|
||||
}
|
||||
|
||||
public function setOrderIndex(int $orderIndex): static
|
||||
{
|
||||
$this->orderIndex = $orderIndex;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeMachine(): TypeMachine
|
||||
{
|
||||
return $this->typeMachine;
|
||||
}
|
||||
|
||||
public function setTypeMachine(TypeMachine $typeMachine): static
|
||||
{
|
||||
$this->typeMachine = $typeMachine;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeComposant(): ModelType
|
||||
{
|
||||
return $this->typeComposant;
|
||||
}
|
||||
|
||||
public function setTypeComposant(ModelType $typeComposant): static
|
||||
{
|
||||
$this->typeComposant = $typeComposant;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use App\Repository\TypeMachinePieceRequirementRepository;
|
||||
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\Annotation\Groups;
|
||||
|
||||
#[ORM\Entity(repositoryClass: TypeMachinePieceRequirementRepository::class)]
|
||||
#[ORM\Table(name: 'type_machine_piece_requirements')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiResource]
|
||||
class TypeMachinePieceRequirement
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
#[Groups(['type_machine:read'])]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
||||
#[Groups(['type_machine:read'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0], name: 'minCount')]
|
||||
#[Groups(['type_machine:read'])]
|
||||
private int $minCount = 0;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, nullable: true, name: 'maxCount')]
|
||||
#[Groups(['type_machine:read'])]
|
||||
private ?int $maxCount = null;
|
||||
|
||||
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => false])]
|
||||
#[Groups(['type_machine:read'])]
|
||||
private bool $required = false;
|
||||
|
||||
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => true], name: 'allowNewModels')]
|
||||
#[Groups(['type_machine:read'])]
|
||||
private bool $allowNewModels = true;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0], name: 'orderIndex')]
|
||||
#[Groups(['type_machine:read'])]
|
||||
private int $orderIndex = 0;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: TypeMachine::class, inversedBy: 'pieceRequirements')]
|
||||
#[ORM\JoinColumn(name: 'typeMachineId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private TypeMachine $typeMachine;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'pieceRequirements')]
|
||||
#[ORM\JoinColumn(name: 'typePieceId', referencedColumnName: 'id', nullable: false)]
|
||||
#[ApiProperty(readableLink: true)]
|
||||
#[Groups(['type_machine:read'])]
|
||||
private ModelType $typePiece;
|
||||
|
||||
/**
|
||||
* @var Collection<int, MachinePieceLink>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'typeMachinePieceRequirement', targetEntity: MachinePieceLink::class)]
|
||||
private Collection $machinePieceLinks;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private \DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->machinePieceLinks = new ArrayCollection();
|
||||
}
|
||||
|
||||
#[ORM\PrePersist]
|
||||
public function setCreatedAtValue(): void
|
||||
{
|
||||
$now = new \DateTimeImmutable();
|
||||
$this->createdAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
|
||||
if ($this->id === null) {
|
||||
$this->id = $this->generateCuid();
|
||||
}
|
||||
}
|
||||
|
||||
#[ORM\PreUpdate]
|
||||
public function setUpdatedAtValue(): void
|
||||
{
|
||||
$this->updatedAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl' . bin2hex(random_bytes(12));
|
||||
}
|
||||
|
||||
public function getId(): ?string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setId(string $id): static
|
||||
{
|
||||
$this->id = $id;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel(?string $label): static
|
||||
{
|
||||
$this->label = $label;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMinCount(): int
|
||||
{
|
||||
return $this->minCount;
|
||||
}
|
||||
|
||||
public function setMinCount(int $minCount): static
|
||||
{
|
||||
$this->minCount = $minCount;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMaxCount(): ?int
|
||||
{
|
||||
return $this->maxCount;
|
||||
}
|
||||
|
||||
public function setMaxCount(?int $maxCount): static
|
||||
{
|
||||
$this->maxCount = $maxCount;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isRequired(): bool
|
||||
{
|
||||
return $this->required;
|
||||
}
|
||||
|
||||
public function setRequired(bool $required): static
|
||||
{
|
||||
$this->required = $required;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isAllowNewModels(): bool
|
||||
{
|
||||
return $this->allowNewModels;
|
||||
}
|
||||
|
||||
public function setAllowNewModels(bool $allowNewModels): static
|
||||
{
|
||||
$this->allowNewModels = $allowNewModels;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getOrderIndex(): int
|
||||
{
|
||||
return $this->orderIndex;
|
||||
}
|
||||
|
||||
public function setOrderIndex(int $orderIndex): static
|
||||
{
|
||||
$this->orderIndex = $orderIndex;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeMachine(): TypeMachine
|
||||
{
|
||||
return $this->typeMachine;
|
||||
}
|
||||
|
||||
public function setTypeMachine(TypeMachine $typeMachine): static
|
||||
{
|
||||
$this->typeMachine = $typeMachine;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypePiece(): ModelType
|
||||
{
|
||||
return $this->typePiece;
|
||||
}
|
||||
|
||||
public function setTypePiece(ModelType $typePiece): static
|
||||
{
|
||||
$this->typePiece = $typePiece;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use App\Repository\TypeMachineProductRequirementRepository;
|
||||
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\Annotation\Groups;
|
||||
|
||||
#[ORM\Entity(repositoryClass: TypeMachineProductRequirementRepository::class)]
|
||||
#[ORM\Table(name: 'type_machine_product_requirements')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiResource]
|
||||
class TypeMachineProductRequirement
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
#[Groups(['type_machine:read'])]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
||||
#[Groups(['type_machine:read'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0], name: 'minCount')]
|
||||
#[Groups(['type_machine:read'])]
|
||||
private int $minCount = 0;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, nullable: true, name: 'maxCount')]
|
||||
#[Groups(['type_machine:read'])]
|
||||
private ?int $maxCount = null;
|
||||
|
||||
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => false])]
|
||||
#[Groups(['type_machine:read'])]
|
||||
private bool $required = false;
|
||||
|
||||
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => true], name: 'allowNewModels')]
|
||||
#[Groups(['type_machine:read'])]
|
||||
private bool $allowNewModels = true;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0], name: 'orderIndex')]
|
||||
#[Groups(['type_machine:read'])]
|
||||
private int $orderIndex = 0;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: TypeMachine::class, inversedBy: 'productRequirements')]
|
||||
#[ORM\JoinColumn(name: 'typeMachineId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private TypeMachine $typeMachine;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'productRequirements')]
|
||||
#[ORM\JoinColumn(name: 'typeProductId', referencedColumnName: 'id', nullable: false)]
|
||||
#[ApiProperty(readableLink: true)]
|
||||
#[Groups(['type_machine:read'])]
|
||||
private ModelType $typeProduct;
|
||||
|
||||
/**
|
||||
* @var Collection<int, MachineProductLink>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'typeMachineProductRequirement', targetEntity: MachineProductLink::class)]
|
||||
private Collection $machineProductLinks;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private \DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->machineProductLinks = new ArrayCollection();
|
||||
}
|
||||
|
||||
#[ORM\PrePersist]
|
||||
public function setCreatedAtValue(): void
|
||||
{
|
||||
$now = new \DateTimeImmutable();
|
||||
$this->createdAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
|
||||
if ($this->id === null) {
|
||||
$this->id = $this->generateCuid();
|
||||
}
|
||||
}
|
||||
|
||||
#[ORM\PreUpdate]
|
||||
public function setUpdatedAtValue(): void
|
||||
{
|
||||
$this->updatedAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl' . bin2hex(random_bytes(12));
|
||||
}
|
||||
|
||||
public function getId(): ?string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setId(string $id): static
|
||||
{
|
||||
$this->id = $id;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel(?string $label): static
|
||||
{
|
||||
$this->label = $label;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMinCount(): int
|
||||
{
|
||||
return $this->minCount;
|
||||
}
|
||||
|
||||
public function setMinCount(int $minCount): static
|
||||
{
|
||||
$this->minCount = $minCount;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMaxCount(): ?int
|
||||
{
|
||||
return $this->maxCount;
|
||||
}
|
||||
|
||||
public function setMaxCount(?int $maxCount): static
|
||||
{
|
||||
$this->maxCount = $maxCount;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isRequired(): bool
|
||||
{
|
||||
return $this->required;
|
||||
}
|
||||
|
||||
public function setRequired(bool $required): static
|
||||
{
|
||||
$this->required = $required;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isAllowNewModels(): bool
|
||||
{
|
||||
return $this->allowNewModels;
|
||||
}
|
||||
|
||||
public function setAllowNewModels(bool $allowNewModels): static
|
||||
{
|
||||
$this->allowNewModels = $allowNewModels;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getOrderIndex(): int
|
||||
{
|
||||
return $this->orderIndex;
|
||||
}
|
||||
|
||||
public function setOrderIndex(int $orderIndex): static
|
||||
{
|
||||
$this->orderIndex = $orderIndex;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeMachine(): TypeMachine
|
||||
{
|
||||
return $this->typeMachine;
|
||||
}
|
||||
|
||||
public function setTypeMachine(TypeMachine $typeMachine): static
|
||||
{
|
||||
$this->typeMachine = $typeMachine;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeProduct(): ModelType
|
||||
{
|
||||
return $this->typeProduct;
|
||||
}
|
||||
|
||||
public function setTypeProduct(ModelType $typeProduct): static
|
||||
{
|
||||
$this->typeProduct = $typeProduct;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
44
src/EventListener/DocumentFileCleanupListener.php
Normal file
44
src/EventListener/DocumentFileCleanupListener.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\EventListener;
|
||||
|
||||
use App\Entity\Document;
|
||||
use App\Service\DocumentStorageService;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
|
||||
use Doctrine\ORM\Events;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
#[AsEntityListener(event: Events::postRemove, method: 'postRemove', entity: Document::class)]
|
||||
class DocumentFileCleanupListener
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DocumentStorageService $storageService,
|
||||
private readonly ?LoggerInterface $logger = null,
|
||||
) {}
|
||||
|
||||
public function postRemove(Document $document): void
|
||||
{
|
||||
$path = $document->getPath();
|
||||
|
||||
// Do not attempt file deletion for Base64 data URIs
|
||||
if ($this->storageService->isBase64DataUri($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$deleted = $this->storageService->delete($path);
|
||||
|
||||
if ($deleted) {
|
||||
$this->logger?->info('Document file deleted from disk', [
|
||||
'documentId' => $document->getId(),
|
||||
'path' => $path,
|
||||
]);
|
||||
} else {
|
||||
$this->logger?->warning('Document file not found on disk during cleanup', [
|
||||
'documentId' => $document->getId(),
|
||||
'path' => $path,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\EventListener;
|
||||
|
||||
use App\Entity\Document;
|
||||
use App\Service\DocumentStorageService;
|
||||
use App\Service\PdfCompressorService;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
|
||||
use Doctrine\ORM\Events;
|
||||
@@ -16,6 +17,7 @@ class DocumentPdfCompressorListener
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PdfCompressorService $pdfCompressor,
|
||||
private readonly DocumentStorageService $storageService,
|
||||
private readonly ?LoggerInterface $logger = null,
|
||||
) {}
|
||||
|
||||
@@ -35,15 +37,26 @@ class DocumentPdfCompressorListener
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $this->pdfCompressor->compressBase64Pdf($document->getPath());
|
||||
$path = $document->getPath();
|
||||
|
||||
if (null === $result) {
|
||||
return;
|
||||
if ($this->storageService->isBase64DataUri($path)) {
|
||||
// Legacy Base64 path
|
||||
$result = $this->pdfCompressor->compressBase64Pdf($path);
|
||||
if (null === $result) {
|
||||
return;
|
||||
}
|
||||
$document->setPath($result['path']);
|
||||
$document->setSize($result['size']);
|
||||
} else {
|
||||
// File-based path
|
||||
$absolutePath = $this->storageService->getAbsolutePath($path);
|
||||
$result = $this->pdfCompressor->compressFile($absolutePath);
|
||||
if (null === $result) {
|
||||
return;
|
||||
}
|
||||
$document->setSize($result['size']);
|
||||
}
|
||||
|
||||
$document->setPath($result['path']);
|
||||
$document->setSize($result['size']);
|
||||
|
||||
$this->logger?->info('PDF compressed', [
|
||||
'document' => $document->getName(),
|
||||
'originalSize' => $result['originalSize'],
|
||||
|
||||
397
src/EventSubscriber/ComposantAuditSubscriber.php
Normal file
397
src/EventSubscriber/ComposantAuditSubscriber.php
Normal file
@@ -0,0 +1,397 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Entity\AuditLog;
|
||||
use App\Entity\Composant;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\ModelType;
|
||||
use App\Entity\Product;
|
||||
use App\Entity\Profile;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\Common\EventSubscriber;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Event\OnFlushEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
use Doctrine\ORM\PersistentCollection;
|
||||
use Doctrine\ORM\UnitOfWork;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
use Throwable;
|
||||
|
||||
use function is_array;
|
||||
use function is_object;
|
||||
use function is_scalar;
|
||||
use function method_exists;
|
||||
|
||||
#[AsDoctrineListener(event: Events::onFlush)]
|
||||
final class ComposantAuditSubscriber implements EventSubscriber
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
Events::onFlush,
|
||||
];
|
||||
}
|
||||
|
||||
public function onFlush(OnFlushEventArgs $args): void
|
||||
{
|
||||
$em = $args->getObjectManager();
|
||||
if (!$em instanceof EntityManagerInterface) {
|
||||
return;
|
||||
}
|
||||
|
||||
$uow = $em->getUnitOfWork();
|
||||
$actorProfileId = $this->resolveActorProfileId();
|
||||
$pendingUpdates = [];
|
||||
$pendingSnapshots = [];
|
||||
$pendingComponents = [];
|
||||
|
||||
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
||||
if (!$entity instanceof Composant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
||||
$snapshot = $this->snapshotComposant($entity);
|
||||
$this->persistAuditLog($em, new AuditLog('composant', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
||||
if (!$entity instanceof Composant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$componentId = (string) $entity->getId();
|
||||
if ('' === $componentId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
||||
if ([] !== $diff) {
|
||||
$pendingUpdates[$componentId] = $this->mergeDiffs($pendingUpdates[$componentId] ?? [], $diff);
|
||||
$pendingSnapshots[$componentId] = $this->snapshotComposant($entity);
|
||||
$pendingComponents[$componentId] = $entity;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityDeletions() as $entity) {
|
||||
if (!$entity instanceof Composant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$snapshot = $this->snapshotComposant($entity);
|
||||
$this->persistAuditLog($em, new AuditLog('composant', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
|
||||
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingComponents);
|
||||
}
|
||||
foreach ($uow->getScheduledCollectionDeletions() as $collection) {
|
||||
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingComponents);
|
||||
}
|
||||
|
||||
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingComponents);
|
||||
|
||||
foreach ($pendingUpdates as $componentId => $diff) {
|
||||
if ([] === $diff) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$component = $pendingComponents[$componentId] ?? null;
|
||||
if (!$component instanceof Composant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$snapshot = $pendingSnapshots[$componentId] ?? $this->snapshotComposant($component);
|
||||
$this->persistAuditLog($em, new AuditLog('composant', $componentId, 'update', $diff, $snapshot, $actorProfileId));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
|
||||
* @param array<string, array<string, mixed>> $pendingSnapshots
|
||||
* @param array<string, Composant> $pendingComponents
|
||||
*/
|
||||
private function collectCollectionUpdate(
|
||||
object $collection,
|
||||
array &$pendingUpdates,
|
||||
array &$pendingSnapshots,
|
||||
array &$pendingComponents,
|
||||
): void {
|
||||
if (!$collection instanceof PersistentCollection) {
|
||||
return;
|
||||
}
|
||||
|
||||
$owner = $collection->getOwner();
|
||||
if (!$owner instanceof Composant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$componentId = (string) $owner->getId();
|
||||
if ('' === $componentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$mapping = $collection->getMapping();
|
||||
$fieldName = $mapping['fieldName'] ?? null;
|
||||
if ('constructeurs' !== $fieldName) {
|
||||
return;
|
||||
}
|
||||
|
||||
$before = $this->normalizeCollection($collection->getSnapshot());
|
||||
$after = $this->normalizeCollection($collection->toArray());
|
||||
|
||||
if ($before === $after) {
|
||||
return;
|
||||
}
|
||||
|
||||
$diff = [
|
||||
'constructeurIds' => [
|
||||
'from' => $before,
|
||||
'to' => $after,
|
||||
],
|
||||
];
|
||||
|
||||
$pendingUpdates[$componentId] = $this->mergeDiffs($pendingUpdates[$componentId] ?? [], $diff);
|
||||
$pendingSnapshots[$componentId] = $this->snapshotComposant($owner);
|
||||
$pendingComponents[$componentId] = $owner;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
|
||||
* @param array<string, array<string, mixed>> $pendingSnapshots
|
||||
* @param array<string, Composant> $pendingComponents
|
||||
*/
|
||||
private function collectCustomFieldValueChanges(
|
||||
UnitOfWork $uow,
|
||||
array &$pendingUpdates,
|
||||
array &$pendingSnapshots,
|
||||
array &$pendingComponents,
|
||||
): void {
|
||||
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
||||
if ($entity instanceof CustomFieldValue) {
|
||||
$this->trackCustomFieldValueChange($entity, null, $entity->getValue(), $pendingUpdates, $pendingSnapshots, $pendingComponents);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
||||
if (!$entity instanceof CustomFieldValue) {
|
||||
continue;
|
||||
}
|
||||
$changeSet = $uow->getEntityChangeSet($entity);
|
||||
if (!isset($changeSet['value'])) {
|
||||
continue;
|
||||
}
|
||||
[$oldVal, $newVal] = $changeSet['value'];
|
||||
if ($oldVal !== $newVal) {
|
||||
$this->trackCustomFieldValueChange($entity, $oldVal, $newVal, $pendingUpdates, $pendingSnapshots, $pendingComponents);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityDeletions() as $entity) {
|
||||
if ($entity instanceof CustomFieldValue) {
|
||||
$this->trackCustomFieldValueChange($entity, $entity->getValue(), null, $pendingUpdates, $pendingSnapshots, $pendingComponents);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
|
||||
* @param array<string, array<string, mixed>> $pendingSnapshots
|
||||
* @param array<string, Composant> $pendingComponents
|
||||
*/
|
||||
private function trackCustomFieldValueChange(
|
||||
CustomFieldValue $cfv,
|
||||
mixed $from,
|
||||
mixed $to,
|
||||
array &$pendingUpdates,
|
||||
array &$pendingSnapshots,
|
||||
array &$pendingComponents,
|
||||
): void {
|
||||
$owner = $cfv->getComposant();
|
||||
if (!$owner instanceof Composant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ownerId = (string) $owner->getId();
|
||||
if ('' === $ownerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$fieldName = 'customField:'.$cfv->getCustomField()->getName();
|
||||
$diff = [$fieldName => ['from' => $from, 'to' => $to]];
|
||||
|
||||
$pendingUpdates[$ownerId] = $this->mergeDiffs($pendingUpdates[$ownerId] ?? [], $diff);
|
||||
$pendingSnapshots[$ownerId] = $this->snapshotComposant($owner);
|
||||
$pendingComponents[$ownerId] = $owner;
|
||||
}
|
||||
|
||||
private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
|
||||
{
|
||||
$uow = $em->getUnitOfWork();
|
||||
$log->initializeAuditLog();
|
||||
$em->persist($log);
|
||||
|
||||
$meta = $em->getClassMetadata(AuditLog::class);
|
||||
$uow->computeChangeSet($meta, $log);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{0:mixed, 1:mixed}> $changeSet
|
||||
*
|
||||
* @return array<string, array{from:mixed, to:mixed}>
|
||||
*/
|
||||
private function buildDiffFromChangeSet(array $changeSet): array
|
||||
{
|
||||
$diff = [];
|
||||
foreach ($changeSet as $field => [$oldValue, $newValue]) {
|
||||
if ('updatedAt' === $field || 'createdAt' === $field) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedOld = $this->normalizeValue($oldValue);
|
||||
$normalizedNew = $this->normalizeValue($newValue);
|
||||
|
||||
if ($normalizedOld === $normalizedNew) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diff[$field] = [
|
||||
'from' => $normalizedOld,
|
||||
'to' => $normalizedNew,
|
||||
];
|
||||
}
|
||||
|
||||
return $diff;
|
||||
}
|
||||
|
||||
private function snapshotComposant(Composant $component): array
|
||||
{
|
||||
return [
|
||||
'id' => $component->getId(),
|
||||
'name' => $component->getName(),
|
||||
'reference' => $component->getReference(),
|
||||
'prix' => $component->getPrix(),
|
||||
'structure' => $component->getStructure(),
|
||||
'typeComposant' => $this->normalizeValue($component->getTypeComposant()),
|
||||
'product' => $this->normalizeValue($component->getProduct()),
|
||||
'constructeurIds' => $this->normalizeCollection($component->getConstructeurs()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<mixed> $items
|
||||
*
|
||||
* @return list<array{id: string, name: string}|string>
|
||||
*/
|
||||
private function normalizeCollection(iterable $items): array
|
||||
{
|
||||
$entries = [];
|
||||
$seen = [];
|
||||
foreach ($items as $item) {
|
||||
if (is_object($item) && method_exists($item, 'getId')) {
|
||||
$id = $item->getId();
|
||||
if (null === $id || '' === $id || isset($seen[(string) $id])) {
|
||||
continue;
|
||||
}
|
||||
$seen[(string) $id] = true;
|
||||
if (method_exists($item, 'getName')) {
|
||||
$entries[] = ['id' => (string) $id, 'name' => (string) $item->getName()];
|
||||
} else {
|
||||
$entries[] = (string) $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
private function normalizeValue(mixed $value): mixed
|
||||
{
|
||||
if (null === $value || is_scalar($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if ($value instanceof DateTimeInterface) {
|
||||
return $value->format(DateTimeInterface::ATOM);
|
||||
}
|
||||
|
||||
if ($value instanceof ModelType) {
|
||||
return [
|
||||
'id' => $value->getId(),
|
||||
'name' => $value->getName(),
|
||||
'code' => $value->getCode(),
|
||||
];
|
||||
}
|
||||
|
||||
if ($value instanceof Product) {
|
||||
return [
|
||||
'id' => $value->getId(),
|
||||
'name' => $value->getName(),
|
||||
'reference' => $value->getReference(),
|
||||
];
|
||||
}
|
||||
|
||||
if ($value instanceof Collection) {
|
||||
return $this->normalizeCollection($value);
|
||||
}
|
||||
|
||||
if (is_object($value) && method_exists($value, 'getId')) {
|
||||
return (string) $value->getId();
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{from:mixed, to:mixed}> $base
|
||||
* @param array<string, array{from:mixed, to:mixed}> $extra
|
||||
*
|
||||
* @return array<string, array{from:mixed, to:mixed}>
|
||||
*/
|
||||
private function mergeDiffs(array $base, array $extra): array
|
||||
{
|
||||
foreach ($extra as $field => $change) {
|
||||
$base[$field] = $change;
|
||||
}
|
||||
|
||||
return $base;
|
||||
}
|
||||
|
||||
private function resolveActorProfileId(): ?string
|
||||
{
|
||||
try {
|
||||
$session = $this->requestStack->getSession();
|
||||
if ($session instanceof SessionInterface) {
|
||||
$profileId = $session->get('profileId');
|
||||
if ($profileId) {
|
||||
return (string) $profileId;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// No session available (CLI context, etc.)
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if ($user instanceof Profile) {
|
||||
return $user->getId();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
168
src/EventSubscriber/ConstructeurAuditSubscriber.php
Normal file
168
src/EventSubscriber/ConstructeurAuditSubscriber.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Entity\AuditLog;
|
||||
use App\Entity\Constructeur;
|
||||
use App\Entity\Profile;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||
use Doctrine\Common\EventSubscriber;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Event\OnFlushEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
use Throwable;
|
||||
|
||||
use function is_scalar;
|
||||
|
||||
#[AsDoctrineListener(event: Events::onFlush)]
|
||||
final class ConstructeurAuditSubscriber implements EventSubscriber
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
Events::onFlush,
|
||||
];
|
||||
}
|
||||
|
||||
public function onFlush(OnFlushEventArgs $args): void
|
||||
{
|
||||
$em = $args->getObjectManager();
|
||||
if (!$em instanceof EntityManagerInterface) {
|
||||
return;
|
||||
}
|
||||
|
||||
$uow = $em->getUnitOfWork();
|
||||
$actorProfileId = $this->resolveActorProfileId();
|
||||
|
||||
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
||||
if (!$entity instanceof Constructeur) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
||||
$snapshot = $this->snapshotConstructeur($entity);
|
||||
$this->persistAuditLog($em, new AuditLog('constructeur', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
||||
if (!$entity instanceof Constructeur) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = (string) $entity->getId();
|
||||
if ('' === $id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
||||
if ([] !== $diff) {
|
||||
$snapshot = $this->snapshotConstructeur($entity);
|
||||
$this->persistAuditLog($em, new AuditLog('constructeur', $id, 'update', $diff, $snapshot, $actorProfileId));
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityDeletions() as $entity) {
|
||||
if (!$entity instanceof Constructeur) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$snapshot = $this->snapshotConstructeur($entity);
|
||||
$this->persistAuditLog($em, new AuditLog('constructeur', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
|
||||
}
|
||||
}
|
||||
|
||||
private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
|
||||
{
|
||||
$uow = $em->getUnitOfWork();
|
||||
$log->initializeAuditLog();
|
||||
$em->persist($log);
|
||||
|
||||
$meta = $em->getClassMetadata(AuditLog::class);
|
||||
$uow->computeChangeSet($meta, $log);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{0:mixed, 1:mixed}> $changeSet
|
||||
*
|
||||
* @return array<string, array{from:mixed, to:mixed}>
|
||||
*/
|
||||
private function buildDiffFromChangeSet(array $changeSet): array
|
||||
{
|
||||
$diff = [];
|
||||
foreach ($changeSet as $field => [$oldValue, $newValue]) {
|
||||
if ('updatedAt' === $field || 'createdAt' === $field) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedOld = $this->normalizeValue($oldValue);
|
||||
$normalizedNew = $this->normalizeValue($newValue);
|
||||
|
||||
if ($normalizedOld === $normalizedNew) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diff[$field] = [
|
||||
'from' => $normalizedOld,
|
||||
'to' => $normalizedNew,
|
||||
];
|
||||
}
|
||||
|
||||
return $diff;
|
||||
}
|
||||
|
||||
private function snapshotConstructeur(Constructeur $constructeur): array
|
||||
{
|
||||
return [
|
||||
'id' => $constructeur->getId(),
|
||||
'name' => $constructeur->getName(),
|
||||
'email' => $constructeur->getEmail(),
|
||||
'phone' => $constructeur->getPhone(),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeValue(mixed $value): mixed
|
||||
{
|
||||
if (null === $value || is_scalar($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if ($value instanceof DateTimeInterface) {
|
||||
return $value->format(DateTimeInterface::ATOM);
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
private function resolveActorProfileId(): ?string
|
||||
{
|
||||
try {
|
||||
$session = $this->requestStack->getSession();
|
||||
if ($session instanceof SessionInterface) {
|
||||
$profileId = $session->get('profileId');
|
||||
if ($profileId) {
|
||||
return (string) $profileId;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// No session available (CLI context, etc.)
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if ($user instanceof Profile) {
|
||||
return $user->getId();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
192
src/EventSubscriber/DocumentAuditSubscriber.php
Normal file
192
src/EventSubscriber/DocumentAuditSubscriber.php
Normal file
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Entity\AuditLog;
|
||||
use App\Entity\Composant;
|
||||
use App\Entity\Document;
|
||||
use App\Entity\Machine;
|
||||
use App\Entity\Piece;
|
||||
use App\Entity\Product;
|
||||
use App\Entity\Profile;
|
||||
use App\Entity\Site;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||
use Doctrine\Common\EventSubscriber;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Event\OnFlushEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
use Throwable;
|
||||
|
||||
use function is_object;
|
||||
use function is_scalar;
|
||||
use function method_exists;
|
||||
|
||||
#[AsDoctrineListener(event: Events::onFlush)]
|
||||
final class DocumentAuditSubscriber implements EventSubscriber
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
Events::onFlush,
|
||||
];
|
||||
}
|
||||
|
||||
public function onFlush(OnFlushEventArgs $args): void
|
||||
{
|
||||
$em = $args->getObjectManager();
|
||||
if (!$em instanceof EntityManagerInterface) {
|
||||
return;
|
||||
}
|
||||
|
||||
$uow = $em->getUnitOfWork();
|
||||
$actorProfileId = $this->resolveActorProfileId();
|
||||
|
||||
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
||||
if (!$entity instanceof Document) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
||||
$snapshot = $this->snapshotDocument($entity);
|
||||
$this->persistAuditLog($em, new AuditLog('document', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
||||
if (!$entity instanceof Document) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = (string) $entity->getId();
|
||||
if ('' === $id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
||||
if ([] !== $diff) {
|
||||
$snapshot = $this->snapshotDocument($entity);
|
||||
$this->persistAuditLog($em, new AuditLog('document', $id, 'update', $diff, $snapshot, $actorProfileId));
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityDeletions() as $entity) {
|
||||
if (!$entity instanceof Document) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$snapshot = $this->snapshotDocument($entity);
|
||||
$this->persistAuditLog($em, new AuditLog('document', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
|
||||
}
|
||||
}
|
||||
|
||||
private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
|
||||
{
|
||||
$uow = $em->getUnitOfWork();
|
||||
$log->initializeAuditLog();
|
||||
$em->persist($log);
|
||||
|
||||
$meta = $em->getClassMetadata(AuditLog::class);
|
||||
$uow->computeChangeSet($meta, $log);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{0:mixed, 1:mixed}> $changeSet
|
||||
*
|
||||
* @return array<string, array{from:mixed, to:mixed}>
|
||||
*/
|
||||
private function buildDiffFromChangeSet(array $changeSet): array
|
||||
{
|
||||
$diff = [];
|
||||
foreach ($changeSet as $field => [$oldValue, $newValue]) {
|
||||
if ('updatedAt' === $field || 'createdAt' === $field) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedOld = $this->normalizeValue($oldValue);
|
||||
$normalizedNew = $this->normalizeValue($newValue);
|
||||
|
||||
if ($normalizedOld === $normalizedNew) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diff[$field] = [
|
||||
'from' => $normalizedOld,
|
||||
'to' => $normalizedNew,
|
||||
];
|
||||
}
|
||||
|
||||
return $diff;
|
||||
}
|
||||
|
||||
private function snapshotDocument(Document $document): array
|
||||
{
|
||||
return [
|
||||
'id' => $document->getId(),
|
||||
'name' => $document->getName(),
|
||||
'filename' => $document->getFilename(),
|
||||
'mimeType' => $document->getMimeType(),
|
||||
'size' => $document->getSize(),
|
||||
'machine' => $this->normalizeValue($document->getMachine()),
|
||||
'composant' => $this->normalizeValue($document->getComposant()),
|
||||
'piece' => $this->normalizeValue($document->getPiece()),
|
||||
'product' => $this->normalizeValue($document->getProduct()),
|
||||
'site' => $this->normalizeValue($document->getSite()),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeValue(mixed $value): mixed
|
||||
{
|
||||
if (null === $value || is_scalar($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if ($value instanceof DateTimeInterface) {
|
||||
return $value->format(DateTimeInterface::ATOM);
|
||||
}
|
||||
|
||||
if ($value instanceof Machine || $value instanceof Composant || $value instanceof Piece || $value instanceof Product || $value instanceof Site) {
|
||||
return [
|
||||
'id' => $value->getId(),
|
||||
'name' => $value->getName(),
|
||||
];
|
||||
}
|
||||
|
||||
if (is_object($value) && method_exists($value, 'getId')) {
|
||||
return (string) $value->getId();
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
private function resolveActorProfileId(): ?string
|
||||
{
|
||||
try {
|
||||
$session = $this->requestStack->getSession();
|
||||
if ($session instanceof SessionInterface) {
|
||||
$profileId = $session->get('profileId');
|
||||
if ($profileId) {
|
||||
return (string) $profileId;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// No session available (CLI context, etc.)
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if ($user instanceof Profile) {
|
||||
return $user->getId();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
403
src/EventSubscriber/MachineAuditSubscriber.php
Normal file
403
src/EventSubscriber/MachineAuditSubscriber.php
Normal file
@@ -0,0 +1,403 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Entity\AuditLog;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\Machine;
|
||||
use App\Entity\ModelType;
|
||||
use App\Entity\Product;
|
||||
use App\Entity\Profile;
|
||||
use App\Entity\Site;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\Common\EventSubscriber;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Event\OnFlushEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
use Doctrine\ORM\PersistentCollection;
|
||||
use Doctrine\ORM\UnitOfWork;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
use Throwable;
|
||||
|
||||
use function is_array;
|
||||
use function is_object;
|
||||
use function is_scalar;
|
||||
use function method_exists;
|
||||
|
||||
#[AsDoctrineListener(event: Events::onFlush)]
|
||||
final class MachineAuditSubscriber implements EventSubscriber
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
Events::onFlush,
|
||||
];
|
||||
}
|
||||
|
||||
public function onFlush(OnFlushEventArgs $args): void
|
||||
{
|
||||
$em = $args->getObjectManager();
|
||||
if (!$em instanceof EntityManagerInterface) {
|
||||
return;
|
||||
}
|
||||
|
||||
$uow = $em->getUnitOfWork();
|
||||
$actorProfileId = $this->resolveActorProfileId();
|
||||
$pendingUpdates = [];
|
||||
$pendingSnapshots = [];
|
||||
$pendingMachines = [];
|
||||
|
||||
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
||||
if (!$entity instanceof Machine) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
||||
$snapshot = $this->snapshotMachine($entity);
|
||||
$this->persistAuditLog($em, new AuditLog('machine', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
||||
if (!$entity instanceof Machine) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$machineId = (string) $entity->getId();
|
||||
if ('' === $machineId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
||||
if ([] !== $diff) {
|
||||
$pendingUpdates[$machineId] = $this->mergeDiffs($pendingUpdates[$machineId] ?? [], $diff);
|
||||
$pendingSnapshots[$machineId] = $this->snapshotMachine($entity);
|
||||
$pendingMachines[$machineId] = $entity;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityDeletions() as $entity) {
|
||||
if (!$entity instanceof Machine) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$snapshot = $this->snapshotMachine($entity);
|
||||
$this->persistAuditLog($em, new AuditLog('machine', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
|
||||
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingMachines);
|
||||
}
|
||||
foreach ($uow->getScheduledCollectionDeletions() as $collection) {
|
||||
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingMachines);
|
||||
}
|
||||
|
||||
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingMachines);
|
||||
|
||||
foreach ($pendingUpdates as $machineId => $diff) {
|
||||
if ([] === $diff) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$machine = $pendingMachines[$machineId] ?? null;
|
||||
if (!$machine instanceof Machine) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$snapshot = $pendingSnapshots[$machineId] ?? $this->snapshotMachine($machine);
|
||||
$this->persistAuditLog($em, new AuditLog('machine', $machineId, 'update', $diff, $snapshot, $actorProfileId));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
|
||||
* @param array<string, array<string, mixed>> $pendingSnapshots
|
||||
* @param array<string, Machine> $pendingMachines
|
||||
*/
|
||||
private function collectCollectionUpdate(
|
||||
object $collection,
|
||||
array &$pendingUpdates,
|
||||
array &$pendingSnapshots,
|
||||
array &$pendingMachines,
|
||||
): void {
|
||||
if (!$collection instanceof PersistentCollection) {
|
||||
return;
|
||||
}
|
||||
|
||||
$owner = $collection->getOwner();
|
||||
if (!$owner instanceof Machine) {
|
||||
return;
|
||||
}
|
||||
|
||||
$machineId = (string) $owner->getId();
|
||||
if ('' === $machineId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$mapping = $collection->getMapping();
|
||||
$fieldName = $mapping['fieldName'] ?? null;
|
||||
if ('constructeurs' !== $fieldName) {
|
||||
return;
|
||||
}
|
||||
|
||||
$before = $this->normalizeCollection($collection->getSnapshot());
|
||||
$after = $this->normalizeCollection($collection->toArray());
|
||||
|
||||
if ($before === $after) {
|
||||
return;
|
||||
}
|
||||
|
||||
$diff = [
|
||||
'constructeurIds' => [
|
||||
'from' => $before,
|
||||
'to' => $after,
|
||||
],
|
||||
];
|
||||
|
||||
$pendingUpdates[$machineId] = $this->mergeDiffs($pendingUpdates[$machineId] ?? [], $diff);
|
||||
$pendingSnapshots[$machineId] = $this->snapshotMachine($owner);
|
||||
$pendingMachines[$machineId] = $owner;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
|
||||
* @param array<string, array<string, mixed>> $pendingSnapshots
|
||||
* @param array<string, Machine> $pendingMachines
|
||||
*/
|
||||
private function collectCustomFieldValueChanges(
|
||||
UnitOfWork $uow,
|
||||
array &$pendingUpdates,
|
||||
array &$pendingSnapshots,
|
||||
array &$pendingMachines,
|
||||
): void {
|
||||
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
||||
if ($entity instanceof CustomFieldValue) {
|
||||
$this->trackCustomFieldValueChange($entity, null, $entity->getValue(), $pendingUpdates, $pendingSnapshots, $pendingMachines);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
||||
if (!$entity instanceof CustomFieldValue) {
|
||||
continue;
|
||||
}
|
||||
$changeSet = $uow->getEntityChangeSet($entity);
|
||||
if (!isset($changeSet['value'])) {
|
||||
continue;
|
||||
}
|
||||
[$oldVal, $newVal] = $changeSet['value'];
|
||||
if ($oldVal !== $newVal) {
|
||||
$this->trackCustomFieldValueChange($entity, $oldVal, $newVal, $pendingUpdates, $pendingSnapshots, $pendingMachines);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityDeletions() as $entity) {
|
||||
if ($entity instanceof CustomFieldValue) {
|
||||
$this->trackCustomFieldValueChange($entity, $entity->getValue(), null, $pendingUpdates, $pendingSnapshots, $pendingMachines);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
|
||||
* @param array<string, array<string, mixed>> $pendingSnapshots
|
||||
* @param array<string, Machine> $pendingMachines
|
||||
*/
|
||||
private function trackCustomFieldValueChange(
|
||||
CustomFieldValue $cfv,
|
||||
mixed $from,
|
||||
mixed $to,
|
||||
array &$pendingUpdates,
|
||||
array &$pendingSnapshots,
|
||||
array &$pendingMachines,
|
||||
): void {
|
||||
$owner = $cfv->getMachine();
|
||||
if (!$owner instanceof Machine) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ownerId = (string) $owner->getId();
|
||||
if ('' === $ownerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$fieldName = 'customField:'.$cfv->getCustomField()->getName();
|
||||
$diff = [$fieldName => ['from' => $from, 'to' => $to]];
|
||||
|
||||
$pendingUpdates[$ownerId] = $this->mergeDiffs($pendingUpdates[$ownerId] ?? [], $diff);
|
||||
$pendingSnapshots[$ownerId] = $this->snapshotMachine($owner);
|
||||
$pendingMachines[$ownerId] = $owner;
|
||||
}
|
||||
|
||||
private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
|
||||
{
|
||||
$uow = $em->getUnitOfWork();
|
||||
$log->initializeAuditLog();
|
||||
$em->persist($log);
|
||||
|
||||
$meta = $em->getClassMetadata(AuditLog::class);
|
||||
$uow->computeChangeSet($meta, $log);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{0:mixed, 1:mixed}> $changeSet
|
||||
*
|
||||
* @return array<string, array{from:mixed, to:mixed}>
|
||||
*/
|
||||
private function buildDiffFromChangeSet(array $changeSet): array
|
||||
{
|
||||
$diff = [];
|
||||
foreach ($changeSet as $field => [$oldValue, $newValue]) {
|
||||
if ('updatedAt' === $field || 'createdAt' === $field) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedOld = $this->normalizeValue($oldValue);
|
||||
$normalizedNew = $this->normalizeValue($newValue);
|
||||
|
||||
if ($normalizedOld === $normalizedNew) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diff[$field] = [
|
||||
'from' => $normalizedOld,
|
||||
'to' => $normalizedNew,
|
||||
];
|
||||
}
|
||||
|
||||
return $diff;
|
||||
}
|
||||
|
||||
private function snapshotMachine(Machine $machine): array
|
||||
{
|
||||
return [
|
||||
'id' => $machine->getId(),
|
||||
'name' => $machine->getName(),
|
||||
'reference' => $machine->getReference(),
|
||||
'prix' => $machine->getPrix(),
|
||||
'site' => $this->normalizeValue($machine->getSite()),
|
||||
'constructeurIds' => $this->normalizeCollection($machine->getConstructeurs()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<mixed> $items
|
||||
*
|
||||
* @return list<array{id: string, name: string}|string>
|
||||
*/
|
||||
private function normalizeCollection(iterable $items): array
|
||||
{
|
||||
$entries = [];
|
||||
$seen = [];
|
||||
foreach ($items as $item) {
|
||||
if (is_object($item) && method_exists($item, 'getId')) {
|
||||
$id = $item->getId();
|
||||
if (null === $id || '' === $id || isset($seen[(string) $id])) {
|
||||
continue;
|
||||
}
|
||||
$seen[(string) $id] = true;
|
||||
if (method_exists($item, 'getName')) {
|
||||
$entries[] = ['id' => (string) $id, 'name' => (string) $item->getName()];
|
||||
} else {
|
||||
$entries[] = (string) $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
private function normalizeValue(mixed $value): mixed
|
||||
{
|
||||
if (null === $value || is_scalar($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if ($value instanceof DateTimeInterface) {
|
||||
return $value->format(DateTimeInterface::ATOM);
|
||||
}
|
||||
|
||||
if ($value instanceof Site) {
|
||||
return [
|
||||
'id' => $value->getId(),
|
||||
'name' => $value->getName(),
|
||||
];
|
||||
}
|
||||
|
||||
if ($value instanceof ModelType) {
|
||||
return [
|
||||
'id' => $value->getId(),
|
||||
'name' => $value->getName(),
|
||||
'code' => $value->getCode(),
|
||||
];
|
||||
}
|
||||
|
||||
if ($value instanceof Product) {
|
||||
return [
|
||||
'id' => $value->getId(),
|
||||
'name' => $value->getName(),
|
||||
'reference' => $value->getReference(),
|
||||
];
|
||||
}
|
||||
|
||||
if ($value instanceof Collection) {
|
||||
return $this->normalizeCollection($value);
|
||||
}
|
||||
|
||||
if (is_object($value) && method_exists($value, 'getId')) {
|
||||
return (string) $value->getId();
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{from:mixed, to:mixed}> $base
|
||||
* @param array<string, array{from:mixed, to:mixed}> $extra
|
||||
*
|
||||
* @return array<string, array{from:mixed, to:mixed}>
|
||||
*/
|
||||
private function mergeDiffs(array $base, array $extra): array
|
||||
{
|
||||
foreach ($extra as $field => $change) {
|
||||
$base[$field] = $change;
|
||||
}
|
||||
|
||||
return $base;
|
||||
}
|
||||
|
||||
private function resolveActorProfileId(): ?string
|
||||
{
|
||||
try {
|
||||
$session = $this->requestStack->getSession();
|
||||
if ($session instanceof SessionInterface) {
|
||||
$profileId = $session->get('profileId');
|
||||
if ($profileId) {
|
||||
return (string) $profileId;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// No session available (CLI context, etc.)
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if ($user instanceof Profile) {
|
||||
return $user->getId();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
180
src/EventSubscriber/ModelTypeAuditSubscriber.php
Normal file
180
src/EventSubscriber/ModelTypeAuditSubscriber.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Entity\AuditLog;
|
||||
use App\Entity\ModelType;
|
||||
use App\Entity\Profile;
|
||||
use App\Enum\ModelCategory;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||
use Doctrine\Common\EventSubscriber;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Event\OnFlushEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
use Throwable;
|
||||
|
||||
use function is_array;
|
||||
use function is_scalar;
|
||||
|
||||
#[AsDoctrineListener(event: Events::onFlush)]
|
||||
final class ModelTypeAuditSubscriber implements EventSubscriber
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
Events::onFlush,
|
||||
];
|
||||
}
|
||||
|
||||
public function onFlush(OnFlushEventArgs $args): void
|
||||
{
|
||||
$em = $args->getObjectManager();
|
||||
if (!$em instanceof EntityManagerInterface) {
|
||||
return;
|
||||
}
|
||||
|
||||
$uow = $em->getUnitOfWork();
|
||||
$actorProfileId = $this->resolveActorProfileId();
|
||||
|
||||
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
||||
if (!$entity instanceof ModelType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
||||
$snapshot = $this->snapshotModelType($entity);
|
||||
$this->persistAuditLog($em, new AuditLog('model_type', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
||||
if (!$entity instanceof ModelType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = (string) $entity->getId();
|
||||
if ('' === $id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
||||
if ([] !== $diff) {
|
||||
$snapshot = $this->snapshotModelType($entity);
|
||||
$this->persistAuditLog($em, new AuditLog('model_type', $id, 'update', $diff, $snapshot, $actorProfileId));
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityDeletions() as $entity) {
|
||||
if (!$entity instanceof ModelType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$snapshot = $this->snapshotModelType($entity);
|
||||
$this->persistAuditLog($em, new AuditLog('model_type', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
|
||||
}
|
||||
}
|
||||
|
||||
private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
|
||||
{
|
||||
$uow = $em->getUnitOfWork();
|
||||
$log->initializeAuditLog();
|
||||
$em->persist($log);
|
||||
|
||||
$meta = $em->getClassMetadata(AuditLog::class);
|
||||
$uow->computeChangeSet($meta, $log);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{0:mixed, 1:mixed}> $changeSet
|
||||
*
|
||||
* @return array<string, array{from:mixed, to:mixed}>
|
||||
*/
|
||||
private function buildDiffFromChangeSet(array $changeSet): array
|
||||
{
|
||||
$diff = [];
|
||||
foreach ($changeSet as $field => [$oldValue, $newValue]) {
|
||||
if ('updatedAt' === $field || 'createdAt' === $field) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedOld = $this->normalizeValue($oldValue);
|
||||
$normalizedNew = $this->normalizeValue($newValue);
|
||||
|
||||
if ($normalizedOld === $normalizedNew) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diff[$field] = [
|
||||
'from' => $normalizedOld,
|
||||
'to' => $normalizedNew,
|
||||
];
|
||||
}
|
||||
|
||||
return $diff;
|
||||
}
|
||||
|
||||
private function snapshotModelType(ModelType $modelType): array
|
||||
{
|
||||
return [
|
||||
'id' => $modelType->getId(),
|
||||
'name' => $modelType->getName(),
|
||||
'code' => $modelType->getCode(),
|
||||
'category' => $modelType->getCategory()->value,
|
||||
'notes' => $modelType->getNotes(),
|
||||
'description' => $modelType->getDescription(),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeValue(mixed $value): mixed
|
||||
{
|
||||
if (null === $value || is_scalar($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if ($value instanceof DateTimeInterface) {
|
||||
return $value->format(DateTimeInterface::ATOM);
|
||||
}
|
||||
|
||||
if ($value instanceof ModelCategory) {
|
||||
return $value->value;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
private function resolveActorProfileId(): ?string
|
||||
{
|
||||
try {
|
||||
$session = $this->requestStack->getSession();
|
||||
if ($session instanceof SessionInterface) {
|
||||
$profileId = $session->get('profileId');
|
||||
if ($profileId) {
|
||||
return (string) $profileId;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// No session available (CLI context, etc.)
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if ($user instanceof Profile) {
|
||||
return $user->getId();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
397
src/EventSubscriber/PieceAuditSubscriber.php
Normal file
397
src/EventSubscriber/PieceAuditSubscriber.php
Normal file
@@ -0,0 +1,397 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Entity\AuditLog;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\ModelType;
|
||||
use App\Entity\Piece;
|
||||
use App\Entity\Product;
|
||||
use App\Entity\Profile;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\Common\EventSubscriber;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Event\OnFlushEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
use Doctrine\ORM\PersistentCollection;
|
||||
use Doctrine\ORM\UnitOfWork;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
use Throwable;
|
||||
|
||||
use function is_array;
|
||||
use function is_object;
|
||||
use function is_scalar;
|
||||
use function method_exists;
|
||||
|
||||
#[AsDoctrineListener(event: Events::onFlush)]
|
||||
final class PieceAuditSubscriber implements EventSubscriber
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
Events::onFlush,
|
||||
];
|
||||
}
|
||||
|
||||
public function onFlush(OnFlushEventArgs $args): void
|
||||
{
|
||||
$em = $args->getObjectManager();
|
||||
if (!$em instanceof EntityManagerInterface) {
|
||||
return;
|
||||
}
|
||||
|
||||
$uow = $em->getUnitOfWork();
|
||||
$actorProfileId = $this->resolveActorProfileId();
|
||||
$pendingUpdates = [];
|
||||
$pendingSnapshots = [];
|
||||
$pendingPieces = [];
|
||||
|
||||
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
||||
if (!$entity instanceof Piece) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
||||
$snapshot = $this->snapshotPiece($entity);
|
||||
$this->persistAuditLog($em, new AuditLog('piece', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
||||
if (!$entity instanceof Piece) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pieceId = (string) $entity->getId();
|
||||
if ('' === $pieceId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
||||
if ([] !== $diff) {
|
||||
$pendingUpdates[$pieceId] = $this->mergeDiffs($pendingUpdates[$pieceId] ?? [], $diff);
|
||||
$pendingSnapshots[$pieceId] = $this->snapshotPiece($entity);
|
||||
$pendingPieces[$pieceId] = $entity;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityDeletions() as $entity) {
|
||||
if (!$entity instanceof Piece) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$snapshot = $this->snapshotPiece($entity);
|
||||
$this->persistAuditLog($em, new AuditLog('piece', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
|
||||
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingPieces);
|
||||
}
|
||||
foreach ($uow->getScheduledCollectionDeletions() as $collection) {
|
||||
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingPieces);
|
||||
}
|
||||
|
||||
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingPieces);
|
||||
|
||||
foreach ($pendingUpdates as $pieceId => $diff) {
|
||||
if ([] === $diff) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$piece = $pendingPieces[$pieceId] ?? null;
|
||||
if (!$piece instanceof Piece) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$snapshot = $pendingSnapshots[$pieceId] ?? $this->snapshotPiece($piece);
|
||||
$this->persistAuditLog($em, new AuditLog('piece', $pieceId, 'update', $diff, $snapshot, $actorProfileId));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
|
||||
* @param array<string, array<string, mixed>> $pendingSnapshots
|
||||
* @param array<string, Piece> $pendingPieces
|
||||
*/
|
||||
private function collectCollectionUpdate(
|
||||
object $collection,
|
||||
array &$pendingUpdates,
|
||||
array &$pendingSnapshots,
|
||||
array &$pendingPieces,
|
||||
): void {
|
||||
if (!$collection instanceof PersistentCollection) {
|
||||
return;
|
||||
}
|
||||
|
||||
$owner = $collection->getOwner();
|
||||
if (!$owner instanceof Piece) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pieceId = (string) $owner->getId();
|
||||
if ('' === $pieceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$mapping = $collection->getMapping();
|
||||
$fieldName = $mapping['fieldName'] ?? null;
|
||||
if ('constructeurs' !== $fieldName) {
|
||||
return;
|
||||
}
|
||||
|
||||
$before = $this->normalizeCollection($collection->getSnapshot());
|
||||
$after = $this->normalizeCollection($collection->toArray());
|
||||
|
||||
if ($before === $after) {
|
||||
return;
|
||||
}
|
||||
|
||||
$diff = [
|
||||
'constructeurIds' => [
|
||||
'from' => $before,
|
||||
'to' => $after,
|
||||
],
|
||||
];
|
||||
|
||||
$pendingUpdates[$pieceId] = $this->mergeDiffs($pendingUpdates[$pieceId] ?? [], $diff);
|
||||
$pendingSnapshots[$pieceId] = $this->snapshotPiece($owner);
|
||||
$pendingPieces[$pieceId] = $owner;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
|
||||
* @param array<string, array<string, mixed>> $pendingSnapshots
|
||||
* @param array<string, Piece> $pendingPieces
|
||||
*/
|
||||
private function collectCustomFieldValueChanges(
|
||||
UnitOfWork $uow,
|
||||
array &$pendingUpdates,
|
||||
array &$pendingSnapshots,
|
||||
array &$pendingPieces,
|
||||
): void {
|
||||
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
||||
if ($entity instanceof CustomFieldValue) {
|
||||
$this->trackCustomFieldValueChange($entity, null, $entity->getValue(), $pendingUpdates, $pendingSnapshots, $pendingPieces);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
||||
if (!$entity instanceof CustomFieldValue) {
|
||||
continue;
|
||||
}
|
||||
$changeSet = $uow->getEntityChangeSet($entity);
|
||||
if (!isset($changeSet['value'])) {
|
||||
continue;
|
||||
}
|
||||
[$oldVal, $newVal] = $changeSet['value'];
|
||||
if ($oldVal !== $newVal) {
|
||||
$this->trackCustomFieldValueChange($entity, $oldVal, $newVal, $pendingUpdates, $pendingSnapshots, $pendingPieces);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityDeletions() as $entity) {
|
||||
if ($entity instanceof CustomFieldValue) {
|
||||
$this->trackCustomFieldValueChange($entity, $entity->getValue(), null, $pendingUpdates, $pendingSnapshots, $pendingPieces);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
|
||||
* @param array<string, array<string, mixed>> $pendingSnapshots
|
||||
* @param array<string, Piece> $pendingPieces
|
||||
*/
|
||||
private function trackCustomFieldValueChange(
|
||||
CustomFieldValue $cfv,
|
||||
mixed $from,
|
||||
mixed $to,
|
||||
array &$pendingUpdates,
|
||||
array &$pendingSnapshots,
|
||||
array &$pendingPieces,
|
||||
): void {
|
||||
$owner = $cfv->getPiece();
|
||||
if (!$owner instanceof Piece) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ownerId = (string) $owner->getId();
|
||||
if ('' === $ownerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$fieldName = 'customField:'.$cfv->getCustomField()->getName();
|
||||
$diff = [$fieldName => ['from' => $from, 'to' => $to]];
|
||||
|
||||
$pendingUpdates[$ownerId] = $this->mergeDiffs($pendingUpdates[$ownerId] ?? [], $diff);
|
||||
$pendingSnapshots[$ownerId] = $this->snapshotPiece($owner);
|
||||
$pendingPieces[$ownerId] = $owner;
|
||||
}
|
||||
|
||||
private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
|
||||
{
|
||||
$uow = $em->getUnitOfWork();
|
||||
$log->initializeAuditLog();
|
||||
$em->persist($log);
|
||||
|
||||
$meta = $em->getClassMetadata(AuditLog::class);
|
||||
$uow->computeChangeSet($meta, $log);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{0:mixed, 1:mixed}> $changeSet
|
||||
*
|
||||
* @return array<string, array{from:mixed, to:mixed}>
|
||||
*/
|
||||
private function buildDiffFromChangeSet(array $changeSet): array
|
||||
{
|
||||
$diff = [];
|
||||
foreach ($changeSet as $field => [$oldValue, $newValue]) {
|
||||
if ('updatedAt' === $field || 'createdAt' === $field) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedOld = $this->normalizeValue($oldValue);
|
||||
$normalizedNew = $this->normalizeValue($newValue);
|
||||
|
||||
if ($normalizedOld === $normalizedNew) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diff[$field] = [
|
||||
'from' => $normalizedOld,
|
||||
'to' => $normalizedNew,
|
||||
];
|
||||
}
|
||||
|
||||
return $diff;
|
||||
}
|
||||
|
||||
private function snapshotPiece(Piece $piece): array
|
||||
{
|
||||
return [
|
||||
'id' => $piece->getId(),
|
||||
'name' => $piece->getName(),
|
||||
'reference' => $piece->getReference(),
|
||||
'prix' => $piece->getPrix(),
|
||||
'typePiece' => $this->normalizeValue($piece->getTypePiece()),
|
||||
'product' => $this->normalizeValue($piece->getProduct()),
|
||||
'productIds' => $piece->getProductIds(),
|
||||
'constructeurIds' => $this->normalizeCollection($piece->getConstructeurs()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<mixed> $items
|
||||
*
|
||||
* @return list<array{id: string, name: string}|string>
|
||||
*/
|
||||
private function normalizeCollection(iterable $items): array
|
||||
{
|
||||
$entries = [];
|
||||
$seen = [];
|
||||
foreach ($items as $item) {
|
||||
if (is_object($item) && method_exists($item, 'getId')) {
|
||||
$id = $item->getId();
|
||||
if (null === $id || '' === $id || isset($seen[(string) $id])) {
|
||||
continue;
|
||||
}
|
||||
$seen[(string) $id] = true;
|
||||
if (method_exists($item, 'getName')) {
|
||||
$entries[] = ['id' => (string) $id, 'name' => (string) $item->getName()];
|
||||
} else {
|
||||
$entries[] = (string) $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
private function normalizeValue(mixed $value): mixed
|
||||
{
|
||||
if (null === $value || is_scalar($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if ($value instanceof DateTimeInterface) {
|
||||
return $value->format(DateTimeInterface::ATOM);
|
||||
}
|
||||
|
||||
if ($value instanceof ModelType) {
|
||||
return [
|
||||
'id' => $value->getId(),
|
||||
'name' => $value->getName(),
|
||||
'code' => $value->getCode(),
|
||||
];
|
||||
}
|
||||
|
||||
if ($value instanceof Product) {
|
||||
return [
|
||||
'id' => $value->getId(),
|
||||
'name' => $value->getName(),
|
||||
'reference' => $value->getReference(),
|
||||
];
|
||||
}
|
||||
|
||||
if ($value instanceof Collection) {
|
||||
return $this->normalizeCollection($value);
|
||||
}
|
||||
|
||||
if (is_object($value) && method_exists($value, 'getId')) {
|
||||
return (string) $value->getId();
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{from:mixed, to:mixed}> $base
|
||||
* @param array<string, array{from:mixed, to:mixed}> $extra
|
||||
*
|
||||
* @return array<string, array{from:mixed, to:mixed}>
|
||||
*/
|
||||
private function mergeDiffs(array $base, array $extra): array
|
||||
{
|
||||
foreach ($extra as $field => $change) {
|
||||
$base[$field] = $change;
|
||||
}
|
||||
|
||||
return $base;
|
||||
}
|
||||
|
||||
private function resolveActorProfileId(): ?string
|
||||
{
|
||||
try {
|
||||
$session = $this->requestStack->getSession();
|
||||
if ($session instanceof SessionInterface) {
|
||||
$profileId = $session->get('profileId');
|
||||
if ($profileId) {
|
||||
return (string) $profileId;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// No session available (CLI context, etc.)
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if ($user instanceof Profile) {
|
||||
return $user->getId();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
396
src/EventSubscriber/ProductAuditSubscriber.php
Normal file
396
src/EventSubscriber/ProductAuditSubscriber.php
Normal file
@@ -0,0 +1,396 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Entity\AuditLog;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\ModelType;
|
||||
use App\Entity\Product;
|
||||
use App\Entity\Profile;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\Common\EventSubscriber;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Event\OnFlushEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
use Doctrine\ORM\PersistentCollection;
|
||||
use Doctrine\ORM\UnitOfWork;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
use Throwable;
|
||||
|
||||
use function is_array;
|
||||
use function is_object;
|
||||
use function is_scalar;
|
||||
use function method_exists;
|
||||
|
||||
/**
|
||||
* Record a lightweight, per-product audit trail.
|
||||
*
|
||||
* This MVP focuses on Product updates and captures:
|
||||
* - scalar field changes (from Doctrine change sets)
|
||||
* - constructeur collection changes (from collection updates)
|
||||
*/
|
||||
#[AsDoctrineListener(event: Events::onFlush)]
|
||||
final class ProductAuditSubscriber implements EventSubscriber
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
Events::onFlush,
|
||||
];
|
||||
}
|
||||
|
||||
public function onFlush(OnFlushEventArgs $args): void
|
||||
{
|
||||
$em = $args->getObjectManager();
|
||||
if (!$em instanceof EntityManagerInterface) {
|
||||
return;
|
||||
}
|
||||
|
||||
$uow = $em->getUnitOfWork();
|
||||
$actorProfileId = $this->resolveActorProfileId();
|
||||
$pendingUpdates = [];
|
||||
$pendingSnapshots = [];
|
||||
$pendingProducts = [];
|
||||
|
||||
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
||||
if (!$entity instanceof Product) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
||||
$snapshot = $this->snapshotProduct($entity);
|
||||
$this->persistAuditLog($em, new AuditLog('product', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
||||
if (!$entity instanceof Product) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$productId = (string) $entity->getId();
|
||||
if ('' === $productId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
||||
if ([] !== $diff) {
|
||||
$pendingUpdates[$productId] = $this->mergeDiffs($pendingUpdates[$productId] ?? [], $diff);
|
||||
$pendingSnapshots[$productId] = $this->snapshotProduct($entity);
|
||||
$pendingProducts[$productId] = $entity;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityDeletions() as $entity) {
|
||||
if (!$entity instanceof Product) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$snapshot = $this->snapshotProduct($entity);
|
||||
$this->persistAuditLog($em, new AuditLog('product', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
|
||||
}
|
||||
|
||||
// Capture constructeur collection updates, which are not included in the change set.
|
||||
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
|
||||
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingProducts);
|
||||
}
|
||||
foreach ($uow->getScheduledCollectionDeletions() as $collection) {
|
||||
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingProducts);
|
||||
}
|
||||
|
||||
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingProducts);
|
||||
|
||||
foreach ($pendingUpdates as $productId => $diff) {
|
||||
if ([] === $diff) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$product = $pendingProducts[$productId] ?? null;
|
||||
if (!$product instanceof Product) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$snapshot = $pendingSnapshots[$productId] ?? $this->snapshotProduct($product);
|
||||
$this->persistAuditLog($em, new AuditLog('product', $productId, 'update', $diff, $snapshot, $actorProfileId));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
|
||||
* @param array<string, array<string, mixed>> $pendingSnapshots
|
||||
* @param array<string, Product> $pendingProducts
|
||||
*/
|
||||
private function collectCollectionUpdate(
|
||||
object $collection,
|
||||
array &$pendingUpdates,
|
||||
array &$pendingSnapshots,
|
||||
array &$pendingProducts,
|
||||
): void {
|
||||
if (!$collection instanceof PersistentCollection) {
|
||||
return;
|
||||
}
|
||||
|
||||
$owner = $collection->getOwner();
|
||||
if (!$owner instanceof Product) {
|
||||
return;
|
||||
}
|
||||
|
||||
$productId = (string) $owner->getId();
|
||||
if ('' === $productId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$mapping = $collection->getMapping();
|
||||
$fieldName = $mapping['fieldName'] ?? null;
|
||||
if ('constructeurs' !== $fieldName) {
|
||||
return;
|
||||
}
|
||||
|
||||
$before = $this->normalizeCollection($collection->getSnapshot());
|
||||
$after = $this->normalizeCollection($collection->toArray());
|
||||
|
||||
if ($before === $after) {
|
||||
return;
|
||||
}
|
||||
|
||||
$diff = [
|
||||
'constructeurIds' => [
|
||||
'from' => $before,
|
||||
'to' => $after,
|
||||
],
|
||||
];
|
||||
|
||||
$pendingUpdates[$productId] = $this->mergeDiffs($pendingUpdates[$productId] ?? [], $diff);
|
||||
$pendingSnapshots[$productId] = $this->snapshotProduct($owner);
|
||||
$pendingProducts[$productId] = $owner;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
|
||||
* @param array<string, array<string, mixed>> $pendingSnapshots
|
||||
* @param array<string, Product> $pendingProducts
|
||||
*/
|
||||
private function collectCustomFieldValueChanges(
|
||||
UnitOfWork $uow,
|
||||
array &$pendingUpdates,
|
||||
array &$pendingSnapshots,
|
||||
array &$pendingProducts,
|
||||
): void {
|
||||
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
||||
if ($entity instanceof CustomFieldValue) {
|
||||
$this->trackCustomFieldValueChange($entity, null, $entity->getValue(), $pendingUpdates, $pendingSnapshots, $pendingProducts);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
||||
if (!$entity instanceof CustomFieldValue) {
|
||||
continue;
|
||||
}
|
||||
$changeSet = $uow->getEntityChangeSet($entity);
|
||||
if (!isset($changeSet['value'])) {
|
||||
continue;
|
||||
}
|
||||
[$oldVal, $newVal] = $changeSet['value'];
|
||||
if ($oldVal !== $newVal) {
|
||||
$this->trackCustomFieldValueChange($entity, $oldVal, $newVal, $pendingUpdates, $pendingSnapshots, $pendingProducts);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityDeletions() as $entity) {
|
||||
if ($entity instanceof CustomFieldValue) {
|
||||
$this->trackCustomFieldValueChange($entity, $entity->getValue(), null, $pendingUpdates, $pendingSnapshots, $pendingProducts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
|
||||
* @param array<string, array<string, mixed>> $pendingSnapshots
|
||||
* @param array<string, Product> $pendingProducts
|
||||
*/
|
||||
private function trackCustomFieldValueChange(
|
||||
CustomFieldValue $cfv,
|
||||
mixed $from,
|
||||
mixed $to,
|
||||
array &$pendingUpdates,
|
||||
array &$pendingSnapshots,
|
||||
array &$pendingProducts,
|
||||
): void {
|
||||
$owner = $cfv->getProduct();
|
||||
if (!$owner instanceof Product) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ownerId = (string) $owner->getId();
|
||||
if ('' === $ownerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$fieldName = 'customField:'.$cfv->getCustomField()->getName();
|
||||
$diff = [$fieldName => ['from' => $from, 'to' => $to]];
|
||||
|
||||
$pendingUpdates[$ownerId] = $this->mergeDiffs($pendingUpdates[$ownerId] ?? [], $diff);
|
||||
$pendingSnapshots[$ownerId] = $this->snapshotProduct($owner);
|
||||
$pendingProducts[$ownerId] = $owner;
|
||||
}
|
||||
|
||||
private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
|
||||
{
|
||||
$uow = $em->getUnitOfWork();
|
||||
// Ensure identifiers and timestamps are set even when persisting during onFlush.
|
||||
$log->initializeAuditLog();
|
||||
$em->persist($log);
|
||||
|
||||
$meta = $em->getClassMetadata(AuditLog::class);
|
||||
$uow->computeChangeSet($meta, $log);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{0:mixed, 1:mixed}> $changeSet
|
||||
*
|
||||
* @return array<string, array{from:mixed, to:mixed}>
|
||||
*/
|
||||
private function buildDiffFromChangeSet(array $changeSet): array
|
||||
{
|
||||
$diff = [];
|
||||
foreach ($changeSet as $field => [$oldValue, $newValue]) {
|
||||
// Skip noisy timestamps managed automatically.
|
||||
if ('updatedAt' === $field || 'createdAt' === $field) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedOld = $this->normalizeValue($oldValue);
|
||||
$normalizedNew = $this->normalizeValue($newValue);
|
||||
|
||||
if ($normalizedOld === $normalizedNew) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diff[$field] = [
|
||||
'from' => $normalizedOld,
|
||||
'to' => $normalizedNew,
|
||||
];
|
||||
}
|
||||
|
||||
return $diff;
|
||||
}
|
||||
|
||||
private function snapshotProduct(Product $product): array
|
||||
{
|
||||
return [
|
||||
'id' => $product->getId(),
|
||||
'name' => $product->getName(),
|
||||
'reference' => $product->getReference(),
|
||||
'supplierPrice' => $product->getSupplierPrice(),
|
||||
'typeProduct' => $this->normalizeValue($product->getTypeProduct()),
|
||||
'constructeurIds' => $this->normalizeCollection($product->getConstructeurs()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{from:mixed, to:mixed}> $base
|
||||
* @param array<string, array{from:mixed, to:mixed}> $extra
|
||||
*
|
||||
* @return array<string, array{from:mixed, to:mixed}>
|
||||
*/
|
||||
private function mergeDiffs(array $base, array $extra): array
|
||||
{
|
||||
foreach ($extra as $field => $change) {
|
||||
$base[$field] = $change;
|
||||
}
|
||||
|
||||
return $base;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<mixed> $items
|
||||
*
|
||||
* @return list<array{id: string, name: string}|string>
|
||||
*/
|
||||
private function normalizeCollection(iterable $items): array
|
||||
{
|
||||
$entries = [];
|
||||
$seen = [];
|
||||
foreach ($items as $item) {
|
||||
if (is_object($item) && method_exists($item, 'getId')) {
|
||||
$id = $item->getId();
|
||||
if (null === $id || '' === $id || isset($seen[(string) $id])) {
|
||||
continue;
|
||||
}
|
||||
$seen[(string) $id] = true;
|
||||
if (method_exists($item, 'getName')) {
|
||||
$entries[] = ['id' => (string) $id, 'name' => (string) $item->getName()];
|
||||
} else {
|
||||
$entries[] = (string) $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
private function normalizeValue(mixed $value): mixed
|
||||
{
|
||||
if (null === $value || is_scalar($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if ($value instanceof DateTimeInterface) {
|
||||
return $value->format(DateTimeInterface::ATOM);
|
||||
}
|
||||
|
||||
if ($value instanceof ModelType) {
|
||||
return [
|
||||
'id' => $value->getId(),
|
||||
'name' => $value->getName(),
|
||||
'code' => $value->getCode(),
|
||||
];
|
||||
}
|
||||
|
||||
if ($value instanceof Collection) {
|
||||
return $this->normalizeCollection($value);
|
||||
}
|
||||
|
||||
if (is_object($value) && method_exists($value, 'getId')) {
|
||||
return (string) $value->getId();
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
private function resolveActorProfileId(): ?string
|
||||
{
|
||||
try {
|
||||
$session = $this->requestStack->getSession();
|
||||
if ($session instanceof SessionInterface) {
|
||||
$profileId = $session->get('profileId');
|
||||
if ($profileId) {
|
||||
return (string) $profileId;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// No session available (CLI context, etc.)
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if ($user instanceof Profile) {
|
||||
return $user->getId();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
76
src/Repository/AuditLogRepository.php
Normal file
76
src/Repository/AuditLogRepository.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\AuditLog;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<AuditLog>
|
||||
*/
|
||||
final class AuditLogRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, AuditLog::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<AuditLog>
|
||||
*/
|
||||
public function findEntityHistory(string $entityType, string $entityId, int $limit = 100): array
|
||||
{
|
||||
return $this->createQueryBuilder('a')
|
||||
->andWhere('a.entityType = :entityType')
|
||||
->andWhere('a.entityId = :entityId')
|
||||
->setParameter('entityType', $entityType)
|
||||
->setParameter('entityId', $entityId)
|
||||
->orderBy('a.createdAt', 'DESC')
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{entityType?: string, action?: string} $filters
|
||||
*
|
||||
* @return array{items: list<AuditLog>, total: int}
|
||||
*/
|
||||
public function findAllPaginated(int $page = 1, int $itemsPerPage = 30, array $filters = []): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('a')
|
||||
->orderBy('a.createdAt', 'DESC')
|
||||
;
|
||||
|
||||
if (!empty($filters['entityType'])) {
|
||||
$qb->andWhere('a.entityType = :entityType')
|
||||
->setParameter('entityType', $filters['entityType'])
|
||||
;
|
||||
}
|
||||
|
||||
if (!empty($filters['action'])) {
|
||||
$qb->andWhere('a.action = :action')
|
||||
->setParameter('action', $filters['action'])
|
||||
;
|
||||
}
|
||||
|
||||
$countQb = clone $qb;
|
||||
$countQb->select('COUNT(a.id)')
|
||||
->resetDQLPart('orderBy')
|
||||
;
|
||||
$total = (int) $countQb->getQuery()->getSingleScalarResult();
|
||||
|
||||
$qb->setFirstResult(($page - 1) * $itemsPerPage)
|
||||
->setMaxResults($itemsPerPage)
|
||||
;
|
||||
|
||||
return [
|
||||
'items' => $qb->getQuery()->getResult(),
|
||||
'total' => $total,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\TypeMachineComponentRequirement;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<TypeMachineComponentRequirement>
|
||||
*/
|
||||
class TypeMachineComponentRequirementRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, TypeMachineComponentRequirement::class);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\TypeMachinePieceRequirement;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<TypeMachinePieceRequirement>
|
||||
*/
|
||||
class TypeMachinePieceRequirementRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, TypeMachinePieceRequirement::class);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\TypeMachineProductRequirement;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<TypeMachineProductRequirement>
|
||||
*/
|
||||
class TypeMachineProductRequirementRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, TypeMachineProductRequirement::class);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\TypeMachine;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<TypeMachine>
|
||||
*/
|
||||
class TypeMachineRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, TypeMachine::class);
|
||||
}
|
||||
|
||||
public function save(TypeMachine $entity, bool $flush = false): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
public function remove(TypeMachine $entity, bool $flush = false): void
|
||||
{
|
||||
$this->getEntityManager()->remove($entity);
|
||||
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
65
src/Security/SessionProfileAuthenticator.php
Normal file
65
src/Security/SessionProfileAuthenticator.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
use App\Entity\Profile;
|
||||
use App\Repository\ProfileRepository;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
|
||||
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
|
||||
|
||||
final class SessionProfileAuthenticator extends AbstractAuthenticator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProfileRepository $profiles,
|
||||
) {}
|
||||
|
||||
public function supports(Request $request): ?bool
|
||||
{
|
||||
if (!$request->hasSession()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $request->getSession()->has('profileId');
|
||||
}
|
||||
|
||||
public function authenticate(Request $request): Passport
|
||||
{
|
||||
$profileId = $request->getSession()->get('profileId');
|
||||
|
||||
return new SelfValidatingPassport(
|
||||
new UserBadge($profileId, function (string $id): Profile {
|
||||
$profile = $this->profiles->find($id);
|
||||
|
||||
if (!$profile || !$profile->isActive()) {
|
||||
throw new CustomUserMessageAuthenticationException('Profil introuvable ou inactif.');
|
||||
}
|
||||
|
||||
return $profile;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||
{
|
||||
// Let the request continue normally
|
||||
return null;
|
||||
}
|
||||
|
||||
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
|
||||
{
|
||||
return new JsonResponse(
|
||||
['message' => $exception->getMessageKey()],
|
||||
JsonResponse::HTTP_UNAUTHORIZED,
|
||||
);
|
||||
}
|
||||
}
|
||||
61
src/Serializer/DocumentNormalizer.php
Normal file
61
src/Serializer/DocumentNormalizer.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Serializer;
|
||||
|
||||
use App\Entity\Document;
|
||||
use ArrayObject;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||
|
||||
use function is_array;
|
||||
|
||||
class DocumentNormalizer implements NormalizerInterface, NormalizerAwareInterface
|
||||
{
|
||||
use NormalizerAwareTrait;
|
||||
|
||||
private const ALREADY_CALLED = 'DOCUMENT_NORMALIZER_ALREADY_CALLED';
|
||||
|
||||
/**
|
||||
* @return null|array<string, mixed>|ArrayObject<int|string, mixed>|bool|float|int|string
|
||||
*/
|
||||
public function normalize(mixed $data, ?string $format = null, array $context = []): array|ArrayObject|bool|float|int|string|null
|
||||
{
|
||||
$context[self::ALREADY_CALLED] = true;
|
||||
|
||||
/** @var null|array<string, mixed> $normalized */
|
||||
$normalized = $this->normalizer->normalize($data, $format, $context);
|
||||
|
||||
if (is_array($normalized) && $data instanceof Document && $data->getId()) {
|
||||
// Remove raw 'path' if present (never expose it to the client)
|
||||
unset($normalized['path']);
|
||||
|
||||
// Always provide URL-based access
|
||||
$normalized['fileUrl'] = '/api/documents/'.$data->getId().'/file';
|
||||
$normalized['downloadUrl'] = '/api/documents/'.$data->getId().'/download';
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
|
||||
{
|
||||
if (isset($context[self::ALREADY_CALLED])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $data instanceof Document;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<class-string, bool>
|
||||
*/
|
||||
public function getSupportedTypes(?string $format): array
|
||||
{
|
||||
return [
|
||||
Document::class => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
141
src/Service/DocumentStorageService.php
Normal file
141
src/Service/DocumentStorageService.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpKernel\KernelInterface;
|
||||
|
||||
use function dirname;
|
||||
|
||||
class DocumentStorageService
|
||||
{
|
||||
private readonly string $storageDir;
|
||||
|
||||
public function __construct(
|
||||
private readonly KernelInterface $kernel,
|
||||
) {
|
||||
$this->storageDir = $this->kernel->getProjectDir().'/var/storage/documents';
|
||||
}
|
||||
|
||||
public function getStorageDir(): string
|
||||
{
|
||||
return $this->storageDir;
|
||||
}
|
||||
|
||||
public function getAbsolutePath(string $relativePath): string
|
||||
{
|
||||
return $this->storageDir.'/'.$relativePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store binary content and return the relative path.
|
||||
* Path format: {year}/{month}/{documentId}.{ext}.
|
||||
*/
|
||||
public function store(string $content, string $documentId, string $extension): string
|
||||
{
|
||||
$now = new DateTimeImmutable();
|
||||
$subDir = $now->format('Y').'/'.$now->format('m');
|
||||
$relativePath = $subDir.'/'.$documentId.'.'.$extension;
|
||||
$absolutePath = $this->storageDir.'/'.$relativePath;
|
||||
|
||||
$dir = dirname($absolutePath);
|
||||
if (!is_dir($dir)) {
|
||||
if (!mkdir($dir, 0o775, true) && !is_dir($dir)) {
|
||||
throw new RuntimeException(sprintf('Cannot create directory "%s"', $dir));
|
||||
}
|
||||
}
|
||||
|
||||
$bytesWritten = file_put_contents($absolutePath, $content);
|
||||
if (false === $bytesWritten) {
|
||||
throw new RuntimeException(sprintf('Cannot write file "%s"', $absolutePath));
|
||||
}
|
||||
|
||||
return $relativePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a file from a given source path (e.g., temp upload).
|
||||
*/
|
||||
public function storeFromPath(string $sourcePath, string $documentId, string $extension): string
|
||||
{
|
||||
$content = file_get_contents($sourcePath);
|
||||
if (false === $content) {
|
||||
throw new RuntimeException(sprintf('Cannot read source file "%s"', $sourcePath));
|
||||
}
|
||||
|
||||
return $this->store($content, $documentId, $extension);
|
||||
}
|
||||
|
||||
public function read(string $relativePath): string
|
||||
{
|
||||
$absolutePath = $this->getAbsolutePath($relativePath);
|
||||
if (!file_exists($absolutePath)) {
|
||||
throw new RuntimeException(sprintf('File not found: "%s"', $absolutePath));
|
||||
}
|
||||
|
||||
$content = file_get_contents($absolutePath);
|
||||
if (false === $content) {
|
||||
throw new RuntimeException(sprintf('Cannot read file "%s"', $absolutePath));
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
public function delete(string $relativePath): bool
|
||||
{
|
||||
$absolutePath = $this->getAbsolutePath($relativePath);
|
||||
if (!file_exists($absolutePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return @unlink($absolutePath);
|
||||
}
|
||||
|
||||
public function exists(string $relativePath): bool
|
||||
{
|
||||
return file_exists($this->getAbsolutePath($relativePath));
|
||||
}
|
||||
|
||||
public function isBase64DataUri(string $path): bool
|
||||
{
|
||||
return str_starts_with($path, 'data:');
|
||||
}
|
||||
|
||||
public function extensionFromMimeType(string $mimeType): string
|
||||
{
|
||||
$map = [
|
||||
'application/pdf' => 'pdf',
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
'image/webp' => 'webp',
|
||||
'image/svg+xml' => 'svg',
|
||||
'image/bmp' => 'bmp',
|
||||
'text/plain' => 'txt',
|
||||
'text/csv' => 'csv',
|
||||
'application/json' => 'json',
|
||||
'application/xml' => 'xml',
|
||||
'application/zip' => 'zip',
|
||||
'audio/mpeg' => 'mp3',
|
||||
'audio/ogg' => 'ogg',
|
||||
'video/mp4' => 'mp4',
|
||||
'video/webm' => 'webm',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
|
||||
'application/msword' => 'doc',
|
||||
'application/vnd.ms-excel' => 'xls',
|
||||
];
|
||||
|
||||
return $map[$mimeType] ?? 'bin';
|
||||
}
|
||||
|
||||
public function extensionFromFilename(string $filename): string
|
||||
{
|
||||
$ext = pathinfo($filename, PATHINFO_EXTENSION);
|
||||
|
||||
return '' !== $ext ? strtolower($ext) : 'bin';
|
||||
}
|
||||
}
|
||||
474
src/Service/ModelTypeCategoryConversionService.php
Normal file
474
src/Service/ModelTypeCategoryConversionService.php
Normal file
@@ -0,0 +1,474 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Profile;
|
||||
use App\Enum\ModelCategory;
|
||||
use App\Repository\ModelTypeRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
use Throwable;
|
||||
|
||||
final class ModelTypeCategoryConversionService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
private readonly ModelTypeRepository $modelTypes,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{canConvert: bool, direction: null|string, itemCount: int, names: list<string>, blockers: list<string>}
|
||||
*/
|
||||
public function checkConversion(string $modelTypeId): array
|
||||
{
|
||||
$modelType = $this->modelTypes->find($modelTypeId);
|
||||
|
||||
if (!$modelType) {
|
||||
return [
|
||||
'canConvert' => false,
|
||||
'direction' => null,
|
||||
'itemCount' => 0,
|
||||
'names' => [],
|
||||
'blockers' => ['Catégorie introuvable.'],
|
||||
];
|
||||
}
|
||||
|
||||
$category = $modelType->getCategory();
|
||||
|
||||
if (ModelCategory::PRODUCT === $category) {
|
||||
return [
|
||||
'canConvert' => false,
|
||||
'direction' => null,
|
||||
'itemCount' => 0,
|
||||
'names' => [],
|
||||
'blockers' => ['La conversion n\'est pas disponible pour les catégories de produit.'],
|
||||
];
|
||||
}
|
||||
|
||||
if (ModelCategory::PIECE === $category) {
|
||||
return $this->checkPieceToComponent($modelTypeId, $modelType->getName());
|
||||
}
|
||||
|
||||
return $this->checkComponentToPiece($modelTypeId, $modelType->getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{success: bool, convertedCount: int, error: null|string}
|
||||
*/
|
||||
public function convert(string $modelTypeId): array
|
||||
{
|
||||
$check = $this->checkConversion($modelTypeId);
|
||||
|
||||
if (!$check['canConvert']) {
|
||||
return [
|
||||
'success' => false,
|
||||
'convertedCount' => 0,
|
||||
'error' => implode(' ', $check['blockers']),
|
||||
];
|
||||
}
|
||||
|
||||
$modelType = $this->modelTypes->find($modelTypeId);
|
||||
|
||||
if (!$modelType) {
|
||||
return ['success' => false, 'convertedCount' => 0, 'error' => 'Catégorie introuvable.'];
|
||||
}
|
||||
|
||||
$category = $modelType->getCategory();
|
||||
|
||||
$direction = ModelCategory::PIECE === $category ? 'piece_to_component' : 'component_to_piece';
|
||||
$names = $check['names'];
|
||||
$modelName = $modelType->getName();
|
||||
$modelCode = $modelType->getCode();
|
||||
|
||||
$this->connection->beginTransaction();
|
||||
|
||||
try {
|
||||
if (ModelCategory::PIECE === $category) {
|
||||
$count = $this->convertPieceToComponent($modelTypeId);
|
||||
} else {
|
||||
$count = $this->convertComponentToPiece($modelTypeId);
|
||||
}
|
||||
|
||||
$this->logConversionAudit($modelTypeId, $modelName, $modelCode, $direction, $count, $names);
|
||||
|
||||
$this->connection->commit();
|
||||
|
||||
return ['success' => true, 'convertedCount' => $count, 'error' => null];
|
||||
} catch (Throwable $e) {
|
||||
$this->connection->rollBack();
|
||||
|
||||
return ['success' => false, 'convertedCount' => 0, 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{canConvert: bool, direction: string, itemCount: int, names: list<string>, blockers: list<string>}
|
||||
*/
|
||||
private function checkPieceToComponent(string $modelTypeId, string $modelTypeName): array
|
||||
{
|
||||
$blockers = [];
|
||||
|
||||
$pieceCount = (int) $this->connection->fetchOne(
|
||||
'SELECT COUNT(*) FROM pieces WHERE typepieceid = :id',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
$names = $this->connection->fetchFirstColumn(
|
||||
'SELECT name FROM pieces WHERE typepieceid = :id ORDER BY name',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
// Check machine links
|
||||
$machineLinked = (int) $this->connection->fetchOne(
|
||||
'SELECT COUNT(*) FROM machine_piece_links mpl
|
||||
JOIN pieces p ON mpl.pieceid = p.id
|
||||
WHERE p.typepieceid = :id',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
if ($machineLinked > 0) {
|
||||
$blockers[] = sprintf('%d pièce(s) liée(s) à des machines.', $machineLinked);
|
||||
}
|
||||
|
||||
// Check 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 if any composant has pieces or sub-components in structure
|
||||
$withStructure = $this->connection->fetchAllAssociative(
|
||||
'SELECT name, structure FROM composants WHERE typecomposantid = :id AND structure IS NOT NULL',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
foreach ($withStructure as $row) {
|
||||
$structure = json_decode($row['structure'], true);
|
||||
|
||||
if (!is_array($structure)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hasPieces = !empty($structure['pieces']);
|
||||
$hasSubcomponents = !empty($structure['subcomponents']);
|
||||
|
||||
if ($hasPieces || $hasSubcomponents) {
|
||||
$parts = [];
|
||||
|
||||
if ($hasPieces) {
|
||||
$parts[] = 'pièces';
|
||||
}
|
||||
|
||||
if ($hasSubcomponents) {
|
||||
$parts[] = 'sous-composants';
|
||||
}
|
||||
|
||||
$blockers[] = sprintf(
|
||||
'Le composant « %s » contient des %s dans sa structure.',
|
||||
$row['name'],
|
||||
implode(' et ', $parts),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check name collision with existing pieces
|
||||
$collisions = $this->connection->fetchFirstColumn(
|
||||
'SELECT c.name FROM composants c
|
||||
WHERE c.typecomposantid = :id
|
||||
AND c.name IN (SELECT p.name FROM pieces p)',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
if ([] !== $collisions) {
|
||||
$blockers[] = sprintf(
|
||||
'Collision de nom avec des pièces existantes : %s.',
|
||||
implode(', ', $collisions),
|
||||
);
|
||||
}
|
||||
|
||||
// Check ModelType name collision
|
||||
$nameCollision = (int) $this->connection->fetchOne(
|
||||
'SELECT COUNT(*) FROM model_types WHERE category = :cat AND name = :name AND id != :id',
|
||||
['cat' => ModelCategory::PIECE->value, 'name' => $modelTypeName, 'id' => $modelTypeId],
|
||||
);
|
||||
|
||||
if ($nameCollision > 0) {
|
||||
$blockers[] = sprintf('Une catégorie de pièce « %s » existe déjà.', $modelTypeName);
|
||||
}
|
||||
|
||||
return [
|
||||
'canConvert' => [] === $blockers,
|
||||
'direction' => 'component_to_piece',
|
||||
'itemCount' => $composantCount,
|
||||
'names' => $names,
|
||||
'blockers' => $blockers,
|
||||
];
|
||||
}
|
||||
|
||||
private function convertPieceToComponent(string $modelTypeId): int
|
||||
{
|
||||
// 1. Insert into composants from pieces
|
||||
$count = $this->connection->executeStatement(
|
||||
'INSERT INTO composants (id, name, reference, prix, structure, typecomposantid, productid, createdat, updatedat)
|
||||
SELECT id, name, reference, prix, NULL, typepieceid, productid, createdat, updatedat
|
||||
FROM pieces
|
||||
WHERE typepieceid = :id',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
// 2. Transfer constructeur associations
|
||||
$this->connection->executeStatement(
|
||||
'INSERT INTO _composantconstructeurs (a, b)
|
||||
SELECT pc.a, pc.b FROM _piececonstructeurs pc
|
||||
WHERE pc.a IN (SELECT id FROM composants WHERE typecomposantid = :id)',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
$this->connection->executeStatement(
|
||||
'DELETE FROM _piececonstructeurs
|
||||
WHERE a IN (SELECT id FROM pieces WHERE typepieceid = :id)',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
// 3. Transfer document references
|
||||
$this->connection->executeStatement(
|
||||
'UPDATE documents SET composantid = pieceid, pieceid = NULL
|
||||
WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
// 4. Transfer custom_field_values references
|
||||
$this->connection->executeStatement(
|
||||
'UPDATE custom_field_values SET composantid = pieceid, pieceid = NULL
|
||||
WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
// 5. Transfer custom_fields from typePiece to typeComposant
|
||||
$this->connection->executeStatement(
|
||||
'UPDATE custom_fields SET typecomposantid = typepieceid, typepieceid = NULL
|
||||
WHERE typepieceid = :id',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
// 6. Delete original pieces
|
||||
$this->connection->executeStatement(
|
||||
'DELETE FROM pieces WHERE typepieceid = :id',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
// 7. Update ModelType
|
||||
$this->connection->executeStatement(
|
||||
'UPDATE model_types
|
||||
SET category = :cat,
|
||||
componentskeleton = pieceskeleton,
|
||||
pieceskeleton = NULL,
|
||||
updatedat = :now
|
||||
WHERE id = :id',
|
||||
[
|
||||
'cat' => ModelCategory::COMPONENT->value,
|
||||
'now' => new DateTimeImmutable()->format('Y-m-d H:i:s'),
|
||||
'id' => $modelTypeId,
|
||||
],
|
||||
);
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function convertComponentToPiece(string $modelTypeId): int
|
||||
{
|
||||
// 1. Insert into pieces from composants
|
||||
$count = $this->connection->executeStatement(
|
||||
'INSERT INTO pieces (id, name, reference, prix, productids, typepieceid, productid, createdat, updatedat)
|
||||
SELECT id, name, reference, prix, NULL, typecomposantid, productid, createdat, updatedat
|
||||
FROM composants
|
||||
WHERE typecomposantid = :id',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
// 2. Transfer constructeur associations
|
||||
$this->connection->executeStatement(
|
||||
'INSERT INTO _piececonstructeurs (a, b)
|
||||
SELECT cc.a, cc.b FROM _composantconstructeurs cc
|
||||
WHERE cc.a IN (SELECT id FROM pieces WHERE typepieceid = :id)',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
$this->connection->executeStatement(
|
||||
'DELETE FROM _composantconstructeurs
|
||||
WHERE a IN (SELECT id FROM composants WHERE typecomposantid = :id)',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
// 3. Transfer document references
|
||||
$this->connection->executeStatement(
|
||||
'UPDATE documents SET pieceid = composantid, composantid = NULL
|
||||
WHERE composantid IN (SELECT id FROM composants WHERE typecomposantid = :id)',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
// 4. Transfer custom_field_values references
|
||||
$this->connection->executeStatement(
|
||||
'UPDATE custom_field_values SET pieceid = composantid, composantid = NULL
|
||||
WHERE composantid IN (SELECT id FROM composants WHERE typecomposantid = :id)',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
// 5. Transfer custom_fields from typeComposant to typePiece
|
||||
$this->connection->executeStatement(
|
||||
'UPDATE custom_fields SET typepieceid = typecomposantid, typecomposantid = NULL
|
||||
WHERE typecomposantid = :id',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
// 6. Delete original composants
|
||||
$this->connection->executeStatement(
|
||||
'DELETE FROM composants WHERE typecomposantid = :id',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
// 7. Update ModelType
|
||||
$this->connection->executeStatement(
|
||||
'UPDATE model_types
|
||||
SET category = :cat,
|
||||
pieceskeleton = componentskeleton,
|
||||
componentskeleton = NULL,
|
||||
updatedat = :now
|
||||
WHERE id = :id',
|
||||
[
|
||||
'cat' => ModelCategory::PIECE->value,
|
||||
'now' => new DateTimeImmutable()->format('Y-m-d H:i:s'),
|
||||
'id' => $modelTypeId,
|
||||
],
|
||||
);
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $names
|
||||
*/
|
||||
private function logConversionAudit(
|
||||
string $modelTypeId,
|
||||
string $modelName,
|
||||
string $modelCode,
|
||||
string $direction,
|
||||
int $convertedCount,
|
||||
array $names,
|
||||
): void {
|
||||
$now = new DateTimeImmutable()->format('Y-m-d H:i:s');
|
||||
$id = 'cl'.bin2hex(random_bytes(12));
|
||||
|
||||
$snapshot = [
|
||||
'id' => $modelTypeId,
|
||||
'name' => $modelName,
|
||||
'code' => $modelCode,
|
||||
];
|
||||
|
||||
$diff = [
|
||||
'direction' => ['from' => null, 'to' => $direction],
|
||||
'convertedCount' => ['from' => null, 'to' => $convertedCount],
|
||||
'convertedNames' => ['from' => null, 'to' => $names],
|
||||
];
|
||||
|
||||
$this->connection->executeStatement(
|
||||
'INSERT INTO audit_logs (id, entitytype, entityid, action, diff, snapshot, actorprofileid, createdat)
|
||||
VALUES (:id, :entityType, :entityId, :action, :diff, :snapshot, :actor, :now)',
|
||||
[
|
||||
'id' => $id,
|
||||
'entityType' => 'model_type',
|
||||
'entityId' => $modelTypeId,
|
||||
'action' => 'convert',
|
||||
'diff' => json_encode($diff),
|
||||
'snapshot' => json_encode($snapshot),
|
||||
'actor' => $this->resolveActorProfileId(),
|
||||
'now' => $now,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveActorProfileId(): ?string
|
||||
{
|
||||
try {
|
||||
$session = $this->requestStack->getSession();
|
||||
if ($session instanceof SessionInterface) {
|
||||
$profileId = $session->get('profileId');
|
||||
if ($profileId) {
|
||||
return (string) $profileId;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if ($user instanceof Profile) {
|
||||
return $user->getId();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,63 @@ namespace App\Service;
|
||||
|
||||
class PdfCompressorService
|
||||
{
|
||||
/**
|
||||
* Compress an actual PDF file on disk. Returns metadata or null if no gain.
|
||||
*
|
||||
* @return null|array{size: int, originalSize: int, saved: int}
|
||||
*/
|
||||
public function compressFile(string $absolutePath): ?array
|
||||
{
|
||||
exec('which qpdf', $qpdfPath, $returnCode);
|
||||
if (0 !== $returnCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!file_exists($absolutePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$originalSize = filesize($absolutePath);
|
||||
if (false === $originalSize) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tempOutput = tempnam(sys_get_temp_dir(), 'pdf_out_');
|
||||
|
||||
$command = sprintf(
|
||||
'qpdf --linearize --object-streams=generate %s %s 2>&1',
|
||||
escapeshellarg($absolutePath),
|
||||
escapeshellarg($tempOutput)
|
||||
);
|
||||
|
||||
exec($command, $cmdOutput, $returnCode);
|
||||
|
||||
if (0 !== $returnCode || !file_exists($tempOutput)) {
|
||||
@unlink($tempOutput);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$compressedSize = filesize($tempOutput);
|
||||
if (false === $compressedSize || $compressedSize >= $originalSize) {
|
||||
@unlink($tempOutput);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!rename($tempOutput, $absolutePath)) {
|
||||
@unlink($tempOutput);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'size' => $compressedSize,
|
||||
'originalSize' => $originalSize,
|
||||
'saved' => $originalSize - $compressedSize,
|
||||
];
|
||||
}
|
||||
|
||||
public function compressBase64Pdf(string $base64Data): ?array
|
||||
{
|
||||
// Check if qpdf is available
|
||||
|
||||
128
src/State/DocumentUploadProcessor.php
Normal file
128
src/State/DocumentUploadProcessor.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Entity\Document;
|
||||
use App\Service\DocumentStorageService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
final class DocumentUploadProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $decorated,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly DocumentStorageService $storageService,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $uriVariables
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
if (null === $request) {
|
||||
throw new BadRequestHttpException('No request available.');
|
||||
}
|
||||
|
||||
$contentType = $request->headers->get('Content-Type', '');
|
||||
|
||||
// Multipart/form-data → file upload
|
||||
if (str_contains($contentType, 'multipart/form-data')) {
|
||||
return $this->handleMultipartUpload($operation, $uriVariables, $context, $request);
|
||||
}
|
||||
|
||||
// Fallback to default processor for legacy JSON/Base64 requests
|
||||
if (!$data instanceof Document) {
|
||||
return $this->decorated->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
return $this->decorated->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
private function handleMultipartUpload(
|
||||
Operation $operation,
|
||||
array $uriVariables,
|
||||
array $context,
|
||||
Request $request,
|
||||
): Document {
|
||||
/** @var null|UploadedFile $file */
|
||||
$file = $request->files->get('file');
|
||||
if (null === $file || !$file->isValid()) {
|
||||
throw new BadRequestHttpException('A valid file is required in the "file" field.');
|
||||
}
|
||||
|
||||
$document = new Document();
|
||||
|
||||
// Metadata from form fields
|
||||
$name = $request->request->get('name', $file->getClientOriginalName());
|
||||
$filename = $file->getClientOriginalName();
|
||||
$mimeType = $file->getMimeType() ?: $request->request->get('mimeType', 'application/octet-stream');
|
||||
$size = $file->getSize();
|
||||
|
||||
$document->setName($name);
|
||||
$document->setFilename($filename);
|
||||
$document->setMimeType($mimeType);
|
||||
$document->setSize((int) $size);
|
||||
|
||||
// Handle entity relations from form fields
|
||||
$this->setRelationsFromRequest($document, $request);
|
||||
|
||||
// Generate CUID early so we can use it for the filename on disk
|
||||
$documentId = 'cl'.bin2hex(random_bytes(12));
|
||||
$document->setId($documentId);
|
||||
|
||||
// Store file on disk
|
||||
$extension = $this->storageService->extensionFromFilename($filename);
|
||||
$relativePath = $this->storageService->storeFromPath(
|
||||
$file->getPathname(),
|
||||
$documentId,
|
||||
$extension,
|
||||
);
|
||||
$document->setPath($relativePath);
|
||||
|
||||
// Persist via decorated processor (triggers prePersist for timestamps)
|
||||
return $this->decorated->process($document, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
private function setRelationsFromRequest(Document $document, Request $request): void
|
||||
{
|
||||
$relationMap = [
|
||||
'machineId' => 'Machine',
|
||||
'composantId' => 'Composant',
|
||||
'pieceId' => 'Piece',
|
||||
'productId' => 'Product',
|
||||
'siteId' => 'Site',
|
||||
];
|
||||
|
||||
foreach ($relationMap as $field => $entityName) {
|
||||
$value = $request->request->get($field);
|
||||
if (!$value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Accept both raw ID and IRI format
|
||||
$id = $value;
|
||||
if (str_contains($value, '/')) {
|
||||
$parts = explode('/', rtrim($value, '/'));
|
||||
$id = end($parts);
|
||||
}
|
||||
|
||||
$entityClass = 'App\Entity\\'.$entityName;
|
||||
$entity = $this->em->getReference($entityClass, $id);
|
||||
$setter = 'set'.$entityName;
|
||||
$document->{$setter}($entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/State/ProfilePasswordHasher.php
Normal file
36
src/State/ProfilePasswordHasher.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Entity\Profile;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
final class ProfilePasswordHasher implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $decorated,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $uriVariables
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if ($data instanceof Profile && $data->getPlainPassword()) {
|
||||
$data->setPassword(
|
||||
$this->passwordHasher->hashPassword($data, $data->getPlainPassword())
|
||||
);
|
||||
$data->eraseCredentials();
|
||||
}
|
||||
|
||||
return $this->decorated->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user