Compare commits

..

76 Commits

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 17:34:43 +01:00
8d83076be0 chore(release) : v1.2.0 2026-01-29 19:55:29 +01:00
Matthieu
997a3ae822 chore(frontend): update submodule to history UI 2026-01-25 21:22:24 +01:00
Matthieu
034c193e4b feat(audit): add history tracking and bump version to 1.1.2 2026-01-25 21:19:42 +01:00
Matthieu
4acc8d1c01 chore(release) : v1.1.1
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:07:45 +01:00
Matthieu
49ff15f18d fix : correct commit message format in release script
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:07:08 +01:00
Matthieu
7a02617d48 chore : add qpdf to Docker image for PDF compression
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:06:08 +01:00
Matthieu
e52eef0491 docs : add PDF compression documentation to README
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:03:36 +01:00
Matthieu
a5118305d3 feat : automatic PDF compression on upload
- Add PdfCompressorService for lossless compression with qpdf
- Add DocumentPdfCompressorListener for automatic compression on persist/update
- Add app:compress-pdf command for batch compression of existing PDFs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:02:26 +01:00
Matthieu
b51671b1d4 chore(release) : v1.1.0
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 15:58:09 +01:00
Matthieu
1643dcf8c2 fix : case-insensitive search filters for all entities 2026-01-25 15:54:07 +01:00
Matthieu
17ab4cdd16 chore : update fixtures with current database data 2026-01-25 15:44:22 +01:00
Matthieu
d9182131d9 chore : reset migrations to single initial schema + add deployment guide 2026-01-25 15:41:06 +01:00
Matthieu
26a7fe64be chore : update frontend submodule to master v1.0.0 2026-01-25 12:07:15 +01:00
Matthieu
4669dec359 chore : add versioning system and release script 2026-01-25 12:02:20 +01:00
Matthieu
3f05fe753e Update frontend submodule for piece requirements and component selections 2026-01-25 11:40:48 +01:00
Matthieu
a502acd234 Support multiple product requirements on pieces 2026-01-25 11:40:41 +01:00
Matthieu
69b199b6dc Update frontend submodule: overview machines + lighter PDF previews 2026-01-25 09:46:23 +01:00
Matthieu
d5f6749f9e wip: api filters and migration helpers 2026-01-23 23:30:05 +01:00
ad7918c993 chore(frontend) : met a jour le sous-module 2026-01-23 19:35:36 +01:00
86447000b1 feat(api) : ajoute pagination et filtres pour les catalogues 2026-01-23 19:35:26 +01:00
7da5eb917a feat(fixtures): ajouter système de fixtures pour les données
- fixtures/data.sql: dump des données actuelles de la base
- fixtures/load.sh: script shell de chargement
- makefile: targets fixtures-dump, fixtures-load, fixtures-reset

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 13:34:07 +01:00
d65eb9ff0f WIP: corrections sérialisation API et script normalisation SQL
Backend:
- Fix Groups sur TypeMachine*Requirement: exposer typePiece/typeComposant/typeProduct
- Fix Groups sur Document, Piece, Product, Composant pour sérialisation
- Add addConstructeur/removeConstructeur sur Piece et Product

Scripts:
- Fix normalize-dump.py: gérer les schémas quotés ("public"."table")

Frontend (sous-module):
- Corrections formulaires et sérialisation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 12:34:00 +01:00
895df7415b chore(frontend): maj sous-module 2026-01-15 13:43:33 +01:00
9abe9fea7f chore(migrations): normaliser le schema 2026-01-15 13:42:36 +01:00
ea45ce9d0a fix(schema): aligner mappings et sortie machine 2026-01-15 13:42:29 +01:00
2c3fbb093a feat(modeles): exposer la structure selon la categorie 2026-01-15 13:42:18 +01:00
3c5fb83673 docs: maj migration et carnet de bord 2026-01-15 13:39:06 +01:00
e1dc8850c0 feat(make): nettoyer le cache Symfony 2026-01-15 13:38:55 +01:00
59622580a9 chore(frontend): maj sous-module 2026-01-15 12:51:38 +01:00
bdd1837247 chore(frontend): mettre a jour le sous-module 2026-01-14 23:11:05 +01:00
40b4b90ed8 wip(api) : machine skeleton + type links 2026-01-12 13:14:19 +01:00
d4bdb76fda docs: ajouter la liaison INV-20260111-02 [INV-20260111-02] 2026-01-11 17:14:53 +01:00
f7fc1bdee2 chore(repo): maj du submodule frontend [INV-20260111-02] 2026-01-11 17:14:38 +01:00
1a751927fa docs: tracer la liaison INV-20260111-01 [INV-20260111-01] 2026-01-11 17:12:46 +01:00
987aa5c15f chore(repo): lier Inventory_frontend en submodule [INV-20260111-01] 2026-01-11 17:12:34 +01:00
d2a1cd0cc4 docs: mettre a jour le plan et le carnet de bord 2026-01-11 17:06:36 +01:00
5222a6bbf9 chore(config): ajuster docker, cors et securite 2026-01-11 17:06:25 +01:00
15e0b23f15 chore(migration): ajouter scripts et migrations de donnees 2026-01-11 17:05:50 +01:00
fab1d25871 feat(api): ajouter les endpoints session, documents, champs et squelette 2026-01-11 17:05:41 +01:00
037ed782a7 feat(api): ajouter les entites et repositories inventory 2026-01-11 17:05:36 +01:00
de8b05a553 docs : mise a jour carnet de bord Session 2
- Resolution probleme Apache
- JWT 100% operationnel
- Reorganisation projet terminee
- Etat des lieux complet dans MIGRATION_PLAN
- 673 lignes de donnees a migrer identifiees

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-10 19:42:30 +01:00
6f9e1ec626 chore(config) : mise a jour configuration Symfony et Docker
- Installation bundles: lexik/jwt, vich/uploader, symfony/uid
- Configuration Docker avec pgAdmin
- Variables environnement pour JWT et PostgreSQL
- VirtualHost Apache pour Symfony public/

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-10 19:42:01 +01:00
8430e9baef docs : ajout documentation migration et reorganisation projet
- Plan de migration detaille (Prisma vers Doctrine)
- Carnet de bord avec etat des lieux Phase 1-2
- Mise a jour CHANGELOG et README
- Reorganisation structure projet (frontend/)
- Gitignore pour archives et frontend

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-10 19:41:55 +01:00
fca3104a39 feat(api) : configuration API Platform avec JWT
- Configuration security.yaml avec firewalls JWT
- Routes API Platform avec prefixe /api
- Controller de test pour validation setup
- htaccess pour mod_rewrite Apache
- Access control pour routes publiques/protegees

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-10 19:41:48 +01:00
50336694f6 feat(auth) : creation entite Profile avec JWT
- Entite Profile implementant UserInterface
- Support authentification JWT via email/password
- Repository avec PasswordUpgraderInterface
- Migration Doctrine pour table profiles
- Script utilitaire creation utilisateur test

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-10 19:41:38 +01:00
c99f76d755 feat(infra) : ajout de pgAdmin avec configuration auto
- Ajout du service pgAdmin dans docker-compose.yml
- Configuration serveur PostgreSQL pre-enregistre (servers.json)
- Fichier pgpass pour authentification automatique
- Port 5050 expose pour acces web
- Configuration lexik/jwt-authentication-bundle

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-10 19:41:32 +01:00
127 changed files with 16857 additions and 12111 deletions

6
.env
View File

@@ -39,3 +39,9 @@ DEFAULT_URI=http://localhost
###> nelmio/cors-bundle ###
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
###< nelmio/cors-bundle ###
###> lexik/jwt-authentication-bundle ###
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=281e2cd303ed9ba4a4a4074e19eac9cea505cc9d82ce79a448bb8eb00c636ebe
###< lexik/jwt-authentication-bundle ###

15
.gitignore vendored
View File

@@ -23,3 +23,18 @@
###> docker ###
docker/.env.docker.local
###< docker ###
###> lexik/jwt-authentication-bundle ###
/config/jwt/*.pem
###< lexik/jwt-authentication-bundle ###
###> migration archives ###
/_archives/
###< migration archives ###
###> frontend ###
/frontend/node_modules/
/frontend/.nuxt/
/frontend/.output/
/frontend/dist/
###< frontend ###

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "Inventory_frontend"]
path = Inventory_frontend
url = gitea@gitea.malio.fr:MALIO-DEV/Inventory_frontend.git

425
CARNET_DE_BORD.md Normal file
View File

@@ -0,0 +1,425 @@
# 📔 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

View File

@@ -1,6 +1,6 @@
# Changelog
Liste des évolutions du projet Ferme
Liste des évolutions du projet inventory
## [0.0.0]
### Parameters

328
DEPLOY.md Normal file
View File

@@ -0,0 +1,328 @@
# Inventory - Guide de Déploiement & Release
## Architecture
```
inventory.malio-dev.fr/ → Frontend Nuxt (statique)
inventory.malio-dev.fr/api → Backend Symfony (PHP-FPM)
```
| Composant | Technologie | Emplacement serveur |
|-----------|-------------|---------------------|
| Backend | Symfony 8 + API Platform | `/var/www/Inventory/` |
| Frontend | Nuxt 4 (statique) | `/var/www/Inventory/Inventory_frontend/.output/public/` |
| Base de données | PostgreSQL 16 | `inventory` |
---
## Prérequis serveur
- **OS** : Ubuntu/Debian
- **PHP** : 8.4 avec extensions : pgsql, intl, zip, gd, mbstring, curl
- **Node.js** : 20+
- **Nginx**
- **PostgreSQL** : 16
- **Composer**
Vérifier :
```bash
php -v # PHP 8.4+
php -m | grep -E 'pgsql|intl|zip|gd|mbstring'
node -v # Node 20+
nginx -v
psql --version
composer --version
```
---
## Déploiement initial
### 1. Cloner le projet
```bash
cd /var/www
sudo git clone --recurse-submodules gitea@gitea.malio.fr:MALIO-DEV/Inventory.git Inventory
sudo chown -R malio:malio Inventory
cd Inventory
git checkout master
git submodule update --init --recursive
```
### 2. Créer la base de données
```bash
sudo -u postgres psql
CREATE DATABASE inventory OWNER ferme_user;
GRANT ALL PRIVILEGES ON DATABASE inventory TO ferme_user;
\q
```
Importer le dump :
```bash
# Copier le dump depuis le PC local
scp backup_v1.0.0_clean.sql malio@192.168.0.159:/tmp/
# Importer
psql -U ferme_user -h 127.0.0.1 -d inventory -f /tmp/backup_v1.0.0_clean.sql
```
### 3. Configurer le backend Symfony
```bash
cd /var/www/Inventory
# Installer les dépendances
composer install --no-dev --optimize-autoloader
# Créer .env.local
cat > .env.local << 'EOF'
APP_ENV=prod
APP_DEBUG=0
APP_SECRET=CHANGE_ME
DATABASE_URL="postgresql://ferme_user:fermerecette@127.0.0.1:5432/inventory?serverVersion=16"
CORS_ALLOW_ORIGIN='^https?://inventory\.malio-dev\.fr$'
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=inventoryjwt
EOF
# Générer APP_SECRET
sed -i "s/CHANGE_ME/$(openssl rand -hex 32)/" .env.local
# Générer les clés JWT
mkdir -p config/jwt
openssl genrsa -out config/jwt/private.pem -aes256 4096
# Passphrase : inventoryjwt
openssl rsa -pubout -in config/jwt/private.pem -out config/jwt/public.pem
chmod 600 config/jwt/private.pem
# Permissions
sudo chown -R www-data:www-data var/
sudo chmod -R 775 var/
# Vider le cache
php bin/console cache:clear --env=prod
```
### 4. Configurer le frontend Nuxt
```bash
cd /var/www/Inventory/Inventory_frontend
# Permissions
sudo chown -R malio:malio .
# Installer les dépendances
npm install
# Créer .env
cat > .env << 'EOF'
NUXT_PUBLIC_API_BASE_URL=http://inventory.malio-dev.fr/api
EOF
# Générer le site statique
npx nuxi generate
```
### 5. Configurer Nginx
```bash
sudo nano /etc/nginx/sites-available/inventory
```
Contenu :
```nginx
server {
listen 80;
server_name inventory.malio-dev.fr;
# Gros fichiers (100MB max)
client_max_body_size 100M;
client_body_timeout 300s;
send_timeout 300s;
access_log /var/log/nginx/inventory-access.log;
error_log /var/log/nginx/inventory-error.log;
# Backend Symfony - /api
location /api {
root /var/www/Inventory/public;
try_files $uri /index.php$is_args$args;
}
location ~ ^/index\.php(/|$) {
fastcgi_pass unix:/run/php/php-fpm.sock;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/Inventory/public/index.php;
fastcgi_param DOCUMENT_ROOT /var/www/Inventory/public;
fastcgi_read_timeout 300s;
internal;
}
# Frontend statique
location / {
root /var/www/Inventory/Inventory_frontend/.output/public;
index index.html;
try_files $uri $uri/ /index.html;
}
}
```
Activer :
```bash
sudo ln -s /etc/nginx/sites-available/inventory /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
### 6. Vérifier
```bash
curl http://inventory.malio-dev.fr
curl http://inventory.malio-dev.fr/api
```
---
## Mises à jour
### Mettre à jour l'application
```bash
cd /var/www/Inventory
# Pull les changements
git pull
git submodule update --init --recursive
# Backend
composer install --no-dev --optimize-autoloader
php bin/console cache:clear --env=prod
sudo chown -R www-data:www-data var/
# Frontend
cd Inventory_frontend
npm install
npx nuxi generate
```
---
## Versioning & Releases
### Source de vérité
Le fichier `VERSION` à la racine contient le numéro de version (ex: `1.0.0`).
Cette version est synchronisée avec :
- Le footer de l'application
- `config/packages/api_platform.yaml`
### Créer une release
```bash
# Depuis le PC de dev
./scripts/release.sh patch # 1.0.0 → 1.0.1
./scripts/release.sh minor # 1.0.0 → 1.1.0
./scripts/release.sh major # 1.0.0 → 2.0.0
./scripts/release.sh 2.0.0 # Version exacte
```
Le script :
1. Vérifie/commit le submodule frontend
2. Met à jour `VERSION` et `api_platform.yaml`
3. Commit et tag les deux repos
4. Affiche les commandes pour push
### Pousser la release
```bash
# Frontend (submodule)
cd Inventory_frontend && git push && git push --tags && cd ..
# Backend
git push && git push --tags
```
### Créer la release sur Gitea
1. Aller sur le dépôt Gitea
2. **Releases** > **New Release**
3. Sélectionner le tag `vX.Y.Z`
4. Ajouter les notes de release
---
## Commandes utiles
```bash
# Logs Nginx
tail -f /var/log/nginx/inventory-error.log
# Logs Symfony
tail -f /var/www/Inventory/var/log/prod.log
# Vider le cache Symfony
php /var/www/Inventory/bin/console cache:clear --env=prod
# Rebuild frontend
cd /var/www/Inventory/Inventory_frontend && npx nuxi generate
# Status PHP-FPM
systemctl status php8.4-fpm
# Reload Nginx
sudo systemctl reload nginx
```
---
## Backup base de données
### Export
```bash
pg_dump -U ferme_user -h 127.0.0.1 -d inventory --no-owner --no-acl --inserts --column-inserts --clean --if-exists > backup_inventory_$(date +%Y%m%d).sql
```
### Import
```bash
psql -U ferme_user -h 127.0.0.1 -d inventory -f backup_inventory_YYYYMMDD.sql
```
---
## Troubleshooting
### Erreur 502 Bad Gateway
```bash
# Vérifier PHP-FPM
systemctl status php8.4-fpm
sudo systemctl restart php8.4-fpm
```
### Erreur 403 Forbidden
```bash
# Vérifier les permissions
sudo chown -R www-data:www-data /var/www/Inventory/var/
sudo chmod -R 775 /var/www/Inventory/var/
```
### Erreur API "No route found"
```bash
# Vider le cache
php /var/www/Inventory/bin/console cache:clear --env=prod
```
### Frontend ne se met pas à jour
```bash
# Rebuild
cd /var/www/Inventory/Inventory_frontend
rm -rf .output
npx nuxi generate
```

1
Inventory_frontend Submodule

Submodule Inventory_frontend added at 6bed715b7f

1419
MIGRATION_PLAN.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
# Projet Ferme
# Projet Inventory
## Installation du projet
### Windows
@@ -12,6 +12,7 @@ Il suffit de suivre cette [doc](https://wiki.malio.fr/bookstack/books/environnem
### 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
make start
make install
```
@@ -20,7 +21,7 @@ Dans le cas ou le `make start` plante à cause du port de la bdd, il faut modifi
### Configuration xdebug
Pour configurer xdebug, il faut ajouter un serveur sur phpstorm. <br>
Pour cela, il faut aller dans **Settings > PHP > Servers** <br>
* Name : ferme-docker
* Name : inventory-docker
* Host : localhost
* Port : 8080
* Path : File/Directory -> l'endroit où est stocké votre projet et le path -> /var/www/html
@@ -36,12 +37,42 @@ 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.
### 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
```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
```
### 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
### 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
# Compresser tous les PDFs
php bin/console app:compress-pdf
```
## Commandes utiles
Pour restart le container
```bash

786
REFACTORING_PLAN.md Normal file
View File

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

138
RELEASE.md Normal file
View File

@@ -0,0 +1,138 @@
# Guide de Release
## Versioning
Le projet utilise le [Semantic Versioning](https://semver.org/) (SemVer) : `MAJOR.MINOR.PATCH`
- **MAJOR** : Changements incompatibles avec les versions précédentes
- **MINOR** : Nouvelles fonctionnalités rétrocompatibles
- **PATCH** : Corrections de bugs rétrocompatibles
La version est centralisée dans le fichier `VERSION` à la racine du projet.
## Créer une release
### Prérequis
- Tous les changements doivent être commités
- Les tests doivent passer
- Être sur la branche à releaser (ex: `main`, `develop`)
### Utilisation du script
```bash
# Afficher l'aide et la version actuelle
./scripts/release.sh
# Bump patch : 1.0.0 → 1.0.1
./scripts/release.sh patch
# Bump minor : 1.0.0 → 1.1.0
./scripts/release.sh minor
# Bump major : 1.0.0 → 2.0.0
./scripts/release.sh major
# Version spécifique
./scripts/release.sh 2.0.0
```
Le script :
1. Met à jour le fichier `VERSION`
2. Met à jour `config/packages/api_platform.yaml`
3. Crée un commit `chore(release): vX.Y.Z`
4. Crée le tag `vX.Y.Z`
### Pousser la release
```bash
git push && git push --tags
```
### Créer la release sur Gitea
1. Aller sur le dépôt Gitea
2. **Releases** > **New Release**
3. Sélectionner le tag `vX.Y.Z`
4. Titre : `v1.0.0` (ou avec un nom descriptif)
5. Description : résumé des changements (voir section Notes de release)
## Notes de release
Template pour les notes de release :
```markdown
## Nouveautés
- Feature A
- Feature B
## Corrections
- Fix du bug X
- Fix du bug Y
## Changements
- Refactoring de Z
- Mise à jour des dépendances
```
## Fichiers impactés par le versioning
| Fichier | Usage |
|---------|-------|
| `VERSION` | Source unique de vérité |
| `config/packages/api_platform.yaml` | Version affichée dans l'API |
| `Inventory_frontend/nuxt.config.ts` | Lit VERSION au build |
| Footer de l'app | Affiche `v{{ appVersion }}` |
## Déploiement en production
### 1. Base de données
Dump de la base locale :
```bash
pg_dump -h localhost -p 5433 -U root -d inventory > backup_v1.0.0.sql
```
Import en production :
```bash
psql -h <PROD_HOST> -U <PROD_USER> -d inventory < backup_v1.0.0.sql
```
### 2. Variables d'environnement production
Créer un fichier `.env.local` en production avec :
```env
APP_ENV=prod
APP_SECRET=<générer avec: openssl rand -hex 32>
DATABASE_URL="postgresql://user:password@host:5432/inventory?serverVersion=16"
CORS_ALLOW_ORIGIN='^https://votre-domaine\.com$'
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=<votre-passphrase>
```
### 3. Build production
Backend :
```bash
composer install --no-dev --optimize-autoloader
php bin/console cache:clear --env=prod
php bin/console doctrine:migrations:migrate --no-interaction
```
Frontend :
```bash
cd Inventory_frontend
NUXT_PUBLIC_API_BASE_URL=https://api.votre-domaine.com yarn build
```
### 4. Checklist avant mise en prod
- [ ] Tests passent
- [ ] Migrations DB testées
- [ ] Variables d'environnement configurées
- [ ] Clés JWT générées
- [ ] CORS configuré
- [ ] SSL/HTTPS actif
- [ ] Backup de la DB prod existante (si upgrade)

2
TODO.md Normal file
View File

@@ -0,0 +1,2 @@
- Doc: ne pas oublier de mettre `make` dans la documentation.
- Note: le probleme d'IP sous WSL, a ajouter dans la doc.

1
VERSION Normal file
View File

@@ -0,0 +1 @@
1.6.2

View File

@@ -11,7 +11,7 @@ fi
# Types autorisés (MINUSCULES uniquement)
# Optionnel: scope => feat(auth) : ...
REGEX='^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9._-]+\))?\ :\ .+'
REGEX='^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test|wip)(\([a-z0-9._-]+\))?\ :\ .+'
if [[ ! "$FIRST_LINE" =~ $REGEX ]]; then
echo "❌ Message de commit invalide."

View File

@@ -12,6 +12,7 @@
"doctrine/doctrine-bundle": "^3.2",
"doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.6",
"lexik/jwt-authentication-bundle": "^3.2",
"nelmio/cors-bundle": "^2.6",
"phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.3",
@@ -27,8 +28,10 @@
"symfony/security-bundle": "8.0.*",
"symfony/serializer": "8.0.*",
"symfony/twig-bundle": "8.0.*",
"symfony/uid": "8.0.*",
"symfony/validator": "8.0.*",
"symfony/yaml": "8.0.*"
"symfony/yaml": "8.0.*",
"vich/uploader-bundle": "^2.9"
},
"config": {
"allow-plugins": {

540
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "bab4560dec1d36eec0b0aa2284bd8559",
"content-hash": "9e0e35659f9b6ef5c0a60262a36b61f2",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -2361,6 +2361,259 @@
},
"time": "2025-10-26T09:35:14+00:00"
},
{
"name": "jms/metadata",
"version": "2.9.0",
"source": {
"type": "git",
"url": "https://github.com/schmittjoh/metadata.git",
"reference": "554319d2e5f0c5d8ccaeffe755eac924e14da330"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/schmittjoh/metadata/zipball/554319d2e5f0c5d8ccaeffe755eac924e14da330",
"reference": "554319d2e5f0c5d8ccaeffe755eac924e14da330",
"shasum": ""
},
"require": {
"php": "^7.2|^8.0"
},
"require-dev": {
"doctrine/cache": "^1.0|^2.0",
"doctrine/coding-standard": "^8.0",
"mikey179/vfsstream": "^1.6.7",
"phpunit/phpunit": "^8.5.42|^9.6.23",
"psr/container": "^1.0|^2.0",
"symfony/cache": "^3.1|^4.0|^5.0|^6.0|^7.0|^8.0",
"symfony/dependency-injection": "^3.1|^4.0|^5.0|^6.0|^7.0|^8.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.x-dev"
}
},
"autoload": {
"psr-4": {
"Metadata\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Johannes M. Schmitt",
"email": "schmittjoh@gmail.com"
},
{
"name": "Asmir Mustafic",
"email": "goetas@gmail.com"
}
],
"description": "Class/method/property metadata management in PHP",
"keywords": [
"annotations",
"metadata",
"xml",
"yaml"
],
"support": {
"issues": "https://github.com/schmittjoh/metadata/issues",
"source": "https://github.com/schmittjoh/metadata/tree/2.9.0"
},
"time": "2025-11-30T20:12:26+00:00"
},
{
"name": "lcobucci/jwt",
"version": "5.6.0",
"source": {
"type": "git",
"url": "https://github.com/lcobucci/jwt.git",
"reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e",
"reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e",
"shasum": ""
},
"require": {
"ext-openssl": "*",
"ext-sodium": "*",
"php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"psr/clock": "^1.0"
},
"require-dev": {
"infection/infection": "^0.29",
"lcobucci/clock": "^3.2",
"lcobucci/coding-standard": "^11.0",
"phpbench/phpbench": "^1.2",
"phpstan/extension-installer": "^1.2",
"phpstan/phpstan": "^1.10.7",
"phpstan/phpstan-deprecation-rules": "^1.1.3",
"phpstan/phpstan-phpunit": "^1.3.10",
"phpstan/phpstan-strict-rules": "^1.5.0",
"phpunit/phpunit": "^11.1"
},
"suggest": {
"lcobucci/clock": ">= 3.2"
},
"type": "library",
"autoload": {
"psr-4": {
"Lcobucci\\JWT\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Luís Cobucci",
"email": "lcobucci@gmail.com",
"role": "Developer"
}
],
"description": "A simple library to work with JSON Web Token and JSON Web Signature",
"keywords": [
"JWS",
"jwt"
],
"support": {
"issues": "https://github.com/lcobucci/jwt/issues",
"source": "https://github.com/lcobucci/jwt/tree/5.6.0"
},
"funding": [
{
"url": "https://github.com/lcobucci",
"type": "github"
},
{
"url": "https://www.patreon.com/lcobucci",
"type": "patreon"
}
],
"time": "2025-10-17T11:30:53+00:00"
},
{
"name": "lexik/jwt-authentication-bundle",
"version": "v3.2.0",
"source": {
"type": "git",
"url": "https://github.com/lexik/LexikJWTAuthenticationBundle.git",
"reference": "60df75dc70ee6f597929cb2f0812adda591dfa4b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/lexik/LexikJWTAuthenticationBundle/zipball/60df75dc70ee6f597929cb2f0812adda591dfa4b",
"reference": "60df75dc70ee6f597929cb2f0812adda591dfa4b",
"shasum": ""
},
"require": {
"ext-openssl": "*",
"lcobucci/jwt": "^5.0",
"php": ">=8.2",
"symfony/clock": "^6.4|^7.0|^8.0",
"symfony/config": "^6.4|^7.0|^8.0",
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
"symfony/deprecation-contracts": "^2.4|^3.0",
"symfony/event-dispatcher": "^6.4|^7.0|^8.0",
"symfony/http-foundation": "^6.4|^7.0|^8.0",
"symfony/http-kernel": "^6.4|^7.0|^8.0",
"symfony/property-access": "^6.4|^7.0|^8.0",
"symfony/security-bundle": "^6.4|^7.0|^8.0",
"symfony/translation-contracts": "^1.0|^2.0|^3.0"
},
"require-dev": {
"api-platform/core": "^3.0|^4.0",
"rector/rector": "^1.2",
"symfony/browser-kit": "^6.4|^7.0|^8.0",
"symfony/console": "^6.4|^7.0|^8.0",
"symfony/dom-crawler": "^6.4|^7.0|^8.0",
"symfony/filesystem": "^6.4|^7.0|^8.0",
"symfony/framework-bundle": "^6.4|^7.0|^8.0",
"symfony/phpunit-bridge": "^6.4|^7.0|^8.0",
"symfony/var-dumper": "^6.4|^7.0|^8.0",
"symfony/yaml": "^6.4|^7.0|^8.0"
},
"suggest": {
"gesdinet/jwt-refresh-token-bundle": "Implements a refresh token system over Json Web Tokens in Symfony",
"spomky-labs/lexik-jose-bridge": "Provides a JWT Token encoder with encryption support"
},
"type": "symfony-bundle",
"autoload": {
"psr-4": {
"Lexik\\Bundle\\JWTAuthenticationBundle\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jeremy Barthe",
"email": "j.barthe@lexik.fr",
"homepage": "https://github.com/jeremyb"
},
{
"name": "Nicolas Cabot",
"email": "n.cabot@lexik.fr",
"homepage": "https://github.com/slashfan"
},
{
"name": "Cedric Girard",
"email": "c.girard@lexik.fr",
"homepage": "https://github.com/cedric-g"
},
{
"name": "Dev Lexik",
"email": "dev@lexik.fr",
"homepage": "https://github.com/lexik"
},
{
"name": "Robin Chalas",
"email": "robin.chalas@gmail.com",
"homepage": "https://github.com/chalasr"
},
{
"name": "Lexik Community",
"homepage": "https://github.com/lexik/LexikJWTAuthenticationBundle/graphs/contributors"
}
],
"description": "This bundle provides JWT authentication for your Symfony REST API",
"homepage": "https://github.com/lexik/LexikJWTAuthenticationBundle",
"keywords": [
"Authentication",
"JWS",
"api",
"bundle",
"jwt",
"rest",
"symfony"
],
"support": {
"issues": "https://github.com/lexik/LexikJWTAuthenticationBundle/issues",
"source": "https://github.com/lexik/LexikJWTAuthenticationBundle/tree/v3.2.0"
},
"funding": [
{
"url": "https://github.com/chalasr",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/lexik/jwt-authentication-bundle",
"type": "tidelift"
}
],
"time": "2025-12-20T17:47:00+00:00"
},
{
"name": "nelmio/cors-bundle",
"version": "2.6.0",
@@ -4613,6 +4866,92 @@
],
"time": "2025-12-31T09:29:34+00:00"
},
{
"name": "symfony/mime",
"version": "v8.0.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/mime.git",
"reference": "7576ce3b2b4d3a2a7fe7020a07a392065d6ffd40"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/mime/zipball/7576ce3b2b4d3a2a7fe7020a07a392065d6ffd40",
"reference": "7576ce3b2b4d3a2a7fe7020a07a392065d6ffd40",
"shasum": ""
},
"require": {
"php": ">=8.4",
"symfony/polyfill-intl-idn": "^1.10",
"symfony/polyfill-mbstring": "^1.0"
},
"conflict": {
"egulias/email-validator": "~3.0.0",
"phpdocumentor/reflection-docblock": "<3.2.2",
"phpdocumentor/type-resolver": "<1.4.0"
},
"require-dev": {
"egulias/email-validator": "^2.1.10|^3.1|^4",
"league/html-to-markdown": "^5.0",
"phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/property-access": "^7.4|^8.0",
"symfony/property-info": "^7.4|^8.0",
"symfony/serializer": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Mime\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Allows manipulating MIME messages",
"homepage": "https://symfony.com",
"keywords": [
"mime",
"mime-type"
],
"support": {
"source": "https://github.com/symfony/mime/tree/v8.0.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-11-16T10:17:21+00:00"
},
{
"name": "symfony/password-hasher",
"version": "v8.0.0",
@@ -4768,6 +5107,93 @@
],
"time": "2025-06-27T09:58:17+00:00"
},
{
"name": "symfony/polyfill-intl-idn",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-idn.git",
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3",
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3",
"shasum": ""
},
"require": {
"php": ">=7.2",
"symfony/polyfill-intl-normalizer": "^1.10"
},
"suggest": {
"ext-intl": "For best performance"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Intl\\Idn\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Laurent Bassin",
"email": "laurent@bassin.info"
},
{
"name": "Trevor Rowbotham",
"email": "trevor.rowbotham@pm.me"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"idn",
"intl",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-09-10T14:38:51+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
"version": "v1.33.0",
@@ -7040,6 +7466,114 @@
],
"time": "2025-12-14T11:28:47+00:00"
},
{
"name": "vich/uploader-bundle",
"version": "v2.9.1",
"source": {
"type": "git",
"url": "https://github.com/dustin10/VichUploaderBundle.git",
"reference": "945939a04a33c0b78c5fbb7ead31533d85112df5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dustin10/VichUploaderBundle/zipball/945939a04a33c0b78c5fbb7ead31533d85112df5",
"reference": "945939a04a33c0b78c5fbb7ead31533d85112df5",
"shasum": ""
},
"require": {
"doctrine/persistence": "^3.0 || ^4.0",
"ext-simplexml": "*",
"jms/metadata": "^2.4",
"php": "^8.1",
"symfony/config": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/console": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/event-dispatcher-contracts": "^3.1",
"symfony/http-foundation": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/http-kernel": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/mime": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/property-access": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/string": "^5.4 || ^6.0 || ^7.0 || ^8.0"
},
"conflict": {
"doctrine/annotations": "<1.12",
"league/flysystem": "<2.0"
},
"require-dev": {
"dg/bypass-finals": "^1.9",
"doctrine/common": "^3.0",
"doctrine/doctrine-bundle": "^2.7 || ^3.0",
"doctrine/mongodb-odm": "^2.4",
"doctrine/orm": "^2.13 || ^3.0",
"ext-sqlite3": "*",
"knplabs/knp-gaufrette-bundle": "dev-master",
"league/flysystem-bundle": "^2.4 || ^3.0",
"league/flysystem-memory": "^2.0 || ^3.0",
"matthiasnoback/symfony-dependency-injection-test": "^5.1 || ^6.0",
"mikey179/vfsstream": "^1.6.11",
"phpunit/phpunit": "^10.5 || ^11.5 || ^12.2",
"symfony/asset": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/browser-kit": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/doctrine-bridge": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/dom-crawler": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/form": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/phpunit-bridge": "^7.3",
"symfony/security-csrf": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/translation": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/twig-bridge": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/twig-bundle": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/validator": "^5.4.22 || ^6.0 || ^7.0 || ^8.0",
"symfony/var-dumper": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/yaml": "^5.4 || ^6.0 || ^7.0 || ^8.0"
},
"suggest": {
"doctrine/doctrine-bundle": "For integration with Doctrine",
"doctrine/mongodb-odm-bundle": "For integration with Doctrine ODM",
"doctrine/orm": "For integration with Doctrine ORM",
"doctrine/phpcr-odm": "For integration with Doctrine PHPCR",
"knplabs/knp-gaufrette-bundle": "For integration with Gaufrette",
"league/flysystem-bundle": "For integration with Flysystem",
"liip/imagine-bundle": "To generate image thumbnails",
"oneup/flysystem-bundle": "For integration with Flysystem",
"symfony/asset": "To generate better links",
"symfony/form": "To handle uploads in forms",
"symfony/yaml": "To use YAML mapping"
},
"type": "symfony-bundle",
"extra": {
"branch-alias": {
"dev-master": "2.x-dev"
}
},
"autoload": {
"psr-4": {
"Vich\\UploaderBundle\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Dustin Dobervich",
"email": "ddobervich@gmail.com"
}
],
"description": "Ease file uploads attached to entities",
"homepage": "https://github.com/dustin10/VichUploaderBundle",
"keywords": [
"file uploads",
"upload"
],
"support": {
"issues": "https://github.com/dustin10/VichUploaderBundle/issues",
"source": "https://github.com/dustin10/VichUploaderBundle/tree/v2.9.1"
},
"time": "2025-12-10T08:23:38+00:00"
},
{
"name": "webmozart/assert",
"version": "2.0.0",
@@ -10208,7 +10742,7 @@
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"stability-flags": {},
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
@@ -10216,6 +10750,6 @@
"ext-ctype": "*",
"ext-iconv": "*"
},
"platform-dev": [],
"platform-dev": {},
"plugin-api-version": "2.9.0"
}

View File

@@ -1,11 +1,23 @@
<?php
declare(strict_types=1);
use ApiPlatform\Symfony\Bundle\ApiPlatformBundle;
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
use Nelmio\CorsBundle\NelmioCorsBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Bundle\TwigBundle\TwigBundle;
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true],
FrameworkBundle::class => ['all' => true],
TwigBundle::class => ['all' => true],
SecurityBundle::class => ['all' => true],
DoctrineBundle::class => ['all' => true],
DoctrineMigrationsBundle::class => ['all' => true],
NelmioCorsBundle::class => ['all' => true],
ApiPlatformBundle::class => ['all' => true],
LexikJWTAuthenticationBundle::class => ['all' => true],
];

View File

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

View File

@@ -1,6 +1,9 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
mapping_types:
modelcategory: string
_text: string
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
@@ -9,7 +12,8 @@ doctrine:
profiling_collect_backtrace: '%kernel.debug%'
orm:
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
naming_strategy: doctrine.orm.naming_strategy.default
quote_strategy: doctrine.orm.quote_strategy.default
identity_generation_preferences:
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
auto_mapping: true

View File

@@ -0,0 +1,4 @@
lexik_jwt_authentication:
secret_key: '%env(resolve:JWT_SECRET_KEY)%'
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
pass_phrase: '%env(JWT_PASSPHRASE)%'

View File

@@ -4,7 +4,8 @@ nelmio_cors:
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
allow_headers: ['Content-Type', 'Authorization']
allow_credentials: true
expose_headers: ['Link']
max_age: 3600
paths:
'^/': null
'^/api/': ~

View File

@@ -2,30 +2,60 @@ security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
App\Entity\Profile:
algorithm: auto
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
users_in_memory: { memory: null }
app_user_provider:
entity:
class: App\Entity\Profile
property: email
firewalls:
dev:
# Ensure dev tools and static assets are always allowed
pattern: ^/(_profiler|_wdt|assets|build)/
security: false
login:
pattern: ^/api/login_check
stateless: true
provider: app_user_provider
json_login:
check_path: /api/login_check
username_path: email
password_path: password
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
api:
pattern: ^/api
stateless: false
main:
lazy: true
provider: users_in_memory
# Activate different ways to authenticate:
# https://symfony.com/doc/current/security.html#the-firewall
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
provider: app_user_provider
# Note: Only the *first* matching rule is applied
access_control:
# - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
- { path: ^/api/session/profile, roles: PUBLIC_ACCESS }
- { path: ^/api/session/profiles, roles: PUBLIC_ACCESS }
- { path: ^/api, roles: PUBLIC_ACCESS }
- { 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 }
when@test:
security:

View File

@@ -768,6 +768,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* property?: scalar|null|Param, // Default: null
* manager_name?: scalar|null|Param, // Default: null
* },
* lexik_jwt?: array{
* class?: scalar|null|Param, // Default: "Lexik\\Bundle\\JWTAuthenticationBundle\\Security\\User\\JWTUser"
* },
* }>,
* firewalls: array<string, array{ // Default: []
* pattern?: scalar|null|Param,
@@ -826,6 +829,10 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* provider?: scalar|null|Param,
* user?: scalar|null|Param, // Default: "REMOTE_USER"
* },
* jwt?: array{
* provider?: scalar|null|Param, // Default: null
* authenticator?: scalar|null|Param, // Default: "lexik_jwt_authentication.security.jwt_authenticator"
* },
* login_link?: array{
* check_route: scalar|null|Param, // Route that will validate the login link - e.g. "app_login_link_verify".
* check_post_only?: scalar|null|Param, // If true, only HTTP POST requests to "check_route" will be handled by the authenticator. // Default: false
@@ -1514,6 +1521,91 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* ...<mixed>
* },
* }
* @psalm-type LexikJwtAuthenticationConfig = array{
* public_key?: scalar|null|Param, // The key used to sign tokens (useless for HMAC). If not set, the key will be automatically computed from the secret key. // Default: null
* additional_public_keys?: list<scalar|null|Param>,
* secret_key?: scalar|null|Param, // The key used to sign tokens. It can be a raw secret (for HMAC), a raw RSA/ECDSA key or the path to a file itself being plaintext or PEM. // Default: null
* pass_phrase?: scalar|null|Param, // The key passphrase (useless for HMAC) // Default: ""
* token_ttl?: scalar|null|Param, // Default: 3600
* allow_no_expiration?: bool|Param, // Allow tokens without "exp" claim (i.e. indefinitely valid, no lifetime) to be considered valid. Caution: usage of this should be rare. // Default: false
* clock_skew?: scalar|null|Param, // Default: 0
* encoder?: array{
* service?: scalar|null|Param, // Default: "lexik_jwt_authentication.encoder.lcobucci"
* signature_algorithm?: scalar|null|Param, // Default: "RS256"
* },
* user_id_claim?: scalar|null|Param, // Default: "username"
* token_extractors?: array{
* authorization_header?: bool|array{
* enabled?: bool|Param, // Default: true
* prefix?: scalar|null|Param, // Default: "Bearer"
* name?: scalar|null|Param, // Default: "Authorization"
* },
* cookie?: bool|array{
* enabled?: bool|Param, // Default: false
* name?: scalar|null|Param, // Default: "BEARER"
* },
* query_parameter?: bool|array{
* enabled?: bool|Param, // Default: false
* name?: scalar|null|Param, // Default: "bearer"
* },
* split_cookie?: bool|array{
* enabled?: bool|Param, // Default: false
* cookies?: list<scalar|null|Param>,
* },
* },
* remove_token_from_body_when_cookies_used?: scalar|null|Param, // Default: true
* set_cookies?: array<string, array{ // Default: []
* lifetime?: scalar|null|Param, // The cookie lifetime. If null, the "token_ttl" option value will be used // Default: null
* samesite?: "none"|"lax"|"strict"|Param, // Default: "lax"
* path?: scalar|null|Param, // Default: "/"
* domain?: scalar|null|Param, // Default: null
* secure?: scalar|null|Param, // Default: true
* httpOnly?: scalar|null|Param, // Default: true
* partitioned?: scalar|null|Param, // Default: false
* split?: list<scalar|null|Param>,
* }>,
* api_platform?: bool|array{ // API Platform compatibility: add check_path in OpenAPI documentation.
* enabled?: bool|Param, // Default: false
* check_path?: scalar|null|Param, // The login check path to add in OpenAPI. // Default: null
* username_path?: scalar|null|Param, // The path to the username in the JSON body. // Default: null
* password_path?: scalar|null|Param, // The path to the password in the JSON body. // Default: null
* },
* access_token_issuance?: bool|array{
* enabled?: bool|Param, // Default: false
* signature?: array{
* algorithm: scalar|null|Param, // The algorithm use to sign the access tokens.
* key: scalar|null|Param, // The signature key. It shall be JWK encoded.
* },
* encryption?: bool|array{
* enabled?: bool|Param, // Default: false
* key_encryption_algorithm: scalar|null|Param, // The key encryption algorithm is used to encrypt the token.
* content_encryption_algorithm: scalar|null|Param, // The key encryption algorithm is used to encrypt the token.
* key: scalar|null|Param, // The encryption key. It shall be JWK encoded.
* },
* },
* access_token_verification?: bool|array{
* enabled?: bool|Param, // Default: false
* signature?: array{
* header_checkers?: list<scalar|null|Param>,
* claim_checkers?: list<scalar|null|Param>,
* mandatory_claims?: list<scalar|null|Param>,
* allowed_algorithms?: list<scalar|null|Param>,
* keyset: scalar|null|Param, // The signature keyset. It shall be JWKSet encoded.
* },
* encryption?: bool|array{
* enabled?: bool|Param, // Default: false
* continue_on_decryption_failure?: bool|Param, // If enable, non-encrypted tokens or tokens that failed during decryption or verification processes are accepted. // Default: false
* header_checkers?: list<scalar|null|Param>,
* allowed_key_encryption_algorithms?: list<scalar|null|Param>,
* allowed_content_encryption_algorithms?: list<scalar|null|Param>,
* keyset: scalar|null|Param, // The encryption keyset. It shall be JWKSet encoded.
* },
* },
* blocklist_token?: bool|array{
* enabled?: bool|Param, // Default: false
* cache?: scalar|null|Param, // Storage to track blocked tokens // Default: "cache.app"
* },
* }
* @psalm-type ConfigType = array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
@@ -1525,6 +1617,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* doctrine_migrations?: DoctrineMigrationsConfig,
* nelmio_cors?: NelmioCorsConfig,
* api_platform?: ApiPlatformConfig,
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* "when@dev"?: array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
@@ -1536,6 +1629,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* doctrine_migrations?: DoctrineMigrationsConfig,
* nelmio_cors?: NelmioCorsConfig,
* api_platform?: ApiPlatformConfig,
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* },
* "when@prod"?: array{
* imports?: ImportsConfig,
@@ -1548,6 +1642,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* doctrine_migrations?: DoctrineMigrationsConfig,
* nelmio_cors?: NelmioCorsConfig,
* api_platform?: ApiPlatformConfig,
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* },
* "when@test"?: array{
* imports?: ImportsConfig,
@@ -1560,6 +1655,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* doctrine_migrations?: DoctrineMigrationsConfig,
* nelmio_cors?: NelmioCorsConfig,
* api_platform?: ApiPlatformConfig,
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* },
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
* imports?: ImportsConfig,

View File

@@ -7,5 +7,8 @@
# To list all registered routes, run the following command:
# bin/console debug:router
api_login_check:
path: /api/login_check
controllers:
resource: routing.controllers

View File

@@ -1,4 +1,4 @@
api_platform:
resource: .
type: api_platform
prefix: /
prefix: /api

View File

@@ -0,0 +1,5 @@
controllers:
resource:
path: ../../src/Controller/
namespace: App\Controller
type: attribute

View File

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

61
create_test_user.php Normal file
View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
require_once __DIR__.'/vendor/autoload.php';
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory;
// Hash the password
$factory = new PasswordHasherFactory([
'common' => ['algorithm' => 'bcrypt'],
'memory-hard' => ['algorithm' => 'argon2i'],
]);
$passwordHasher = $factory->getPasswordHasher('common');
$hashedPassword = $passwordHasher->hash('admin123');
// Connect to database
$pdo = new PDO(
'pgsql:host=db;port=5432;dbname=inventory',
'root',
'root'
);
// Check if table exists
$tableExists = $pdo->query("SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'profiles')")->fetchColumn();
if ($tableExists) {
echo "Table 'profiles' exists.\n";
// Check if user exists
$userExists = $pdo->prepare('SELECT COUNT(*) FROM profiles WHERE email = ?');
$userExists->execute(['admin@admin.com']);
if ($userExists->fetchColumn() > 0) {
echo "User admin@admin.com already exists. Updating password...\n";
$stmt = $pdo->prepare('UPDATE profiles SET password = ? WHERE email = ?');
$stmt->execute([$hashedPassword, 'admin@admin.com']);
echo "Password updated!\n";
} else {
echo "Creating user admin@admin.com...\n";
$stmt = $pdo->prepare('INSERT INTO profiles (id, email, first_name, last_name, is_active, roles, password, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, NOW(), NOW())');
$id = 'cl'.substr(strtolower(base_convert(random_bytes(12), 2, 36)), 0, 24);
$stmt->execute([
$id,
'admin@admin.com',
'Admin',
'User',
true,
json_encode(['ROLE_USER', 'ROLE_ADMIN']),
$hashedPassword,
]);
echo "User created!\n";
}
} else {
echo "Table 'profiles' does not exist yet. Run migrations first.\n";
}
echo "\nTest credentials:\n";
echo "Email: admin@admin.com\n";
echo "Password: admin123\n";

View File

@@ -14,6 +14,7 @@ services:
XDEBUG_CLIENT_HOST: ${XDEBUG_CLIENT_HOST:-host.docker.internal}
XDEBUG_CONFIG: client_host=${XDEBUG_CLIENT_HOST:-host.docker.internal} client_port=9003
DATABASE_URL: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?serverVersion=16&charset=utf8"
CORS_ALLOW_ORIGIN: ${CORS_ALLOW_ORIGIN}
volumes:
- ./:/var/www/html
- ~/.cache:/var/www/.cache # Pour la cache de composer
@@ -29,8 +30,8 @@ services:
depends_on:
- db
ports:
- "8080:80"
- "3000:3000"
- "8081:80"
- "3001:3000"
restart: unless-stopped
db:
image: postgres:16-alpine
@@ -41,7 +42,20 @@ services:
volumes:
- pg_data:/var/lib/postgresql/data
ports:
- "${POSTGRES_PORT:-5432}:5432"
- "${POSTGRES_PORT:-5433}:5432"
restart: unless-stopped
adminer:
container_name: adminer-${DOCKER_APP_NAME}
image: adminer:latest
environment:
ADMINER_DEFAULT_SERVER: db
ADMINER_DESIGN: dracula
ports:
- "${ADMINER_PORT:-5050}:8080"
depends_on:
- db
restart: unless-stopped
volumes:
pg_data:

View File

@@ -1,9 +1,9 @@
DOCKER_APP_NAME=ferme
DOCKER_APP_NAME=inventory
DOCKER_PHP_VERSION=8.4.6
DOCKER_NODE_VERSION=24.12.0
APP_USER=www-data
POSTGRES_DB=ferme
POSTGRES_DB=inventory
POSTGRES_USER=root
POSTGRES_PASSWORD=root
POSTGRES_PORT=5432
XDEBUG_CLIENT_HOST=host.docker.internal
XDEBUG_CLIENT_HOST=host.docker.internal

35
docker/.env.docker.local Normal file
View File

@@ -0,0 +1,35 @@
DOCKER_APP_NAME=inventory
DOCKER_PHP_VERSION=8.4.6
DOCKER_NODE_VERSION=24.12.0
APP_USER=www-data
CURRENT_UID=1000
CURRENT_GID=1000
# PostgreSQL
POSTGRES_DB=inventory
POSTGRES_USER=root
POSTGRES_PASSWORD=root
#
# CORS
CORS_ALLOW_ORIGIN=^https?://(localhost|127\\.0\\.0\\.1)(:[0-9]+)?$
POSTGRES_PORT=5433
# pgAdmin
PGADMIN_EMAIL=admin@admin.com
PGADMIN_PASSWORD=admin
PGADMIN_PORT=5050
# XDebug
XDEBUG_CLIENT_HOST=host.docker.internal
# Symfony (pour future migration)
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
NESTJS_PORT=3000
SESSION_SECRET=changeme_session_secret
CORS_ORIGIN=http://localhost:3001

2
docker/pgadmin/pgpass Normal file
View File

@@ -0,0 +1,2 @@
db:5432:inventory:root:root
db:5432:*:root:root

View File

@@ -0,0 +1,15 @@
{
"Servers": {
"1": {
"Name": "Inventory PostgreSQL",
"Group": "Servers",
"Host": "db",
"Port": 5432,
"MaintenanceDB": "inventory",
"Username": "root",
"SSLMode": "prefer",
"PassFile": "/var/lib/pgadmin/pgpass",
"Comment": "Serveur PostgreSQL du projet Inventory"
}
}
}

View File

@@ -33,6 +33,7 @@ RUN apt-get update && apt-get install -y \
wget \
git \
unzip \
qpdf \
&& docker-php-ext-install -j$(nproc) \
intl \
zip \

View File

@@ -1,26 +1,15 @@
<VirtualHost *:80>
DocumentRoot /var/www/html
ServerName localhost
DocumentRoot /var/www/html/public
AliasMatch "^/api(/.*)?" "/var/www/html/public$1"
# API Symfony
<Directory /var/www/html/public>
Options FollowSymLinks
Options +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
AliasMatch "^(/.*)?" "/var/www/html/frontend/dist$1"
<Directory /var/www/html/frontend/dist>
AllowOverride All
Order allow,deny
Allow from All
RewriteEngine on
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
RewriteRule ^ index.html [L]
</Directory>
ErrorLog "${APACHE_LOG_DIR}/error.log"
CustomLog "${APACHE_LOG_DIR}/access.log" combined
# Logs
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>

1092
fixtures/data.sql Normal file

File diff suppressed because one or more lines are too long

42
fixtures/load.sh Executable file
View File

@@ -0,0 +1,42 @@
#!/bin/bash
# Load fixtures into the database
# Usage: ./fixtures/load.sh [--reset]
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
# Load environment variables
if [ -f "$PROJECT_DIR/.env" ]; then
export $(grep -v '^#' "$PROJECT_DIR/.env" | xargs)
fi
DB_USER="${POSTGRES_USER:-root}"
DB_NAME="${POSTGRES_DB:-inventory}"
CONTAINER="${DB_CONTAINER:-inventory-db-1}"
echo "Loading fixtures into $DB_NAME..."
# Check if --reset flag is passed
if [ "$1" == "--reset" ]; then
echo "Resetting database (truncating all tables)..."
docker exec -i "$CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -c "
DO \$\$ DECLARE
r RECORD;
BEGIN
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename != 'doctrine_migration_versions') LOOP
EXECUTE 'TRUNCATE TABLE ' || quote_ident(r.tablename) || ' CASCADE';
END LOOP;
END \$\$;
"
fi
# Load fixtures with foreign key checks disabled
docker exec -i "$CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" <<EOF
SET session_replication_role = replica;
$(cat "$SCRIPT_DIR/data.sql")
SET session_replication_role = DEFAULT;
EOF
echo "Fixtures loaded successfully!"

24
frontend/.gitignore vendored
View File

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

View File

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

View File

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

View File

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

11892
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

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

View File

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

View File

@@ -19,6 +19,8 @@ EXEC_PHP_ROOT = $(DOCKER) exec -t -u root $(PHP_CONTAINER)
EXEC_PHP_INTERACTIVE = $(DOCKER) exec -it -u $(APP_USER) $(PHP_CONTAINER)
EXEC_PHP_INTERACTIVE_ROOT = $(DOCKER) exec -it -u root $(PHP_CONTAINER)
FILES =
DATA_SQL ?= data.sql
DATA_SQL_NORM ?= data_norm.sql
#========================================================================================
@@ -31,6 +33,11 @@ start: env-init
@echo "**** START CONTAINERS ****"
@cp --update=none docker/.env.docker docker/.env.docker.local
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d
@echo ""
@echo "URLs disponibles:"
@echo "- Symfony API: http://localhost:8081/api"
@echo "- Nuxt (Inventory_frontend): http://localhost:3001"
@echo "- pgAdmin: http://localhost:5050"
# Éteint le container
stop:
@@ -49,16 +56,16 @@ composer-install:
$(EXEC_PHP) composer install
build-nuxtJS:
# $(EXEC_PHP) cp -n frontend/.env.dist frontend/.env.local
$(EXEC_PHP) sh -lc "cd frontend && npm install && npm run build:dist"
# $(EXEC_PHP) cp -n Inventory_frontend/.env.dist Inventory_frontend/.env.local
$(EXEC_PHP) sh -lc "cd Inventory_frontend && npm install && npm run generate"
dev-nuxt:
$(EXEC_PHP) sh -c "cd frontend && npm run dev"
$(EXEC_PHP) sh -lc "cd Inventory_frontend && npm run dev"
delete_built_dir:
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d
$(DOCKER) exec -u root $(PHP_CONTAINER) rm -rf vendor/
$(DOCKER) exec -u root $(PHP_CONTAINER) rm -rf frontend/node_modules
$(DOCKER) exec -u root $(PHP_CONTAINER) rm -rf Inventory_frontend/node_modules
remove_orphans:
$(DOCKER_COMPOSE) kill
@@ -85,6 +92,10 @@ db-restart:
cache-clear:
$(SYMFONY_CONSOLE) cache:clear
cache-clear-full:
$(SYMFONY_CONSOLE) cache:clear
$(EXEC_PHP) rm -rf var/cache/*
copy-git-hook:
$(EXEC_PHP) cp pre-commit .git/hooks/
$(EXEC_PHP) cp commit-msg .git/hooks/
@@ -108,3 +119,31 @@ test:
wait:
sleep 10
# Normalize pgAdmin data-only dump and import into DB
import-data:
python3 scripts/normalize-dump.py $(DATA_SQL) $(DATA_SQL_NORM) --lower
$(DOCKER_COMPOSE) exec -T db psql -U $(POSTGRES_USER) -d $(POSTGRES_DB) -v ON_ERROR_STOP=1 -c "SET session_replication_role = replica;"
$(DOCKER_COMPOSE) exec -T db psql -U $(POSTGRES_USER) -d $(POSTGRES_DB) -v ON_ERROR_STOP=1 < $(DATA_SQL_NORM)
$(DOCKER_COMPOSE) exec -T db psql -U $(POSTGRES_USER) -d $(POSTGRES_DB) -v ON_ERROR_STOP=1 -c "SET session_replication_role = DEFAULT;"
# Fixtures management
fixtures-dump:
@echo "Dumping current database to fixtures/data.sql..."
$(DOCKER_COMPOSE) exec -T db pg_dump -U $(POSTGRES_USER) -d $(POSTGRES_DB) \
--data-only --inserts --no-owner --no-privileges \
--exclude-table=doctrine_migration_versions \
| grep -v "^pg_dump:" | grep -v "^\\\\restrict" > fixtures/data.sql
@echo "Fixtures saved to fixtures/data.sql"
fixtures-load:
@echo "Loading fixtures from fixtures/data.sql (FK checks disabled)..."
$(DOCKER_COMPOSE) exec -T db psql -U $(POSTGRES_USER) -d $(POSTGRES_DB) -c "SET session_replication_role = replica;"
-$(DOCKER_COMPOSE) exec -T db psql -U $(POSTGRES_USER) -d $(POSTGRES_DB) < fixtures/data.sql
$(DOCKER_COMPOSE) exec -T db psql -U $(POSTGRES_USER) -d $(POSTGRES_DB) -c "SET session_replication_role = DEFAULT;"
@echo "Fixtures loaded!"
fixtures-reset:
@echo "Resetting database and loading fixtures..."
$(DOCKER_COMPOSE) exec -T db psql -U $(POSTGRES_USER) -d $(POSTGRES_DB) -c "DO \$$\$$ DECLARE r RECORD; BEGIN FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename != 'doctrine_migration_versions') LOOP EXECUTE 'TRUNCATE TABLE ' || quote_ident(r.tablename) || ' CASCADE'; END LOOP; END \$$\$$;"
$(MAKE) fixtures-load

35
migratebdd.md Normal file
View File

@@ -0,0 +1,35 @@
# 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"
```

View File

@@ -0,0 +1,891 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260125143939 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$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(<<<'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(<<<'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(<<<'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(<<<'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(<<<'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 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(<<<'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(<<<'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(<<<'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(<<<'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');
$this->addSql('ALTER TABLE model_types ALTER pieceskeleton TYPE JSONB');
$this->addSql('ALTER TABLE model_types ALTER productskeleton TYPE JSONB');
$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(<<<'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(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_969587902f024c2') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_969587902f024c2 RENAME TO IDX_96958790158582C3';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_96958790cc8a4cee') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_96958790cc8a4cee RENAME TO IDX_96958790DF92E79B';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_f609e59e169f1cf6') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_f609e59e169f1cf6 RENAME TO IDX_F609E59E4CA601C8';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_f609e59e2f024c2') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_f609e59e2f024c2 RENAME TO IDX_F609E59E158582C3';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_29a51f9857b7763a') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_29a51f9857b7763a RENAME TO IDX_29A51F9840C2D03B';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_29a51f982f024c2') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_29a51f982f024c2 RENAME TO IDX_29A51F98158582C3';
END IF;
END $$;
SQL
);
}
}

View File

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

70
public/.htaccess Normal file
View File

@@ -0,0 +1,70 @@
# Use the front controller as index file. It serves as a fallback solution when
# every other rewrite/redirect fails (e.g. in an aliased environment without
# mod_rewrite). Additionally, this reduces the matching process for the
# start page (path "/") because otherwise Apache will apply the rewriting rules
# to each configured DirectoryIndex file (e.g. index.php, index.html, index.pl).
DirectoryIndex index.php
# By default, Apache does not evaluate symbolic links if you did not enable this
# feature in your server configuration. Uncomment the following line if you
# install assets as symlinks or if you experience problems related to symlinks
# when compiling LESS/Sass/CoffeeScript assets.
# Options +FollowSymlinks
# Disabling MultiViews prevents unwanted negotiation, e.g. "/index" should not resolve
# to the front controller "/index.php" but be rewritten to "/index.php/index".
<IfModule mod_negotiation.c>
Options -MultiViews
</IfModule>
<IfModule mod_rewrite.c>
# This Option needs to be enabled for RewriteRule, otherwise it will show an error like
# 'Options FollowSymLinks or SymLinksIfOwnerMatch is off which implies that RewriteRule directive is forbidden'
Options +FollowSymlinks
RewriteEngine On
# Determine the RewriteBase automatically and set it as environment variable.
# If you are using Apache aliases to do mass virtual hosting or installed the
# project in a subdirectory, the base path will be prepended to allow proper
# resolution of the index.php file and to redirect to the correct URI. It will
# work in environments without path prefix as well, providing a safe, one-size
# fits all solution. But as you do not need it in this case, you can comment
# the following 2 lines to eliminate the overhead.
RewriteCond %{REQUEST_URI}::$0 ^(/.+)/(.*)::\2$
RewriteRule .* - [E=BASE:%1]
# Sets the HTTP_AUTHORIZATION header removed by Apache
RewriteCond %{HTTP:Authorization} .+
RewriteRule ^ - [E=HTTP_AUTHORIZATION:%0]
# Redirect to URI without front controller to prevent duplicate content
# (with and without `/index.php`). Only do this redirect on the initial
# rewrite by Apache and not on subsequent cycles. Otherwise we would get an
# endless redirect loop (request -> rewrite to front controller ->
# redirect to URI without front controller -> request -> ...).
# So in case you get a "too many redirects" error or you always get redirected
# to the start page because your Apache does not expose the REDIRECT_STATUS
# environment variable, you have 2 choices:
# - disable this feature by commenting the following 2 lines or
# - use Apache >= 2.3.9 and replace all L flags by END flags and remove the
# following RewriteCond (best solution)
RewriteCond %{ENV:REDIRECT_STATUS} =""
RewriteRule ^index\.php(?:/(.*)|$) %{ENV:BASE}/$1 [R=301,L]
# If the requested filename exists, simply serve it.
# We only want to let Apache serve files and not directories.
# Rewrite all other queries to the front controller.
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ %{ENV:BASE}/index.php [L]
</IfModule>
<IfModule !mod_rewrite.c>
<IfModule mod_alias.c>
# When mod_rewrite is not available, we instruct a temporary redirect of
# the start page to the front controller explicitly so that the website
# and the generated links can still be used.
RedirectMatch 307 ^/$ /index.php/
# RedirectTemp cannot be used instead
</IfModule>
</IfModule>

View File

@@ -0,0 +1,5 @@
INSERT INTO public.profiles (id, firstname, lastname, email, isactive, createdat, updatedat)
VALUES
('admin-default-profile', 'Admin', 'General', 'admin@admin.fr', true, '2025-09-23 13:09:47.804', '2025-09-23 13:09:47.804'),
('cmhab2j3x003g47v77xhnm1ff', 'Elodie', 'Souriau', 'elodie@gg.fr', true, '2025-10-28 08:29:25.437', '2025-10-28 08:29:25.437')
ON CONFLICT (id) DO NOTHING;

View File

@@ -0,0 +1,32 @@
DO $$
DECLARE
r RECORD;
BEGIN
FOR r IN
SELECT table_schema, table_name, column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND column_name <> lower(column_name)
ORDER BY table_name, column_name
LOOP
-- Skip if a lowercase version already exists to avoid collisions.
IF EXISTS (
SELECT 1
FROM information_schema.columns c
WHERE c.table_schema = r.table_schema
AND c.table_name = r.table_name
AND c.column_name = lower(r.column_name)
) THEN
RAISE NOTICE 'Skip %.%: % -> % (target exists)', r.table_name, r.column_name, r.column_name, lower(r.column_name);
ELSE
EXECUTE format(
'ALTER TABLE %I.%I RENAME COLUMN %I TO %I',
r.table_schema,
r.table_name,
r.column_name,
lower(r.column_name)
);
RAISE NOTICE 'Renamed %.%: % -> %', r.table_name, r.column_name, r.column_name, lower(r.column_name);
END IF;
END LOOP;
END $$;

View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -euo pipefail
SOURCE_DB="${SOURCE_DB:-inventory_data}"
TARGET_DB="${TARGET_DB:-inventory}"
PGHOST="${PGHOST:-localhost}"
PGPORT="${PGPORT:-5433}"
PGUSER="${PGUSER:-postgres}"
PGPASSWORD="${PGPASSWORD:-postgres}"
DUMP_FILE="${DUMP_FILE:-/tmp/inventory_data_dump.sql}"
export PGPASSWORD
EXCLUDE_TABLES=(
"doctrine_migration_versions"
"migration_versions"
"_prisma_migrations"
"profiles"
)
EXCLUDE_ARGS=()
for table in "${EXCLUDE_TABLES[@]}"; do
EXCLUDE_ARGS+=(--exclude-table-data="$table")
done
echo "Dumping data from ${SOURCE_DB}..."
pg_dump \
--data-only \
--inserts \
--no-owner \
--no-privileges \
"${EXCLUDE_ARGS[@]}" \
-h "${PGHOST}" \
-p "${PGPORT}" \
-U "${PGUSER}" \
"${SOURCE_DB}" > "${DUMP_FILE}"
echo "Restoring data into ${TARGET_DB}..."
psql \
-h "${PGHOST}" \
-p "${PGPORT}" \
-U "${PGUSER}" \
"${TARGET_DB}" < "${DUMP_FILE}"
echo "Done. Data copied from ${SOURCE_DB} to ${TARGET_DB}."

177
scripts/normalize-dump.py Normal file
View File

@@ -0,0 +1,177 @@
#!/usr/bin/env python3
import re
import sys
INSERT_RE = re.compile(
r"(?P<prefix>INSERT\s+INTO\s+[^;]*?\()(?P<cols>[^)]*)(?P<suffix>\)\s+VALUES)",
re.IGNORECASE | re.DOTALL,
)
TABLE_RE = re.compile(
r"(?P<before>INSERT\s+INTO\s+)(?P<table>(?:\"[^\"]+\"\.|[A-Za-z_][\w$]*\.)?\"[^\"]+\"|(?:\"[^\"]+\"\.|[A-Za-z_][\w$]*\.)?[A-Za-z_][\w$]*)",
re.IGNORECASE,
)
CREATE_DB_RE = re.compile(r"^CREATE\s+DATABASE\s+.+?;$", re.IGNORECASE | re.MULTILINE)
CONNECT_RE = re.compile(r"^\\connect\\s+.+?$", re.IGNORECASE | re.MULTILINE)
TABLE_NAME_MAP = {
"ModelType": "model_types",
"TypeMachine": "type_machines",
"TypeMachineComponentRequirement": "type_machine_component_requirements",
"TypeMachinePieceRequirement": "type_machine_piece_requirements",
"TypeMachineProductRequirement": "type_machine_product_requirements",
"MachinePieceLink": "machine_piece_links",
"MachineComponentLink": "machine_component_links",
"MachineProductLink": "machine_product_links",
"Machine": "machines",
"Product": "products",
"Piece": "pieces",
"Composant": "composants",
"Profile": "profiles",
"CustomField": "custom_fields",
"CustomFieldValue": "custom_field_values",
"Document": "documents",
"Constructeur": "constructeurs",
"Site": "sites",
}
SKIP_TABLES = {
"_prisma_migrations",
}
def to_snake(name: str) -> str:
out = []
length = len(name)
for i, ch in enumerate(name):
if ch.isupper():
prev = name[i - 1] if i > 0 else ""
nxt = name[i + 1] if i + 1 < length else ""
if i > 0 and (prev.islower() or prev.isdigit() or (prev.isupper() and nxt.islower())):
out.append("_")
out.append(ch.lower())
else:
out.append(ch)
return "".join(out)
def to_lower_compact(name: str) -> str:
return name.replace("_", "").lower()
def remap_table(ident: str, mode: str) -> str:
mapped = TABLE_NAME_MAP.get(ident)
if mapped is not None:
return mapped
if mode == "snake":
return to_snake(ident)
if mode == "lower":
return to_lower_compact(ident)
raise ValueError(f"Unsupported mode: {mode}")
def extract_table_ident(prefix: str) -> str | None:
match = TABLE_RE.search(prefix)
if not match:
return None
table = match.group("table")
# Handle quoted schema like "public"."TableName"
if '"."' in table:
parts = table.split('"."', 1)
ident = parts[1].strip('"')
elif "." in table:
_, ident = table.split(".", 1)
ident = ident.strip('"')
else:
ident = table.strip('"')
return ident
def normalize_table_name(prefix: str, mode: str) -> str:
def repl(match: re.Match[str]) -> str:
table = match.group("table")
schema = ""
ident = table
# Handle quoted schema like "public"."TableName"
if '"."' in table:
parts = table.split('"."', 1)
schema_name = parts[0].strip('"')
ident = parts[1].strip('"')
schema = f'"{schema_name}".'
elif "." in table:
schema_part, ident = table.split(".", 1)
schema_name = schema_part.strip('"')
schema = f'"{schema_name}".'
ident = ident.strip('"')
else:
ident = table.strip('"')
mapped = remap_table(ident, mode)
return f'{match.group("before")}{schema}"{mapped}"'
return TABLE_RE.sub(repl, prefix)
def remap_columns(cols: str, mode: str) -> str:
def repl(match: re.Match[str]) -> str:
name = match.group(1)
if mode == "snake":
if any(ch.isupper() for ch in name):
return f"\"{to_snake(name)}\""
return match.group(0)
if mode == "lower":
return f"\"{to_lower_compact(name)}\""
raise ValueError(f"Unsupported mode: {mode}")
return re.sub(r"\"([^\"]+)\"", repl, cols)
def normalize_dump(sql: str, mode: str) -> str:
sql = CREATE_DB_RE.sub("", sql)
sql = CONNECT_RE.sub("", sql)
def repl(match: re.Match[str]) -> str:
raw_prefix = match.group("prefix")
ident = extract_table_ident(raw_prefix)
if ident is not None:
mapped = remap_table(ident, mode)
if ident in SKIP_TABLES or mapped in SKIP_TABLES:
return ""
prefix = normalize_table_name(raw_prefix, mode)
cols = remap_columns(match.group("cols"), mode)
return f"{prefix}{cols}{match.group('suffix')}"
return INSERT_RE.sub(repl, sql)
def main() -> int:
if len(sys.argv) not in (3, 4):
print("Usage: scripts/normalize-dump.py <input.sql> <output.sql> [--snake|--lower]", file=sys.stderr)
return 1
src = sys.argv[1]
dst = sys.argv[2]
mode = "lower"
if len(sys.argv) == 4:
if sys.argv[3] == "--snake":
mode = "snake"
elif sys.argv[3] == "--lower":
mode = "lower"
else:
print("Invalid mode. Use --snake or --lower.", file=sys.stderr)
return 1
with open(src, "r", encoding="utf-8") as f:
data = f.read()
normalized = normalize_dump(data, mode)
with open(dst, "w", encoding="utf-8") as f:
f.write(normalized)
return 0
if __name__ == "__main__":
raise SystemExit(main())

195
scripts/release.sh Executable file
View File

@@ -0,0 +1,195 @@
#!/bin/bash
set -e
# Couleurs pour l'affichage
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Répertoire racine du projet
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
VERSION_FILE="$PROJECT_ROOT/VERSION"
API_PLATFORM_FILE="$PROJECT_ROOT/config/packages/api_platform.yaml"
FRONTEND_DIR="$PROJECT_ROOT/Inventory_frontend"
# Lire la version actuelle
current_version=$(cat "$VERSION_FILE" | tr -d '\n')
# Fonction pour afficher l'aide
show_help() {
echo -e "${BLUE}Usage:${NC} $0 [version|bump_type]"
echo ""
echo "Arguments:"
echo " version Version spécifique (ex: 1.2.3)"
echo " bump_type Type de bump: major, minor, patch"
echo ""
echo "Exemples:"
echo " $0 1.0.0 # Définit la version à 1.0.0"
echo " $0 patch # 1.0.0 -> 1.0.1"
echo " $0 minor # 1.0.0 -> 1.1.0"
echo " $0 major # 1.0.0 -> 2.0.0"
echo ""
echo -e "Version actuelle: ${GREEN}$current_version${NC}"
}
# Fonction pour bumper la version
bump_version() {
local version=$1
local bump_type=$2
IFS='.' read -r major minor patch <<< "$version"
case $bump_type in
major)
major=$((major + 1))
minor=0
patch=0
;;
minor)
minor=$((minor + 1))
patch=0
;;
patch)
patch=$((patch + 1))
;;
esac
echo "$major.$minor.$patch"
}
# Vérifier les arguments
if [ $# -eq 0 ]; then
show_help
exit 0
fi
arg=$1
# Déterminer la nouvelle version
case $arg in
major|minor|patch)
new_version=$(bump_version "$current_version" "$arg")
;;
-h|--help|help)
show_help
exit 0
;;
*)
# Vérifier le format de version (semver basique)
if [[ $arg =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
new_version=$arg
else
echo -e "${RED}Erreur:${NC} Format de version invalide: $arg"
echo "Utilisez le format X.Y.Z (ex: 1.2.3) ou major/minor/patch"
exit 1
fi
;;
esac
echo -e "${BLUE}Release v$new_version${NC}"
echo "================================"
echo -e "Version actuelle: ${YELLOW}$current_version${NC}"
echo -e "Nouvelle version: ${GREEN}$new_version${NC}"
echo ""
# Vérifier qu'on n'est pas sur une version identique
if [ "$current_version" = "$new_version" ]; then
echo -e "${YELLOW}La version est déjà $new_version${NC}"
exit 0
fi
# Demander confirmation
read -p "Continuer ? (y/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Annulé."
exit 0
fi
cd "$PROJECT_ROOT"
# ===========================================
# ÉTAPE 1 : Gérer le submodule frontend
# ===========================================
echo ""
echo -e "${BLUE}[1/6]${NC} Vérification du submodule frontend..."
cd "$FRONTEND_DIR"
# Vérifier s'il y a des changements non commités dans le submodule
if ! git diff --quiet --exit-code || ! git diff --cached --quiet --exit-code; then
echo -e "${YELLOW}Changements détectés dans le submodule frontend${NC}"
git status --short
echo ""
read -p "Commiter ces changements dans le submodule ? (y/N) " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
git add -A
git commit -m "chore(release) : prepare v$new_version"
else
echo -e "${RED}Erreur:${NC} Veuillez d'abord commiter les changements du submodule."
exit 1
fi
fi
# ===========================================
# ÉTAPE 2 : Tag le submodule
# ===========================================
echo -e "${BLUE}[2/6]${NC} Création du tag v$new_version dans le submodule..."
# Vérifier si le tag existe déjà
if git rev-parse "v$new_version" >/dev/null 2>&1; then
echo -e "${YELLOW}Le tag v$new_version existe déjà dans le submodule${NC}"
else
git tag -a "v$new_version" -m "Release v$new_version"
echo -e "${GREEN}Tag v$new_version créé dans le submodule${NC}"
fi
# Retourner au projet principal
cd "$PROJECT_ROOT"
# ===========================================
# ÉTAPE 3 : Mettre à jour VERSION
# ===========================================
echo -e "${BLUE}[3/6]${NC} Mise à jour du fichier VERSION..."
echo "$new_version" > "$VERSION_FILE"
# ===========================================
# ÉTAPE 4 : Mettre à jour api_platform.yaml
# ===========================================
echo -e "${BLUE}[4/6]${NC} Mise à jour de api_platform.yaml..."
sed -i "s/version: .*/version: $new_version/" "$API_PLATFORM_FILE"
# ===========================================
# ÉTAPE 5 : Commit principal (avec mise à jour du submodule)
# ===========================================
echo -e "${BLUE}[5/6]${NC} Création du commit principal..."
git add "$VERSION_FILE" "$API_PLATFORM_FILE" "$FRONTEND_DIR"
git commit -m "chore(release) : v$new_version"
# ===========================================
# ÉTAPE 6 : Tag principal
# ===========================================
echo -e "${BLUE}[6/6]${NC} Création du tag v$new_version..."
git tag -a "v$new_version" -m "Release v$new_version"
echo ""
echo -e "${GREEN}✓ Release v$new_version préparée avec succès !${NC}"
echo ""
echo "================================"
echo -e "${YELLOW}Prochaines étapes :${NC}"
echo ""
echo "1. Pousser le submodule frontend :"
echo -e " ${BLUE}cd Inventory_frontend && git push && git push --tags && cd ..${NC}"
echo ""
echo "2. Pousser le projet principal :"
echo -e " ${BLUE}git push && git push --tags${NC}"
echo ""
echo "3. Créer les releases sur Gitea :"
echo " - Inventory_frontend : tag v$new_version"
echo " - Inventory (backend) : tag v$new_version"
echo ""
echo "================================"

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
function parseDatabaseUrl(string $url): array
{
$parts = parse_url($url);
if ($parts === false) {
throw new RuntimeException('Invalid database URL.');
}
$host = $parts['host'] ?? 'localhost';
$port = isset($parts['port']) ? (int) $parts['port'] : 5432;
$user = $parts['user'] ?? '';
$pass = $parts['pass'] ?? '';
$db = ltrim($parts['path'] ?? '', '/');
return [
'dsn' => sprintf('pgsql:host=%s;port=%d;dbname=%s', $host, $port, $db),
'user' => $user,
'pass' => $pass,
];
}
function connect(string $url): PDO
{
$config = parseDatabaseUrl($url);
$pdo = new PDO($config['dsn'], $config['user'], $config['pass'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
return $pdo;
}
$sourceUrl = getenv('SOURCE_DATABASE_URL') ?: '';
$targetUrl = getenv('TARGET_DATABASE_URL') ?: '';
if ($sourceUrl === '' || $targetUrl === '') {
fwrite(STDERR, "Usage: SOURCE_DATABASE_URL=... TARGET_DATABASE_URL=... php scripts/validate-migration.php\n");
exit(1);
}
$tables = [
'sites',
'type_machines',
'machines',
'model_types',
'composants',
'pieces',
'products',
'constructeurs',
'documents',
'custom_fields',
'custom_field_values',
'machine_component_links',
'machine_piece_links',
'machine_product_links',
'type_machine_component_requirements',
'type_machine_piece_requirements',
'type_machine_product_requirements',
'_machineconstructeurs',
'_composantconstructeurs',
'_piececonstructeurs',
'_productconstructeurs',
'profiles',
];
$skipTables = array_filter(array_map('trim', explode(',', getenv('SKIP_TABLES') ?: 'profiles')));
$sourceTableMap = [
'model_types' => ['ModelType', 'model_types'],
'_machineconstructeurs' => ['_MachineConstructeurs', '_machineconstructeurs'],
'_composantconstructeurs' => ['_ComposantConstructeurs', '_composantconstructeurs'],
'_piececonstructeurs' => ['_PieceConstructeurs', '_piececonstructeurs'],
'_productconstructeurs' => ['_ProductConstructeurs', '_productconstructeurs'],
];
function resolveTable(PDO $db, array $candidates): ?string
{
foreach ($candidates as $candidate) {
$exists = (bool) $db
->query(sprintf("SELECT to_regclass('public.%s')", $candidate))
->fetchColumn();
if ($exists) {
return $candidate;
}
$quoted = (bool) $db
->query(sprintf("SELECT to_regclass('public.\"%s\"')", $candidate))
->fetchColumn();
if ($quoted) {
return sprintf('"%s"', $candidate);
}
}
return null;
}
$source = connect($sourceUrl);
$target = connect($targetUrl);
$hasDifferences = false;
foreach ($tables as $table) {
if (in_array($table, $skipTables, true)) {
continue;
}
$sourceCandidates = $sourceTableMap[$table] ?? [$table];
$sourceTable = resolveTable($source, $sourceCandidates);
$sourceExists = $sourceTable !== null;
$targetExists = (bool) $target
->query(sprintf("SELECT to_regclass('public.%s')", $table))
->fetchColumn();
if (!$sourceExists || !$targetExists) {
$hasDifferences = true;
printf(
"%s: source=%s target=%s\n",
$table,
$sourceExists ? 'exists' : 'missing',
$targetExists ? 'exists' : 'missing'
);
continue;
}
$sourceCount = (int) $source->query(sprintf('SELECT COUNT(*) FROM public.%s', $sourceTable))->fetchColumn();
$targetCount = (int) $target->query(sprintf('SELECT COUNT(*) FROM public.%s', $table))->fetchColumn();
if ($sourceCount !== $targetCount) {
$hasDifferences = true;
printf("%s: source=%d target=%d\n", $table, $sourceCount, $targetCount);
}
}
if ($hasDifferences) {
exit(2);
}
echo "Counts match for all tables.\n";

View File

@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Repository\DocumentRepository;
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;
#[AsCommand(
name: 'app:compress-pdf',
description: 'Compress all PDF documents stored in database without quality loss',
)]
class CompressPdfCommand extends Command
{
public function __construct(
private readonly DocumentRepository $documentRepository,
private readonly EntityManagerInterface $em,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be compressed without actually doing it')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = $input->getOption('dry-run');
// Check if qpdf is installed
exec('which qpdf', $qpdfPath, $returnCode);
if (0 !== $returnCode) {
$io->error('qpdf is not installed. Run: sudo apt install qpdf');
return Command::FAILURE;
}
$documents = $this->documentRepository->findBy(['mimeType' => 'application/pdf']);
if (empty($documents)) {
$io->info('No PDF documents found.');
return Command::SUCCESS;
}
$io->title('PDF Compression');
$io->text(sprintf('Found %d PDF documents', count($documents)));
$totalSaved = 0;
$compressed = 0;
foreach ($documents as $document) {
$base64Data = $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
));
} else {
$io->text(sprintf(
' - %s: Already optimal (%s)',
$document->getName(),
$this->formatBytes($originalSize)
));
}
@unlink($tempInput);
@unlink($tempOutput);
}
if (!$dryRun && $compressed > 0) {
$this->em->flush();
$io->success(sprintf(
'Compressed %d/%d PDFs. Total space saved: %s',
$compressed,
count($documents),
$this->formatBytes($totalSaved)
));
} elseif ($dryRun) {
$io->info('Dry run completed. No changes made.');
} else {
$io->info('No PDFs needed compression.');
}
return 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];
}
}

View File

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

View File

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

View File

@@ -0,0 +1,294 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\CustomField;
use App\Entity\CustomFieldValue;
use App\Repository\ComposantRepository;
use App\Repository\CustomFieldRepository;
use App\Repository\CustomFieldValueRepository;
use App\Repository\MachineRepository;
use App\Repository\PieceRepository;
use App\Repository\ProductRepository;
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/custom-fields/values')]
class CustomFieldValueController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly CustomFieldRepository $customFieldRepository,
private readonly CustomFieldValueRepository $customFieldValueRepository,
private readonly MachineRepository $machineRepository,
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
{
$payload = $this->decodePayload($request);
if ($payload instanceof JsonResponse) {
return $payload;
}
$customField = $this->resolveCustomField($payload);
if ($customField instanceof JsonResponse) {
return $customField;
}
$target = $this->resolveTarget($payload);
if ($target instanceof JsonResponse) {
return $target;
}
$value = new CustomFieldValue();
$value->setCustomField($customField);
$value->setValue((string) ($payload['value'] ?? ''));
$this->applyTarget($value, $target['type'], $target['entity']);
$this->entityManager->persist($value);
$this->entityManager->flush();
return $this->json($this->normalizeCustomFieldValue($value));
}
#[Route('/upsert', name: 'custom_field_values_upsert', methods: ['POST'])]
public function upsert(Request $request): JsonResponse
{
$payload = $this->decodePayload($request);
if ($payload instanceof JsonResponse) {
return $payload;
}
$customField = $this->resolveCustomField($payload);
if ($customField instanceof JsonResponse) {
return $customField;
}
$target = $this->resolveTarget($payload);
if ($target instanceof JsonResponse) {
return $target;
}
$existing = $this->customFieldValueRepository->findOneBy([
'customField' => $customField,
$target['type'] => $target['entity'],
]);
if ($existing instanceof CustomFieldValue) {
$existing->setValue((string) ($payload['value'] ?? ''));
$this->entityManager->flush();
return $this->json($this->normalizeCustomFieldValue($existing));
}
$value = new CustomFieldValue();
$value->setCustomField($customField);
$value->setValue((string) ($payload['value'] ?? ''));
$this->applyTarget($value, $target['type'], $target['entity']);
$this->entityManager->persist($value);
$this->entityManager->flush();
return $this->json($this->normalizeCustomFieldValue($value));
}
#[Route('/{entityType}/{entityId}', name: 'custom_field_values_list', methods: ['GET'])]
public function listByEntity(string $entityType, string $entityId): JsonResponse
{
$target = $this->resolveTarget([
'entityType' => $entityType,
'entityId' => $entityId,
]);
if ($target instanceof JsonResponse) {
return $target;
}
$values = $this->customFieldValueRepository->findBy([
$target['type'] => $target['entity'],
]);
return $this->json(array_map(
fn (CustomFieldValue $value) => $this->normalizeCustomFieldValue($value),
$values
));
}
#[Route('/{id}', name: 'custom_field_values_update', methods: ['PATCH'])]
public function update(string $id, Request $request): JsonResponse
{
$value = $this->customFieldValueRepository->find($id);
if (!$value instanceof CustomFieldValue) {
return $this->json(['success' => false, 'error' => 'Custom field value not found.'], 404);
}
$payload = $this->decodePayload($request);
if ($payload instanceof JsonResponse) {
return $payload;
}
if (array_key_exists('value', $payload)) {
$value->setValue((string) $payload['value']);
}
$this->entityManager->flush();
return $this->json($this->normalizeCustomFieldValue($value));
}
#[Route('/{id}', name: 'custom_field_values_delete', methods: ['DELETE'])]
public function delete(string $id): JsonResponse
{
$value = $this->customFieldValueRepository->find($id);
if (!$value instanceof CustomFieldValue) {
return $this->json(['success' => false, 'error' => 'Custom field value not found.'], 404);
}
$this->entityManager->remove($value);
$this->entityManager->flush();
return $this->json(['success' => true]);
}
private function decodePayload(Request $request): array|JsonResponse
{
$payload = json_decode($request->getContent(), true);
if (!is_array($payload)) {
return $this->json(['success' => false, 'error' => 'Invalid JSON payload.'], 400);
}
return $payload;
}
private function resolveCustomField(array $payload): CustomField|JsonResponse
{
$customFieldId = isset($payload['customFieldId']) ? trim((string) $payload['customFieldId']) : '';
if ('' !== $customFieldId) {
$customField = $this->customFieldRepository->find($customFieldId);
if ($customField instanceof CustomField) {
return $customField;
}
return $this->json(['success' => false, 'error' => 'Custom field not found.'], 404);
}
$customFieldName = isset($payload['customFieldName']) ? trim((string) $payload['customFieldName']) : '';
if ('' === $customFieldName) {
return $this->json(['success' => false, 'error' => 'customFieldId or customFieldName is required.'], 400);
}
$customField = new CustomField();
$customField->setName($customFieldName);
$customField->setType((string) ($payload['customFieldType'] ?? 'text'));
$customField->setRequired((bool) ($payload['customFieldRequired'] ?? false));
$options = $payload['customFieldOptions'] ?? null;
if (is_array($options)) {
$customField->setOptions($options);
}
$this->entityManager->persist($customField);
return $customField;
}
private function resolveTarget(array $payload): array|JsonResponse
{
$entityType = isset($payload['entityType']) ? strtolower((string) $payload['entityType']) : '';
$entityId = isset($payload['entityId']) ? trim((string) $payload['entityId']) : '';
if ('' === $entityType || '' === $entityId) {
foreach (['machine', 'composant', 'piece', 'product'] as $candidate) {
$key = $candidate.'Id';
if (!isset($payload[$key])) {
continue;
}
$entityType = $candidate;
$entityId = trim((string) $payload[$key]);
break;
}
}
if ('' === $entityType || '' === $entityId) {
return $this->json(['success' => false, 'error' => 'Entity target is missing.'], 400);
}
return match ($entityType) {
'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),
};
}
private function resolveEntity(string $type, string $id, $repository): array|JsonResponse
{
$entity = $repository->find($id);
if (!$entity) {
return $this->json(['success' => false, 'error' => sprintf('%s not found.', $type)], 404);
}
return ['type' => $type, 'entity' => $entity];
}
private function applyTarget(CustomFieldValue $value, string $type, object $entity): void
{
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;
}
}
private function normalizeCustomFieldValue(CustomFieldValue $value): array
{
$customField = $value->getCustomField();
return [
'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(),
'orderIndex' => $customField->getOrderIndex(),
],
'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),
];
}
}

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Document;
use App\Repository\ComposantRepository;
use App\Repository\DocumentRepository;
use App\Repository\MachineRepository;
use App\Repository\PieceRepository;
use App\Repository\ProductRepository;
use App\Repository\SiteRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/documents')]
class DocumentQueryController extends AbstractController
{
public function __construct(
private readonly DocumentRepository $documentRepository,
private readonly SiteRepository $siteRepository,
private readonly MachineRepository $machineRepository,
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
{
$site = $this->siteRepository->find($id);
if (!$site) {
return $this->json(['success' => false, 'error' => 'Site not found.'], 404);
}
$documents = $this->documentRepository->findBy(['site' => $site]);
return $this->json($this->normalizeDocuments($documents));
}
#[Route('/machine/{id}', name: 'documents_by_machine', methods: ['GET'])]
public function listByMachine(string $id): JsonResponse
{
$machine = $this->machineRepository->find($id);
if (!$machine) {
return $this->json(['success' => false, 'error' => 'Machine not found.'], 404);
}
$documents = $this->documentRepository->findBy(['machine' => $machine]);
return $this->json($this->normalizeDocuments($documents));
}
#[Route('/composant/{id}', name: 'documents_by_composant', methods: ['GET'])]
public function listByComposant(string $id): JsonResponse
{
$composant = $this->composantRepository->find($id);
if (!$composant) {
return $this->json(['success' => false, 'error' => 'Composant not found.'], 404);
}
$documents = $this->documentRepository->findBy(['composant' => $composant]);
return $this->json($this->normalizeDocuments($documents));
}
#[Route('/piece/{id}', name: 'documents_by_piece', methods: ['GET'])]
public function listByPiece(string $id): JsonResponse
{
$piece = $this->pieceRepository->find($id);
if (!$piece) {
return $this->json(['success' => false, 'error' => 'Piece not found.'], 404);
}
$documents = $this->documentRepository->findBy(['piece' => $piece]);
return $this->json($this->normalizeDocuments($documents));
}
#[Route('/product/{id}', name: 'documents_by_product', methods: ['GET'])]
public function listByProduct(string $id): JsonResponse
{
$product = $this->productRepository->find($id);
if (!$product) {
return $this->json(['success' => false, 'error' => 'Product not found.'], 404);
}
$documents = $this->documentRepository->findBy(['product' => $product]);
return $this->json($this->normalizeDocuments($documents));
}
/**
* @param Document[] $documents
*/
private function normalizeDocuments(array $documents): array
{
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(),
'composantId' => $document->getComposant()?->getId(),
'pieceId' => $document->getPiece()?->getId(),
'productId' => $document->getProduct()?->getId(),
'createdAt' => $document->getCreatedAt()->format(DATE_ATOM),
'updatedAt' => $document->getUpdatedAt()->format(DATE_ATOM),
];
}, $documents);
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\CustomField;
use App\Entity\CustomFieldValue;
use App\Entity\Machine;
use App\Repository\CustomFieldValueRepository;
use App\Repository\MachineRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/machines')]
class MachineCustomFieldsController extends AbstractController
{
public function __construct(
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
{
$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) {
if (!$customField instanceof CustomField) {
continue;
}
$existing = $this->customFieldValueRepository->findOneBy([
'machine' => $machine,
'customField' => $customField,
]);
if ($existing instanceof CustomFieldValue) {
continue;
}
$value = new CustomFieldValue();
$value->setMachine($machine);
$value->setCustomField($customField);
$value->setValue($customField->getDefaultValue() ?? '');
$this->entityManager->persist($value);
}
$this->entityManager->flush();
$values = $this->customFieldValueRepository->findBy(['machine' => $machine]);
return $this->json([
'success' => true,
'machineId' => $machine->getId(),
'customFieldValues' => array_map(
static fn (CustomFieldValue $value) => [
'id' => $value->getId(),
'value' => $value->getValue(),
'customFieldId' => $value->getCustomField()->getId(),
],
$values
),
]);
}
}

View File

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

View File

@@ -0,0 +1,766 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Composant;
use App\Entity\CustomField;
use App\Entity\Machine;
use App\Entity\MachineComponentLink;
use App\Entity\MachinePieceLink;
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 track which IDs are children
$childIds = [];
foreach ($normalizedComponentLinks as $link) {
$parentId = $link['parentComponentLinkId'] ?? null;
if ($parentId && isset($componentIndex[$parentId])) {
$componentIndex[$parentId]['childLinks'][] = $link;
$childIds[$link['id']] = true;
}
}
// Add pieces to components recursively
$this->attachPiecesToComponents($componentIndex, $normalizedPieceLinks);
// Only return root-level components (exclude children already nested)
$rootComponents = array_filter(
$componentIndex,
static fn (array $link) => !isset($childIds[$link['id']]),
);
return [
'machine' => $this->normalizeMachine($machine),
'componentLinks' => array_values($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;
}
}
// 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 (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));
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\ProfileRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Routing\Attribute\Route;
final class SessionProfileController
{
public function __construct(private readonly ProfileRepository $profiles) {}
#[Route('/api/session/profile', name: 'api_session_profile_get', methods: ['GET'])]
public function getActiveProfile(Request $request): JsonResponse
{
$session = $request->getSession();
if (!$session instanceof SessionInterface) {
return new JsonResponse(['message' => 'Session indisponible.'], JsonResponse::HTTP_INTERNAL_SERVER_ERROR);
}
$profileId = $session->get('profileId');
if (!$profileId) {
return new JsonResponse(['message' => 'Aucun profil actif.'], JsonResponse::HTTP_UNAUTHORIZED);
}
$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(),
'firstName' => $profile->getFirstName(),
'lastName' => $profile->getLastName(),
'email' => $profile->getEmail(),
'isActive' => $profile->isActive(),
'roles' => $profile->getRoles(),
]);
}
#[Route('/api/session/profile', name: 'api_session_profile_post', methods: ['POST'])]
public function activateProfile(Request $request): JsonResponse
{
$session = $request->getSession();
if (!$session instanceof SessionInterface) {
return new JsonResponse(['message' => 'Session indisponible.'], JsonResponse::HTTP_INTERNAL_SERVER_ERROR);
}
$payload = $request->toArray();
$profileId = $payload['profileId'] ?? null;
if (!$profileId) {
return new JsonResponse(['message' => 'profileId est requis.'], JsonResponse::HTTP_BAD_REQUEST);
}
$profile = $this->profiles->find($profileId);
if (!$profile || !$profile->isActive()) {
return new JsonResponse(['message' => 'Profil introuvable ou inactif.'], JsonResponse::HTTP_UNAUTHORIZED);
}
$session->set('profileId', $profile->getId());
return new JsonResponse([
'id' => $profile->getId(),
'firstName' => $profile->getFirstName(),
'lastName' => $profile->getLastName(),
'email' => $profile->getEmail(),
'isActive' => $profile->isActive(),
'roles' => $profile->getRoles(),
]);
}
#[Route('/api/session/profile', name: 'api_session_profile_delete', methods: ['DELETE'])]
public function logout(Request $request): JsonResponse
{
$session = $request->getSession();
if ($session instanceof SessionInterface) {
$session->invalidate();
}
return new JsonResponse(['success' => true]);
}
}

View File

@@ -0,0 +1,80 @@
<?php
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
{
$items = $this->profiles->createQueryBuilder('p')
->andWhere('p.isActive = :active')
->setParameter('active', true)
->orderBy('p.firstName', 'ASC')
->getQuery()
->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(),
];
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
class TestController extends AbstractController
{
#[Route('/api/test', name: 'api_test', methods: ['GET', 'POST'])]
public function test(): JsonResponse
{
return $this->json(['status' => 'ok', 'message' => 'Test endpoint works!']);
}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Doctrine\QuoteStrategy;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\ORM\Internal\SQLResultCasing;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\JoinColumnMapping;
use Doctrine\ORM\Mapping\ManyToManyOwningSideMapping;
use Doctrine\ORM\Mapping\QuoteStrategy;
use function array_map;
use function array_merge;
use function assert;
use function explode;
use function implode;
/**
* Quote all identifiers to preserve camelCase column names in Postgres.
*/
final class AlwaysQuoteStrategy implements QuoteStrategy
{
use SQLResultCasing;
public function getColumnName(string $fieldName, ClassMetadata $class, AbstractPlatform $platform): string
{
return $platform->quoteSingleIdentifier($class->fieldMappings[$fieldName]->columnName);
}
public function getTableName(ClassMetadata $class, AbstractPlatform $platform): string
{
$tableName = $platform->quoteSingleIdentifier($class->table['name']);
if (!empty($class->table['schema'])) {
return $platform->quoteSingleIdentifier($class->table['schema']).'.'.$tableName;
}
return $tableName;
}
public function getSequenceName(array $definition, ClassMetadata $class, AbstractPlatform $platform): string
{
return implode('.', array_map(
static fn (string $part) => $platform->quoteSingleIdentifier($part),
explode('.', $definition['sequenceName']),
));
}
public function getJoinTableName(
ManyToManyOwningSideMapping $association,
ClassMetadata $class,
AbstractPlatform $platform,
): string {
$schema = '';
if (isset($association->joinTable->schema)) {
$schema = $platform->quoteSingleIdentifier($association->joinTable->schema).'.';
}
return $schema.$platform->quoteSingleIdentifier($association->joinTable->name);
}
public function getJoinColumnName(JoinColumnMapping $joinColumn, ClassMetadata $class, AbstractPlatform $platform): string
{
return $platform->quoteSingleIdentifier($joinColumn->name);
}
public function getReferencedJoinColumnName(
JoinColumnMapping $joinColumn,
ClassMetadata $class,
AbstractPlatform $platform,
): string {
return $platform->quoteSingleIdentifier($joinColumn->referencedColumnName);
}
public function getIdentifierColumnNames(ClassMetadata $class, AbstractPlatform $platform): array
{
$quotedColumnNames = [];
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;
$assocQuotedColumnNames = array_map(
static fn (JoinColumnMapping $joinColumn) => $platform->quoteSingleIdentifier($joinColumn->name),
$joinColumns,
);
$quotedColumnNames = array_merge($quotedColumnNames, $assocQuotedColumnNames);
}
return $quotedColumnNames;
}
public function getColumnAlias(
string $columnName,
int $counter,
AbstractPlatform $platform,
?ClassMetadata $class = null,
): string {
return $this->getSQLResultCasing($platform, $columnName.'_'.$counter);
}
}

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

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\AuditLogRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AuditLogRepository::class)]
#[ORM\Table(name: 'audit_logs')]
#[ORM\Index(name: 'idx_audit_entity', columns: ['entityType', 'entityId'])]
#[ORM\Index(name: 'idx_audit_created_at', columns: ['createdAt'])]
#[ORM\HasLifecycleCallbacks]
class AuditLog
{
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 50)]
private string $entityType;
#[ORM\Column(type: Types::STRING, length: 36)]
private string $entityId;
#[ORM\Column(type: Types::STRING, length: 20)]
private string $action;
#[ORM\Column(type: Types::JSON, nullable: true)]
private ?array $diff = null;
#[ORM\Column(type: Types::JSON, nullable: true)]
private ?array $snapshot = null;
#[ORM\Column(type: Types::STRING, length: 36, nullable: true)]
private ?string $actorProfileId = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private DateTimeImmutable $createdAt;
public function __construct(
string $entityType,
string $entityId,
string $action,
?array $diff = null,
?array $snapshot = null,
?string $actorProfileId = null,
) {
$this->entityType = $entityType;
$this->entityId = $entityId;
$this->action = $action;
$this->diff = $diff;
$this->snapshot = $snapshot;
$this->actorProfileId = $actorProfileId;
}
#[ORM\PrePersist]
public function initializeAuditLog(): void
{
if (!isset($this->createdAt)) {
$this->createdAt = new DateTimeImmutable();
}
if (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);
}
}

282
src/Entity/Composant.php Normal file
View File

@@ -0,0 +1,282 @@
<?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 App\Repository\ComposantRepository;
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: ComposantRepository::class)]
#[ORM\Table(name: 'composants')]
#[ORM\HasLifecycleCallbacks]
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typeComposant' => 'exact'])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])]
#[ApiResource(
normalizationContext: ['groups' => ['composant:read']],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 200
)]
class Composant
{
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['composant:read', 'document:list'])]
private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
#[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::DECIMAL, precision: 10, scale: 2, nullable: true)]
#[Groups(['composant:read'])]
private ?string $prix = null;
#[ORM\Column(type: Types::JSON, nullable: true)]
#[Groups(['composant:read'])]
private ?array $structure = null;
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'composants')]
#[ORM\JoinColumn(name: 'typeComposantId', referencedColumnName: 'id', nullable: true)]
#[Groups(['composant:read'])]
private ?ModelType $typeComposant = null;
#[ORM\ManyToOne(targetEntity: Product::class, inversedBy: 'composants')]
#[ORM\JoinColumn(name: 'productId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['composant:read'])]
private ?Product $product = null;
/**
* @var Collection<int, Constructeur>
*/
#[ORM\ManyToMany(targetEntity: Constructeur::class, inversedBy: 'composants')]
#[ORM\JoinTable(
name: '_ComposantConstructeurs',
joinColumns: [new ORM\JoinColumn(name: 'A', referencedColumnName: 'id', onDelete: 'CASCADE')],
inverseJoinColumns: [new ORM\InverseJoinColumn(name: 'B', referencedColumnName: 'id', onDelete: 'CASCADE')]
)]
#[Groups(['composant:read'])]
private Collection $constructeurs;
/**
* @var Collection<int, Document>
*/
#[ORM\OneToMany(mappedBy: 'composant', targetEntity: Document::class)]
#[Groups(['composant:read'])]
private Collection $documents;
/**
* @var Collection<int, CustomFieldValue>
*/
#[ORM\OneToMany(mappedBy: 'composant', targetEntity: CustomFieldValue::class)]
#[Groups(['composant:read'])]
private Collection $customFieldValues;
/**
* @var Collection<int, MachineComponentLink>
*/
#[ORM\OneToMany(mappedBy: 'composant', targetEntity: MachineComponentLink::class)]
private Collection $machineLinks;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
#[Groups(['composant:read'])]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
#[Groups(['composant:read'])]
private DateTimeImmutable $updatedAt;
public function __construct()
{
$this->constructeurs = new ArrayCollection();
$this->documents = new ArrayCollection();
$this->customFieldValues = new ArrayCollection();
$this->machineLinks = new ArrayCollection();
}
#[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 setId(string $id): static
{
$this->id = $id;
return $this;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = mb_strtoupper(mb_substr($name, 0, 1)).mb_substr($name, 1);
return $this;
}
public function getReference(): ?string
{
return $this->reference;
}
public function setReference(?string $reference): static
{
$this->reference = $reference;
return $this;
}
public function getPrix(): ?string
{
return $this->prix;
}
public function setPrix(?string $prix): static
{
$this->prix = $prix;
return $this;
}
public function getStructure(): ?array
{
return $this->structure;
}
public function setStructure(?array $structure): static
{
$this->structure = $structure;
return $this;
}
public function getTypeComposant(): ?ModelType
{
return $this->typeComposant;
}
public function setTypeComposant(?ModelType $typeComposant): static
{
$this->typeComposant = $typeComposant;
return $this;
}
public function getProduct(): ?Product
{
return $this->product;
}
public function setProduct(?Product $product): static
{
$this->product = $product;
return $this;
}
/**
* @return Collection<int, Constructeur>
*/
public function getConstructeurs(): Collection
{
return $this->constructeurs;
}
/**
* @param iterable<Constructeur> $constructeurs
*/
public function setConstructeurs(iterable $constructeurs): static
{
$this->constructeurs = new ArrayCollection();
foreach ($constructeurs as $constructeur) {
if ($constructeur instanceof Constructeur && !$this->constructeurs->contains($constructeur)) {
$this->constructeurs->add($constructeur);
}
}
return $this;
}
public function addConstructeur(Constructeur $constructeur): static
{
if (!$this->constructeurs->contains($constructeur)) {
$this->constructeurs->add($constructeur);
}
return $this;
}
public function removeConstructeur(Constructeur $constructeur): static
{
$this->constructeurs->removeElement($constructeur);
return $this;
}
/**
* @return Collection<int, Document>
*/
public function getDocuments(): Collection
{
return $this->documents;
}
/**
* @return Collection<int, CustomFieldValue>
*/
public function getCustomFieldValues(): Collection
{
return $this->customFieldValues;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): DateTimeImmutable
{
return $this->updatedAt;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

155
src/Entity/Constructeur.php Normal file
View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
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;
#[ORM\Entity(repositoryClass: ConstructeurRepository::class)]
#[ORM\Table(name: 'constructeurs')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 200
)]
class Constructeur
{
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
private string $name;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
private ?string $email = null;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
private ?string $phone = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private DateTimeImmutable $updatedAt;
/**
* @var Collection<int, Machine>
*/
#[ORM\ManyToMany(targetEntity: Machine::class, mappedBy: 'constructeurs')]
private Collection $machines;
/**
* @var Collection<int, Composant>
*/
#[ORM\ManyToMany(targetEntity: Composant::class, mappedBy: 'constructeurs')]
private Collection $composants;
/**
* @var Collection<int, Piece>
*/
#[ORM\ManyToMany(targetEntity: Piece::class, mappedBy: 'constructeurs')]
private Collection $pieces;
/**
* @var Collection<int, Product>
*/
#[ORM\ManyToMany(targetEntity: Product::class, mappedBy: 'constructeurs')]
private Collection $products;
public function __construct()
{
$this->machines = new ArrayCollection();
$this->composants = new ArrayCollection();
$this->pieces = new ArrayCollection();
$this->products = new ArrayCollection();
}
#[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 setId(string $id): static
{
$this->id = $id;
return $this;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(?string $email): static
{
$this->email = $email;
return $this;
}
public function getPhone(): ?string
{
return $this->phone;
}
public function setPhone(?string $phone): static
{
$this->phone = $phone;
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));
}
}

211
src/Entity/CustomField.php Normal file
View File

@@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\CustomFieldRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: CustomFieldRepository::class)]
#[ORM\Table(name: 'custom_fields')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource]
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: ModelType::class, inversedBy: 'customFields')]
#[ORM\JoinColumn(name: 'typeComposantId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?ModelType $typeComposant = null;
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'pieceCustomFields')]
#[ORM\JoinColumn(name: 'typePieceId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?ModelType $typePiece = null;
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'productCustomFields')]
#[ORM\JoinColumn(name: 'typeProductId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?ModelType $typeProduct = null;
/**
* @var Collection<int, CustomFieldValue>
*/
#[ORM\OneToMany(mappedBy: 'customField', targetEntity: CustomFieldValue::class)]
private Collection $customFieldValues;
#[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->customFieldValues = new ArrayCollection();
}
#[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 setId(string $id): static
{
$this->id = $id;
return $this;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getType(): string
{
return $this->type;
}
public function setType(string $type): static
{
$this->type = $type;
return $this;
}
public function isRequired(): bool
{
return $this->required;
}
public function setRequired(bool $required): static
{
$this->required = $required;
return $this;
}
public function getDefaultValue(): ?string
{
return $this->defaultValue;
}
public function setDefaultValue(?string $defaultValue): static
{
$this->defaultValue = $defaultValue;
return $this;
}
public function getOptions(): ?array
{
return $this->options;
}
public function setOptions(?array $options): static
{
$this->options = $options;
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 getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): DateTimeImmutable
{
return $this->updatedAt;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\CustomFieldValueRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: CustomFieldValueRepository::class)]
#[ORM\Table(name: 'custom_field_values')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource]
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')]
#[ORM\JoinColumn(name: 'machineId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?Machine $machine = null;
#[ORM\ManyToOne(targetEntity: Composant::class, inversedBy: 'customFieldValues')]
#[ORM\JoinColumn(name: 'composantId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?Composant $composant = null;
#[ORM\ManyToOne(targetEntity: Piece::class, inversedBy: 'customFieldValues')]
#[ORM\JoinColumn(name: 'pieceId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?Piece $piece = null;
#[ORM\ManyToOne(targetEntity: Product::class, inversedBy: 'customFieldValues')]
#[ORM\JoinColumn(name: 'productId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?Product $product = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
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 setId(string $id): static
{
$this->id = $id;
return $this;
}
public function getValue(): string
{
return $this->value;
}
public function setValue(string $value): static
{
$this->value = $value;
return $this;
}
public function getCustomField(): CustomField
{
return $this->customField;
}
public function setCustomField(CustomField $customField): static
{
$this->customField = $customField;
return $this;
}
public function getMachine(): ?Machine
{
return $this->machine;
}
public function setMachine(?Machine $machine): static
{
$this->machine = $machine;
return $this;
}
public function getComposant(): ?Composant
{
return $this->composant;
}
public function setComposant(?Composant $composant): static
{
$this->composant = $composant;
return $this;
}
public function getPiece(): ?Piece
{
return $this->piece;
}
public function setPiece(?Piece $piece): static
{
$this->piece = $piece;
return $this;
}
public function getProduct(): ?Product
{
return $this->product;
}
public function setProduct(?Product $product): static
{
$this->product = $product;
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));
}
}

256
src/Entity/Document.php Normal file
View File

@@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Repository\DocumentRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: DocumentRepository::class)]
#[ORM\Table(name: 'documents')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
operations: [
new GetCollection(normalizationContext: ['groups' => ['document:list']]),
new Get(normalizationContext: ['groups' => ['document:list', 'document:detail']]),
new Post(),
new Put(),
new Delete(),
],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 200
)]
class Document
{
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['document:list', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255)]
#[Groups(['document:list', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
private string $name;
#[ORM\Column(type: Types::STRING, length: 255)]
#[Groups(['document:list', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
private string $filename;
#[ORM\Column(type: Types::TEXT)]
#[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:list', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
private string $mimeType;
#[ORM\Column(type: Types::INTEGER)]
#[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')]
#[Groups(['document:list'])]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
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 setId(string $id): static
{
$this->id = $id;
return $this;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getFilename(): string
{
return $this->filename;
}
public function setFilename(string $filename): static
{
$this->filename = $filename;
return $this;
}
public function getPath(): string
{
return $this->path;
}
public function setPath(string $path): static
{
$this->path = $path;
return $this;
}
public function getMimeType(): string
{
return $this->mimeType;
}
public function setMimeType(string $mimeType): static
{
$this->mimeType = $mimeType;
return $this;
}
public function getSize(): int
{
return $this->size;
}
public function setSize(int $size): static
{
$this->size = $size;
return $this;
}
public function getMachine(): ?Machine
{
return $this->machine;
}
public function setMachine(?Machine $machine): static
{
$this->machine = $machine;
return $this;
}
public function getComposant(): ?Composant
{
return $this->composant;
}
public function setComposant(?Composant $composant): static
{
$this->composant = $composant;
return $this;
}
public function getPiece(): ?Piece
{
return $this->piece;
}
public function setPiece(?Piece $piece): static
{
$this->piece = $piece;
return $this;
}
public function getProduct(): ?Product
{
return $this->product;
}
public function setProduct(?Product $product): static
{
$this->product = $product;
return $this;
}
public function getSite(): ?Site
{
return $this->site;
}
public function setSite(?Site $site): static
{
$this->site = $site;
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));
}
}

254
src/Entity/Machine.php Normal file
View File

@@ -0,0 +1,254 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\MachineRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: MachineRepository::class)]
#[ORM\Table(name: 'machines')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource]
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)]
private ?string $reference = null;
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2, nullable: true)]
private ?string $prix = null;
#[ORM\ManyToOne(targetEntity: Site::class, inversedBy: 'machines')]
#[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>
*/
#[ORM\ManyToMany(targetEntity: Constructeur::class, inversedBy: 'machines')]
#[ORM\JoinTable(
name: '_MachineConstructeurs',
joinColumns: [new ORM\JoinColumn(name: 'A', referencedColumnName: 'id', onDelete: 'CASCADE')],
inverseJoinColumns: [new ORM\InverseJoinColumn(name: 'B', referencedColumnName: 'id', onDelete: 'CASCADE')]
)]
private Collection $constructeurs;
/**
* @var Collection<int, MachineComponentLink>
*/
#[ORM\OneToMany(mappedBy: 'machine', targetEntity: MachineComponentLink::class)]
private Collection $componentLinks;
/**
* @var Collection<int, MachinePieceLink>
*/
#[ORM\OneToMany(mappedBy: 'machine', targetEntity: MachinePieceLink::class)]
private Collection $pieceLinks;
/**
* @var Collection<int, MachineProductLink>
*/
#[ORM\OneToMany(mappedBy: 'machine', targetEntity: MachineProductLink::class)]
private Collection $productLinks;
/**
* @var Collection<int, Document>
*/
#[ORM\OneToMany(mappedBy: 'machine', targetEntity: Document::class)]
private Collection $documents;
/**
* @var Collection<int, CustomFieldValue>
*/
#[ORM\OneToMany(mappedBy: 'machine', targetEntity: CustomFieldValue::class)]
private Collection $customFieldValues;
#[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->constructeurs = new ArrayCollection();
$this->componentLinks = new ArrayCollection();
$this->pieceLinks = new ArrayCollection();
$this->productLinks = new ArrayCollection();
$this->documents = new ArrayCollection();
$this->customFieldValues = new ArrayCollection();
}
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$now = new DateTimeImmutable();
$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 setId(string $id): static
{
$this->id = $id;
return $this;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getReference(): ?string
{
return $this->reference;
}
public function setReference(?string $reference): static
{
$this->reference = $reference;
return $this;
}
public function getPrix(): ?string
{
return $this->prix;
}
public function setPrix(?string $prix): static
{
$this->prix = $prix;
return $this;
}
public function getSite(): Site
{
return $this->site;
}
public function setSite(Site $site): static
{
$this->site = $site;
return $this;
}
public function getTypeMachine(): ?TypeMachine
{
return $this->typeMachine;
}
public function setTypeMachine(?TypeMachine $typeMachine): static
{
$this->typeMachine = $typeMachine;
return $this;
}
/**
* @return Collection<int, Constructeur>
*/
public function getConstructeurs(): Collection
{
return $this->constructeurs;
}
/**
* @return Collection<int, MachineComponentLink>
*/
public function getComponentLinks(): Collection
{
return $this->componentLinks;
}
/**
* @return Collection<int, MachinePieceLink>
*/
public function getPieceLinks(): Collection
{
return $this->pieceLinks;
}
/**
* @return Collection<int, MachineProductLink>
*/
public function getProductLinks(): Collection
{
return $this->productLinks;
}
/**
* @return Collection<int, Document>
*/
public function getDocuments(): Collection
{
return $this->documents;
}
/**
* @return Collection<int, CustomFieldValue>
*/
public function getCustomFieldValues(): Collection
{
return $this->customFieldValues;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): DateTimeImmutable
{
return $this->updatedAt;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\MachineComponentLinkRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: MachineComponentLinkRepository::class)]
#[ORM\Table(name: 'machine_component_links')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource]
class MachineComponentLink
{
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private ?string $id = null;
#[ORM\ManyToOne(targetEntity: Machine::class, inversedBy: 'componentLinks')]
#[ORM\JoinColumn(name: 'machineId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Machine $machine;
#[ORM\ManyToOne(targetEntity: Composant::class, inversedBy: 'machineLinks')]
#[ORM\JoinColumn(name: 'composantId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Composant $composant;
#[ORM\ManyToOne(targetEntity: MachineComponentLink::class, inversedBy: 'childLinks')]
#[ORM\JoinColumn(name: 'parentLinkId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?MachineComponentLink $parentLink = null;
/**
* @var Collection<int, 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>
*/
#[ORM\OneToMany(mappedBy: 'parentLink', targetEntity: MachinePieceLink::class)]
private Collection $pieceLinks;
/**
* @var Collection<int, MachineProductLink>
*/
#[ORM\OneToMany(mappedBy: 'parentComponentLink', targetEntity: MachineProductLink::class)]
private Collection $productLinks;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'nameOverride')]
private ?string $nameOverride = null;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'referenceOverride')]
private ?string $referenceOverride = null;
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2, nullable: true, name: 'prixOverride')]
private ?string $prixOverride = null;
#[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->childLinks = new ArrayCollection();
$this->pieceLinks = new ArrayCollection();
$this->productLinks = new ArrayCollection();
}
#[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 setId(string $id): static
{
$this->id = $id;
return $this;
}
public function getMachine(): Machine
{
return $this->machine;
}
public function setMachine(Machine $machine): static
{
$this->machine = $machine;
return $this;
}
public function getComposant(): Composant
{
return $this->composant;
}
public function setComposant(Composant $composant): static
{
$this->composant = $composant;
return $this;
}
public function getParentLink(): ?MachineComponentLink
{
return $this->parentLink;
}
public function setParentLink(?MachineComponentLink $parentLink): static
{
$this->parentLink = $parentLink;
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;
}
public function setNameOverride(?string $nameOverride): static
{
$this->nameOverride = $nameOverride;
return $this;
}
public function getReferenceOverride(): ?string
{
return $this->referenceOverride;
}
public function setReferenceOverride(?string $referenceOverride): static
{
$this->referenceOverride = $referenceOverride;
return $this;
}
public function getPrixOverride(): ?string
{
return $this->prixOverride;
}
public function setPrixOverride(?string $prixOverride): static
{
$this->prixOverride = $prixOverride;
return $this;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\MachinePieceLinkRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: MachinePieceLinkRepository::class)]
#[ORM\Table(name: 'machine_piece_links')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource]
class MachinePieceLink
{
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private ?string $id = null;
#[ORM\ManyToOne(targetEntity: Machine::class, inversedBy: 'pieceLinks')]
#[ORM\JoinColumn(name: 'machineId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Machine $machine;
#[ORM\ManyToOne(targetEntity: Piece::class, inversedBy: 'machineLinks')]
#[ORM\JoinColumn(name: 'pieceId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Piece $piece;
#[ORM\ManyToOne(targetEntity: MachineComponentLink::class, inversedBy: 'pieceLinks')]
#[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>
*/
#[ORM\OneToMany(mappedBy: 'parentPieceLink', targetEntity: MachineProductLink::class)]
private Collection $productLinks;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'nameOverride')]
private ?string $nameOverride = null;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'referenceOverride')]
private ?string $referenceOverride = null;
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2, nullable: true, name: 'prixOverride')]
private ?string $prixOverride = null;
#[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->productLinks = new ArrayCollection();
}
#[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 setId(string $id): static
{
$this->id = $id;
return $this;
}
public function getMachine(): Machine
{
return $this->machine;
}
public function setMachine(Machine $machine): static
{
$this->machine = $machine;
return $this;
}
public function getPiece(): Piece
{
return $this->piece;
}
public function setPiece(Piece $piece): static
{
$this->piece = $piece;
return $this;
}
public function getParentLink(): ?MachineComponentLink
{
return $this->parentLink;
}
public function setParentLink(?MachineComponentLink $parentLink): static
{
$this->parentLink = $parentLink;
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;
}
public function setNameOverride(?string $nameOverride): static
{
$this->nameOverride = $nameOverride;
return $this;
}
public function getReferenceOverride(): ?string
{
return $this->referenceOverride;
}
public function setReferenceOverride(?string $referenceOverride): static
{
$this->referenceOverride = $referenceOverride;
return $this;
}
public function getPrixOverride(): ?string
{
return $this->prixOverride;
}
public function setPrixOverride(?string $prixOverride): static
{
$this->prixOverride = $prixOverride;
return $this;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\MachineProductLinkRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: MachineProductLinkRepository::class)]
#[ORM\Table(name: 'machine_product_links')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource]
class MachineProductLink
{
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private ?string $id = null;
#[ORM\ManyToOne(targetEntity: Machine::class, inversedBy: 'productLinks')]
#[ORM\JoinColumn(name: 'machineId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Machine $machine;
#[ORM\ManyToOne(targetEntity: Product::class, inversedBy: 'machineLinks')]
#[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;
/**
* @var Collection<int, MachineProductLink>
*/
#[ORM\OneToMany(mappedBy: 'parentLink', targetEntity: MachineProductLink::class)]
private Collection $childLinks;
#[ORM\ManyToOne(targetEntity: MachineComponentLink::class, inversedBy: 'productLinks')]
#[ORM\JoinColumn(name: 'parentComponentLinkId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?MachineComponentLink $parentComponentLink = null;
#[ORM\ManyToOne(targetEntity: MachinePieceLink::class, inversedBy: 'productLinks')]
#[ORM\JoinColumn(name: 'parentPieceLinkId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?MachinePieceLink $parentPieceLink = null;
#[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->childLinks = new ArrayCollection();
}
#[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 setId(string $id): static
{
$this->id = $id;
return $this;
}
public function getMachine(): Machine
{
return $this->machine;
}
public function setMachine(Machine $machine): static
{
$this->machine = $machine;
return $this;
}
public function getProduct(): Product
{
return $this->product;
}
public function setProduct(Product $product): static
{
$this->product = $product;
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;
}
public function setParentLink(?MachineProductLink $parentLink): static
{
$this->parentLink = $parentLink;
return $this;
}
public function getParentComponentLink(): ?MachineComponentLink
{
return $this->parentComponentLink;
}
public function setParentComponentLink(?MachineComponentLink $parentComponentLink): static
{
$this->parentComponentLink = $parentComponentLink;
return $this;
}
public function getParentPieceLink(): ?MachinePieceLink
{
return $this->parentPieceLink;
}
public function setParentPieceLink(?MachinePieceLink $parentPieceLink): static
{
$this->parentPieceLink = $parentPieceLink;
return $this;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

338
src/Entity/ModelType.php Normal file
View File

@@ -0,0 +1,338 @@
<?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 App\Enum\ModelCategory;
use App\Repository\ModelTypeRepository;
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\Annotation\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(
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 200
)]
class ModelType
{
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['type_machine:read', 'model_type:read'])]
private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 120)]
#[Groups(['type_machine:read', 'model_type:read', 'model_type:write'])]
private string $name;
#[ORM\Column(type: Types::STRING, length: 60, unique: true)]
#[Groups(['type_machine:read', 'model_type:read', 'model_type:write'])]
private string $code;
#[ORM\Column(enumType: ModelCategory::class)]
#[Groups(['type_machine:read', 'model_type:read', 'model_type:write'])]
private ModelCategory $category;
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Groups(['type_machine:read', 'model_type:read', 'model_type:write'])]
private ?string $notes = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Groups(['type_machine:read', 'model_type:read', 'model_type:write'])]
private ?string $description = null;
#[ORM\Column(type: Types::JSON, nullable: true, name: 'componentSkeleton')]
#[Groups(['model_type:read'])]
private ?array $componentSkeleton = null;
#[ORM\Column(type: Types::JSON, nullable: true, name: 'pieceSkeleton')]
#[Groups(['model_type:read'])]
private ?array $pieceSkeleton = null;
#[ORM\Column(type: Types::JSON, nullable: true, name: 'productSkeleton')]
#[Groups(['model_type:read'])]
private ?array $productSkeleton = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
#[Groups(['model_type:read'])]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
#[Groups(['model_type:read'])]
private DateTimeImmutable $updatedAt;
private ?array $pendingStructure = null;
/**
* @var Collection<int, Composant>
*/
#[ORM\OneToMany(mappedBy: 'typeComposant', targetEntity: Composant::class)]
private Collection $composants;
/**
* @var Collection<int, Piece>
*/
#[ORM\OneToMany(mappedBy: 'typePiece', targetEntity: Piece::class)]
private Collection $pieces;
/**
* @var Collection<int, Product>
*/
#[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>
*/
#[ORM\OneToMany(mappedBy: 'typeComposant', targetEntity: CustomField::class)]
private Collection $customFields;
/**
* @var Collection<int, CustomField>
*/
#[ORM\OneToMany(mappedBy: 'typePiece', targetEntity: CustomField::class)]
private Collection $pieceCustomFields;
/**
* @var Collection<int, CustomField>
*/
#[ORM\OneToMany(mappedBy: 'typeProduct', targetEntity: CustomField::class)]
private Collection $productCustomFields;
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();
}
#[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 setId(string $id): static
{
$this->id = $id;
return $this;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = mb_strtoupper(mb_substr($name, 0, 1)).mb_substr($name, 1);
return $this;
}
public function getCode(): string
{
return $this->code;
}
public function setCode(string $code): static
{
$this->code = $code;
return $this;
}
public function getCategory(): ModelCategory
{
return $this->category;
}
public function setCategory(ModelCategory $category): static
{
$this->category = $category;
if (null !== $this->pendingStructure) {
$this->applyStructureForCategory($this->pendingStructure, $category);
$this->pendingStructure = null;
}
return $this;
}
public function getNotes(): ?string
{
return $this->notes;
}
public function setNotes(?string $notes): static
{
$this->notes = $notes;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function getComponentSkeleton(): ?array
{
return $this->componentSkeleton;
}
public function setComponentSkeleton(?array $componentSkeleton): static
{
$this->componentSkeleton = $componentSkeleton;
return $this;
}
public function getPieceSkeleton(): ?array
{
return $this->pieceSkeleton;
}
public function setPieceSkeleton(?array $pieceSkeleton): static
{
$this->pieceSkeleton = $pieceSkeleton;
return $this;
}
public function getProductSkeleton(): ?array
{
return $this->productSkeleton;
}
public function setProductSkeleton(?array $productSkeleton): static
{
$this->productSkeleton = $productSkeleton;
return $this;
}
#[Groups(['model_type:read'])]
public function getStructure(): ?array
{
return match ($this->category) {
ModelCategory::COMPONENT => $this->componentSkeleton,
ModelCategory::PIECE => $this->pieceSkeleton,
ModelCategory::PRODUCT => $this->productSkeleton,
};
}
#[Groups(['model_type:write'])]
public function setStructure(?array $structure): static
{
if (!isset($this->category)) {
$this->pendingStructure = $structure;
return $this;
}
$this->applyStructureForCategory($structure, $this->category);
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));
}
private function applyStructureForCategory(?array $structure, ModelCategory $category): void
{
if (ModelCategory::COMPONENT === $category) {
$this->componentSkeleton = $structure;
$this->pieceSkeleton = null;
$this->productSkeleton = null;
return;
}
if (ModelCategory::PIECE === $category) {
$this->pieceSkeleton = $structure;
$this->componentSkeleton = null;
$this->productSkeleton = null;
return;
}
$this->productSkeleton = $structure;
$this->componentSkeleton = null;
$this->pieceSkeleton = null;
}
}

308
src/Entity/Piece.php Normal file
View File

@@ -0,0 +1,308 @@
<?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 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\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: PieceRepository::class)]
#[ORM\Table(name: 'pieces')]
#[ORM\HasLifecycleCallbacks]
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typePiece' => 'exact'])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])]
#[ApiResource(
normalizationContext: ['groups' => ['piece:read']],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 200
)]
class Piece
{
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['piece:read', 'document:list'])]
private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
#[Groups(['piece:read', 'document:list'])]
private string $name;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
#[Groups(['piece:read'])]
private ?string $reference = null;
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2, nullable: true)]
#[Groups(['piece:read'])]
private ?string $prix = null;
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'pieces')]
#[ORM\JoinColumn(name: 'typePieceId', referencedColumnName: 'id', nullable: true)]
#[Groups(['piece:read'])]
private ?ModelType $typePiece = null;
#[ORM\ManyToOne(targetEntity: Product::class, inversedBy: 'pieces')]
#[ORM\JoinColumn(name: 'productId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['piece:read'])]
private ?Product $product = null;
#[ORM\Column(type: Types::JSON, nullable: true, name: 'productIds')]
#[Groups(['piece:read'])]
private ?array $productIds = null;
/**
* @var Collection<int, Constructeur>
*/
#[ORM\ManyToMany(targetEntity: Constructeur::class, inversedBy: 'pieces')]
#[ORM\JoinTable(
name: '_PieceConstructeurs',
joinColumns: [new ORM\JoinColumn(name: 'A', referencedColumnName: 'id', onDelete: 'CASCADE')],
inverseJoinColumns: [new ORM\InverseJoinColumn(name: 'B', referencedColumnName: 'id', onDelete: 'CASCADE')]
)]
#[Groups(['piece:read'])]
private Collection $constructeurs;
/**
* @var Collection<int, Document>
*/
#[ORM\OneToMany(mappedBy: 'piece', targetEntity: Document::class)]
#[Groups(['piece:read'])]
private Collection $documents;
/**
* @var Collection<int, CustomFieldValue>
*/
#[ORM\OneToMany(mappedBy: 'piece', targetEntity: CustomFieldValue::class)]
#[Groups(['piece:read'])]
private Collection $customFieldValues;
/**
* @var Collection<int, MachinePieceLink>
*/
#[ORM\OneToMany(mappedBy: 'piece', targetEntity: MachinePieceLink::class)]
private Collection $machineLinks;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
#[Groups(['piece:read'])]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
#[Groups(['piece:read'])]
private DateTimeImmutable $updatedAt;
public function __construct()
{
$this->constructeurs = new ArrayCollection();
$this->documents = new ArrayCollection();
$this->customFieldValues = new ArrayCollection();
$this->machineLinks = new ArrayCollection();
}
#[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 setId(string $id): static
{
$this->id = $id;
return $this;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getReference(): ?string
{
return $this->reference;
}
public function setReference(?string $reference): static
{
$this->reference = $reference;
return $this;
}
public function getPrix(): ?string
{
return $this->prix;
}
public function setPrix(?string $prix): static
{
$this->prix = $prix;
return $this;
}
public function getTypePiece(): ?ModelType
{
return $this->typePiece;
}
public function setTypePiece(?ModelType $typePiece): static
{
$this->typePiece = $typePiece;
return $this;
}
public function getProduct(): ?Product
{
return $this->product;
}
public function setProduct(?Product $product): static
{
$this->product = $product;
if ($product && empty($this->productIds)) {
$productId = $product->getId();
$this->productIds = $productId ? [$productId] : null;
}
if (!$product && empty($this->productIds)) {
$this->productIds = null;
}
return $this;
}
/**
* @return string[]
*/
public function getProductIds(): array
{
if (!is_array($this->productIds)) {
return [];
}
return array_values(
array_filter(
array_map(
static fn ($value) => is_string($value) ? trim($value) : '',
$this->productIds,
),
static fn (string $value) => '' !== $value,
),
);
}
public function setProductIds(?array $productIds): static
{
if (!is_array($productIds)) {
$this->productIds = null;
return $this;
}
$normalized = array_values(
array_unique(
array_filter(
array_map(
static fn ($value) => is_string($value) ? trim($value) : '',
$productIds,
),
static fn (string $value) => '' !== $value,
),
),
);
$this->productIds = [] === $normalized ? null : $normalized;
return $this;
}
/**
* @return Collection<int, Constructeur>
*/
public function getConstructeurs(): Collection
{
return $this->constructeurs;
}
public function addConstructeur(Constructeur $constructeur): static
{
if (!$this->constructeurs->contains($constructeur)) {
$this->constructeurs->add($constructeur);
}
return $this;
}
public function removeConstructeur(Constructeur $constructeur): static
{
$this->constructeurs->removeElement($constructeur);
return $this;
}
/**
* @return Collection<int, Document>
*/
public function getDocuments(): Collection
{
return $this->documents;
}
/**
* @return Collection<int, CustomFieldValue>
*/
public function getCustomFieldValues(): Collection
{
return $this->customFieldValues;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): DateTimeImmutable
{
return $this->updatedAt;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

247
src/Entity/Product.php Normal file
View File

@@ -0,0 +1,247 @@
<?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 App\Repository\ProductRepository;
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: ProductRepository::class)]
#[ORM\Table(name: 'products')]
#[ORM\HasLifecycleCallbacks]
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typeProduct' => 'exact'])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])]
#[ApiResource(
normalizationContext: ['groups' => ['product:read']],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 200
)]
class Product
{
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['product:read', 'document:list'])]
private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
#[Groups(['product:read', 'document:list'])]
private string $name;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
#[Groups(['product:read'])]
private ?string $reference = null;
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2, nullable: true, name: 'supplierPrice')]
#[Groups(['product:read'])]
private ?string $supplierPrice = null;
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'products')]
#[ORM\JoinColumn(name: 'typeProductId', referencedColumnName: 'id', nullable: true)]
#[Groups(['product:read'])]
private ?ModelType $typeProduct = null;
/**
* @var Collection<int, Constructeur>
*/
#[ORM\ManyToMany(targetEntity: Constructeur::class, inversedBy: 'products')]
#[ORM\JoinTable(
name: '_ProductConstructeurs',
joinColumns: [new ORM\JoinColumn(name: 'A', referencedColumnName: 'id', onDelete: 'CASCADE')],
inverseJoinColumns: [new ORM\InverseJoinColumn(name: 'B', referencedColumnName: 'id', onDelete: 'CASCADE')]
)]
#[Groups(['product:read'])]
private Collection $constructeurs;
/**
* @var Collection<int, Document>
*/
#[ORM\OneToMany(mappedBy: 'product', targetEntity: Document::class)]
#[Groups(['product:read'])]
private Collection $documents;
/**
* @var Collection<int, CustomFieldValue>
*/
#[ORM\OneToMany(mappedBy: 'product', targetEntity: CustomFieldValue::class)]
#[Groups(['product:read'])]
private Collection $customFieldValues;
/**
* @var Collection<int, Piece>
*/
#[ORM\OneToMany(mappedBy: 'product', targetEntity: Piece::class)]
private Collection $pieces;
/**
* @var Collection<int, Composant>
*/
#[ORM\OneToMany(mappedBy: 'product', targetEntity: Composant::class)]
private Collection $composants;
/**
* @var Collection<int, MachineProductLink>
*/
#[ORM\OneToMany(mappedBy: 'product', targetEntity: MachineProductLink::class)]
private Collection $machineLinks;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
#[Groups(['product:read'])]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
#[Groups(['product:read'])]
private DateTimeImmutable $updatedAt;
public function __construct()
{
$this->constructeurs = new ArrayCollection();
$this->documents = new ArrayCollection();
$this->customFieldValues = new ArrayCollection();
$this->pieces = new ArrayCollection();
$this->composants = new ArrayCollection();
$this->machineLinks = new ArrayCollection();
}
#[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 setId(string $id): static
{
$this->id = $id;
return $this;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getReference(): ?string
{
return $this->reference;
}
public function setReference(?string $reference): static
{
$this->reference = $reference;
return $this;
}
public function getSupplierPrice(): ?string
{
return $this->supplierPrice;
}
public function setSupplierPrice(?string $supplierPrice): static
{
$this->supplierPrice = $supplierPrice;
return $this;
}
public function getTypeProduct(): ?ModelType
{
return $this->typeProduct;
}
public function setTypeProduct(?ModelType $typeProduct): static
{
$this->typeProduct = $typeProduct;
return $this;
}
/**
* @return Collection<int, Constructeur>
*/
public function getConstructeurs(): Collection
{
return $this->constructeurs;
}
public function addConstructeur(Constructeur $constructeur): static
{
if (!$this->constructeurs->contains($constructeur)) {
$this->constructeurs->add($constructeur);
}
return $this;
}
public function removeConstructeur(Constructeur $constructeur): static
{
$this->constructeurs->removeElement($constructeur);
return $this;
}
/**
* @return Collection<int, Document>
*/
public function getDocuments(): Collection
{
return $this->documents;
}
/**
* @return Collection<int, CustomFieldValue>
*/
public function getCustomFieldValues(): Collection
{
return $this->customFieldValues;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): DateTimeImmutable
{
return $this->updatedAt;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

223
src/Entity/Profile.php Normal file
View File

@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Repository\ProfileRepository;
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\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: ProfileRepository::class)]
#[ORM\Table(name: 'profiles')]
#[ORM\UniqueConstraint(name: 'UNIQ_email', columns: ['email'])]
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
operations: [
new Get(),
new GetCollection(),
new Post(),
new Put(),
new Delete(),
],
normalizationContext: ['groups' => ['profile:read']],
denormalizationContext: ['groups' => ['profile:write']]
)]
class Profile implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\Column(type: 'string', length: 36)]
#[Groups(['profile:read'])]
private ?string $id = null;
#[ORM\Column(type: 'string', length: 180, unique: true, nullable: true)]
#[Assert\Email]
#[Groups(['profile:read', 'profile:write'])]
private ?string $email = null;
#[ORM\Column(type: 'string', length: 100, name: 'firstname')]
#[Assert\NotBlank]
#[Groups(['profile:read', 'profile:write'])]
private string $firstName;
#[ORM\Column(type: 'string', length: 100, name: 'lastname')]
#[Assert\NotBlank]
#[Groups(['profile:read', 'profile:write'])]
private string $lastName;
#[ORM\Column(type: 'boolean', options: ['default' => true], name: 'isactive')]
#[Groups(['profile:read', 'profile:write'])]
private bool $isActive = true;
/**
* @var list<string> The user roles
*/
#[ORM\Column(type: 'json', options: ['default' => '["ROLE_USER"]'])]
#[Groups(['profile:read', 'profile:write'])]
private array $roles = ['ROLE_USER'];
/**
* @var string The hashed password
*/
#[ORM\Column(type: 'string', nullable: true)]
#[Groups(['profile:write'])]
private ?string $password = null;
#[ORM\Column(type: 'datetime_immutable', name: 'createdat')]
#[Groups(['profile:read'])]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: 'datetime_immutable', name: 'updatedat')]
#[Groups(['profile:read'])]
private DateTimeImmutable $updatedAt;
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();
}
public function getId(): ?string
{
return $this->id;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(?string $email): static
{
$this->email = $email;
return $this;
}
public function getFirstName(): string
{
return $this->firstName;
}
public function setFirstName(string $firstName): static
{
$this->firstName = $firstName;
return $this;
}
public function getLastName(): string
{
return $this->lastName;
}
public function setLastName(string $lastName): static
{
$this->lastName = $lastName;
return $this;
}
public function isActive(): bool
{
return $this->isActive;
}
public function setIsActive(bool $isActive): static
{
$this->isActive = $isActive;
return $this;
}
/**
* @see UserInterface
*/
public function getUserIdentifier(): string
{
return (string) $this->email;
}
/**
* @see UserInterface
*
* @return list<string>
*/
public function getRoles(): array
{
$roles = $this->roles;
// guarantee every user at least has ROLE_USER
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
/**
* @param list<string> $roles
*/
public function setRoles(array $roles): static
{
$this->roles = $roles;
return $this;
}
/**
* @see PasswordAuthenticatedUserInterface
*/
public function getPassword(): ?string
{
return $this->password;
}
public function setPassword(string $password): static
{
$this->password = $password;
return $this;
}
/**
* @see UserInterface
*/
public function eraseCredentials(): void
{
// If you store any temporary, sensitive data on the user, clear it here
// $this->plainPassword = null;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): DateTimeImmutable
{
return $this->updatedAt;
}
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
}
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new DateTimeImmutable();
}
}

266
src/Entity/Site.php Normal file
View File

@@ -0,0 +1,266 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Repository\SiteRepository;
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;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: SiteRepository::class)]
#[ORM\Table(name: 'sites')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
operations: [
new Get(),
new GetCollection(),
new Post(),
new Put(),
new Delete(),
],
paginationClientItemsPerPage: true,
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')]
private string $contactName = '';
#[ORM\Column(type: Types::STRING, length: 20, options: ['default' => ''], name: 'contactPhone')]
private string $contactPhone = '';
#[ORM\Column(type: Types::STRING, length: 500, options: ['default' => ''], name: 'contactAddress')]
private string $contactAddress = '';
#[ORM\Column(type: Types::STRING, length: 10, options: ['default' => ''], name: 'contactPostalCode')]
private string $contactPostalCode = '';
#[ORM\Column(type: Types::STRING, length: 100, options: ['default' => ''], name: 'contactCity')]
private string $contactCity = '';
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private DateTimeImmutable $updatedAt;
/**
* @var Collection<int, Machine>
*/
#[ORM\OneToMany(targetEntity: Machine::class, mappedBy: 'site', cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $machines;
/**
* @var Collection<int, Document>
*/
#[ORM\OneToMany(targetEntity: Document::class, mappedBy: 'site', cascade: ['remove'], orphanRemoval: true)]
private Collection $documents;
public function __construct()
{
$this->machines = new ArrayCollection();
$this->documents = new ArrayCollection();
}
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
// Générer un ID CUID-compatible si nécessaire
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new DateTimeImmutable();
}
// Getters et Setters
public function getId(): ?string
{
return $this->id;
}
public function setId(string $id): static
{
$this->id = $id;
return $this;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getContactName(): string
{
return $this->contactName;
}
public function setContactName(string $contactName): static
{
$this->contactName = $contactName;
return $this;
}
public function getContactPhone(): string
{
return $this->contactPhone;
}
public function setContactPhone(string $contactPhone): static
{
$this->contactPhone = $contactPhone;
return $this;
}
public function getContactAddress(): string
{
return $this->contactAddress;
}
public function setContactAddress(string $contactAddress): static
{
$this->contactAddress = $contactAddress;
return $this;
}
public function getContactPostalCode(): string
{
return $this->contactPostalCode;
}
public function setContactPostalCode(string $contactPostalCode): static
{
$this->contactPostalCode = $contactPostalCode;
return $this;
}
public function getContactCity(): string
{
return $this->contactCity;
}
public function setContactCity(string $contactCity): static
{
$this->contactCity = $contactCity;
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->setSite($this);
}
return $this;
}
public function removeMachine(Machine $machine): static
{
if ($this->machines->removeElement($machine)) {
// set the owning side to null (unless already changed)
if ($machine->getSite() === $this) {
$machine->setSite(null);
}
}
return $this;
}
/**
* @return Collection<int, Document>
*/
public function getDocuments(): Collection
{
return $this->documents;
}
public function addDocument(Document $document): static
{
if (!$this->documents->contains($document)) {
$this->documents->add($document);
$document->setSite($this);
}
return $this;
}
public function removeDocument(Document $document): static
{
if ($this->documents->removeElement($document)) {
// set the owning side to null (unless already changed)
if ($document->getSite() === $this) {
$document->setSite(null);
}
}
return $this;
}
private function generateCuid(): string
{
// Génération d'un ID compatible CUID (format: cl + 24 caractères)
return 'cl'.bin2hex(random_bytes(12));
}
}

401
src/Entity/TypeMachine.php Normal file
View File

@@ -0,0 +1,401 @@
<?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: 200
)]
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();
}
}

View File

@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\TypeMachineComponentRequirementRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
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 (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 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;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\TypeMachinePieceRequirementRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
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 (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 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;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\TypeMachineProductRequirementRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
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 (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 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;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Enum;
enum ModelCategory: string
{
case COMPONENT = 'COMPONENT';
case PIECE = 'PIECE';
case PRODUCT = 'PRODUCT';
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\EventListener;
use App\Entity\Document;
use App\Service\PdfCompressorService;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use Doctrine\ORM\Events;
use Psr\Log\LoggerInterface;
#[AsEntityListener(event: Events::prePersist, method: 'prePersist', entity: Document::class)]
#[AsEntityListener(event: Events::preUpdate, method: 'preUpdate', entity: Document::class)]
class DocumentPdfCompressorListener
{
public function __construct(
private readonly PdfCompressorService $pdfCompressor,
private readonly ?LoggerInterface $logger = null,
) {}
public function prePersist(Document $document): void
{
$this->compressIfPdf($document);
}
public function preUpdate(Document $document): void
{
$this->compressIfPdf($document);
}
private function compressIfPdf(Document $document): void
{
if ('application/pdf' !== $document->getMimeType()) {
return;
}
$result = $this->pdfCompressor->compressBase64Pdf($document->getPath());
if (null === $result) {
return;
}
$document->setPath($result['path']);
$document->setSize($result['size']);
$this->logger?->info('PDF compressed', [
'document' => $document->getName(),
'originalSize' => $result['originalSize'],
'compressedSize' => $result['size'],
'saved' => $result['saved'],
]);
}
}

View File

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

View File

@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\AuditLog;
use App\Entity\Constructeur;
use App\Entity\Profile;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Throwable;
use function is_scalar;
#[AsDoctrineListener(event: Events::onFlush)]
final class ConstructeurAuditSubscriber implements EventSubscriber
{
public function __construct(
private readonly RequestStack $requestStack,
private readonly Security $security,
) {}
public function getSubscribedEvents(): array
{
return [
Events::onFlush,
];
}
public function onFlush(OnFlushEventArgs $args): void
{
$em = $args->getObjectManager();
if (!$em instanceof EntityManagerInterface) {
return;
}
$uow = $em->getUnitOfWork();
$actorProfileId = $this->resolveActorProfileId();
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!$entity instanceof Constructeur) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
$snapshot = $this->snapshotConstructeur($entity);
$this->persistAuditLog($em, new AuditLog('constructeur', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof Constructeur) {
continue;
}
$id = (string) $entity->getId();
if ('' === $id) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ([] !== $diff) {
$snapshot = $this->snapshotConstructeur($entity);
$this->persistAuditLog($em, new AuditLog('constructeur', $id, 'update', $diff, $snapshot, $actorProfileId));
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if (!$entity instanceof Constructeur) {
continue;
}
$snapshot = $this->snapshotConstructeur($entity);
$this->persistAuditLog($em, new AuditLog('constructeur', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
}
}
private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
{
$uow = $em->getUnitOfWork();
$log->initializeAuditLog();
$em->persist($log);
$meta = $em->getClassMetadata(AuditLog::class);
$uow->computeChangeSet($meta, $log);
}
/**
* @param array<string, array{0:mixed, 1:mixed}> $changeSet
*
* @return array<string, array{from:mixed, to:mixed}>
*/
private function buildDiffFromChangeSet(array $changeSet): array
{
$diff = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) {
if ('updatedAt' === $field || 'createdAt' === $field) {
continue;
}
$normalizedOld = $this->normalizeValue($oldValue);
$normalizedNew = $this->normalizeValue($newValue);
if ($normalizedOld === $normalizedNew) {
continue;
}
$diff[$field] = [
'from' => $normalizedOld,
'to' => $normalizedNew,
];
}
return $diff;
}
private function snapshotConstructeur(Constructeur $constructeur): array
{
return [
'id' => $constructeur->getId(),
'name' => $constructeur->getName(),
'email' => $constructeur->getEmail(),
'phone' => $constructeur->getPhone(),
];
}
private function normalizeValue(mixed $value): mixed
{
if (null === $value || is_scalar($value)) {
return $value;
}
if ($value instanceof DateTimeInterface) {
return $value->format(DateTimeInterface::ATOM);
}
return (string) $value;
}
private function resolveActorProfileId(): ?string
{
try {
$session = $this->requestStack->getSession();
if ($session instanceof SessionInterface) {
$profileId = $session->get('profileId');
if ($profileId) {
return (string) $profileId;
}
}
} catch (Throwable) {
// No session available (CLI context, etc.)
}
$user = $this->security->getUser();
if ($user instanceof Profile) {
return $user->getId();
}
return null;
}
}

View File

@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\AuditLog;
use App\Entity\Composant;
use App\Entity\Document;
use App\Entity\Machine;
use App\Entity\Piece;
use App\Entity\Product;
use App\Entity\Profile;
use App\Entity\Site;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Throwable;
use function is_object;
use function is_scalar;
use function method_exists;
#[AsDoctrineListener(event: Events::onFlush)]
final class DocumentAuditSubscriber implements EventSubscriber
{
public function __construct(
private readonly RequestStack $requestStack,
private readonly Security $security,
) {}
public function getSubscribedEvents(): array
{
return [
Events::onFlush,
];
}
public function onFlush(OnFlushEventArgs $args): void
{
$em = $args->getObjectManager();
if (!$em instanceof EntityManagerInterface) {
return;
}
$uow = $em->getUnitOfWork();
$actorProfileId = $this->resolveActorProfileId();
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!$entity instanceof Document) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
$snapshot = $this->snapshotDocument($entity);
$this->persistAuditLog($em, new AuditLog('document', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof Document) {
continue;
}
$id = (string) $entity->getId();
if ('' === $id) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ([] !== $diff) {
$snapshot = $this->snapshotDocument($entity);
$this->persistAuditLog($em, new AuditLog('document', $id, 'update', $diff, $snapshot, $actorProfileId));
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if (!$entity instanceof Document) {
continue;
}
$snapshot = $this->snapshotDocument($entity);
$this->persistAuditLog($em, new AuditLog('document', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
}
}
private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
{
$uow = $em->getUnitOfWork();
$log->initializeAuditLog();
$em->persist($log);
$meta = $em->getClassMetadata(AuditLog::class);
$uow->computeChangeSet($meta, $log);
}
/**
* @param array<string, array{0:mixed, 1:mixed}> $changeSet
*
* @return array<string, array{from:mixed, to:mixed}>
*/
private function buildDiffFromChangeSet(array $changeSet): array
{
$diff = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) {
if ('updatedAt' === $field || 'createdAt' === $field) {
continue;
}
$normalizedOld = $this->normalizeValue($oldValue);
$normalizedNew = $this->normalizeValue($newValue);
if ($normalizedOld === $normalizedNew) {
continue;
}
$diff[$field] = [
'from' => $normalizedOld,
'to' => $normalizedNew,
];
}
return $diff;
}
private function snapshotDocument(Document $document): array
{
return [
'id' => $document->getId(),
'name' => $document->getName(),
'filename' => $document->getFilename(),
'mimeType' => $document->getMimeType(),
'size' => $document->getSize(),
'machine' => $this->normalizeValue($document->getMachine()),
'composant' => $this->normalizeValue($document->getComposant()),
'piece' => $this->normalizeValue($document->getPiece()),
'product' => $this->normalizeValue($document->getProduct()),
'site' => $this->normalizeValue($document->getSite()),
];
}
private function normalizeValue(mixed $value): mixed
{
if (null === $value || is_scalar($value)) {
return $value;
}
if ($value instanceof DateTimeInterface) {
return $value->format(DateTimeInterface::ATOM);
}
if ($value instanceof Machine || $value instanceof Composant || $value instanceof Piece || $value instanceof Product || $value instanceof Site) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
];
}
if (is_object($value) && method_exists($value, 'getId')) {
return (string) $value->getId();
}
return (string) $value;
}
private function resolveActorProfileId(): ?string
{
try {
$session = $this->requestStack->getSession();
if ($session instanceof SessionInterface) {
$profileId = $session->get('profileId');
if ($profileId) {
return (string) $profileId;
}
}
} catch (Throwable) {
// No session available (CLI context, etc.)
}
$user = $this->security->getUser();
if ($user instanceof Profile) {
return $user->getId();
}
return null;
}
}

View File

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

Some files were not shown because too many files have changed in this diff Show More