Compare commits

...

48 Commits

Author SHA1 Message Date
Matthieu
31408ded7f chore : bump version to 1.9.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:52:21 +01:00
Matthieu
4054fb24e6 feat(site) : add color field to sites + frontend submodule update
- Add color VARCHAR(7) column to sites entity
- Migration with IF NOT EXISTS for idempotence
- Update reference config
- Frontend: site color picker, dark mode, card styling improvements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:51:55 +01:00
Matthieu
32ba4928df chore(frontend) : update submodule — site edit + card buttons alignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:11:55 +01:00
edf7d0fa9e chore(frontend) : update submodule — changelog v1.9.0 2026-03-09 13:04:54 +01:00
233927df19 chore : sync frontend submodule and update reference config
Update frontend submodule pointer to latest UI refactor.
Update config/reference.php with Symfony auto-generated changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:14:49 +01:00
dcb5f15769 docs : update CLAUDE.md with custom controllers, fields architecture and factories
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:43:19 +01:00
d3cd3fc3ce feat(machine) : add custom field management on machine detail page
- Fix: return customFieldValues in structure endpoint (was hardcoded null)
- Frontend: add editor to create/edit/delete custom field definitions
- Tests: add integration tests for structure values + definition CRUD

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:36:14 +01:00
33fc80cbc2 fix(security) : disable session migration on API firewall
Symfony's default session_fixation_strategy (migrate) regenerated the
session ID on every authenticated request, breaking concurrent API calls
from the SPA — only the first request succeeded, all others got 401.
The login controller already calls $session->migrate(true) explicitly,
so disabling automatic migration is safe.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 14:30:38 +01:00
33e3f25850 docs : update project documentation and frontend submodule pointer
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 13:47:46 +01:00
efc6ec5691 test(api) : add comprehensive API test suite (161 tests)
- Add AbstractApiTestCase with auth helpers and entity factories
- Add tests for all entities: Machine, Piece, Composant, Product, Site,
  ModelType, Constructeur, CustomField, CustomFieldValue, Document,
  MachineComponentLink, MachinePieceLink, MachineProductLink, Profile
- Add controller tests: CommentController, EntityHistory
- Add HealthCheck, Filter, Pagination, Validation, Session tests
- Test auth (401), authorization (403), CRUD, and edge cases

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 13:42:56 +01:00
b342d0e50a fix(security) : harden auth, session, document access and health endpoint
- Remove orphaned PUBLIC_ACCESS rule for deleted /api/test route
- Remove JWT login firewall (app is session-based only)
- Set APP_SECRET placeholder (real value must be in .env.local)
- Remove JWT env vars from .env
- Add session regeneration on login (prevent session fixation)
- Remove Document.path from API serialization groups (prevent path leak)
- Restrict health check details to ROLE_ADMIN (anonymes get status only)
- Add path traversal guard in DocumentStorageService
- Convert CreateProfileCommand password to interactive hidden prompt
- Restrict Profile Get endpoint to ROLE_ADMIN
- Change api firewall to stateless: false (matches session-based auth)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 13:42:09 +01:00
0709d01240 chore(config) : add DAMA test bundle, update API Platform config, improve makefile
- Register DAMADoctrineTestBundle for test env (transaction rollback)
- Update API Platform title/description, add pagination defaults
- Configure services for new controllers and commands
- Update makefile targets

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 13:39:44 +01:00
74f77a3ba8 refactor(backend) : extract CuidEntityTrait, abstract audit subscriber, merge history controllers
- Extract shared ID generation + timestamps into CuidEntityTrait used by all entities
- Create AbstractAuditSubscriber to deduplicate audit logic across 7 subscribers
- Merge per-entity history controllers into single EntityHistoryController
- Delete redundant ComposantHistory/MachineHistory/PieceHistory/ProductHistoryController
- Add OpenApiDecorator for API documentation customization
- Disable failOnDeprecation in PHPUnit (vendor API Platform deprecation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 13:39:03 +01:00
bab13e5c57 chore : clean project config — untrack .idea/, gitignore Zone.Identifier and frontend/, blank JWT secret
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 02:01:54 +01:00
Matthieu
378026ebce chore(frontend) : update submodule — add buttons repositioned
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:32:56 +01:00
Matthieu
ea2b813728 chore(frontend) : update submodule — product delete confirmation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:07:06 +01:00
Matthieu
20653b9046 docs(changelog) : add delete confirmation dialog entry
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:59:48 +01:00
Matthieu
c6deef6028 chore(frontend) : update submodule — delete confirmation dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:59:10 +01:00
Matthieu
e922b14419 feat(api) : add /api/health endpoint for monitoring
- Returns status, version, timestamp, PHP version, DB latency and memory usage
- Accessible without authentication (PUBLIC_ACCESS)
- Returns 200 when healthy, 503 when degraded (DB down)
2026-03-06 09:51:09 +01:00
Matthieu
d16b042739 chore(frontend) : update submodule — changelog v1.8.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:39:07 +01:00
Matthieu
2b3c1fe08e docs(changelog) : complete v1.8.1 changelog with all frontend changes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:36:37 +01:00
Matthieu
51248b7854 chore(release) : v1.8.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:27:29 +01:00
Matthieu
0e11f4ad2d refactor(api) : remove TypeMachine skeleton system, fix ModelType serialization
- Remove TypeMachine, TypeMachineComponentRequirement, TypeMachinePieceRequirement,
  TypeMachineProductRequirement entities and related repositories/state processor
- Replace MachineSkeletonController with MachineStructureController
- Link CustomField directly to Machine instead of TypeMachine
- Add migration to drop TypeMachine tables and migrate custom fields to machines
- Fix ModelType serialization: Annotation\Groups → Attribute\Groups (Symfony 8 compat)
  and add product:read, composant:read, piece:read groups for embedded category display
- Fix Profile: same Annotation → Attribute import
- Fix SearchFilter: partial → ipartial on Comment and Document
- Update frontend submodule (remove skeleton pages/components, simplify machine creation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:26:16 +01:00
Matthieu
f2539099bc chore(frontend) : update submodule — DataTable global + filtres server-side
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 16:07:03 +01:00
Matthieu
e5dc60467e feat(api) : ajout filtres SearchFilter ipartial sur noms de types et commentaires
- Piece : typePiece.name ipartial
- Composant : typeComposant.name ipartial
- Product : typeProduct.name ipartial + OrderFilter supplierPrice
- Comment : entityName partial + OrderFilter authorName, status

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 16:05:24 +01:00
Matthieu
fbc0372bd6 docs(readme) : comprehensive project documentation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:49:35 +01:00
Matthieu
1483b0075b chore(frontend) : update submodule — README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:45:53 +01:00
Matthieu
74e88923dc chore(frontend) : update submodule — README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:38:19 +01:00
Matthieu
ef61d1a0d3 chore : remove obsolete docs and update submodule
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:10:40 +01:00
Matthieu
3f0fb0d5c2 chore : remove stale TODO.md and temp files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:06:08 +01:00
Matthieu
dd1497beac chore : bump v1.8.0, update changelog, gitignore and submodule
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:01:00 +01:00
Matthieu
7cd8772617 chore(frontend) : update submodule — navbar reorder and icons
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:31:36 +01:00
Matthieu
d89c97f0a0 feat(documents) : filesystem storage, server-side pagination and PDF compression
- Add DocumentStorageService for file-based storage (replaces Base64 in DB)
- Add DocumentServeController with /file and /download endpoints
- Add DocumentUploadProcessor using FormData + filesystem storage
- Add DocumentNormalizer exposing fileUrl/downloadUrl on all responses
- Add DocumentFileCleanupListener for automatic file deletion
- Add MigrateDocumentsToFilesystemCommand (Base64 → files, memory-safe)
- Add ApiFilter (SearchFilter, ExistsFilter, OrderFilter) on Document entity
- Add PdfCompressorService + refactor CompressPdfCommand for batch processing
- Fix TypeMachine PUT: deserialize=false + validate=false to prevent
  UniqueEntity false positive and writableLink collection interference
- Update CHANGELOG for v1.8.0
- Update frontend submodule

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:18:55 +01:00
Matthieu
7a5dd0b555 feat(skeleton) : add custom PUT processor and edit guard for linked machines
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 10:13:45 +01:00
Matthieu
44d69db560 chore(frontend) : update submodule — description field on catalog forms
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:35:57 +01:00
Matthieu
453065c9f0 feat(entities) : add description field to Piece and Composant
Add nullable TEXT description column to both pieces and composants
tables with corresponding Doctrine entity mappings, getters/setters
and serialization groups.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:35:37 +01:00
Matthieu
eb85323116 chore(frontend) : update submodule — fix site edit modal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 16:33:34 +01:00
Matthieu
2dfa501a65 fix(sites) : add PATCH operation and fix migration constraint drop
Add Patch operation to Site entity (was only Put, causing 405 errors).
Fix migration to use ALTER TABLE DROP CONSTRAINT instead of DROP INDEX
for the piece name unique constraint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 16:33:22 +01:00
Matthieu
c22f9dbf2b chore(release) : bump version to 1.7.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:36:58 +01:00
Matthieu
27a1b09d62 chore(frontend) : update submodule — comments system and constructeur fixes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:06:31 +01:00
Matthieu
7bbb693924 feat(comments) : add comment entity, controller and migration
Create Comment entity with API Platform annotations (GET, PATCH, DELETE).
Add CommentController with POST (create), PATCH (resolve) and GET
(unresolved count) endpoints. Add migration for comments table and
piece reference unique index.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:06:25 +01:00
Matthieu
9661fd5d91 fix(entities) : add unique constraints for constructeur name and piece reference
Add UniqueEntity validation on Constructeur.name and Piece.reference.
Move unique DB constraint from piece name to piece reference column.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:06:19 +01:00
Matthieu
d9ab583879 chore(frontend) : update submodule — package-lock.json
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:02:17 +01:00
Matthieu
5d41bda997 fix(ui) : replace checkbox with toggle switch for boolean custom fields
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:56:56 +01:00
Matthieu
3d037083c6 feat(ui) : display role badge in profile dropdown
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:42:09 +01:00
Matthieu
a3e440c254 feat(permissions) : add role-based access control system
Backend:
- Add role hierarchy (ADMIN > GESTIONNAIRE > VIEWER > USER) in security.yaml
- Add password authentication on profile activation (SessionProfileController)
- Add SessionProfileAuthenticator with stateless API firewall
- Add ProfilePasswordHasher state processor for API Platform
- Add security annotations on all 18 API Platform entities
- Add denyAccessUnlessGranted on all 13 custom controllers
- Add AdminProfileController for profile/role management (/api/admin/profiles)
- Add InitProfilePasswordsCommand for initial admin setup
- Simplify SessionProfilesController to list-only (removed create/delete)

Frontend (submodule update):
- Add usePermissions composable (isAdmin, canEdit, canView, isGranted)
- Add password login modal on profiles page
- Add admin backoffice page for profile management
- Disable all form fields for ROLE_VIEWER across all edit/create pages
- Show navigation buttons for all roles, hide destructive actions for viewers
- Add readonly mode to ModelTypeForm and site/constructeur modals
- Guard /admin routes in middleware
- Configure Vite proxy for API requests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 13:37:12 +01:00
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
127 changed files with 10123 additions and 7786 deletions

7
.env
View File

@@ -16,7 +16,7 @@
###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=
APP_SECRET=change_me_in_env_local
APP_SHARE_DIR=var/share
###< symfony/framework-bundle ###
@@ -40,8 +40,3 @@ DEFAULT_URI=http://localhost
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 ###

19
.gitignore vendored
View File

@@ -32,9 +32,20 @@ docker/.env.docker.local
/_archives/
###< migration archives ###
###> temp files ###
*.sql
*.har
FEATURE_IDEAS.md
###< temp files ###
###> frontend ###
/frontend/node_modules/
/frontend/.nuxt/
/frontend/.output/
/frontend/dist/
/frontend/
###< frontend ###
###> ide ###
/.idea/
###< ide ###
###> wsl ###
*:Zone.Identifier
###< wsl ###

9
.idea/.gitignore generated vendored
View File

@@ -1,9 +0,0 @@
# Default ignored files
/shelf/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/

144
.idea/Inventory.iml generated
View File

@@ -1,144 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" packagePrefix="App\" />
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" packagePrefix="App\Tests\" />
<excludeFolder url="file://$MODULE_DIR$/public/bundles" />
<excludeFolder url="file://$MODULE_DIR$/var" />
<excludeFolder url="file://$MODULE_DIR$/vendor/api-platform/doctrine-common" />
<excludeFolder url="file://$MODULE_DIR$/vendor/api-platform/doctrine-orm" />
<excludeFolder url="file://$MODULE_DIR$/vendor/api-platform/documentation" />
<excludeFolder url="file://$MODULE_DIR$/vendor/api-platform/http-cache" />
<excludeFolder url="file://$MODULE_DIR$/vendor/api-platform/hydra" />
<excludeFolder url="file://$MODULE_DIR$/vendor/api-platform/json-schema" />
<excludeFolder url="file://$MODULE_DIR$/vendor/api-platform/jsonld" />
<excludeFolder url="file://$MODULE_DIR$/vendor/api-platform/metadata" />
<excludeFolder url="file://$MODULE_DIR$/vendor/api-platform/openapi" />
<excludeFolder url="file://$MODULE_DIR$/vendor/api-platform/serializer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/api-platform/state" />
<excludeFolder url="file://$MODULE_DIR$/vendor/api-platform/symfony" />
<excludeFolder url="file://$MODULE_DIR$/vendor/api-platform/validator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/clue/ndjson-react" />
<excludeFolder url="file://$MODULE_DIR$/vendor/composer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/collections" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/common" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/dbal" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/deprecations" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/doctrine-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/doctrine-migrations-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/event-manager" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/inflector" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/instantiator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/lexer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/migrations" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/orm" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/persistence" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/sql-formatter" />
<excludeFolder url="file://$MODULE_DIR$/vendor/evenement/evenement" />
<excludeFolder url="file://$MODULE_DIR$/vendor/fidry/cpu-core-counter" />
<excludeFolder url="file://$MODULE_DIR$/vendor/friendsofphp/php-cs-fixer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/nelmio/cors-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpdocumentor/reflection-common" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpdocumentor/reflection-docblock" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpdocumentor/type-resolver" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpstan/phpdoc-parser" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/cache" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/clock" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/container" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/event-dispatcher" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/link" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/log" />
<excludeFolder url="file://$MODULE_DIR$/vendor/react/cache" />
<excludeFolder url="file://$MODULE_DIR$/vendor/react/child-process" />
<excludeFolder url="file://$MODULE_DIR$/vendor/react/dns" />
<excludeFolder url="file://$MODULE_DIR$/vendor/react/event-loop" />
<excludeFolder url="file://$MODULE_DIR$/vendor/react/promise" />
<excludeFolder url="file://$MODULE_DIR$/vendor/react/socket" />
<excludeFolder url="file://$MODULE_DIR$/vendor/react/stream" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/diff" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/asset" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/cache" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/cache-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/clock" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/config" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/console" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dependency-injection" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/deprecation-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/doctrine-bridge" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dotenv" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/error-handler" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/event-dispatcher" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/event-dispatcher-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/expression-language" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/filesystem" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/finder" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/flex" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/framework-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-foundation" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-kernel" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/options-resolver" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/password-hasher" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-intl-grapheme" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-intl-normalizer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-mbstring" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-php85" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-uuid" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/process" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/property-access" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/property-info" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/routing" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/runtime" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/security-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/security-core" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/security-csrf" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/security-http" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/serializer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/service-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/stopwatch" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/string" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/translation-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/twig-bridge" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/twig-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/type-info" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/uid" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/validator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/var-dumper" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/var-exporter" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/web-link" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/yaml" />
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/twig" />
<excludeFolder url="file://$MODULE_DIR$/vendor/webmozart/assert" />
<excludeFolder url="file://$MODULE_DIR$/vendor/willdurand/negotiation" />
<excludeFolder url="file://$MODULE_DIR$/vendor/myclabs/deep-copy" />
<excludeFolder url="file://$MODULE_DIR$/vendor/nikic/php-parser" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phar-io/manifest" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phar-io/version" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-code-coverage" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-file-iterator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-invoker" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-text-template" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-timer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/phpunit" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/cli-parser" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/comparator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/complexity" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/environment" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/exporter" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/global-state" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/lines-of-code" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/object-enumerator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/object-reflector" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/recursion-context" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/type" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/version" />
<excludeFolder url="file://$MODULE_DIR$/vendor/staabm/side-effects-detector" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/browser-kit" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/css-selector" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dom-crawler" />
<excludeFolder url="file://$MODULE_DIR$/vendor/theseer/tokenizer" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@@ -1,5 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>

12
.idea/dataSources.xml generated
View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="ferme" uuid="f407a514-c6b4-4b26-9555-445a85892502">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://localhost:5432/ferme</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="InertiaPackage">
<option name="directoryPaths">
<list />
</option>
</component>
</project>

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MaterialThemeProjectNewConfig">
<option name="metadata">
<MTProjectMetadataState>
<option name="migrated" value="true" />
<option name="pristineConfig" value="false" />
<option name="userId" value="-70fca0d0:19b8da49b68:-7ffe" />
</MTProjectMetadataState>
</option>
</component>
</project>

8
.idea/modules.xml generated
View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/ferme.iml" filepath="$PROJECT_DIR$/.idea/ferme.iml" />
</modules>
</component>
</project>

160
.idea/php.xml generated
View File

@@ -1,160 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MessDetectorOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCSFixerOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCodeSnifferOptionsConfiguration">
<option name="highlightLevel" value="WARNING" />
<option name="transferred" value="true" />
</component>
<component name="PhpIncludePathManager">
<include_path>
<path value="$PROJECT_DIR$/vendor/symfony/framework-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/uid" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-normalizer" />
<path value="$PROJECT_DIR$/vendor/symfony/cache-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/finder" />
<path value="$PROJECT_DIR$/vendor/symfony/asset" />
<path value="$PROJECT_DIR$/vendor/symfony/var-exporter" />
<path value="$PROJECT_DIR$/vendor/symfony/runtime" />
<path value="$PROJECT_DIR$/vendor/symfony/error-handler" />
<path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/clock" />
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher-contracts" />
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
<path value="$PROJECT_DIR$/vendor/symfony/doctrine-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/type-info" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-migrations-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/deprecations" />
<path value="$PROJECT_DIR$/vendor/doctrine/migrations" />
<path value="$PROJECT_DIR$/vendor/doctrine/event-manager" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/lexer" />
<path value="$PROJECT_DIR$/vendor/doctrine/orm" />
<path value="$PROJECT_DIR$/vendor/doctrine/inflector" />
<path value="$PROJECT_DIR$/vendor/doctrine/sql-formatter" />
<path value="$PROJECT_DIR$/vendor/doctrine/common" />
<path value="$PROJECT_DIR$/vendor/doctrine/collections" />
<path value="$PROJECT_DIR$/vendor/doctrine/instantiator" />
<path value="$PROJECT_DIR$/vendor/doctrine/persistence" />
<path value="$PROJECT_DIR$/vendor/sebastian/lines-of-code" />
<path value="$PROJECT_DIR$/vendor/sebastian/type" />
<path value="$PROJECT_DIR$/vendor/doctrine/dbal" />
<path value="$PROJECT_DIR$/vendor/evenement/evenement" />
<path value="$PROJECT_DIR$/vendor/sebastian/diff" />
<path value="$PROJECT_DIR$/vendor/sebastian/cli-parser" />
<path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" />
<path value="$PROJECT_DIR$/vendor/sebastian/environment" />
<path value="$PROJECT_DIR$/vendor/sebastian/complexity" />
<path value="$PROJECT_DIR$/vendor/sebastian/comparator" />
<path value="$PROJECT_DIR$/vendor/sebastian/recursion-context" />
<path value="$PROJECT_DIR$/vendor/sebastian/exporter" />
<path value="$PROJECT_DIR$/vendor/sebastian/global-state" />
<path value="$PROJECT_DIR$/vendor/webmozart/assert" />
<path value="$PROJECT_DIR$/vendor/sebastian/object-enumerator" />
<path value="$PROJECT_DIR$/vendor/sebastian/version" />
<path value="$PROJECT_DIR$/vendor/api-platform/jsonld" />
<path value="$PROJECT_DIR$/vendor/api-platform/hydra" />
<path value="$PROJECT_DIR$/vendor/willdurand/negotiation" />
<path value="$PROJECT_DIR$/vendor/api-platform/http-cache" />
<path value="$PROJECT_DIR$/vendor/api-platform/serializer" />
<path value="$PROJECT_DIR$/vendor/api-platform/doctrine-orm" />
<path value="$PROJECT_DIR$/vendor/api-platform/validator" />
<path value="$PROJECT_DIR$/vendor/api-platform/state" />
<path value="$PROJECT_DIR$/vendor/api-platform/metadata" />
<path value="$PROJECT_DIR$/vendor/api-platform/symfony" />
<path value="$PROJECT_DIR$/vendor/api-platform/openapi" />
<path value="$PROJECT_DIR$/vendor/api-platform/documentation" />
<path value="$PROJECT_DIR$/vendor/friendsofphp/php-cs-fixer" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/type-resolver" />
<path value="$PROJECT_DIR$/vendor/api-platform/json-schema" />
<path value="$PROJECT_DIR$/vendor/api-platform/doctrine-common" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-common" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-docblock" />
<path value="$PROJECT_DIR$/vendor/composer" />
<path value="$PROJECT_DIR$/vendor/psr/log" />
<path value="$PROJECT_DIR$/vendor/psr/link" />
<path value="$PROJECT_DIR$/vendor/psr/cache" />
<path value="$PROJECT_DIR$/vendor/psr/clock" />
<path value="$PROJECT_DIR$/vendor/clue/ndjson-react" />
<path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" />
<path value="$PROJECT_DIR$/vendor/psr/container" />
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
<path value="$PROJECT_DIR$/vendor/react/promise" />
<path value="$PROJECT_DIR$/vendor/twig/twig" />
<path value="$PROJECT_DIR$/vendor/fidry/cpu-core-counter" />
<path value="$PROJECT_DIR$/vendor/react/stream" />
<path value="$PROJECT_DIR$/vendor/react/child-process" />
<path value="$PROJECT_DIR$/vendor/react/cache" />
<path value="$PROJECT_DIR$/vendor/react/event-loop" />
<path value="$PROJECT_DIR$/vendor/nelmio/cors-bundle" />
<path value="$PROJECT_DIR$/vendor/staabm/side-effects-detector" />
<path value="$PROJECT_DIR$/vendor/react/socket" />
<path value="$PROJECT_DIR$/vendor/react/dns" />
<path value="$PROJECT_DIR$/vendor/phar-io/version" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpdoc-parser" />
<path value="$PROJECT_DIR$/vendor/myclabs/deep-copy" />
<path value="$PROJECT_DIR$/vendor/phar-io/manifest" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-code-coverage" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-text-template" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-timer" />
<path value="$PROJECT_DIR$/vendor/symfony/flex" />
<path value="$PROJECT_DIR$/vendor/symfony/validator" />
<path value="$PROJECT_DIR$/vendor/phpunit/phpunit" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" />
<path value="$PROJECT_DIR$/vendor/symfony/security-csrf" />
<path value="$PROJECT_DIR$/vendor/symfony/dependency-injection" />
<path value="$PROJECT_DIR$/vendor/symfony/security-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/password-hasher" />
<path value="$PROJECT_DIR$/vendor/symfony/http-kernel" />
<path value="$PROJECT_DIR$/vendor/symfony/console" />
<path value="$PROJECT_DIR$/vendor/symfony/filesystem" />
<path value="$PROJECT_DIR$/vendor/symfony/cache" />
<path value="$PROJECT_DIR$/vendor/symfony/web-link" />
<path value="$PROJECT_DIR$/vendor/symfony/serializer" />
<path value="$PROJECT_DIR$/vendor/symfony/css-selector" />
<path value="$PROJECT_DIR$/vendor/symfony/twig-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/process" />
<path value="$PROJECT_DIR$/vendor/symfony/security-http" />
<path value="$PROJECT_DIR$/vendor/symfony/config" />
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher" />
<path value="$PROJECT_DIR$/vendor/symfony/browser-kit" />
<path value="$PROJECT_DIR$/vendor/symfony/options-resolver" />
<path value="$PROJECT_DIR$/vendor/symfony/service-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/yaml" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-mbstring" />
<path value="$PROJECT_DIR$/vendor/symfony/string" />
<path value="$PROJECT_DIR$/vendor/symfony/property-access" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-uuid" />
<path value="$PROJECT_DIR$/vendor/symfony/property-info" />
<path value="$PROJECT_DIR$/vendor/symfony/dom-crawler" />
<path value="$PROJECT_DIR$/vendor/symfony/var-dumper" />
<path value="$PROJECT_DIR$/vendor/symfony/expression-language" />
<path value="$PROJECT_DIR$/vendor/symfony/http-foundation" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php85" />
<path value="$PROJECT_DIR$/vendor/symfony/routing" />
<path value="$PROJECT_DIR$/vendor/symfony/security-core" />
<path value="$PROJECT_DIR$/vendor/symfony/dotenv" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-grapheme" />
<path value="$PROJECT_DIR$/vendor/symfony/stopwatch" />
</include_path>
</component>
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />
<component name="PhpStanOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PhpUnit">
<phpunit_settings>
<PhpUnitSettings custom_loader_path="$PROJECT_DIR$/vendor/autoload.php" />
</phpunit_settings>
</component>
<component name="PsalmOptionsConfiguration">
<option name="transferred" value="true" />
</component>
</project>

6
.idea/symfony2.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Symfony2PluginSettings">
<option name="pluginEnabled" value="true" />
</component>
</project>

6
.idea/vcs.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

6
.idea/workspace.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ComposerSettings">
<execution />
</component>
</project>

View File

@@ -1,425 +0,0 @@
# 📔 Carnet de Bord - Migration Inventory → Symfony
**Projet** : Migration backend NestJS/Prisma → Symfony/API Platform
**Début** : 2026-01-10
**Objectif** : Migrer vers Symfony + JWT + API Platform propre et maintenable
---
## 🔗 Convention de liaison des commits (INV)
- **Format** : `[INV-YYYYMMDD-XX]`
- **Usage** : même code dans les commits du backend **et** du frontend + ajout ici pour retrouver le duo rapidement.
## 🧾 Journal des liaisons INV
- INV-20260111-01 : ajout du lien submodule `Inventory_frontend` (commit backend : `987aa5c`, commit frontend : `936a73f`)
- INV-20260111-02 : alignement front API Platform + sessions (commit backend : `f7fc1bd`, commit frontend : `e99f053`)
## 🎯 Contexte
- **Situation initiale** :
- `Inventory_backend/` : NestJS + Prisma (fonctionnel, ~11k lignes)
- `Inventory_frontend/` : Nuxt 3 (fonctionnel, 105 fichiers)
- Base de données PostgreSQL avec données en production
- **Objectif** :
- Backend Symfony 8 + API Platform + JWT
- Garder les données existantes (migration Prisma → Doctrine)
- Frontend Nuxt connecté au nouveau backend
- Docker : 2 backends en parallèle pendant transition
---
## ✅ Phase 1 : Préparation (TERMINÉE - 10/01/2026)
### Ce qui a été fait
#### 1. Docker & Infrastructure ✅
- **pgAdmin ajouté** au docker-compose.yml
- Port : 5050
- Login : admin@admin.com / admin
- Container : `pgadmin-inventory`
- Volume persistant : `pgadmin_data`
- **Serveur PostgreSQL pré-configuré** :
- Fichier `docker/pgadmin/servers.json` monté automatiquement
- Fichier `docker/pgadmin/pgpass` pour authentification sans mot de passe
- Connexion automatique à `db:5432/inventory` au démarrage
- Nom du serveur : "Inventory PostgreSQL"
#### 2. Bundles Symfony installés ✅
```bash
# Versions installées
- lexik/jwt-authentication-bundle: v3.2.0
- vich/uploader-bundle: v2.9.1
- symfony/uid: 8.0.*
```
#### 3. JWT Configuration ✅
- **Clés RSA générées** : `config/jwt/private.pem` + `public.pem`
- **security.yaml configuré** :
- Firewall `login` : pattern `^/api/login_check` avec `json_login`
- Firewall `api` : pattern `^/api` avec `jwt` authenticator
- Provider : `app_user_provider` (entité Profile via email)
- Password hasher : bcrypt auto
#### 4. Entité Profile créée ✅
**Fichier** : `src/Entity/Profile.php`
**Caractéristiques** :
- Implémente `UserInterface` + `PasswordAuthenticatedUserInterface`
- Champs :
- `id` : string (30 chars, CUID-compatible pour Prisma)
- `email` : string unique (username pour JWT)
- `password` : string (hashed)
- `roles` : array JSON (ROLE_USER par défaut)
- `firstName`, `lastName` : string
- `isActive` : boolean
- `createdAt`, `updatedAt` : DateTimeImmutable
- Repository : `ProfileRepository` avec `PasswordUpgraderInterface`
- API Platform : endpoints CRUD auto-générés
#### 5. Base de Données ✅
- **Migration créée** : `Version20260110175413`
- **Table** : `profiles` créée avec succès
- **Utilisateur test créé** :
```
Email: admin@admin.com
Password: admin123
Roles: ['ROLE_USER', 'ROLE_ADMIN']
```
#### 6. API Platform ✅
- **Endpoint racine** : http://localhost:8081/api/
- **Réponse** :
```json
{
"@context": "/api/contexts/Entrypoint",
"@id": "/api/",
"@type": "Entrypoint",
"profile": "/api/profiles"
}
```
- **OpenAPI Docs** : Configurées (à tester)
#### 7. Configuration Apache ✅
- **VirtualHost** : `docker/php/config/vhost.conf`
- **DocumentRoot** : `/var/www/html/public`
- **AllowOverride** : All (pour `.htaccess`)
- **Port** : 8081 (Apache) → accessible depuis l'hôte
#### 8. Routing Symfony ✅
- **Routes définies** :
- `/api/login_check` : Login JWT
- `/api/test` : Test endpoint (TestController)
- `/api/*` : API Platform auto-routes
- **Vérification** :
```bash
php bin/console debug:router api_test
php bin/console router:match /api/test --method=GET
# ✅ Route found and matches
```
#### 9. .htaccess créé ✅
**Fichier** : `public/.htaccess`
**Contenu** : Symfony standard avec mod_rewrite
---
### ⚠️ Problèmes identifiés
#### 1. Routes inaccessibles via Apache (404)
**Symptôme** :
```bash
curl http://localhost:8081/api/test
# → 404 Not Found
```
**Tests effectués** :
- ✅ Route existe : `php bin/console debug:router api_test`
- ✅ Route match : `php bin/console router:match /api/test`
- ✅ Symfony fonctionne : `curl http://localhost:8081/api/` → JSON OK
- ✅ PHP built-in server OK :
```bash
php -S localhost:9000
curl http://localhost:9000/api/test
# → {"status":"ok","message":"Test endpoint works!"}
```
- ❌ Apache 404 : Depuis l'hôte via port 8081
**Diagnostic** :
- Le problème est **Apache-spécifique**
- Symfony/PHP fonctionnent correctement
- Le `.htaccess` n'est probablement **PAS lu par Apache**
- Hypothèses :
1. `AllowOverride All` non pris en compte
2. `mod_rewrite` mal configuré
3. Ordre des directives Apache incorrect
4. Problème de permissions sur `.htaccess`
**Actions à faire** :
- [ ] Vérifier permissions `.htaccess` dans container
- [ ] Tester `apache2ctl configtest`
- [ ] Activer logs de rewrite : `LogLevel alert rewrite:trace3`
- [ ] Tester FallbackResource dans vhost au lieu de `.htaccess`
#### 2. JWT Login non testé
**Raison** : Bloqué par problème #1 (routes inaccessibles)
**Actions à faire** :
- [ ] Résoudre problème Apache
- [ ] Tester `POST /api/login_check` avec credentials
- [ ] Vérifier génération du token JWT
- [ ] Tester route protégée avec token
---
## 📝 Configuration Actuelle
### Docker Compose
```yaml
services:
web:
ports:
- "8081:80" # Symfony API
- "3001:3000" # (prévu pour Nuxt)
db:
ports:
- "5433:5432" # PostgreSQL
pgadmin:
ports:
- "5050:80" # pgAdmin
```
### Variables d'Environnement
**Fichier** : `docker/.env.docker.local`
```env
# PostgreSQL
POSTGRES_DB=inventory
POSTGRES_USER=root
POSTGRES_PASSWORD=root
POSTGRES_PORT=5433
---
## ✅ Phase 2 : Migration DB + Frontend (TERMINÉE - 10/01/2026)
### Ce qui a été fait
#### 1. Entités Doctrine alignées Prisma ✅
- **Toutes les entités manquantes** créées (Machine, ModelType, Composant, Piece, Product, Links, Requirements, CustomField, Document, etc.)
- **IDs en string(36)** pour compatibilité CUID/UUID
- **Colonnes Prisma en camelCase** conservées via `name="..."` (ex: `machineId`, `createdAt`, `supplierPrice`)
- **Corrections** :
- `Document.path` passé en `TEXT`
- `CustomField.options` nullable
- `TypeMachineComponentRequirement.required` corrigé
#### 2. Migration DB inventory-data → inventory ✅
- **Dump data-only + normalisation** (conversion des identifiants quoted vers lowercase)
- **Mapping table Prisma** : `"ModelType"` → `model_types`
- **Exclusions** : `profiles`, `_prisma_migrations`
- **Import validé** : `Counts match for all tables.`
Scripts utiles :
```bash
scripts/normalize-dump.py
scripts/validate-migration.php
```
#### 3. Frontend basculé sur Inventory_frontend ✅
- `make dev-nuxt` pointe vers `Inventory_frontend/`
- `README.md` mis à jour
- **Base API** ajustée : `http://localhost:8081/api`
Fichiers modifiés :
```
makefile
README.md
Inventory_frontend/.env
Inventory_frontend/nuxt.config.ts
Inventory_frontend/app/services/modelTypes.ts
```
---
# pgAdmin
PGADMIN_EMAIL=admin@admin.com
PGADMIN_PASSWORD=admin
PGADMIN_PORT=5050
# Symfony
APP_ENV=dev
APP_SECRET=changeme_super_secret_key_123456789
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=your_jwt_passphrase_change_me
# NestJS (pour futur parallèle)
NESTJS_PORT=3000
SESSION_SECRET=changeme_session_secret
CORS_ORIGIN=http://localhost:3001
```
---
## 🚧 Phase 2 : Debugging & Tests (EN COURS)
### Objectifs
- [x] Résoudre problème Apache `.htaccess`
- [ ] Tester authentification JWT complète
- [ ] Créer endpoint de test public fonctionnel
- [ ] Documenter la solution Apache
### Prochaines étapes
1. **Fix Apache** : Logs de debug + test FallbackResource
2. **Test JWT** : Login + génération token + route protégée
3. **Documentation** : Documenter la config Apache qui fonctionne
---
## 📊 Métriques
### Temps passé
- **Phase 1** : ~3h (exploration + setup + debugging)
- **Problème Apache** : ~1h30 (en cours)
### Fichiers créés/modifiés
**Nouveaux fichiers** :
- `src/Entity/Profile.php`
- `src/Repository/ProfileRepository.php`
- `src/Controller/TestController.php`
- `public/.htaccess`
- `config/routes/routing.controllers.yaml`
- `create_test_user.php` (script utilitaire)
- `migrations/Version20260110175413.php`
- `docker/pgadmin/servers.json` (config serveur PostgreSQL)
- `docker/pgadmin/pgpass` (credentials PostgreSQL)
- `CARNET_DE_BORD.md` (ce fichier)
**Fichiers modifiés** :
- `docker-compose.yml` (+ pgAdmin)
- `docker/.env.docker.local` (+ variables Symfony/JWT/pgAdmin)
- `docker/php/config/vhost.conf` (DocumentRoot → public/)
- `config/packages/security.yaml` (JWT firewalls)
- `config/routes.yaml` (+ api_login_check)
- `composer.json` (+ lexik JWT, vich uploader)
---
## 🎓 Leçons Apprises
### 1. Symfony 8 + API Platform
- **Attributs PHP 8** : `use Symfony\Component\Routing\Attribute\Route;` (pas `Annotation`)
- **Routes controllers** : Nécessite `config/routes/routing.controllers.yaml` avec `type: attribute`
- **API Platform** : Auto-génère les endpoints CRUD avec `#[ApiResource]`
### 2. JWT Authentication
- **3 composants** :
1. Firewall `login` : `json_login` intercepte `/api/login_check`
2. Firewall `api` : `jwt` vérifie le token sur `/api/*`
3. Access control : `PUBLIC_ACCESS` vs `IS_AUTHENTICATED_FULLY`
- **username_path** : Permet de mapper `email` au lieu de `username`
- **Provider** : Doit être défini dans le firewall `login`
### 3. Doctrine Migrations
- **ID Prisma CUID** : Garder en `string(30)` pour compatibilité
- **Lifecycle callbacks** : `#[ORM\PrePersist]` pour `createdAt`/`updatedAt`
- **UserInterface** : Nécessite `getUserIdentifier()`, `getRoles()`, `eraseCredentials()`
### 4. Docker & Apache
- **`.htaccess` vs VirtualHost** : Le vhost peut override le `.htaccess`
- **AllowOverride All** : Indispensable pour que `.htaccess` fonctionne
- **FallbackResource** : Alternative au mod_rewrite dans `.htaccess`
- **Debugging** : Tester avec PHP built-in server pour isoler le problème
---
## 📚 Ressources Utiles
### Accès aux Services
```
🌐 pgAdmin: http://localhost:5050
└─ Login: admin@admin.com / admin
└─ Serveur: "Inventory PostgreSQL" (pré-configuré)
└─ Database: inventory
└─ Note: Le serveur PostgreSQL est automatiquement connecté au démarrage
🌐 API Platform: http://localhost:8081/api/
└─ Docs: http://localhost:8081/api/docs (à venir)
🗄️ PostgreSQL: localhost:5433
└─ User: root / root
└─ Database: inventory
```
### Commandes fréquentes
```bash
# Symfony
make shell # Entrer dans le container
php bin/console cache:clear # Clear cache
make cache-clear-full # Clear cache + purge var/cache
php bin/console debug:router # Lister routes
php bin/console debug:firewall # Lister firewalls
php bin/console doctrine:migrations:migrate # Exécuter migrations
# Docker
make start # Démarrer containers
make stop # Arrêter containers
docker logs -f php-inventory-apache # Logs Apache
docker logs -f pgadmin-inventory # Logs pgAdmin
docker exec php-inventory-apache bash # Shell root
# Tests API
curl http://localhost:8081/api/ # Test API Platform
curl -X POST http://localhost:8081/api/login_check \
-H "Content-Type: application/json" \
-d '{"email":"admin@admin.com","password":"admin123"}'
```
### Documentation
- [Lexik JWT Bundle](https://github.com/lexik/LexikJWTAuthenticationBundle/blob/2.x/Resources/doc/index.rst)
- [API Platform Security](https://api-platform.com/docs/core/security/)
- [Symfony Security](https://symfony.com/doc/current/security.html)
---
## 🔄 Historique des Changements
### 2026-01-15 - Session 3
- ✅ Filtre API Platform `category` sur `ModelType`
- ✅ Normalisation des structures `ModelType` (structure ↔ skeleton)
- ✅ Migration `custom_fields.options` en JSON
- ✅ Ajout commande `make cache-clear-full`
- ✅ Correctifs frontend: headers API Platform, pagination par catégorie, persistance tri
### 2026-01-10 - Session 2 (20h30)
- ✅ Problème Apache résolu (routes fonctionnelles)
- ✅ Phase 2 complète (JWT 100% opérationnel)
- ✅ Authentification testée avec succès
- ✅ Réorganisation projet (frontend/ + _archives/)
- ✅ État des lieux dans MIGRATION_PLAN.md
- ✅ 5 commits conventionnels créés
- 📊 Base inventory-data analysée (673 lignes)
### 2026-01-10 - Session 1 (19h00)
- ✅ Création projet migration
- ✅ Phase 1 complète (pgAdmin, JWT, Profile, migrations)
- ⚠️ Problème Apache identifié (routes 404)
- 📝 Carnet de bord créé
---
**Dernière mise à jour** : 2026-01-15 13:45
**Statut** : Phase 3 EN COURS ⚠️ - Migrations et intégration frontend

View File

@@ -1,15 +1,78 @@
# Changelog
Liste des évolutions du projet inventory
## [1.8.1] - 2026-03-05
## [0.0.0]
### Parameters
Ajouter dans le fichier .env
- DEFAULT_URI
- DATABASE_URL
### Ajouts
- **Composant DataTable generique** : nouveau composant `DataTable.vue` + composable `useDataTable.ts` avec tri, recherche, pagination et filtres server-side. Toutes les pages catalogue (composants, pieces, produits, documents, constructeurs, commentaires, journal d'audit, admin) migrees vers ce composant partage.
- **Messages d'erreur humanises** : les erreurs backend (violations de contraintes, erreurs serveur) sont desormais traduites en messages comprehensibles pour l'utilisateur final (`errorMessages.ts`).
- **Icones Lucide dans la navbar** : reorganisation des groupes de navigation et ajout d'icones pour chaque section.
- **Modal d'ajout d'entites aux machines** (`AddEntityToMachineModal.vue`) : ajout direct de composants, pieces et produits depuis la fiche machine.
- **Filtres SearchFilter ipartial** sur les noms de types de modeles et commentaires cote API.
### Added
### Refactoring
- **Suppression du systeme TypeMachine (squelettes machines)** : les entites `TypeMachine`, `TypeMachineComponentRequirement`, `TypeMachinePieceRequirement`, `TypeMachineProductRequirement` sont supprimees avec leurs repositories et state processors. Les champs personnalises machines sont desormais lies directement a chaque machine (relation `CustomField → Machine`).
- **Suppression des pages squelettes machines** : pages `/machine-skeleton`, `/type/[id]`, `/type/edit/[id]` et tous les composants associes (`TypeEditForm`, `MachineSkeletonSummary`, `MachineCreatePreview`, selectors de requirements, `useMachineTypesApi`, `useMachineSkeletonEditor`, `useMachineCreateSelections`, `useMachineCreatePreview`).
- **Simplification de la creation de machines** : plus besoin de selectionner un squelette, ajout direct de composants/pieces/produits.
- **Refactoring MachineStructureController** : remplacement de `MachineSkeletonController` par `MachineStructureController` avec gestion directe de la structure machine.
- **Migration de toutes les tables vers DataTable** : suppression du code de tableau duplique dans chaque page au profit du composant generique.
### Changed
### Corrections
- **Suppression catalogue avec confirmation** : la suppression d'une piece ou d'un composant dans le catalogue affiche desormais une modale de confirmation listant les elements qui seront supprimes en cascade (documents, liaisons machine, valeurs de champs personnalises) au lieu de bloquer la suppression.
- **Fix affichage categorie sur les pages edit** : les categories (produit, composant, piece) s'affichent correctement sur les pages d'edition au lieu de "Categorie inconnue". Cause : import `Serializer\Annotation\Groups` obsolete dans `ModelType` (remplace par `Attribute\Groups` pour Symfony 8) + groupes de serialisation manquants (`product:read`, `composant:read`, `piece:read`).
- Fix import `Serializer\Annotation\Groups``Attribute\Groups` dans `Profile`.
- Fix filtre `SearchFilter` : `partial``ipartial` sur `Comment.entityName` et `Document.name`/`Document.filename` pour recherche insensible a la casse.
### Fixed
### Migration requise
```bash
docker compose exec web php bin/console doctrine:migrations:migrate
```
## [1.8.0] - 2026-03-03
### Ajouts
- **Stockage documents sur disque** : les documents sont desormais stockes en fichiers sur le systeme de fichiers au lieu de Base64 en base de donnees. Les endpoints `/api/documents/{id}/file` et `/api/documents/{id}/download` servent les fichiers directement.
- **Commande de migration** `app:migrate-documents-to-filesystem` : migre les documents existants (Base64 → fichiers) avec dry-run, batch-size et limit.
- **Pagination serveur sur la page Documents** : recherche, tri (date/nom/taille), filtre par rattachement (site/machine/composant/piece/produit), selecteur par page (20/50/100).
- **Compression PDF automatique** : les documents PDF uploades sont compresses automatiquement via Ghostscript. Commande `app:compress-pdf` pour compresser les PDFs existants.
- **Nettoyage automatique des fichiers** : suppression du fichier sur disque lors de la suppression d'un document.
- **Champ description** sur les entites Piece et Composant, visible dans les catalogues avec popover au survol.
### Corrections
- Fix normalisation des documents : `fileUrl` et `downloadUrl` toujours exposes dans l'API (meme sans `path` dans le groupe de serialisation).
- Fix recursion infinie dans `DocumentNormalizer` (`getSupportedTypes` retourne `false` pour desactiver le cache).
- Fix edition de squelettes machines : `deserialize: false` + `validate: false` sur le PUT pour eviter le conflit UniqueEntity et l'interference du deserialiseur avec les collections writableLink.
- Fix sites : ajout operation PATCH et correction migration contrainte.
- Retrocompatibilite : le controleur de service gere transparentement les anciens documents Base64 et les nouveaux fichiers.
### Migration requise
```bash
docker compose exec php php bin/console doctrine:migrations:migrate
docker compose exec php php bin/console app:migrate-documents-to-filesystem
```
## [1.7.0] - 2026-03-02
### Ajouts
- **Systeme de commentaires / tickets** : les utilisateurs peuvent laisser des commentaires sur les fiches (machines, pieces, composants, produits, categories, squelettes). Les gestionnaires peuvent les resoudre.
- **Page commentaires** (`/comments`) : vue centralisee avec filtres (statut, type d'entite), pagination et liens cliquables vers les fiches.
- **Badge notifications** : compteur de commentaires ouverts sur l'avatar utilisateur et dans le menu profil (polling 60s).
- **Controle d'acces par roles** : ROLE_ADMIN, ROLE_GESTIONNAIRE, ROLE_VIEWER avec permissions granulaires sur toutes les pages.
- **Badge de role** dans le dropdown du profil utilisateur.
- **Journal d'audit etendu** : audit logging sur machines, constructeurs, types de modeles, documents et conversions.
- **Commande `app:init-profile-passwords`** : initialisation en masse des mots de passe et roles.
### Corrections
- Toggle switch pour les champs personnalises booleens (remplace les checkboxes).
- Recherche constructeur : filtrage cote client au lieu d'appels API debounce.
- Prevention des doublons de noms de constructeurs et de references de pieces (contraintes unique).
- Fix creation de squelettes machines : pagination, duplication, champs personnalises.
### Migration requise
```bash
docker compose exec php php bin/console doctrine:migrations:migrate
docker compose exec php php bin/console app:init-profile-passwords
```
## [1.6.0] - 2026-02-12
- Version initiale avec gestion du parc machines, pieces, composants, produits et categories.

203
CLAUDE.md Normal file
View File

@@ -0,0 +1,203 @@
# CLAUDE.md — Inventory Project
## Project Overview
Application de gestion d'inventaire industriel (machines, pièces, composants, produits).
Mono-repo avec backend Symfony et frontend Nuxt en submodule git.
## Stack
| Layer | Tech | Version |
|-------|------|---------|
| Backend | Symfony + API Platform | 8.0 / ^4.2 |
| PHP | PHP | >=8.4 |
| Database | PostgreSQL | 16 |
| Frontend | Nuxt (SPA, SSR off) | 4 |
| UI | Vue 3 Composition API + TypeScript | 3.5 / 5.7 |
| CSS | TailwindCSS 4 + DaisyUI 5 | |
| Auth | Session-based (cookies, pas JWT) | |
| Containers | Docker Compose | |
## Project Structure
```
Inventory/ # Backend Symfony (repo principal)
├── src/Entity/ # Entités Doctrine (annotations PHP 8 attributes)
├── src/Controller/ # Controllers custom (session, comments, audit…)
├── src/EventSubscriber/ # Audit subscribers (onFlush)
├── config/ # Config Symfony
├── migrations/ # Migrations Doctrine (raw SQL PostgreSQL)
├── docker/ # Dockerfile + .env.docker
├── scripts/ # release.sh, normalize-dump.py
├── fixtures/ # SQL fixtures
├── tests/ # PHPUnit
├── pre-commit, commit-msg # Git hooks
├── makefile # Commandes Docker/dev
├── VERSION # Source unique de version (semver)
├── Inventory_frontend/ # ← SUBMODULE GIT (repo séparé)
│ ├── app/pages/ # Pages Nuxt (file-based routing)
│ ├── app/components/ # Composants Vue (auto-imported)
│ ├── app/composables/ # Composables Vue
│ ├── app/shared/ # Types, utils, validation
│ ├── app/middleware/ # Auth middleware global
│ └── app/services/ # Service layer (wrappers useApi)
```
## Key Commands
```bash
# Docker
make start # Démarrer les containers
make stop # Arrêter
make shell # Shell interactif (nécessite un TTY)
make install # Install complet (composer + npm + build)
# Backend
make test # PHPUnit (tous les tests)
make test FILES=tests/Api/Entity/MachineTest.php # Un test spécifique
make php-cs-fixer-allow-risky # Linter PHP (cs-fixer)
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate
# Frontend (dans Inventory_frontend/)
npm run dev # Dev server (port 3001)
npm run build # Build production
npm run lint:fix # ESLint fix
npx nuxi typecheck # TypeScript check (0 errors attendu)
# Release
./scripts/release.sh patch # Bump patch version (ou minor/major)
```
## Git Conventions
### Branches
- `master` — production
- `develop` — branche principale de dev (cible des PR)
- `feat/xxx`, `fix/xxx`, `refactor/xxx` — branches de travail
### Commit Message Format (enforced by hook)
```
<type>(<scope optionnel>) : <message>
```
**Espace obligatoire autour du `:`**. Types autorisés (minuscules) :
`build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`, `wip`
Exemples :
- `feat(auth) : add login page`
- `fix(machines) : prevent null crash on skeleton creation`
### Pre-commit Hook
1. php-cs-fixer sur les fichiers PHP stagés
2. PHPUnit — bloque le commit si tests échouent
### Submodule Workflow
Le frontend est un submodule git. Lors d'un commit frontend :
1. Commit dans `Inventory_frontend/` d'abord
2. Commit dans le repo principal pour mettre à jour le pointeur submodule
3. Push les deux repos
## Architecture Backend
### Entités Principales
`Machine`, `Piece`, `Composant`, `Product`, `Constructeur`, `Site`, `ModelType`, `CustomField`, `CustomFieldValue`, `Document`, `AuditLog`, `Comment`, `Profile`, `MachineComponentLink`, `MachinePieceLink`, `MachineProductLink`
### Patterns
- **IDs** : CUID-like strings (`'cl' + bin2hex(random_bytes(12))`), pas d'auto-increment
- **ORM** : Attributs PHP 8 (`#[ORM\Column(...)]`, `#[Groups([...])]`)
- **Lifecycle** : `#[ORM\HasLifecycleCallbacks]` avec `PrePersist`/`PreUpdate` pour `createdAt`/`updatedAt`
- **Sécurité** : `security: "is_granted('ROLE_...')"` sur chaque opération API Platform
- **Audit** : Subscribers Doctrine `onFlush` capturent diff + snapshot complet
- **Migrations** : Raw SQL PostgreSQL avec `IF NOT EXISTS`/`IF EXISTS` pour idempotence
### Custom Controllers (pas API Platform)
- `MachineStructureController``/api/machines/{id}/structure` (GET/PATCH) : hiérarchie complète machine avec normalisation JSON manuelle (pas Symfony Serializer). Source principale de données pour la page détail machine.
- `MachineCustomFieldsController``/api/machines/{id}/add-custom-fields` (POST) : initialise les CustomFieldValue manquants pour une machine.
- `CustomFieldValueController``/api/custom-fields/values/*` : CRUD + upsert pour les valeurs de champs perso.
### Custom Fields — Architecture
- **Composants/Pièces/Produits** : définitions dans le JSON `structure` du ModelType
- **Machines** : définitions = entités `CustomField` liées directement via `machineId` FK (pas de ModelType)
- Les deux partagent la même entité `CustomFieldValue` pour stocker les valeurs
### Rôles (hiérarchie)
```
ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
```
### PostgreSQL — ATTENTION
- Les noms de colonnes sont **TOUJOURS EN MINUSCULES** dans PG
- Doctrine utilise camelCase (`typePieceId`) mais PG stocke `typepieceid`
- Le SQL brut doit utiliser les noms lowercase
- Tables de jointure many-to-many : colonnes `a` et `b` (ex: `_piececonstructeurs`)
## Architecture Frontend
### Patterns
- **Composables** : `interface Deps { ... }` + `export function useXxx(deps: Deps)`
- **Communication composants** : Props + Events uniquement (pas de provide/inject)
- **API** : `useApi.ts` wraps fetch avec `credentials: 'include'` pour les cookies session
- **Content-Type** : `application/ld+json` pour POST/PUT, `application/merge-patch+json` pour PATCH
- **Auth** : `useProfileSession` + middleware global `profile.global.ts`
- **Permissions** : `usePermissions.ts` miroir de la hiérarchie backend côté client
- **Auto-imports** : Nuxt auto-importe composants (`components/`) et composables (`composables/`)
### DaisyUI Classes
- Input : `input input-bordered input-sm md:input-md`
- Textarea : `textarea textarea-bordered textarea-sm md:textarea-md`
- Select : `select select-bordered select-sm md:select-md`
- Button : `btn btn-sm md:btn-md btn-primary`
## Règles Importantes
### CLAUDE.md — Maintenance obligatoire
- **Toujours consulter** ce fichier en début de conversation pour respecter les conventions
- **Mettre à jour** ce fichier quand une nouvelle convention, pattern ou décision architecturale est établie
- **Utiliser comme source de vérité** pour les commandes, patterns et règles du projet
### Toujours faire AVANT de modifier du code
1. **Lire le fichier** avant de l'éditer — ne jamais proposer de changements sur du code non lu
2. **Comprendre le pattern existant** — reproduire le style du fichier (noms, indentation, structure)
3. **Vérifier les deux repos** — un changement peut impacter backend ET frontend
### Après chaque modification
1. Backend PHP : `make php-cs-fixer-allow-risky`
2. Frontend : `npm run lint:fix` puis `npx nuxi typecheck` si fichiers TS modifiés
### Ne jamais faire
- Ajouter des features non demandées, du code mort, ou des abstractions prématurées
- Utiliser `provide/inject` — le codebase utilise Props + Events
- Utiliser JWT/tokens — l'auth est session-based
- Écrire du SQL avec des noms camelCase — PostgreSQL = lowercase
- Committer sans que l'utilisateur le demande explicitement
- Force push sans confirmation explicite
- Modifier la config git
### Submodule — Synchronisation
Quand les branches `master` et `develop` divergent sur l'un des deux repos, **toujours les synchroniser** :
- Main repo : `git checkout master && git merge develop && git push`
- Frontend : `git checkout develop && git merge master && git push` (ou l'inverse selon le cas)
## Tests
### Stack de test
- **PHPUnit 12** + **API Platform Test** (`ApiTestCase`)
- **DAMA DoctrineTestBundle** — wrappe chaque test dans une transaction avec rollback automatique (pas de TRUNCATE)
- Base de test : même PG, env `test`
### Commandes
Voir section "Key Commands". Commande additionnelle :
```bash
make test-setup # Créer/mettre à jour le schéma test
```
### Pattern de test
- Hériter de `AbstractApiTestCase` (helpers auth + factories)
- Ne PAS faire de TRUNCATE/cleanup dans tearDown — DAMA s'en occupe par rollback
- Factories : `createProfile()`, `createMachine()`, `createSite()`, `createComposant()`, `createPiece()`, `createProduct()`, `createCustomField()`, `createCustomFieldValue()`, `createModelType()`, `createMachineComponentLink()`, `createMachinePieceLink()`, `createMachineProductLink()`
- Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`
## URLs Locales
- API Symfony : `http://localhost:8081/api`
- Nuxt dev : `http://localhost:3001`
- Adminer (PG) : `http://localhost:5050`
- PG direct : `localhost:5433` (user: root, pass: root, db: inventory)

214
DEPLOY.md
View File

@@ -1,17 +1,29 @@
# Inventory - Guide de Déploiement & Release
# Inventory Guide de Déploiement
## Architecture
Guide pour déployer l'application sur un serveur de production.
## Architecture de production
```
inventory.malio-dev.fr/ → Frontend Nuxt (statique)
inventory.malio-dev.fr/api → Backend Symfony (PHP-FPM)
inventory.malio-dev.fr/ → Frontend Nuxt (fichiers statiques servis par Nginx)
inventory.malio-dev.fr/api → Backend Symfony (PHP-FPM derrière Nginx)
```
| 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` |
| Frontend | Nuxt 4 (site statique) | `/var/www/Inventory/Inventory_frontend/.output/public/` |
| Base de données | PostgreSQL 16 | Base `inventory` |
### Schéma simplifié
```
Navigateur
↓ HTTPS
Nginx (reverse proxy)
├── /api/* → PHP-FPM (Symfony) → PostgreSQL
└── /* → Fichiers statiques (Nuxt build)
```
---
@@ -24,7 +36,8 @@ inventory.malio-dev.fr/api → Backend Symfony (PHP-FPM)
- **PostgreSQL** : 16
- **Composer**
Vérifier :
### Vérification des prérequis
```bash
php -v # PHP 8.4+
php -m | grep -E 'pgsql|intl|zip|gd|mbstring'
@@ -73,10 +86,10 @@ psql -U ferme_user -h 127.0.0.1 -d inventory -f /tmp/backup_v1.0.0_clean.sql
```bash
cd /var/www/Inventory
# Installer les dépendances
# Installer les dépendances (sans les outils de dev)
composer install --no-dev --optimize-autoloader
# Créer .env.local
# Créer le fichier de configuration locale
cat > .env.local << 'EOF'
APP_ENV=prod
APP_DEBUG=0
@@ -85,28 +98,20 @@ 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
# Générer un secret aléatoire
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
# Permissions pour le dossier var/ (cache, logs)
sudo chown -R www-data:www-data var/
sudo chmod -R 775 var/
# Vider le cache
php bin/console cache:clear --env=prod
# Appliquer les migrations (si première installation ou mise à jour)
php bin/console doctrine:migrations:migrate --no-interaction
```
### 4. Configurer le frontend Nuxt
@@ -120,7 +125,7 @@ sudo chown -R malio:malio .
# Installer les dépendances
npm install
# Créer .env
# Créer le fichier d'environnement
cat > .env << 'EOF'
NUXT_PUBLIC_API_BASE_URL=http://inventory.malio-dev.fr/api
EOF
@@ -141,7 +146,7 @@ server {
listen 80;
server_name inventory.malio-dev.fr;
# Gros fichiers (100MB max)
# Gros fichiers (100MB max pour les uploads de documents)
client_max_body_size 100M;
client_body_timeout 300s;
send_timeout 300s;
@@ -149,12 +154,13 @@ server {
access_log /var/log/nginx/inventory-access.log;
error_log /var/log/nginx/inventory-error.log;
# Backend Symfony - /api
# Backend Symfony — toutes les requêtes /api
location /api {
root /var/www/Inventory/public;
try_files $uri /index.php$is_args$args;
}
# PHP-FPM (exécute le code PHP)
location ~ ^/index\.php(/|$) {
fastcgi_pass unix:/run/php/php-fpm.sock;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
@@ -165,27 +171,27 @@ server {
internal;
}
# Frontend statique
# Frontend statique — tout le reste
location / {
root /var/www/Inventory/Inventory_frontend/.output/public;
index index.html;
try_files $uri $uri/ /index.html;
try_files $uri $uri/ /index.html; # SPA fallback
}
}
```
Activer :
Activer le site :
```bash
sudo ln -s /etc/nginx/sites-available/inventory /etc/nginx/sites-enabled/
sudo nginx -t
sudo nginx -t # Vérifier la syntaxe
sudo systemctl reload nginx
```
### 6. Vérifier
```bash
curl http://inventory.malio-dev.fr
curl http://inventory.malio-dev.fr/api
curl http://inventory.malio-dev.fr # Frontend
curl http://inventory.malio-dev.fr/api # API (doc Swagger)
```
---
@@ -197,12 +203,13 @@ curl http://inventory.malio-dev.fr/api
```bash
cd /var/www/Inventory
# Pull les changements
# Récupérer les changements
git pull
git submodule update --init --recursive
# Backend
composer install --no-dev --optimize-autoloader
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console cache:clear --env=prod
sudo chown -R www-data:www-data var/
@@ -214,56 +221,76 @@ npx nuxi generate
---
## Versioning & Releases
## Backup base de données
### 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
### Export (faire un backup)
```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
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
```
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
### Import (restaurer un backup)
```bash
# Frontend (submodule)
cd Inventory_frontend && git push && git push --tags && cd ..
# Backend
git push && git push --tags
psql -U ferme_user -h 127.0.0.1 -d inventory -f backup_inventory_YYYYMMDD.sql
```
### 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
## Troubleshooting
### Erreur 502 Bad Gateway
PHP-FPM ne tourne pas ou est crashé :
```bash
systemctl status php8.4-fpm
sudo systemctl restart php8.4-fpm
```
### Erreur 403 Forbidden
Problème de permissions sur les fichiers :
```bash
sudo chown -R www-data:www-data /var/www/Inventory/var/
sudo chmod -R 775 /var/www/Inventory/var/
```
### Erreur API "No route found"
Le cache Symfony est probablement périmé :
```bash
php /var/www/Inventory/bin/console cache:clear --env=prod
```
### Frontend ne se met pas à jour
Les fichiers statiques sont en cache. Rebuilder :
```bash
cd /var/www/Inventory/Inventory_frontend
rm -rf .output
npx nuxi generate
```
### L'API retourne 401 sur toutes les requêtes
La session PHP ne se crée pas correctement. Vérifier :
```bash
# Vérifier que le dossier de sessions existe et est accessible
ls -la /var/lib/php/sessions/
# Ou vérifier les logs Symfony
tail -f /var/www/Inventory/var/log/prod.log
```
---
## Commandes utiles en production
```bash
# Logs Nginx
tail -f /var/log/nginx/inventory-error.log
tail -f /var/log/nginx/inventory-access.log
# Logs Symfony
tail -f /var/www/Inventory/var/log/prod.log
@@ -274,55 +301,12 @@ php /var/www/Inventory/bin/console cache:clear --env=prod
# Rebuild frontend
cd /var/www/Inventory/Inventory_frontend && npx nuxi generate
# Status PHP-FPM
# Status des services
systemctl status php8.4-fpm
systemctl status nginx
systemctl status postgresql
# Reload Nginx
# Redémarrer les services
sudo systemctl restart php8.4-fpm
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
```

File diff suppressed because it is too large Load Diff

353
README.md
View File

@@ -1,92 +1,305 @@
# Projet Inventory
# Inventory
## Installation du projet
### Windows
Pour windows, il faut installer le WSL2, Ubuntu, docker et nvm.
Il suffit de suivre cette [doc](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/windows)
Application de gestion d'inventaire industriel pour **Malio**. Gestion complète du parc machines, des pièces, composants, produits, fournisseurs et documents associés, avec traçabilité et contrôle d'accès par rôles.
### Linux
Pour linux, il faut installer docker et nvm.
Il suffit de suivre cette [doc](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/linux)
## C'est quoi ce projet ?
Inventory est une application web qui permet de gérer un parc de machines industrielles. Concrètement, elle permet de :
- **Cataloguer** les machines d'une usine, site par site
- **Décomposer** chaque machine en composants, pièces et produits (structure arborescente)
- **Suivre** les fournisseurs/constructeurs de chaque élément
- **Stocker** les documents techniques (PDF, images, fiches techniques)
- **Tracer** toutes les modifications (qui a changé quoi, quand) via un journal d'audit
- **Commenter** les fiches pour collaborer entre équipes
- **Gérer les accès** avec un système de rôles (admin, gestionnaire, lecteur)
L'application se compose de deux parties :
- Un **backend** (API REST) qui gère les données, la sécurité et la logique métier
- Un **frontend** (interface web) qui affiche les données et permet l'interaction utilisateur
## Stack technique
| Couche | Technologie | Version | Rôle |
|--------|-------------|---------|------|
| Backend | Symfony + API Platform | 8.0 / 4.2 | API REST, logique métier, sécurité |
| PHP | PHP | >= 8.4 | Langage backend |
| Base de données | PostgreSQL | 16 | Stockage des données |
| Frontend | Nuxt (SPA, SSR off) | 4 | Framework web (rendu côté client) |
| UI | Vue 3 Composition API + TypeScript | 3.5 / 5.7 | Composants d'interface |
| CSS | TailwindCSS + DaisyUI | 4 / 5 | Mise en page et composants visuels |
| Conteneurs | Docker Compose | | Environnement de développement |
## Prérequis
- **Docker** et **Docker Compose** (pour lancer le projet sans rien installer)
- **Node.js** >= 20 (via [nvm](https://github.com/nvm-sh/nvm))
- **make** (normalement déjà installé sur Linux/macOS)
### Guides d'installation de l'environnement
| OS | Documentation |
|----|---------------|
| Windows | [WSL2 + Ubuntu + Docker](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/windows) |
| Linux | [Docker + nvm](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/linux) |
## Installation rapide
### 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
# 1. Cloner le projet avec le frontend (submodule)
git clone --recurse-submodules <url-du-repo>
cd Inventory
# 2. Démarrer les conteneurs Docker (PHP, PostgreSQL, Adminer)
make start
# 3. Installer les dépendances et builder le projet
make install
```
Dans le cas ou le `make start` plante à cause du port de la bdd, il faut modifier **POSTGRES_PORT** dans le fichier .env.docker.local, remplacer le par un port disponible.
### Configuration xdebug
Pour configurer xdebug, il faut ajouter un serveur sur phpstorm. <br>
Pour cela, il faut aller dans **Settings > PHP > Servers** <br>
* Name : inventory-docker
* Host : localhost
* Port : 8080
* Path : File/Directory -> l'endroit où est stocké votre projet et le path -> /var/www/html
> Si `make start` échoue sur le port de la BDD, modifier `POSTGRES_PORT` dans `docker/.env.docker.local`.
Pour que xdebug fonctionne sur windows, il faut modifier la variable **XDEBUG_CLIENT_HOST** par votre ip local
### Que fait `make install` ?
## Utilisation du projet
### Backend
L'api est disponible sur http://localhost:8080/api
Pour la bdd toutes les infos sont dans le fichier **docker/.env.docker.local**
Vous pouvez modifier le port si nécessaire.
1. Installe les dépendances PHP (via Composer)
2. Installe les dépendances Node.js (via npm)
3. Build le frontend Nuxt
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
### Premier lancement
## Compression automatique des PDFs
Une fois l'installation terminée, tu peux :
Les documents PDF uploadés sont automatiquement compressés sans perte de qualité grâce à **qpdf**.
1. Charger des données de test : `make fixtures-load`
2. Lancer le frontend en mode dev : `make dev-nuxt`
3. Ouvrir l'application : http://localhost:3001
### Prérequis
```bash
# Installation de qpdf (outil système)
sudo apt install qpdf
## URLs locales
# 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
```
| Service | URL | Description |
|---------|-----|-------------|
| API Symfony | http://localhost:8081/api | Documentation interactive de l'API (Swagger) |
| Frontend Nuxt | http://localhost:3001 | L'application web |
| Adminer (BDD) | http://localhost:5050 | Interface web pour explorer la base de données |
| PostgreSQL | `localhost:5433` | Connexion directe (user: root, pass: root, db: inventory) |
## Commandes utiles
Pour restart le container
### Docker
| Commande | Description |
|----------|-------------|
| `make start` | Démarrer les conteneurs |
| `make stop` | Arrêter les conteneurs |
| `make restart` | Redémarrer les conteneurs |
| `make shell` | Ouvrir un terminal dans le conteneur PHP (pour lancer des commandes Symfony) |
| `make reset` | Reset complet (supprime les volumes, réinstalle tout) |
### Backend
| Commande | Description |
|----------|-------------|
| `make test` | Lancer les tests PHPUnit |
| `make test FILES=tests/Api/Entity/MachineTest.php` | Lancer un test spécifique |
| `make test-setup` | Créer/mettre à jour la base de test |
| `make php-cs-fixer-allow-risky` | Formatter le code PHP (indentation, espaces, etc.) |
| `make cache-clear` | Vider le cache Symfony (à faire si tu as des erreurs bizarres) |
| `make db-reset` | Reset de la BDD (supprime toutes les données) |
| `make fixtures-load` | Charger les données de test |
| `make fixtures-dump` | Sauvegarder la BDD actuelle dans fixtures/data.sql |
### Frontend
| Commande | Description |
|----------|-------------|
| `make dev-nuxt` | Lancer le serveur de dev Nuxt (avec rechargement automatique) |
| `make build-nuxtJS` | Builder le frontend pour la production |
### Release
```bash
make restart
./scripts/release.sh patch # Bump patch (ou minor / major)
```
Pour lancer les TU
```bash
make test
Synchronise automatiquement la version dans `VERSION`, `api_platform.yaml` et `nuxt.config.ts`, crée le tag git et pousse les deux repos.
## Architecture globale
### Comment ça marche ?
```
Pour accéder au container et lance des commandes
```bash
make shell
┌──────────────────┐ HTTP (JSON) ┌──────────────────┐ SQL ┌────────────┐
│ Frontend │ ◄─────────────────► │ Backend │ ◄──────────► │ PostgreSQL │
│ (Nuxt/Vue) │ cookies session │ (Symfony/API) │ │ (BDD) │
│ localhost:3001 │ │ localhost:8081 │ │ port 5433 │
└──────────────────┘ └──────────────────┘ └────────────┘
│ │
Interface web API REST + logique
pour l'utilisateur métier + sécurité
```
Pour clear le cache Symfony
```bash
make cache-clear
1. L'utilisateur ouvre le navigateur sur `localhost:3001`
2. Le frontend (Vue/Nuxt) affiche l'interface
3. Quand l'utilisateur fait une action (créer une machine, etc.), le frontend envoie une requête HTTP à l'API backend
4. Le backend valide la requête, vérifie les permissions, exécute la logique métier
5. Le backend lit/écrit dans PostgreSQL et renvoie une réponse JSON
6. Le frontend met à jour l'interface avec les nouvelles données
### Structure du projet
```
Inventory/ # Backend Symfony (repo principal)
├── src/
│ ├── Entity/ # Les "modèles" de données (Machine, Piece, etc.)
│ ├── Controller/ # Les endpoints API personnalisés
│ ├── EventSubscriber/ # Logique déclenchée automatiquement (audit, etc.)
│ ├── Command/ # Commandes CLI (lancer via php bin/console)
│ ├── Service/ # Services métier (stockage fichiers, PDF, etc.)
│ ├── State/ # Processeurs API Platform (hashage mot de passe, upload)
│ ├── Repository/ # Requêtes BDD personnalisées
│ ├── Security/ # Authentification par session
│ └── Serializer/ # Conversion entité ↔ JSON personnalisée
├── config/ # Configuration Symfony (routes, sécurité, etc.)
├── migrations/ # Scripts de modification de la BDD
├── fixtures/ # Données de test (SQL)
├── tests/ # Tests automatisés (PHPUnit)
├── scripts/ # Utilitaires (release, migration, normalisation)
├── docker/ # Dockerfile + config Docker
├── makefile # Commandes de dev raccourcies
├── VERSION # Version courante (ex: 1.8.1)
└── Inventory_frontend/ # Submodule git (frontend, repo séparé)
├── app/pages/ # Les pages de l'app (1 fichier = 1 route URL)
├── app/components/ # Composants Vue réutilisables
├── app/composables/ # Logique métier partagée (appels API, états)
├── app/shared/ # Types TypeScript, utilitaires, validation
├── app/middleware/ # Vérification de session automatique
└── app/services/ # Couche service (wrappers API)
```
### Entités principales (les "tables" de la BDD)
| Entité | Description | Exemple |
|--------|-------------|---------|
| `Machine` | Machines du parc industriel | "CNC Mazak 01" |
| `Composant` | Composants fonctionnels d'une machine | "Broche principale" |
| `Piece` | Pièces détachées/de rechange | "Roulement SKF 6205" |
| `Product` | Produits fournisseur (consommables, outillage) | "Huile de coupe X" |
| `Site` | Sites physiques / usines | "Usine de Strasbourg" |
| `Constructeur` | Fournisseurs / fabricants | "SKF", "Mazak" |
| `ModelType` | Catégories avec squelettes de structure | "Type: Moteur électrique" |
| `CustomField` / `CustomFieldValue` | Champs personnalisés (dynamiques) | "Tension : 220V" |
| `Document` | Documents uploadés (PDF, images, etc.) | "Fiche technique CNC.pdf" |
| `AuditLog` | Journal d'audit (historique des modifications) | "Machine X modifiée par Jean" |
| `Comment` | Commentaires / tickets sur les fiches | "Vérifier le roulement" |
| `Profile` | Comptes utilisateurs avec rôles | "admin@malio.fr (ADMIN)" |
### Structure hiérarchique d'une machine
Une machine peut contenir une arborescence de composants, pièces et produits :
```
Machine "CNC Mazak 01"
├── Composant "Broche principale"
│ ├── Pièce "Roulement avant"
│ │ └── Produit "Graisse SKF LGMT2"
│ └── Pièce "Joint d'étanchéité"
├── Composant "Système hydraulique"
│ ├── Pièce "Pompe HP"
│ └── Produit "Huile hydraulique ISO 46"
└── Produit "Filtre à air cabine"
```
### Rôles et permissions
```
ROLE_ADMIN → Tout faire + gérer les utilisateurs
↓ hérite de
ROLE_GESTIONNAIRE → Créer, modifier, supprimer les données
↓ hérite de
ROLE_VIEWER → Lecture seule sur toutes les données
↓ hérite de
ROLE_USER → Accès de base (rôle minimum)
```
### Authentification
Authentification par **session (cookies)**, pas de JWT. Le profil actif est stocké en session côté serveur. Concrètement :
1. L'utilisateur choisit son profil sur la page de login
2. Il entre son mot de passe
3. Le backend crée une session et envoie un cookie au navigateur
4. À chaque requête suivante, le navigateur envoie automatiquement ce cookie
5. Le backend vérifie le cookie et identifie l'utilisateur
### Base de données — Points importants
PostgreSQL 16 avec les particularités suivantes :
- **IDs** : chaînes CUID (`'cl' + bin2hex(random_bytes(12))`), pas d'auto-increment
- **Noms de colonnes** : toujours en **minuscules** dans PostgreSQL (Doctrine map `typePieceId``typepieceid`)
- **Audit** : les subscribers Doctrine `onFlush` capturent le diff + snapshot complet de chaque modification
- **Migrations** : SQL brut avec `IF NOT EXISTS` / `IF EXISTS` pour l'idempotence
## Services Docker
| Service | Image | Port | Rôle |
|---------|-------|------|------|
| `web` | PHP 8.4 + Apache + Node | 8081, 3001 | API Symfony + Nuxt dev |
| `db` | PostgreSQL 16 Alpine | 5433 | Base de données |
| `adminer` | Adminer | 5050 | Interface web pour explorer la BDD |
## Xdebug
Configuration PhpStorm / VSCode :
- **Serveur** : `inventory-docker`
- **Host** : `localhost`
- **Port** : `8081`
- **Path mapping** : racine du projet → `/var/www/html`
> Sous WSL, modifier `XDEBUG_CLIENT_HOST` dans `docker/.env.docker.local` avec votre IP locale.
## Git
### Branches
- `master` : production
- `develop` : branche principale de dev (cible des PR)
- `feat/xxx`, `fix/xxx`, `refactor/xxx` : branches de travail
### Convention de commit (enforced par un hook)
```
<type>(<scope>) : <message>
```
**Espace obligatoire autour du `:`**. Types autorisés (minuscules) :
`feat`, `fix`, `perf`, `refactor`, `chore`, `docs`, `test`, `style`, `build`, `ci`, `revert`, `wip`
Exemples :
```
feat(machines) : add clone functionality
fix(documents) : prevent duplicate upload
refactor(audit) : merge history controllers
chore(deps) : update composer packages
```
### Pre-commit hook
Le hook `pre-commit` s'exécute automatiquement avant chaque commit :
1. **php-cs-fixer** — Formate automatiquement les fichiers PHP modifiés
2. **PHPUnit** — Lance les tests. Si un test échoue, le commit est bloqué
### Submodule frontend
Le frontend est un **submodule git** dans `Inventory_frontend/` (c'est un repo git séparé, inclus dans le repo principal). Workflow de commit :
1. Commiter dans `Inventory_frontend/` d'abord
2. Commiter dans le repo principal pour mettre à jour le pointeur du submodule
3. Pousser les deux repos
## Documentation détaillée
- **[docs/BACKEND.md](docs/BACKEND.md)** : guide complet du backend (entités, controllers, API, audit, tests)
- **[docs/FRONTEND.md](docs/FRONTEND.md)** : guide complet du frontend (pages, composables, composants, patterns)
- **[DEPLOY.md](DEPLOY.md)** : guide de déploiement serveur (Nginx, PHP-FPM, PostgreSQL)
- **[RELEASE.md](RELEASE.md)** : processus de release et versioning
- **[CHANGELOG.md](CHANGELOG.md)** : historique des versions
- **[Frontend README](Inventory_frontend/README.md)** : documentation du frontend Nuxt

View File

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

View File

@@ -1,12 +1,18 @@
# Guide de Release
## Versioning
## C'est quoi une release ?
Le projet utilise le [Semantic Versioning](https://semver.org/) (SemVer) : `MAJOR.MINOR.PATCH`
Une release c'est une **version officielle** de l'application. Chaque release a un numéro de version (ex: `1.8.1`) et est marquée par un **tag git**.
- **MAJOR** : Changements incompatibles avec les versions précédentes
- **MINOR** : Nouvelles fonctionnalités rétrocompatibles
- **PATCH** : Corrections de bugs rétrocompatibles
## Versioning (Semantic Versioning)
Le projet utilise le [Semantic Versioning](https://semver.org/) : `MAJOR.MINOR.PATCH`
| Type | Quand l'utiliser | Exemple |
|------|------------------|---------|
| **PATCH** | Correction de bug, pas de nouvelle fonctionnalité | `1.8.0``1.8.1` |
| **MINOR** | Nouvelle fonctionnalité, rétrocompatible | `1.8.1``1.9.0` |
| **MAJOR** | Changement majeur, potentiellement incompatible | `1.9.0``2.0.0` |
La version est centralisée dans le fichier `VERSION` à la racine du projet.
@@ -14,9 +20,9 @@ La version est centralisée dans le fichier `VERSION` à la racine du projet.
### Prérequis
- Tous les changements doivent être commités
- Les tests doivent passer
- Être sur la branche à releaser (ex: `main`, `develop`)
- Tous les changements doivent être commités (pas de fichiers modifiés non commités)
- Les tests doivent passer (`make test`)
- Être sur la branche à releaser (généralement `develop` ou `master`)
### Utilisation du script
@@ -24,28 +30,38 @@ La version est centralisée dans le fichier `VERSION` à la racine du projet.
# Afficher l'aide et la version actuelle
./scripts/release.sh
# Bump patch : 1.0.0 → 1.0.1
# Bump patch : 1.8.1 → 1.8.2
./scripts/release.sh patch
# Bump minor : 1.0.0 → 1.1.0
# Bump minor : 1.8.1 → 1.9.0
./scripts/release.sh minor
# Bump major : 1.0.0 → 2.0.0
# Bump major : 1.8.1 → 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`
### Que fait le script ?
1. Vérifie qu'il n'y a pas de changements non commités
2. Vérifie/commit le submodule frontend si nécessaire
3. Met à jour le fichier `VERSION` avec le nouveau numéro
4. Met à jour `config/packages/api_platform.yaml` (version affichée dans l'API)
5. Crée un commit `chore(release) : vX.Y.Z`
6. Crée le tag git `vX.Y.Z`
7. Affiche les commandes pour pousser
### Pousser la release
Après avoir exécuté le script :
```bash
# Pousser le frontend d'abord (si modifié)
cd Inventory_frontend && git push && git push --tags && cd ..
# Pousser le backend
git push && git push --tags
```
@@ -54,12 +70,21 @@ git push && git push --tags
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)
4. Titre : `vX.Y.Z` (ou avec un nom descriptif)
5. Description : résumé des changements (copier depuis CHANGELOG.md)
## Fichiers impactés par le versioning
| Fichier | Rôle |
|---------|------|
| `VERSION` | Source unique de vérité |
| `config/packages/api_platform.yaml` | Version affichée dans la doc API (Swagger) |
| `Inventory_frontend/nuxt.config.ts` | Lit `VERSION` au build pour l'afficher dans le footer |
| Footer de l'app | Affiche `v{{ appVersion }}` |
## Notes de release
Template pour les notes de release :
Template pour les notes de release (à copier dans Gitea) :
```markdown
## Nouveautés
@@ -73,66 +98,25 @@ Template pour les notes de release :
## Changements
- Refactoring de Z
- Mise à jour des dépendances
## Migration requise
\`\`\`bash
docker compose exec web php bin/console doctrine:migrations:migrate
\`\`\`
```
## Fichiers impactés par le versioning
## Déploiement après une release
| 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 }}` |
Voir [DEPLOY.md](DEPLOY.md) pour les instructions de mise à jour en production.
## 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 :
En résumé :
```bash
# Sur le serveur de production
cd /var/www/Inventory
git pull
git submodule update --init --recursive
composer install --no-dev --optimize-autoloader
php bin/console cache:clear --env=prod
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console cache:clear --env=prod
cd Inventory_frontend && npm install && npx nuxi generate
```
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)

View File

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

View File

@@ -1 +1 @@
1.6.1
1.9.0

View File

@@ -86,9 +86,11 @@
}
},
"require-dev": {
"dama/doctrine-test-bundle": "^8.6",
"friendsofphp/php-cs-fixer": "^3.92",
"phpunit/phpunit": "^12.5",
"symfony/browser-kit": "8.0.*",
"symfony/css-selector": "8.0.*"
"symfony/css-selector": "8.0.*",
"symfony/http-client": "8.0.*"
}
}

257
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": "9e0e35659f9b6ef5c0a60262a36b61f2",
"content-hash": "97c89001351c3dcf060e2b9b5f37a8a6",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -7980,6 +7980,75 @@
],
"time": "2024-05-06T16:37:16+00:00"
},
{
"name": "dama/doctrine-test-bundle",
"version": "v8.6.0",
"source": {
"type": "git",
"url": "https://github.com/dmaicher/doctrine-test-bundle.git",
"reference": "f7e3487643e685432f7e27c50cac64e9f8c515a4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dmaicher/doctrine-test-bundle/zipball/f7e3487643e685432f7e27c50cac64e9f8c515a4",
"reference": "f7e3487643e685432f7e27c50cac64e9f8c515a4",
"shasum": ""
},
"require": {
"doctrine/dbal": "^3.3 || ^4.0",
"doctrine/doctrine-bundle": "^2.11.0 || ^3.0",
"php": ">= 8.2",
"psr/cache": "^2.0 || ^3.0",
"symfony/cache": "^6.4 || ^7.3 || ^8.0",
"symfony/framework-bundle": "^6.4 || ^7.3 || ^8.0"
},
"conflict": {
"phpunit/phpunit": "<11.0"
},
"require-dev": {
"behat/behat": "^3.0",
"friendsofphp/php-cs-fixer": "^3.27",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^11.5.41|| ^12.3.14",
"symfony/dotenv": "^6.4 || ^7.3 || ^8.0",
"symfony/process": "^6.4 || ^7.3 || ^8.0"
},
"type": "symfony-bundle",
"extra": {
"branch-alias": {
"dev-master": "8.x-dev"
}
},
"autoload": {
"psr-4": {
"DAMA\\DoctrineTestBundle\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "David Maicher",
"email": "mail@dmaicher.de"
}
],
"description": "Symfony bundle to isolate doctrine database tests and improve test performance",
"keywords": [
"doctrine",
"isolation",
"performance",
"symfony",
"testing",
"tests"
],
"support": {
"issues": "https://github.com/dmaicher/doctrine-test-bundle/issues",
"source": "https://github.com/dmaicher/doctrine-test-bundle/tree/v8.6.0"
},
"time": "2026-01-21T07:39:44+00:00"
},
{
"name": "evenement/evenement",
"version": "v3.0.2",
@@ -10344,16 +10413,16 @@
},
{
"name": "symfony/browser-kit",
"version": "v8.0.3",
"version": "v8.0.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/browser-kit.git",
"reference": "efc7cc6d442b80c8cb0ad0b29bdb0c9418cee9ee"
"reference": "0d998c101e1920fc68572209d1316fec0db728ef"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/browser-kit/zipball/efc7cc6d442b80c8cb0ad0b29bdb0c9418cee9ee",
"reference": "efc7cc6d442b80c8cb0ad0b29bdb0c9418cee9ee",
"url": "https://api.github.com/repos/symfony/browser-kit/zipball/0d998c101e1920fc68572209d1316fec0db728ef",
"reference": "0d998c101e1920fc68572209d1316fec0db728ef",
"shasum": ""
},
"require": {
@@ -10392,7 +10461,7 @@
"description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/browser-kit/tree/v8.0.3"
"source": "https://github.com/symfony/browser-kit/tree/v8.0.4"
},
"funding": [
{
@@ -10412,7 +10481,7 @@
"type": "tidelift"
}
],
"time": "2025-12-16T08:10:18+00:00"
"time": "2026-01-13T13:06:50+00:00"
},
{
"name": "symfony/css-selector",
@@ -10553,6 +10622,180 @@
],
"time": "2025-12-06T17:00:47+00:00"
},
{
"name": "symfony/http-client",
"version": "v8.0.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
"reference": "ade9bd433450382f0af154661fc8e72758b4de36"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client/zipball/ade9bd433450382f0af154661fc8e72758b4de36",
"reference": "ade9bd433450382f0af154661fc8e72758b4de36",
"shasum": ""
},
"require": {
"php": ">=8.4",
"psr/log": "^1|^2|^3",
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
"symfony/service-contracts": "^2.5|^3"
},
"conflict": {
"amphp/amp": "<3",
"php-http/discovery": "<1.15"
},
"provide": {
"php-http/async-client-implementation": "*",
"php-http/client-implementation": "*",
"psr/http-client-implementation": "1.0",
"symfony/http-client-implementation": "3.0"
},
"require-dev": {
"amphp/http-client": "^5.3.2",
"amphp/http-tunnel": "^2.0",
"guzzlehttp/promises": "^1.4|^2.0",
"nyholm/psr7": "^1.0",
"php-http/httplug": "^1.0|^2.0",
"psr/http-client": "^1.0",
"symfony/cache": "^7.4|^8.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/messenger": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/rate-limiter": "^7.4|^8.0",
"symfony/stopwatch": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
"homepage": "https://symfony.com",
"keywords": [
"http"
],
"support": {
"source": "https://github.com/symfony/http-client/tree/v8.0.7"
},
"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": "2026-03-06T13:17:40+00:00"
},
{
"name": "symfony/http-client-contracts",
"version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client-contracts.git",
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.6-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Contracts\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Test/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Generic abstractions related to HTTP clients",
"homepage": "https://symfony.com",
"keywords": [
"abstractions",
"contracts",
"decoupling",
"interfaces",
"interoperability",
"standards"
],
"support": {
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-04-29T11:18:49+00:00"
},
{
"name": "symfony/options-resolver",
"version": "v8.0.0",

View File

@@ -3,6 +3,7 @@
declare(strict_types=1);
use ApiPlatform\Symfony\Bundle\ApiPlatformBundle;
use DAMA\DoctrineTestBundle\DAMADoctrineTestBundle;
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
@@ -20,4 +21,5 @@ return [
NelmioCorsBundle::class => ['all' => true],
ApiPlatformBundle::class => ['all' => true],
LexikJWTAuthenticationBundle::class => ['all' => true],
DAMADoctrineTestBundle::class => ['test' => true],
];

View File

@@ -1,9 +1,12 @@
api_platform:
title: Hello API Platform
version: 1.4.0
title: Inventory API
description: API de gestion d'inventaire industriel — machines, pièces, composants, produits.
version: 1.8.1
defaults:
stateless: false
cache_headers:
vary: ['Content-Type', 'Authorization', 'Origin']
pagination_items_per_page: 30
pagination_maximum_items_per_page: 200
pagination_fetch_join_collection: true
pagination_partial: false

View File

@@ -1,4 +1,9 @@
security:
# Login controller already calls $session->migrate(true) on login.
# Keeping 'migrate' would regenerate the session ID on every authenticated
# API request, which breaks concurrent requests from the SPA (race condition).
session_fixation_strategy: none
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
@@ -18,44 +23,36 @@ security:
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
session_public:
pattern: ^/api/session/profiles?$
security: false
api:
pattern: ^/api
stateless: false
custom_authenticators:
- App\Security\SessionProfileAuthenticator
main:
lazy: true
provider: app_user_provider
role_hierarchy:
ROLE_ADMIN: ROLE_GESTIONNAIRE
ROLE_GESTIONNAIRE: ROLE_VIEWER
ROLE_VIEWER: ROLE_USER
# Note: Only the *first* matching rule is applied
access_control:
- { path: ^/api/session/profile, roles: PUBLIC_ACCESS }
- { path: ^/api/session/profiles, roles: PUBLIC_ACCESS }
- { path: ^/api, roles: PUBLIC_ACCESS }
- { path: ^/api/session/profile$, roles: PUBLIC_ACCESS }
- { path: ^/api/session/profiles, roles: PUBLIC_ACCESS, methods: [GET] }
- { path: ^/api/admin, roles: ROLE_ADMIN }
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
- { path: ^/api/test, roles: PUBLIC_ACCESS }
- { path: ^/api/health$, roles: PUBLIC_ACCESS }
- { path: ^/docs, roles: PUBLIC_ACCESS }
- { path: ^/contexts, roles: PUBLIC_ACCESS }
- { path: ^/\.well-known, roles: PUBLIC_ACCESS }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
- { path: ^/api, roles: ROLE_VIEWER }
when@test:
security:

View File

@@ -33,3 +33,8 @@ services:
App\EventSubscriber\ComposantAuditSubscriber:
tags:
- { name: doctrine.event_subscriber }
App\OpenApi\OpenApiDecorator:
decorates: 'api_platform.openapi.factory'
arguments:
$decorated: '@.inner'

846
docs/BACKEND.md Normal file
View File

@@ -0,0 +1,846 @@
# Guide Backend — Inventory
Guide complet du backend Symfony pour comprendre comment tout fonctionne, même si tu débutes.
## Table des matières
1. [Vue d'ensemble](#vue-densemble)
2. [Comment fonctionne une API REST](#comment-fonctionne-une-api-rest)
3. [Symfony + API Platform — les bases](#symfony--api-platform--les-bases)
4. [Les Entités (les modèles de données)](#les-entités)
5. [Les Controllers (les endpoints personnalisés)](#les-controllers)
6. [Le système d'audit](#le-système-daudit)
7. [L'authentification par session](#lauthentification-par-session)
8. [Les services](#les-services)
9. [Les migrations de base de données](#les-migrations)
10. [Les tests](#les-tests)
11. [Flux complet d'une requête](#flux-complet-dune-requête)
12. [Commandes Symfony utiles](#commandes-symfony-utiles)
---
## Vue d'ensemble
Le backend est une **API REST** construite avec :
- **Symfony 8** : le framework PHP (gère le routing, la sécurité, la config, etc.)
- **API Platform 4.2** : une surcouche qui génère automatiquement les endpoints CRUD à partir des entités
- **Doctrine ORM** : fait le lien entre les objets PHP et les tables PostgreSQL
- **PostgreSQL 16** : la base de données relationnelle
### Le principe
Au lieu d'écrire manuellement chaque endpoint (GET /machines, POST /machines, etc.), **API Platform** les génère automatiquement à partir des entités PHP. Tu déclares tes champs, tes relations, tes règles de sécurité directement sur la classe PHP, et API Platform fait le reste.
---
## Comment fonctionne une API REST
### C'est quoi une API REST ?
Une API REST c'est un serveur qui répond à des requêtes HTTP (comme un site web, mais au lieu de renvoyer du HTML, il renvoie du JSON).
### Les verbes HTTP
| Verbe | Action | Exemple |
|-------|--------|---------|
| `GET` | Lire des données | `GET /api/machines` → liste toutes les machines |
| `POST` | Créer une donnée | `POST /api/machines` + body JSON → crée une machine |
| `PUT` | Remplacer une donnée | `PUT /api/machines/123` + body JSON → remplace la machine 123 |
| `PATCH` | Modifier partiellement | `PATCH /api/machines/123` + body JSON → modifie certains champs |
| `DELETE` | Supprimer | `DELETE /api/machines/123` → supprime la machine 123 |
### Les codes de réponse HTTP
| Code | Signification | Quand |
|------|---------------|-------|
| `200` | OK | Requête réussie |
| `201` | Created | Ressource créée avec succès (POST) |
| `204` | No Content | Suppression réussie (DELETE) |
| `400` | Bad Request | Données invalides envoyées |
| `401` | Unauthorized | Pas connecté / session expirée |
| `403` | Forbidden | Connecté mais pas les permissions |
| `404` | Not Found | La ressource n'existe pas |
| `409` | Conflict | Doublon (ex: nom déjà pris) |
| `500` | Server Error | Bug côté serveur |
### Le format JSON-LD
L'API utilise **JSON-LD** (JSON Linked Data), une extension de JSON qui ajoute des métadonnées :
```json
{
"@context": "/api/contexts/Machine",
"@id": "/api/machines/cl1a2b3c4d5e6f7g8h9i0j1k",
"@type": "Machine",
"id": "cl1a2b3c4d5e6f7g8h9i0j1k",
"name": "CNC Mazak 01",
"reference": "CNM-001",
"prix": "50000.00",
"site": "/api/sites/cl9z8y7x6w5v4u3t2s1r0q",
"createdAt": "2026-01-15T10:30:00+00:00"
}
```
Points importants :
- `@id` est l'**IRI** (Internationalized Resource Identifier) : c'est l'identifiant unique de la ressource dans l'API
- Les relations utilisent des IRIs : `"site": "/api/sites/cl9z8..."` au lieu d'un simple ID
- Les collections retournent un format hydra avec pagination :
```json
{
"@context": "/api/contexts/Machine",
"@id": "/api/machines",
"@type": "hydra:Collection",
"hydra:totalItems": 42,
"hydra:member": [
{ "@id": "/api/machines/cl...", "name": "CNC 01", ... },
{ "@id": "/api/machines/cl...", "name": "Tour 02", ... }
]
}
```
---
## Symfony + API Platform — les bases
### La structure des fichiers
```
src/
├── Entity/ # Les classes PHP qui représentent les tables de la BDD
├── Controller/ # Les endpoints HTTP personnalisés (quand API Platform ne suffit pas)
├── EventSubscriber/ # Du code qui s'exécute automatiquement quand quelque chose se passe
├── Repository/ # Les requêtes SQL personnalisées
├── Service/ # La logique métier réutilisable
├── State/ # Les processeurs API Platform (interceptent le flux CRUD)
├── Security/ # L'authentification
├── Serializer/ # Personnalisation de la conversion entité ↔ JSON
├── Command/ # Commandes CLI (php bin/console app:xxx)
├── Enum/ # Les énumérations PHP (ex: catégories)
└── OpenApi/ # Personnalisation de la doc Swagger
```
### Comment Symfony traite une requête
```
Requête HTTP
Symfony Router (quel code doit répondre ?)
Sécurité (l'utilisateur a-t-il le droit ?)
Controller ou API Platform (traitement)
Doctrine ORM (lecture/écriture en BDD)
Serializer (conversion entité → JSON)
Réponse HTTP (JSON envoyé au frontend)
```
### Les attributs PHP 8
Le projet utilise les **attributs PHP 8** (les `#[...]`) au lieu des annotations (les `@...`). C'est la syntaxe moderne de PHP :
```php
// Attribut PHP 8 (ce qu'on utilise) ✅
#[ORM\Column(type: 'string', length: 255)]
private string $name;
// Annotation (ancien style, on ne l'utilise pas) ❌
/** @ORM\Column(type="string", length=255) */
```
---
## Les Entités
Les entités sont les classes PHP qui représentent les tables de la base de données. Chaque propriété de la classe correspond à une colonne.
### Anatomie d'une entité
Prenons un exemple simplifié :
```php
<?php
// src/Entity/Machine.php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Delete;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: MachineRepository::class)] // ← Lié à une table en BDD
#[ORM\HasLifecycleCallbacks] // ← Active les hooks PrePersist/PreUpdate
#[ApiResource( // ← Génère les endpoints API
operations: [
new GetCollection(security: "is_granted('ROLE_VIEWER')"), // GET /api/machines
new Get(security: "is_granted('ROLE_VIEWER')"), // GET /api/machines/{id}
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"), // POST /api/machines
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"), // PATCH /api/machines/{id}
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"), // DELETE /api/machines/{id}
],
normalizationContext: ['groups' => ['machine:read']], // ← Quels champs exposer en lecture
denormalizationContext: ['groups' => ['machine:write']], // ← Quels champs accepter en écriture
paginationItemsPerPage: 30, // ← 30 résultats par page
)]
class Machine
{
#[ORM\Id]
#[ORM\Column(type: 'string', length: 36)]
#[Groups(['machine:read'])] // ← Exposé en lecture uniquement
private string $id;
#[ORM\Column(type: 'string', length: 255, unique: true)]
#[Groups(['machine:read', 'machine:write'])] // ← Exposé en lecture ET écriture
private string $name;
#[ORM\Column(type: 'decimal', precision: 10, scale: 2, nullable: true)]
#[Groups(['machine:read', 'machine:write'])]
private ?string $prix = null;
#[ORM\ManyToOne(targetEntity: Site::class)] // ← Relation : chaque machine appartient à un site
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] // ← Obligatoire, supprimé en cascade
#[Groups(['machine:read', 'machine:write'])]
private Site $site;
#[ORM\Column(type: 'datetime_immutable')]
#[Groups(['machine:read'])] // ← Lecture seule (pas dans machine:write)
private \DateTimeImmutable $createdAt;
// ... getters et setters
}
```
### Décryptage des attributs importants
| Attribut | Signification |
|----------|--------------|
| `#[ORM\Entity]` | Cette classe est stockée en BDD |
| `#[ORM\Column]` | Cette propriété est une colonne |
| `#[ORM\Id]` | C'est la clé primaire |
| `#[ORM\ManyToOne]` | Relation N→1 (plusieurs machines → un site) |
| `#[ORM\OneToMany]` | Relation 1→N (un site → plusieurs machines) |
| `#[ORM\ManyToMany]` | Relation N→N (machines ↔ constructeurs) |
| `#[ApiResource]` | API Platform génère les endpoints CRUD |
| `#[Groups]` | Contrôle quels champs sont visibles/modifiables |
| `security: "is_granted('ROLE_X')"` | Qui a le droit d'utiliser cet endpoint |
### Le trait CuidEntityTrait
Toutes les entités utilisent un trait partagé qui génère les IDs et gère les timestamps :
```php
// src/Entity/Trait/CuidEntityTrait.php
trait CuidEntityTrait
{
#[ORM\PrePersist] // ← S'exécute automatiquement AVANT l'insertion en BDD
public function generateId(): void
{
if (!isset($this->id)) {
$this->id = 'cl' . bin2hex(random_bytes(12)); // ← Génère un ID unique de 26 chars
}
$this->createdAt = new \DateTimeImmutable();
$this->updatedAt = new \DateTimeImmutable();
}
#[ORM\PreUpdate] // ← S'exécute automatiquement AVANT une mise à jour
public function updateTimestamp(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
}
```
### Les entités du projet
#### Entités "catalogue" (les éléments qu'on gère)
| Entité | Table | Champs clés | Relations |
|--------|-------|-------------|-----------|
| **Machine** | `machine` | name, reference, prix | → Site, ↔ Constructeur, → Documents |
| **Composant** | `composant` | name, reference, description, prix, structure (JSON) | → ModelType, → Product, ↔ Constructeur |
| **Piece** | `piece` | name, reference, description, prix, productIds (JSON) | → ModelType, → Product, ↔ Constructeur |
| **Product** | `product` | name, reference, supplierPrice | → ModelType, ↔ Constructeur |
#### Entités de classification
| Entité | Table | Champs clés | Rôle |
|--------|-------|-------------|------|
| **Site** | `site` | name, contactName, contactPhone, contactAddress | Regrouper les machines par lieu |
| **Constructeur** | `constructeur` | name, email, phone | Fournisseurs/fabricants partagés |
| **ModelType** | `model_type` | name, code, category (enum), skeletons (JSON) | Catégoriser composants/pièces/produits |
#### Entités de liaison hiérarchique (structure machine)
| Entité | Rôle | Relations |
|--------|------|-----------|
| **MachineComponentLink** | Lie un composant à une machine | → Machine, → Composant, → parent (self) |
| **MachinePieceLink** | Lie une pièce à une machine | → Machine, → Piece, → parent composant |
| **MachineProductLink** | Lie un produit à une machine | → Machine, → Product, → parent (flexible) |
Ces entités permettent la **structure arborescente** : un composant peut contenir des pièces, qui contiennent des produits.
#### Entités de métadonnées
| Entité | Rôle |
|--------|------|
| **CustomField** | Définition d'un champ personnalisé (nom, type, options) |
| **CustomFieldValue** | Valeur d'un champ personnalisé pour une entité donnée |
| **Document** | Fichier uploadé (PDF, image) rattaché à une entité |
| **AuditLog** | Entrée du journal d'audit (diff + snapshot) |
| **Comment** | Commentaire/ticket sur une fiche |
| **Profile** | Compte utilisateur (email, rôle, mot de passe hashé) |
### Les relations entre entités (schéma simplifié)
```
Site ──1:N──► Machine ──1:N──► MachineComponentLink ──► Composant
│ │
│ └──1:N──► MachinePieceLink ──► Piece
│ │
│ └──1:N──► MachineProductLink ──► Product
└──N:N──► Constructeur (via table de jointure)
ModelType ──1:N──► Composant / Piece / Product
└──► CustomField ──1:N──► CustomFieldValue
Machine / Composant / Piece / Product ──1:N──► Document
──1:N──► CustomFieldValue
```
---
## Les Controllers
API Platform génère automatiquement les endpoints CRUD standard. Les controllers personnalisés gèrent les cas plus complexes.
### Liste des controllers
#### Authentification (3 controllers)
**SessionProfileController** (`/api/session/profile`) — Login/Logout
```
POST /api/session/profile → Se connecter (payload: { profileId, password })
GET /api/session/profile → Récupérer le profil connecté
DELETE /api/session/profile → Se déconnecter
```
**SessionProfilesController** (`/api/session/profiles`) — Liste des profils
```
GET /api/session/profiles → Liste tous les profils actifs (page de login)
```
**AdminProfileController** (`/api/admin/profiles`) — Administration des utilisateurs
```
GET /api/admin/profiles → Liste tous les profils (ADMIN only)
POST /api/admin/profiles → Créer un profil
PUT /api/admin/profiles/{id}/role → Changer le rôle d'un profil
PUT /api/admin/profiles/{id}/password → Réinitialiser un mot de passe
PUT /api/admin/profiles/{id}/deactivate → Désactiver un profil
```
#### Données et logique métier
**MachineStructureController** — Structure hiérarchique des machines
```
GET /api/machines/{id}/structure → Récupérer l'arborescence complète
PATCH /api/machines/{id}/structure → Modifier l'arborescence
POST /api/machines/{id}/clone → Cloner une machine avec toute sa structure
```
**MachineCustomFieldsController** — Champs personnalisés machines
```
POST /api/machines/{id}/custom-fields/init → Initialiser les champs personnalisés manquants
```
**EntityHistoryController** — Historique d'audit par entité
```
GET /api/{entityType}/{id}/history → 200 derniers événements d'audit
```
**ActivityLogController** — Journal d'activité global
```
GET /api/activity-log → Liste paginée avec filtres (entityType, action)
```
**CommentController** — Commentaires/tickets
```
POST /api/comments → Créer un commentaire
PATCH /api/comments/{id}/resolve → Résoudre un commentaire
GET /api/comments/unresolved-count → Nombre de commentaires non résolus
```
**CustomFieldValueController** — Valeurs de champs personnalisés
```
POST /api/custom-field-values → Créer/mettre à jour une valeur (upsert)
DELETE /api/custom-field-values/{id} → Supprimer une valeur
```
#### Fichiers
**DocumentQueryController** — Requêter les documents par entité
```
GET /api/documents/by-site/{id} → Documents d'un site
GET /api/documents/by-machine/{id} → Documents d'une machine
GET /api/documents/by-composant/{id} → Documents d'un composant
GET /api/documents/by-piece/{id} → Documents d'une pièce
GET /api/documents/by-product/{id} → Documents d'un produit
```
**DocumentServeController** — Servir les fichiers
```
GET /api/documents/{id}/file → Afficher le fichier (inline)
GET /api/documents/{id}/download → Télécharger le fichier (attachment)
```
#### Monitoring
**HealthCheckController** — Vérification de santé
```
GET /api/health → Version, latence BDD, mémoire, version PHP
```
### Exemple de controller commenté
```php
<?php
// src/Controller/CommentController.php
namespace App\Controller;
use App\Entity\Comment;
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;
class CommentController extends AbstractController
{
#[Route('/api/comments', methods: ['POST'])] // ← Définit l'URL et le verbe HTTP
public function create(
Request $request, // ← La requête HTTP entrante
EntityManagerInterface $em, // ← Pour écrire en BDD (injecté automatiquement)
): JsonResponse {
$this->denyAccessUnlessGranted('ROLE_VIEWER'); // ← Vérifie que l'utilisateur est connecté
$data = json_decode($request->getContent(), true); // ← Parse le body JSON
$comment = new Comment();
$comment->setContent($data['content']);
$comment->setEntityType($data['entityType']);
$comment->setEntityId($data['entityId']);
// ... autres champs
$em->persist($comment); // ← Dit à Doctrine "je veux sauvegarder ça"
$em->flush(); // ← Exécute réellement le INSERT SQL
return $this->json($comment, 201); // ← Renvoie le commentaire créé avec le code 201
}
}
```
---
## Le système d'audit
Chaque modification sur les entités principales est automatiquement enregistrée dans un journal d'audit. C'est un des points forts de l'application.
### Comment ça marche ?
Les **Event Subscribers** de Doctrine interceptent les opérations de base de données **avant** qu'elles soient exécutées (événement `onFlush`).
```
L'utilisateur modifie une machine
Doctrine détecte le changement
onFlush se déclenche
Le subscriber calcule le diff (ancien → nouveau)
Le subscriber crée un AuditLog avec :
- entityType : "machine"
- entityId : "cl1a2b3c..."
- action : "update"
- diff : { "name": { "from": "CNC 01", "to": "CNC 02" } }
- snapshot : { état complet de la machine }
- actorProfileId : "cl9z8y7x..." (qui a fait la modif)
Les deux (machine + audit log) sont sauvegardés en même temps
```
### Le diff
Le diff capture exactement ce qui a changé :
```json
{
"name": { "from": "CNC Mazak 01", "to": "CNC Mazak 02" },
"prix": { "from": "45000.00", "to": "50000.00" },
"constructeurIds": {
"from": ["cl111...", "cl222..."],
"to": ["cl111...", "cl333..."]
}
}
```
### Le snapshot
Le snapshot capture l'état complet de l'entité au moment de la modification :
```json
{
"id": "cl1a2b3c...",
"name": "CNC Mazak 02",
"reference": "CNM-001",
"prix": "50000.00",
"siteId": "cl9z8y7x...",
"constructeurIds": ["cl111...", "cl333..."]
}
```
### Les subscribers d'audit
| Subscriber | Entité | Type |
|------------|--------|------|
| MachineAuditSubscriber | Machine | Complex (avec constructeurs + custom fields) |
| ComposantAuditSubscriber | Composant | Complex |
| PieceAuditSubscriber | Piece | Complex |
| ProductAuditSubscriber | Product | Complex |
| ConstructeurAuditSubscriber | Constructeur | Simple |
| DocumentAuditSubscriber | Document | Simple |
| ModelTypeAuditSubscriber | ModelType | Simple |
**Simple** = suit seulement les champs de l'entité
**Complex** = suit aussi les relations ManyToMany (constructeurs) et les champs personnalisés
### AbstractAuditSubscriber
La classe de base qui contient toute la logique partagée :
```php
abstract class AbstractAuditSubscriber implements EventSubscriber
{
// Méthode à implémenter par chaque subscriber
abstract protected function getEntityClass(): string; // Ex: Machine::class
abstract protected function getEntityType(): string; // Ex: 'machine'
abstract protected function buildSnapshot($entity): array; // Construit le snapshot
// Deux chemins d'exécution :
// 1. onFlushSimple() : pour les entités sans collections ManyToMany
// 2. onFlushComplex() : pour les entités avec constructeurs (détecte les ajouts/suppressions)
}
```
### Autres subscribers
| Subscriber | Rôle |
|------------|------|
| **PieceProductSyncSubscriber** | Synchronise le champ `productIds` sur Piece quand un Product est lié/délié |
| **UniqueConstraintSubscriber** | Capture les erreurs de doublon PostgreSQL et renvoie un message clair |
---
## L'authentification par session
### Le flux complet
```
1. GET /api/session/profiles
→ Retourne la liste des profils actifs (nom, prénom, email, hasPassword)
→ Le frontend affiche la page de login avec les profils disponibles
2. POST /api/session/profile
Body: { "profileId": "cl...", "password": "secret" }
→ Le backend vérifie le mot de passe
→ Si OK : stocke profileId dans la session PHP, retourne le profil
→ Si KO : retourne 401
3. GET /api/session/profile (à chaque chargement de page)
→ Le navigateur envoie le cookie de session automatiquement
→ Le backend retrouve le profil via la session
→ Retourne le profil connecté ou 401
4. DELETE /api/session/profile
→ Supprime le profileId de la session
→ L'utilisateur est déconnecté
```
### La sécurité sur les endpoints
Chaque endpoint API Platform a une règle de sécurité :
```php
new GetCollection(security: "is_granted('ROLE_VIEWER')") // Lecture → minimum ROLE_VIEWER
new Post(security: "is_granted('ROLE_GESTIONNAIRE')") // Création → minimum ROLE_GESTIONNAIRE
```
Les controllers personnalisés utilisent :
```php
$this->denyAccessUnlessGranted('ROLE_VIEWER');
```
### La hiérarchie des rôles
Grâce à la hiérarchie, un ADMIN a automatiquement tous les rôles inférieurs :
```
ROLE_ADMIN ─── a aussi ──► ROLE_GESTIONNAIRE ──► ROLE_VIEWER ──► ROLE_USER
```
Donc `is_granted('ROLE_VIEWER')` accepte aussi les GESTIONNAIRES et les ADMINS.
---
## Les services
### DocumentStorageService
Gère le stockage des fichiers sur le système de fichiers :
```php
// Stocker un fichier uploadé
$path = $storageService->store($uploadedFile, $entityType, $entityId);
// Supprimer un fichier
$storageService->delete($path);
```
Les fichiers sont stockés dans `var/documents/{entityType}/{entityId}/{filename}`.
### PdfCompressorService
Compresse les fichiers PDF via Ghostscript pour réduire leur taille :
```php
$compressorService->compress($filePath);
```
### ModelTypeCategoryConversionService
Permet de convertir la catégorie d'un ModelType (ex: transformer un type "composant" en type "pièce").
---
## Les migrations
Les migrations sont des scripts SQL qui modifient la structure de la base de données. Elles sont dans le dossier `migrations/`.
### Principe
Quand tu ajoutes un champ à une entité, il faut créer une migration pour mettre à jour la BDD :
```bash
# Générer une migration à partir des changements détectés
make shell
php bin/console doctrine:migrations:diff
# Appliquer les migrations
php bin/console doctrine:migrations:migrate
```
### Particularités PostgreSQL
Les migrations utilisent du **SQL brut** avec des gardes pour l'idempotence :
```sql
-- On peut relancer la migration sans erreur
ALTER TABLE machine ADD COLUMN IF NOT EXISTS description TEXT;
DROP INDEX IF EXISTS idx_machine_name;
CREATE UNIQUE INDEX IF NOT EXISTS idx_machine_name ON machine (name);
```
**Attention aux noms de colonnes** : PostgreSQL stocke tout en **minuscules**. Donc `typePieceId` en PHP devient `typepieceid` en SQL. Toujours utiliser des noms lowercase dans le SQL brut.
---
## Les tests
### Stack de test
- **PHPUnit 12** : framework de test PHP
- **API Platform Test** : utilitaires pour tester des endpoints API
- **DAMA DoctrineTestBundle** : wrappe chaque test dans une transaction avec rollback automatique (pas besoin de nettoyer la BDD entre les tests)
### Structure
```
tests/
├── AbstractApiTestCase.php # Classe de base avec helpers
└── Api/
└── Entity/
├── MachineTest.php # Tests des endpoints machine
├── SiteTest.php # Tests des endpoints site
└── ...
```
### Exemple de test
```php
class MachineTest extends AbstractApiTestCase
{
public function testCreateMachine(): void
{
// Créer un client HTTP connecté en tant que gestionnaire
$client = $this->createGestionnaireClient();
// Créer un site (prérequis)
$site = $this->createSite();
// Envoyer une requête POST pour créer une machine
$client->request('POST', '/api/machines', [
'json' => [
'name' => 'Machine Test',
'reference' => 'MT-001',
'site' => '/api/sites/' . $site->getId(),
],
]);
// Vérifier que la réponse est 201 Created
$this->assertResponseStatusCodeSame(201);
// Vérifier le contenu de la réponse
$this->assertJsonContains([
'name' => 'Machine Test',
'reference' => 'MT-001',
]);
}
}
```
### Helpers disponibles dans AbstractApiTestCase
| Méthode | Description |
|---------|-------------|
| `createViewerClient()` | Client HTTP connecté avec ROLE_VIEWER |
| `createGestionnaireClient()` | Client HTTP connecté avec ROLE_GESTIONNAIRE |
| `createAdminClient()` | Client HTTP connecté avec ROLE_ADMIN |
| `createProfile()` | Crée un profil utilisateur en BDD |
| `createSite()` | Crée un site en BDD |
| `createMachine()` | Crée une machine en BDD |
### Lancer les tests
```bash
make test # Tous les tests
make test FILES=tests/Api/Entity/MachineTest.php # Un fichier
make test-setup # (Re)créer la BDD de test
```
---
## Flux complet d'une requête
### Exemple : créer une machine
```
1. Le frontend envoie :
POST /api/machines
Content-Type: application/ld+json
Cookie: PHPSESSID=abc123
{
"name": "CNC Mazak 01",
"reference": "CNM-001",
"prix": "50000.00",
"site": "/api/sites/cl9z8y7x..."
}
2. Symfony reçoit la requête
→ Le routeur identifie : c'est un endpoint API Platform (POST sur Machine)
3. Sécurité
→ Vérifie le cookie de session → retrouve le profil connecté
→ Vérifie is_granted('ROLE_GESTIONNAIRE') → OK
4. Désérialisation (JSON → objet PHP)
→ API Platform convertit le JSON en objet Machine
→ Le champ "site" (IRI) est résolu en objet Site
→ Seuls les champs du groupe 'machine:write' sont acceptés
5. Validation
→ Vérifie les contraintes (name non vide, site existe, etc.)
6. Persistence (objet PHP → BDD)
→ Doctrine déclenche PrePersist (CuidEntityTrait)
→ Génère l'ID : "cl" + 24 hex chars aléatoires
→ Set createdAt et updatedAt
→ Doctrine détecte l'INSERT à faire
7. Audit (onFlush)
→ MachineAuditSubscriber détecte la nouvelle machine
→ Crée un AuditLog avec action='create', diff, snapshot
→ L'AuditLog est aussi ajouté à la transaction
8. Flush
→ Doctrine exécute les requêtes SQL :
INSERT INTO machine (id, name, reference, ...) VALUES (...)
INSERT INTO audit_log (id, entity_type, entity_id, action, diff, snapshot, ...) VALUES (...)
9. Sérialisation (objet PHP → JSON)
→ API Platform convertit la Machine en JSON-LD
→ Seuls les champs du groupe 'machine:read' sont inclus
10. Réponse
HTTP/1.1 201 Created
{
"@context": "/api/contexts/Machine",
"@id": "/api/machines/cl1a2b3c...",
"@type": "Machine",
"id": "cl1a2b3c...",
"name": "CNC Mazak 01",
...
}
```
---
## Commandes Symfony utiles
Lancer ces commandes dans le conteneur Docker (`make shell` pour y entrer) :
| Commande | Description |
|----------|-------------|
| `php bin/console debug:router` | Voir toutes les routes disponibles |
| `php bin/console debug:config api_platform` | Voir la config API Platform |
| `php bin/console doctrine:schema:validate` | Vérifier que les entités sont synchronisées avec la BDD |
| `php bin/console doctrine:migrations:diff` | Générer une migration à partir des changements |
| `php bin/console doctrine:migrations:migrate` | Appliquer les migrations |
| `php bin/console cache:clear` | Vider le cache (résout beaucoup de problèmes) |
| `php bin/console app:compress-pdf` | Compresser les PDFs existants |
| `php bin/console app:create-profile` | Créer un profil utilisateur |
---
## Résumé des points clés pour un débutant
1. **API Platform génère les endpoints CRUD automatiquement** à partir des entités — tu n'as pas besoin d'écrire de controllers pour les opérations standard
2. **Les attributs PHP 8** (`#[...]`) remplacent les annotations et configurent tout : BDD, API, sérialisation, sécurité
3. **Les groupes de sérialisation** (`machine:read`, `machine:write`) contrôlent quels champs sont visibles/modifiables
4. **L'audit est automatique** : chaque modification est tracée sans rien avoir à faire manuellement
5. **L'authentification est par session (cookies)**, pas par tokens JWT
6. **Les IDs sont des CUID** (chaînes aléatoires), pas des auto-increment
7. **PostgreSQL stocke les noms en minuscules** : attention dans le SQL brut
8. **Les tests utilisent des transactions** : chaque test est isolé et la BDD est nettoyée automatiquement

1052
docs/FRONTEND.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -37,7 +37,7 @@ start: env-init
@echo "URLs disponibles:"
@echo "- Symfony API: http://localhost:8081/api"
@echo "- Nuxt (Inventory_frontend): http://localhost:3001"
@echo "- pgAdmin: http://localhost:5050"
@echo "- adminer: http://localhost:5050"
# Éteint le container
stop:
@@ -117,6 +117,10 @@ php-cs-fixer-allow-risky:
test:
$(EXEC_PHP) php -d memory_limit="512M" vendor/bin/phpunit $(FILES)
test-setup:
$(SYMFONY_CONSOLE) doctrine:database:create --if-not-exists --env=test
$(SYMFONY_CONSOLE) doctrine:schema:update --force --env=test
wait:
sleep 10

View File

@@ -1,35 +0,0 @@
# Migration DB (manuel)
Ce guide explique comment importer un dump SQL venant de pgAdmin dans la base Docker.
## 1) Export pgAdmin
Dans pgAdmin:
- Format: Plain
- Options: Use INSERT commands + Use column inserts
- Fichier: `data.sql`
## 2) Normaliser le dump
Convertit les colonnes camelCase en lowercase compact.
```bash
python3 scripts/normalize-dump.py data.sql data_norm.sql --lower
```
## 3) Importer dans la base Docker
Utilise `session_replication_role` pour eviter les erreurs de contraintes circulaires.
```bash
docker compose --env-file docker/.env.docker.local exec -T db psql -U root -d inventory -v ON_ERROR_STOP=1 -c "SET session_replication_role = replica;"
docker compose --env-file docker/.env.docker.local exec -T db psql -U root -d inventory -v ON_ERROR_STOP=1 < data_norm.sql
docker compose --env-file docker/.env.docker.local exec -T db psql -U root -d inventory -v ON_ERROR_STOP=1 -c "SET session_replication_role = DEFAULT;"
```
## 4) Verifier
```bash
docker compose --env-file docker/.env.docker.local exec -T db psql -U $POSTGRES_USER -d $POSTGRES_DB -c "\\dt"
```

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260302103003 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create comments table + make piece reference unique instead of name';
}
public function up(Schema $schema): void
{
// Comments table (IF NOT EXISTS in case first attempt partially succeeded)
$this->addSql('CREATE TABLE IF NOT EXISTS comments (id VARCHAR(36) NOT NULL, content TEXT NOT NULL, entity_type VARCHAR(50) NOT NULL, entity_id VARCHAR(36) NOT NULL, entity_name VARCHAR(255) DEFAULT NULL, author_id VARCHAR(36) NOT NULL, author_name VARCHAR(255) NOT NULL, status VARCHAR(20) NOT NULL, resolved_by_id VARCHAR(36) DEFAULT NULL, resolved_by_name VARCHAR(255) DEFAULT NULL, resolved_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IF NOT EXISTS idx_comment_entity_status ON comments (entity_type, entity_id, status)');
$this->addSql('COMMENT ON COLUMN comments.resolved_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN comments.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN comments.updated_at IS \'(DC2Type:datetime_immutable)\'');
// Piece: remove unique constraint on name (it's a constraint, not just an index)
$this->addSql('ALTER TABLE pieces DROP CONSTRAINT IF EXISTS uniq_b92d74725e237e06');
// Deduplicate piece references before adding unique constraint
$this->addSql("
UPDATE pieces p
SET reference = p.reference || '-' || LEFT(p.id, 6)
FROM (
SELECT id, reference,
ROW_NUMBER() OVER (PARTITION BY reference ORDER BY createdat) AS rn
FROM pieces
WHERE reference IS NOT NULL AND reference != ''
) dup
WHERE p.id = dup.id AND dup.rn > 1
");
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uniq_pieces_reference ON pieces (reference)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE IF EXISTS comments');
$this->addSql('DROP INDEX IF EXISTS uniq_pieces_reference');
$this->addSql('CREATE UNIQUE INDEX uniq_b92d74725e237e06 ON pieces (name)');
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260302120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add description column to pieces and composants tables';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE pieces ADD COLUMN IF NOT EXISTS description TEXT DEFAULT NULL');
$this->addSql('ALTER TABLE composants ADD COLUMN IF NOT EXISTS description TEXT DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE pieces DROP COLUMN IF EXISTS description');
$this->addSql('ALTER TABLE composants DROP COLUMN IF EXISTS description');
}
}

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260304120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Remove TypeMachine skeleton system, link custom fields directly to machines';
}
public function up(Schema $schema): void
{
// 1. Drop requirement FK columns on link tables
$this->addSql('ALTER TABLE machine_component_links DROP COLUMN IF EXISTS typemachinecomponentrequirementid');
$this->addSql('ALTER TABLE machine_piece_links DROP COLUMN IF EXISTS typemachinepiecerequirementid');
$this->addSql('ALTER TABLE machine_product_links DROP COLUMN IF EXISTS typemachineproductrequirementid');
// 2. Add machineid column to custom_fields (new direct FK to machines)
$this->addSql('ALTER TABLE custom_fields ADD COLUMN IF NOT EXISTS machineid VARCHAR(36) DEFAULT NULL');
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'fk_custom_fields_machine' AND table_name = 'custom_fields'
) THEN
ALTER TABLE custom_fields ADD CONSTRAINT fk_custom_fields_machine
FOREIGN KEY (machineid) REFERENCES machines(id) ON DELETE CASCADE;
END IF;
END $$;
SQL);
// 3. Enable pgcrypto for gen_random_bytes (needed for CUID generation)
$this->addSql('CREATE EXTENSION IF NOT EXISTS pgcrypto');
// 4. Migrate existing custom fields: copy from TypeMachine to each linked Machine
$this->addSql(<<<'SQL'
INSERT INTO custom_fields (id, name, type, required, defaultvalue, options, orderindex, machineid, createdat, updatedat)
SELECT
'cl' || encode(gen_random_bytes(12), 'hex'),
cf.name, cf.type, cf.required, cf.defaultvalue, cf.options, cf.orderindex,
m.id,
NOW(), NOW()
FROM custom_fields cf
JOIN machines m ON m.typemachineid = cf.typemachineid
WHERE cf.typemachineid IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM custom_fields existing
WHERE existing.machineid = m.id AND existing.name = cf.name
)
SQL);
// 4. Delete original TypeMachine-linked custom fields (now migrated)
$this->addSql('DELETE FROM custom_fields WHERE typemachineid IS NOT NULL');
// 5. Drop typemachineid column from custom_fields
$this->addSql('ALTER TABLE custom_fields DROP COLUMN IF EXISTS typemachineid');
// 6. Drop typemachineid column from machines
$this->addSql('ALTER TABLE machines DROP COLUMN IF EXISTS typemachineid');
// 7. Drop requirement tables (order matters: these reference type_machines)
$this->addSql('DROP TABLE IF EXISTS type_machine_component_requirements');
$this->addSql('DROP TABLE IF EXISTS type_machine_piece_requirements');
$this->addSql('DROP TABLE IF EXISTS type_machine_product_requirements');
// 8. Drop type_machines table
$this->addSql('DROP TABLE IF EXISTS type_machines');
}
public function down(Schema $schema): void
{
// Recreate type_machines table
$this->addSql(<<<'SQL'
CREATE TABLE IF NOT EXISTS type_machines (
id VARCHAR(36) NOT NULL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
description TEXT DEFAULT NULL,
category VARCHAR(255) DEFAULT NULL,
maintenancefrequency VARCHAR(255) DEFAULT NULL,
components JSON DEFAULT NULL,
criticalparts JSON DEFAULT NULL,
machinepieces JSON DEFAULT NULL,
specifications JSON DEFAULT NULL,
createdat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updatedat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
)
SQL);
// Recreate requirement tables
$this->addSql(<<<'SQL'
CREATE TABLE IF NOT EXISTS type_machine_component_requirements (
id VARCHAR(36) NOT NULL PRIMARY KEY,
typemachineid VARCHAR(36) NOT NULL REFERENCES type_machines(id) ON DELETE CASCADE,
typecomposantid VARCHAR(36) NOT NULL REFERENCES model_types(id),
label VARCHAR(255) DEFAULT NULL,
mincount INTEGER DEFAULT 1,
maxcount INTEGER DEFAULT NULL,
required BOOLEAN DEFAULT true,
allownewmodels BOOLEAN DEFAULT true,
orderindex INTEGER DEFAULT 0,
createdat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updatedat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
)
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE IF NOT EXISTS type_machine_piece_requirements (
id VARCHAR(36) NOT NULL PRIMARY KEY,
typemachineid VARCHAR(36) NOT NULL REFERENCES type_machines(id) ON DELETE CASCADE,
typepieceid VARCHAR(36) NOT NULL REFERENCES model_types(id),
label VARCHAR(255) DEFAULT NULL,
mincount INTEGER DEFAULT 0,
maxcount INTEGER DEFAULT NULL,
required BOOLEAN DEFAULT false,
allownewmodels BOOLEAN DEFAULT true,
orderindex INTEGER DEFAULT 0,
createdat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updatedat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
)
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE IF NOT EXISTS type_machine_product_requirements (
id VARCHAR(36) NOT NULL PRIMARY KEY,
typemachineid VARCHAR(36) NOT NULL REFERENCES type_machines(id) ON DELETE CASCADE,
typeproductid VARCHAR(36) NOT NULL REFERENCES model_types(id),
label VARCHAR(255) DEFAULT NULL,
mincount INTEGER DEFAULT 0,
maxcount INTEGER DEFAULT NULL,
required BOOLEAN DEFAULT false,
allownewmodels BOOLEAN DEFAULT true,
orderindex INTEGER DEFAULT 0,
createdat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updatedat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
)
SQL);
// Re-add typemachineid to machines
$this->addSql('ALTER TABLE machines ADD COLUMN IF NOT EXISTS typemachineid VARCHAR(36) DEFAULT NULL');
// Re-add typemachineid to custom_fields
$this->addSql('ALTER TABLE custom_fields ADD COLUMN IF NOT EXISTS typemachineid VARCHAR(36) DEFAULT NULL');
// Re-add requirement FK columns to link tables
$this->addSql('ALTER TABLE machine_component_links ADD COLUMN IF NOT EXISTS typemachinecomponentrequirementid VARCHAR(36) DEFAULT NULL');
$this->addSql('ALTER TABLE machine_piece_links ADD COLUMN IF NOT EXISTS typemachinepiecerequirementid VARCHAR(36) DEFAULT NULL');
$this->addSql('ALTER TABLE machine_product_links ADD COLUMN IF NOT EXISTS typemachineproductrequirementid VARCHAR(36) DEFAULT NULL');
// Drop machine FK on custom_fields
$this->addSql('ALTER TABLE custom_fields DROP COLUMN IF EXISTS machineid');
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260309120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add color column to sites table';
}
public function up(Schema $schema): void
{
$this->addSql("ALTER TABLE sites ADD COLUMN IF NOT EXISTS color VARCHAR(7) NOT NULL DEFAULT ''");
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE sites DROP COLUMN IF EXISTS color');
}
}

View File

@@ -4,7 +4,7 @@
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
colors="true"
failOnDeprecation="true"
failOnDeprecation="false"
failOnNotice="true"
failOnWarning="true"
bootstrap="tests/bootstrap.php"
@@ -40,5 +40,6 @@
</source>
<extensions>
<bootstrap class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension" />
</extensions>
</phpunit>

View File

@@ -4,7 +4,10 @@ declare(strict_types=1);
namespace App\Command;
use App\Entity\Document;
use App\Repository\DocumentRepository;
use App\Service\DocumentStorageService;
use App\Service\PdfCompressorService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
@@ -13,15 +16,20 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function count;
use function strlen;
#[AsCommand(
name: 'app:compress-pdf',
description: 'Compress all PDF documents stored in database without quality loss',
description: 'Compress all PDF documents without quality loss',
)]
class CompressPdfCommand extends Command
{
public function __construct(
private readonly DocumentRepository $documentRepository,
private readonly EntityManagerInterface $em,
private readonly PdfCompressorService $pdfCompressor,
private readonly DocumentStorageService $storageService,
) {
parent::__construct();
}
@@ -61,87 +69,13 @@ class CompressPdfCommand extends Command
$compressed = 0;
foreach ($documents as $document) {
$base64Data = $document->getPath();
$path = $document->getPath();
// Remove data URI prefix if present
if (str_contains($base64Data, ',')) {
$base64Data = explode(',', $base64Data, 2)[1];
}
$pdfContent = base64_decode($base64Data, true);
if (false === $pdfContent) {
$io->warning(sprintf('Failed to decode document: %s', $document->getName()));
continue;
}
$originalSize = strlen($pdfContent);
if ($dryRun) {
$io->text(sprintf(
' [DRY-RUN] Would compress: %s (%s)',
$document->getName(),
$this->formatBytes($originalSize)
));
continue;
}
// Create temp files
$tempInput = tempnam(sys_get_temp_dir(), 'pdf_in_');
$tempOutput = tempnam(sys_get_temp_dir(), 'pdf_out_');
file_put_contents($tempInput, $pdfContent);
// Compress with qpdf (lossless)
$command = sprintf(
'qpdf --linearize --object-streams=generate %s %s 2>&1',
escapeshellarg($tempInput),
escapeshellarg($tempOutput)
);
exec($command, $cmdOutput, $returnCode);
if (0 !== $returnCode || !file_exists($tempOutput)) {
$io->warning(sprintf('Failed to compress: %s', $document->getName()));
@unlink($tempInput);
@unlink($tempOutput);
continue;
}
$compressedContent = file_get_contents($tempOutput);
$compressedSize = strlen($compressedContent);
// Only update if we actually saved space
if ($compressedSize < $originalSize) {
$saved = $originalSize - $compressedSize;
$totalSaved += $saved;
++$compressed;
// Rebuild base64 with data URI prefix
$newBase64 = 'data:application/pdf;base64,'.base64_encode($compressedContent);
$document->setPath($newBase64);
$document->setSize($compressedSize);
$io->text(sprintf(
' ✓ %s: %s → %s (-%s, -%.1f%%)',
$document->getName(),
$this->formatBytes($originalSize),
$this->formatBytes($compressedSize),
$this->formatBytes($saved),
($saved / $originalSize) * 100
));
if ($this->storageService->isBase64DataUri($path)) {
$this->compressBase64Document($document, $path, $dryRun, $io, $totalSaved, $compressed);
} else {
$io->text(sprintf(
' - %s: Already optimal (%s)',
$document->getName(),
$this->formatBytes($originalSize)
));
$this->compressFileDocument($document, $path, $dryRun, $io, $totalSaved, $compressed);
}
@unlink($tempInput);
@unlink($tempOutput);
}
if (!$dryRun && $compressed > 0) {
@@ -161,6 +95,115 @@ class CompressPdfCommand extends Command
return Command::SUCCESS;
}
private function compressBase64Document(
Document $document,
string $path,
bool $dryRun,
SymfonyStyle $io,
int &$totalSaved,
int &$compressed,
): void {
$base64Data = $path;
if (str_contains($base64Data, ',')) {
$base64Data = explode(',', $base64Data, 2)[1];
}
$pdfContent = base64_decode($base64Data, true);
if (false === $pdfContent) {
$io->warning(sprintf('Failed to decode document: %s', $document->getName()));
return;
}
$originalSize = strlen($pdfContent);
if ($dryRun) {
$io->text(sprintf(
' [DRY-RUN] Would compress (base64): %s (%s)',
$document->getName(),
$this->formatBytes($originalSize)
));
return;
}
$result = $this->pdfCompressor->compressBase64Pdf($path);
if (null !== $result) {
$document->setPath($result['path']);
$document->setSize($result['size']);
$totalSaved += $result['saved'];
++$compressed;
$io->text(sprintf(
' OK %s: %s → %s (-%s, -%.1f%%)',
$document->getName(),
$this->formatBytes($result['originalSize']),
$this->formatBytes($result['size']),
$this->formatBytes($result['saved']),
($result['saved'] / $result['originalSize']) * 100
));
} else {
$io->text(sprintf(
' - %s: Already optimal (%s)',
$document->getName(),
$this->formatBytes($originalSize)
));
}
}
private function compressFileDocument(
Document $document,
string $path,
bool $dryRun,
SymfonyStyle $io,
int &$totalSaved,
int &$compressed,
): void {
$absolutePath = $this->storageService->getAbsolutePath($path);
if (!file_exists($absolutePath)) {
$io->warning(sprintf('File not found: %s (%s)', $document->getName(), $path));
return;
}
$originalSize = filesize($absolutePath);
if (false === $originalSize) {
return;
}
if ($dryRun) {
$io->text(sprintf(
' [DRY-RUN] Would compress (file): %s (%s)',
$document->getName(),
$this->formatBytes($originalSize)
));
return;
}
$result = $this->pdfCompressor->compressFile($absolutePath);
if (null !== $result) {
$document->setSize($result['size']);
$totalSaved += $result['saved'];
++$compressed;
$io->text(sprintf(
' OK %s: %s → %s (-%s, -%.1f%%)',
$document->getName(),
$this->formatBytes($result['originalSize']),
$this->formatBytes($result['size']),
$this->formatBytes($result['saved']),
($result['saved'] / $result['originalSize']) * 100
));
} else {
$io->text(sprintf(
' - %s: Already optimal (%s)',
$document->getName(),
$this->formatBytes($originalSize)
));
}
}
private function formatBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB'];

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Profile;
use App\Repository\ProfileRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use function in_array;
#[AsCommand(
name: 'app:create-profile',
description: 'Create a new profile with the given credentials',
)]
class CreateProfileCommand extends Command
{
public function __construct(
private readonly ProfileRepository $profiles,
private readonly EntityManagerInterface $em,
private readonly UserPasswordHasherInterface $passwordHasher,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('firstName', InputArgument::REQUIRED, 'First name')
->addArgument('lastName', InputArgument::REQUIRED, 'Last name')
->addOption('email', null, InputOption::VALUE_REQUIRED, 'Email address')
->addOption('role', null, InputOption::VALUE_REQUIRED, 'Role (ROLE_ADMIN, ROLE_GESTIONNAIRE, ROLE_VIEWER)', 'ROLE_VIEWER')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$firstName = $input->getArgument('firstName');
$lastName = $input->getArgument('lastName');
$email = $input->getOption('email');
$password = $io->askHidden('Password');
if (null === $password || '' === $password) {
$io->error('Le mot de passe est requis.');
return Command::FAILURE;
}
$role = $input->getOption('role');
$allowedRoles = ['ROLE_ADMIN', 'ROLE_GESTIONNAIRE', 'ROLE_VIEWER', 'ROLE_USER'];
if (!in_array($role, $allowedRoles, true)) {
$io->error('Role invalide. Roles autorisés : '.implode(', ', $allowedRoles));
return Command::FAILURE;
}
if (null !== $email && '' !== $email) {
$existing = $this->profiles->findOneBy(['email' => $email]);
if (null !== $existing) {
$io->error('Un profil avec cet email existe déjà.');
return Command::FAILURE;
}
}
$profile = new Profile();
$profile->setFirstName($firstName);
$profile->setLastName($lastName);
$profile->setRoles([$role]);
$profile->setIsActive(true);
if (null !== $email && '' !== $email) {
$profile->setEmail($email);
}
$profile->setPassword(
$this->passwordHasher->hashPassword($profile, $password)
);
$this->em->persist($profile);
$this->em->flush();
$io->success(sprintf(
'Profil créé : %s %s (ID: %s, Role: %s)',
$firstName,
$lastName,
$profile->getId(),
$role,
));
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Repository\ProfileRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use function count;
use function in_array;
#[AsCommand(
name: 'app:init-profile-passwords',
description: 'Initialize all profile passwords to first letter of firstName + "123"',
)]
class InitProfilePasswordsCommand extends Command
{
public function __construct(
private readonly ProfileRepository $profiles,
private readonly EntityManagerInterface $em,
private readonly UserPasswordHasherInterface $passwordHasher,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$all = $this->profiles->findAll();
if (0 === count($all)) {
$io->warning('Aucun profil trouvé.');
return Command::SUCCESS;
}
// Promote first profile to ROLE_ADMIN if none exists
$hasAdmin = false;
foreach ($all as $profile) {
if (in_array('ROLE_ADMIN', $profile->getRoles(), true)) {
$hasAdmin = true;
break;
}
}
$isFirst = true;
$count = 0;
foreach ($all as $profile) {
// Set password: first letter of firstName + "123"
$firstLetter = mb_strtoupper(mb_substr($profile->getFirstName(), 0, 1));
$plain = $firstLetter.'123';
$hashed = $this->passwordHasher->hashPassword($profile, $plain);
$profile->setPassword($hashed);
// Set roles: first profile → ADMIN, others → VIEWER (minimum to use the app)
if (!$hasAdmin && $isFirst) {
$profile->setRoles(['ROLE_ADMIN']);
$io->writeln(sprintf(' %s %s → mdp: %s — ROLE_ADMIN', $profile->getFirstName(), $profile->getLastName(), $plain));
$isFirst = false;
} elseif (in_array('ROLE_USER', $profile->getRoles(), true) && !in_array('ROLE_VIEWER', $profile->getRoles(), true) && !in_array('ROLE_GESTIONNAIRE', $profile->getRoles(), true) && !in_array('ROLE_ADMIN', $profile->getRoles(), true)) {
$profile->setRoles(['ROLE_VIEWER']);
$io->writeln(sprintf(' %s %s → mdp: %s — ROLE_VIEWER', $profile->getFirstName(), $profile->getLastName(), $plain));
} else {
$io->writeln(sprintf(' %s %s → mdp: %s — %s', $profile->getFirstName(), $profile->getLastName(), $plain, implode(', ', $profile->getRoles())));
}
++$count;
}
$this->em->flush();
$io->success(sprintf('%d mot(s) de passe initialisé(s).', $count));
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,218 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Repository\DocumentRepository;
use App\Service\DocumentStorageService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Throwable;
use function count;
use function strlen;
#[AsCommand(
name: 'app:migrate-documents-to-filesystem',
description: 'Migrate document storage from Base64 in DB to filesystem',
)]
class MigrateDocumentsToFilesystemCommand extends Command
{
public function __construct(
private readonly DocumentRepository $documentRepository,
private readonly EntityManagerInterface $em,
private readonly DocumentStorageService $storageService,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be migrated without making changes')
->addOption('batch-size', null, InputOption::VALUE_REQUIRED, 'Number of documents to process before flushing', '50')
->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Max documents to migrate (for testing)', '0')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = $input->getOption('dry-run');
$batchSize = (int) $input->getOption('batch-size');
$limit = (int) $input->getOption('limit');
$io->title('Document Storage Migration: Base64 → Filesystem');
// Verify storage directory is writable
$storageDir = $this->storageService->getStorageDir();
if (!$dryRun) {
if (!is_dir($storageDir)) {
mkdir($storageDir, 0o775, true);
}
if (!is_writable($storageDir)) {
$io->error("Storage directory is not writable: {$storageDir}");
return Command::FAILURE;
}
$io->text("Storage directory: {$storageDir}");
}
// Step 1: fetch only IDs of Base64 documents (no heavy path column loaded)
$conn = $this->em->getConnection();
$ids = $conn->fetchFirstColumn("SELECT id FROM documents WHERE path LIKE 'data:%'");
$total = count($ids);
$migrated = 0;
$skipped = 0;
$errors = 0;
$totalBytes = 0;
$io->text(sprintf('Found %d documents with Base64 data to migrate', $total));
if (0 === $total) {
$io->success('Nothing to migrate — all documents are already file-based.');
return Command::SUCCESS;
}
// Step 2: process one document at a time to avoid memory exhaustion
foreach ($ids as $index => $docId) {
if ($limit > 0 && $migrated >= $limit) {
$io->text("Reached limit of {$limit} documents.");
break;
}
// Fetch single row with raw SQL to keep memory flat
$row = $conn->fetchAssociative(
'SELECT id, name, filename, path, mimetype, size FROM documents WHERE id = ?',
[$docId]
);
if (!$row) {
++$skipped;
continue;
}
$path = $row['path'];
if (!$this->storageService->isBase64DataUri($path)) {
++$skipped;
continue;
}
$docName = $row['name'] ?: $row['filename'];
$filename = $row['filename'] ?: $row['name'];
$mimeType = $row['mimetype'] ?? 'application/octet-stream';
// Extract binary content from data URI
$parts = explode(',', $path, 2);
$base64 = $parts[1] ?? '';
$content = base64_decode($base64, true);
// Free the raw row immediately
unset($row, $path, $base64, $parts);
if (false === $content || '' === $content) {
$io->warning(sprintf('[%d/%d] Cannot decode: %s (id: %s)', $index + 1, $total, $docName, $docId));
++$errors;
continue;
}
$fileSize = strlen($content);
$extension = $this->storageService->extensionFromFilename(
$filename ?: ('file.'.$this->storageService->extensionFromMimeType($mimeType))
);
if ($dryRun) {
$io->text(sprintf(
' [DRY-RUN] Would migrate: %s (%s)',
$docName,
$this->formatBytes($fileSize)
));
++$migrated;
$totalBytes += $fileSize;
unset($content);
continue;
}
try {
$relativePath = $this->storageService->store($content, $docId, $extension);
unset($content);
// Update DB directly — avoid loading entity with huge path
$conn->executeStatement(
'UPDATE documents SET path = ?, size = ? WHERE id = ?',
[$relativePath, $fileSize, $docId]
);
++$migrated;
$totalBytes += $fileSize;
$io->text(sprintf(
' [OK] %s → %s (%s)',
$docName,
$relativePath,
$this->formatBytes($fileSize)
));
} catch (Throwable $e) {
unset($content);
$io->error(sprintf(
' [FAIL] %s: %s',
$docName,
$e->getMessage()
));
++$errors;
continue;
}
if (0 === $migrated % $batchSize) {
$io->text(sprintf(' ... %d migrated so far', $migrated));
}
}
$io->newLine();
$io->table(
['Metric', 'Count'],
[
['Total documents', (string) $total],
['Migrated', (string) $migrated],
['Skipped (already file-based)', (string) $skipped],
['Errors', (string) $errors],
['Total bytes written', $this->formatBytes($totalBytes)],
]
);
if ($dryRun) {
$io->info('Dry run completed. No changes were made.');
} elseif ($errors > 0) {
$io->warning(sprintf('Migration completed with %d errors.', $errors));
} else {
$io->success('Migration completed successfully.');
}
return $errors > 0 ? Command::FAILURE : Command::SUCCESS;
}
private function formatBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB'];
$i = 0;
while ($bytes >= 1024 && $i < count($units) - 1) {
$bytes /= 1024;
++$i;
}
return round($bytes, 2).' '.$units[$i];
}
}

View File

@@ -7,11 +7,12 @@ namespace App\Controller;
use App\Repository\AuditLogRepository;
use App\Repository\ProfileRepository;
use DateTimeInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
final class ActivityLogController
final class ActivityLogController extends AbstractController
{
public function __construct(
private readonly AuditLogRepository $auditLogs,
@@ -21,6 +22,8 @@ final class ActivityLogController
#[Route('/api/activity-logs', name: 'api_activity_logs', methods: ['GET'])]
public function __invoke(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$page = max(1, $request->query->getInt('page', 1));
$itemsPerPage = min(100, max(1, $request->query->getInt('itemsPerPage', 30)));

View File

@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Profile;
use App\Repository\ProfileRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use function count;
use function in_array;
#[Route('/api/admin/profiles')]
final class AdminProfileController extends AbstractController
{
public function __construct(
private readonly ProfileRepository $profiles,
private readonly EntityManagerInterface $entityManager,
private readonly UserPasswordHasherInterface $passwordHasher,
) {}
#[Route('', name: 'admin_profiles_list', methods: ['GET'])]
public function list(): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$items = $this->profiles->findBy([], ['firstName' => 'ASC']);
return new JsonResponse(array_map([$this, 'serializeProfile'], $items));
}
#[Route('', name: 'admin_profiles_create', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$payload = $request->toArray();
$firstName = trim((string) ($payload['firstName'] ?? ''));
$lastName = trim((string) ($payload['lastName'] ?? ''));
if ('' === $firstName || '' === $lastName) {
return new JsonResponse(['message' => 'firstName et lastName sont requis.'], JsonResponse::HTTP_BAD_REQUEST);
}
$email = trim((string) ($payload['email'] ?? ''));
$password = $payload['password'] ?? null;
$role = $payload['role'] ?? 'ROLE_VIEWER';
$allowedRoles = ['ROLE_ADMIN', 'ROLE_GESTIONNAIRE', 'ROLE_VIEWER', 'ROLE_USER'];
if (!in_array($role, $allowedRoles, true)) {
return new JsonResponse(['message' => 'Role invalide.'], JsonResponse::HTTP_BAD_REQUEST);
}
$profile = new Profile();
$profile->setFirstName($firstName);
$profile->setLastName($lastName);
$profile->setIsActive(true);
$profile->setRoles([$role]);
if ('' !== $email) {
$profile->setEmail($email);
}
if (null !== $password && '' !== $password) {
$profile->setPassword(
$this->passwordHasher->hashPassword($profile, $password)
);
}
$this->entityManager->persist($profile);
$this->entityManager->flush();
return new JsonResponse($this->serializeProfile($profile), JsonResponse::HTTP_CREATED);
}
#[Route('/{id}/role', name: 'admin_profiles_update_role', methods: ['PUT'])]
public function updateRole(string $id, Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$profile = $this->profiles->find($id);
if (!$profile) {
return new JsonResponse(['message' => 'Profil introuvable.'], JsonResponse::HTTP_NOT_FOUND);
}
$payload = $request->toArray();
$role = $payload['role'] ?? null;
$allowedRoles = ['ROLE_ADMIN', 'ROLE_GESTIONNAIRE', 'ROLE_VIEWER', 'ROLE_USER'];
if (!$role || !in_array($role, $allowedRoles, true)) {
return new JsonResponse(['message' => 'Role invalide.'], JsonResponse::HTTP_BAD_REQUEST);
}
// Prevent removing the last admin
if (in_array('ROLE_ADMIN', $profile->getRoles(), true) && 'ROLE_ADMIN' !== $role) {
$adminCount = $this->countAdmins();
if ($adminCount <= 1) {
return new JsonResponse(
['message' => 'Impossible de retirer le dernier administrateur.'],
JsonResponse::HTTP_CONFLICT
);
}
}
$profile->setRoles([$role]);
$this->entityManager->flush();
return new JsonResponse($this->serializeProfile($profile));
}
#[Route('/{id}/password', name: 'admin_profiles_update_password', methods: ['PUT'])]
public function updatePassword(string $id, Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$profile = $this->profiles->find($id);
if (!$profile) {
return new JsonResponse(['message' => 'Profil introuvable.'], JsonResponse::HTTP_NOT_FOUND);
}
$payload = $request->toArray();
$password = $payload['password'] ?? '';
if ('' === $password) {
return new JsonResponse(['message' => 'Le mot de passe est requis.'], JsonResponse::HTTP_BAD_REQUEST);
}
$profile->setPassword(
$this->passwordHasher->hashPassword($profile, $password)
);
$this->entityManager->flush();
return new JsonResponse($this->serializeProfile($profile));
}
#[Route('/{id}/deactivate', name: 'admin_profiles_deactivate', methods: ['PUT'])]
public function deactivate(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$profile = $this->profiles->find($id);
if (!$profile) {
return new JsonResponse(['message' => 'Profil introuvable.'], JsonResponse::HTTP_NOT_FOUND);
}
// Prevent deactivating the last admin
if (in_array('ROLE_ADMIN', $profile->getRoles(), true)) {
$adminCount = $this->countAdmins();
if ($adminCount <= 1) {
return new JsonResponse(
['message' => 'Impossible de desactiver le dernier administrateur.'],
JsonResponse::HTTP_CONFLICT
);
}
}
$profile->setIsActive(false);
$this->entityManager->flush();
return new JsonResponse($this->serializeProfile($profile));
}
private function serializeProfile(Profile $profile): array
{
return [
'id' => $profile->getId(),
'firstName' => $profile->getFirstName(),
'lastName' => $profile->getLastName(),
'email' => $profile->getEmail(),
'isActive' => $profile->isActive(),
'hasPassword' => null !== $profile->getPassword() && '' !== $profile->getPassword(),
'roles' => $profile->getRoles(),
'createdAt' => $profile->getCreatedAt()->format('c'),
'updatedAt' => $profile->getUpdatedAt()->format('c'),
];
}
private function countAdmins(): int
{
$all = $this->profiles->findBy(['isActive' => true]);
return count(array_filter(
$all,
static fn (Profile $p) => in_array('ROLE_ADMIN', $p->getRoles(), true)
));
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Comment;
use App\Repository\ProfileRepository;
use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/comments')]
final class CommentController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ProfileRepository $profiles,
) {}
#[Route('', name: 'api_comments_create', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$session = $request->getSession();
$profileId = $session->get('profileId');
if (!$profileId) {
return $this->json(['message' => 'Aucun profil actif.'], 401);
}
$profile = $this->profiles->find($profileId);
if (!$profile) {
return $this->json(['message' => 'Profil introuvable.'], 401);
}
$payload = json_decode($request->getContent(), true);
if (!is_array($payload)) {
return $this->json(['message' => 'Payload JSON invalide.'], 400);
}
$content = trim((string) ($payload['content'] ?? ''));
$entityType = trim((string) ($payload['entityType'] ?? ''));
$entityId = trim((string) ($payload['entityId'] ?? ''));
$entityName = isset($payload['entityName']) ? trim((string) $payload['entityName']) : null;
if ('' === $content) {
return $this->json(['message' => 'Le contenu est requis.'], 400);
}
$allowedTypes = ['machine', 'piece', 'composant', 'product', 'piece_category', 'component_category', 'product_category', 'machine_skeleton'];
if (!in_array($entityType, $allowedTypes, true)) {
return $this->json(['message' => 'Type d\'entité invalide.'], 400);
}
if ('' === $entityId) {
return $this->json(['message' => 'L\'identifiant de l\'entité est requis.'], 400);
}
$authorName = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
if ('' === $authorName) {
$authorName = $profile->getEmail() ?? 'Inconnu';
}
$comment = new Comment();
$comment->setContent($content);
$comment->setEntityType($entityType);
$comment->setEntityId($entityId);
$comment->setEntityName($entityName);
$comment->setAuthorId($profileId);
$comment->setAuthorName($authorName);
$this->entityManager->persist($comment);
$this->entityManager->flush();
return $this->json($this->normalize($comment), 201);
}
#[Route('/{id}/resolve', name: 'api_comments_resolve', methods: ['PATCH'])]
public function resolve(string $id, Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
$comment = $this->entityManager->getRepository(Comment::class)->find($id);
if (!$comment) {
return $this->json(['message' => 'Commentaire introuvable.'], 404);
}
$session = $request->getSession();
$profileId = $session->get('profileId');
$profile = $profileId ? $this->profiles->find($profileId) : null;
$resolverName = 'Inconnu';
if ($profile) {
$resolverName = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
if ('' === $resolverName) {
$resolverName = $profile->getEmail() ?? 'Inconnu';
}
}
$comment->setStatus('resolved');
$comment->setResolvedById($profileId);
$comment->setResolvedByName($resolverName);
$comment->setResolvedAt(new DateTimeImmutable());
$this->entityManager->flush();
return $this->json($this->normalize($comment));
}
#[Route('/stats/unresolved-count', name: 'api_comments_unresolved_count', methods: ['GET'])]
public function unresolvedCount(): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$count = $this->entityManager->getRepository(Comment::class)
->count(['status' => 'open'])
;
return $this->json(['count' => $count]);
}
private function normalize(Comment $comment): array
{
return [
'id' => $comment->getId(),
'content' => $comment->getContent(),
'entityType' => $comment->getEntityType(),
'entityId' => $comment->getEntityId(),
'entityName' => $comment->getEntityName(),
'authorId' => $comment->getAuthorId(),
'authorName' => $comment->getAuthorName(),
'status' => $comment->getStatus(),
'resolvedById' => $comment->getResolvedById(),
'resolvedByName' => $comment->getResolvedByName(),
'resolvedAt' => $comment->getResolvedAt()?->format(DateTimeInterface::ATOM),
'createdAt' => $comment->getCreatedAt()->format(DateTimeInterface::ATOM),
'updatedAt' => $comment->getUpdatedAt()->format(DateTimeInterface::ATOM),
];
}
}

View File

@@ -34,6 +34,8 @@ class CustomFieldValueController extends AbstractController
#[Route('', name: 'custom_field_values_create', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
$payload = $this->decodePayload($request);
if ($payload instanceof JsonResponse) {
return $payload;
@@ -63,6 +65,8 @@ class CustomFieldValueController extends AbstractController
#[Route('/upsert', name: 'custom_field_values_upsert', methods: ['POST'])]
public function upsert(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
$payload = $this->decodePayload($request);
if ($payload instanceof JsonResponse) {
return $payload;
@@ -104,6 +108,8 @@ class CustomFieldValueController extends AbstractController
#[Route('/{entityType}/{entityId}', name: 'custom_field_values_list', methods: ['GET'])]
public function listByEntity(string $entityType, string $entityId): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$target = $this->resolveTarget([
'entityType' => $entityType,
'entityId' => $entityId,
@@ -126,6 +132,8 @@ class CustomFieldValueController extends AbstractController
#[Route('/{id}', name: 'custom_field_values_update', methods: ['PATCH'])]
public function update(string $id, Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
$value = $this->customFieldValueRepository->find($id);
if (!$value instanceof CustomFieldValue) {
return $this->json(['success' => false, 'error' => 'Custom field value not found.'], 404);
@@ -148,6 +156,8 @@ class CustomFieldValueController extends AbstractController
#[Route('/{id}', name: 'custom_field_values_delete', methods: ['DELETE'])]
public function delete(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
$value = $this->customFieldValueRepository->find($id);
if (!$value instanceof CustomFieldValue) {
return $this->json(['success' => false, 'error' => 'Custom field value not found.'], 404);

View File

@@ -30,6 +30,8 @@ class DocumentQueryController extends AbstractController
#[Route('/site/{id}', name: 'documents_by_site', methods: ['GET'])]
public function listBySite(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$site = $this->siteRepository->find($id);
if (!$site) {
return $this->json(['success' => false, 'error' => 'Site not found.'], 404);
@@ -43,6 +45,8 @@ class DocumentQueryController extends AbstractController
#[Route('/machine/{id}', name: 'documents_by_machine', methods: ['GET'])]
public function listByMachine(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$machine = $this->machineRepository->find($id);
if (!$machine) {
return $this->json(['success' => false, 'error' => 'Machine not found.'], 404);
@@ -56,6 +60,8 @@ class DocumentQueryController extends AbstractController
#[Route('/composant/{id}', name: 'documents_by_composant', methods: ['GET'])]
public function listByComposant(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$composant = $this->composantRepository->find($id);
if (!$composant) {
return $this->json(['success' => false, 'error' => 'Composant not found.'], 404);
@@ -69,6 +75,8 @@ class DocumentQueryController extends AbstractController
#[Route('/piece/{id}', name: 'documents_by_piece', methods: ['GET'])]
public function listByPiece(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$piece = $this->pieceRepository->find($id);
if (!$piece) {
return $this->json(['success' => false, 'error' => 'Piece not found.'], 404);
@@ -82,6 +90,8 @@ class DocumentQueryController extends AbstractController
#[Route('/product/{id}', name: 'documents_by_product', methods: ['GET'])]
public function listByProduct(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$product = $this->productRepository->find($id);
if (!$product) {
return $this->json(['success' => false, 'error' => 'Product not found.'], 404);
@@ -102,7 +112,8 @@ class DocumentQueryController extends AbstractController
'id' => $document->getId(),
'name' => $document->getName(),
'filename' => $document->getFilename(),
'path' => $document->getPath(),
'fileUrl' => '/api/documents/'.$document->getId().'/file',
'downloadUrl' => '/api/documents/'.$document->getId().'/download',
'mimeType' => $document->getMimeType(),
'size' => $document->getSize(),
'siteId' => $document->getSite()?->getId(),

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\DocumentRepository;
use App\Service\DocumentStorageService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Attribute\Route;
use function strlen;
#[Route('/api/documents')]
class DocumentServeController extends AbstractController
{
public function __construct(
private readonly DocumentRepository $documentRepository,
private readonly DocumentStorageService $storageService,
) {}
#[Route('/{id}/file', name: 'document_serve_file', methods: ['GET'])]
public function serve(string $id): Response
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$document = $this->documentRepository->find($id);
if (!$document) {
return $this->json(['error' => 'Document not found.'], 404);
}
$path = $document->getPath();
// Backward compatibility: serve Base64 data URIs from DB
if ($this->storageService->isBase64DataUri($path)) {
$parts = explode(',', $path, 2);
$content = base64_decode($parts[1] ?? '', true);
if (false === $content) {
return $this->json(['error' => 'Invalid document data.'], 500);
}
return new Response($content, 200, [
'Content-Type' => $document->getMimeType(),
'Content-Disposition' => ResponseHeaderBag::DISPOSITION_INLINE.'; filename="'.$document->getFilename().'"',
'Content-Length' => (string) strlen($content),
'Cache-Control' => 'private, max-age=3600',
]);
}
// File-based path: serve from disk
$absolutePath = $this->storageService->getAbsolutePath($path);
if (!file_exists($absolutePath)) {
return $this->json(['error' => 'File not found on disk.'], 404);
}
$response = new BinaryFileResponse($absolutePath);
$response->headers->set('Content-Type', $document->getMimeType());
$response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_INLINE,
$document->getFilename()
);
$response->headers->set('Cache-Control', 'private, max-age=3600');
return $response;
}
#[Route('/{id}/download', name: 'document_download_file', methods: ['GET'])]
public function download(string $id): Response
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$document = $this->documentRepository->find($id);
if (!$document) {
return $this->json(['error' => 'Document not found.'], 404);
}
$path = $document->getPath();
if ($this->storageService->isBase64DataUri($path)) {
$parts = explode(',', $path, 2);
$content = base64_decode($parts[1] ?? '', true);
if (false === $content) {
return $this->json(['error' => 'Invalid document data.'], 500);
}
return new Response($content, 200, [
'Content-Type' => 'application/octet-stream',
'Content-Disposition' => ResponseHeaderBag::DISPOSITION_ATTACHMENT.'; filename="'.$document->getFilename().'"',
'Content-Length' => (string) strlen($content),
]);
}
$absolutePath = $this->storageService->getAbsolutePath($path);
if (!file_exists($absolutePath)) {
return $this->json(['error' => 'File not found on disk.'], 404);
}
$response = new BinaryFileResponse($absolutePath);
$response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
$document->getFilename()
);
return $response;
}
}

View File

@@ -6,32 +6,76 @@ namespace App\Controller;
use App\Repository\AuditLogRepository;
use App\Repository\ComposantRepository;
use App\Repository\MachineRepository;
use App\Repository\PieceRepository;
use App\Repository\ProductRepository;
use App\Repository\ProfileRepository;
use DateTimeInterface;
use Doctrine\ORM\EntityRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class ComposantHistoryController
final class EntityHistoryController extends AbstractController
{
/** @var array<string, array{repo: EntityRepository<object>, label: string}> */
private readonly array $entityConfig;
public function __construct(
private readonly ComposantRepository $components,
MachineRepository $machines,
PieceRepository $pieces,
ComposantRepository $composants,
ProductRepository $products,
private readonly AuditLogRepository $auditLogs,
private readonly ProfileRepository $profiles,
) {}
) {
$this->entityConfig = [
'machine' => ['repo' => $machines, 'label' => 'Machine introuvable.'],
'piece' => ['repo' => $pieces, 'label' => 'Pièce introuvable.'],
'composant' => ['repo' => $composants, 'label' => 'Composant introuvable.'],
'product' => ['repo' => $products, 'label' => 'Produit introuvable.'],
];
}
#[Route('/api/machines/{id}/history', name: 'api_machine_history', methods: ['GET'])]
public function machineHistory(string $id): JsonResponse
{
return $this->entityHistory('machine', $id);
}
#[Route('/api/pieces/{id}/history', name: 'api_piece_history', methods: ['GET'])]
public function pieceHistory(string $id): JsonResponse
{
return $this->entityHistory('piece', $id);
}
#[Route('/api/composants/{id}/history', name: 'api_composant_history', methods: ['GET'])]
public function __invoke(string $id): JsonResponse
public function composantHistory(string $id): JsonResponse
{
$component = $this->components->find($id);
if (!$component) {
return $this->entityHistory('composant', $id);
}
#[Route('/api/products/{id}/history', name: 'api_product_history', methods: ['GET'])]
public function productHistory(string $id): JsonResponse
{
return $this->entityHistory('product', $id);
}
private function entityHistory(string $type, string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$config = $this->entityConfig[$type];
$entity = $config['repo']->find($id);
if (!$entity) {
return new JsonResponse(
['message' => 'Composant introuvable.'],
['message' => $config['label']],
Response::HTTP_NOT_FOUND,
);
}
$logs = $this->auditLogs->findEntityHistory('composant', $id, 200);
$logs = $this->auditLogs->findEntityHistory($type, $id, 200);
$actorIds = array_values(array_unique(array_filter(array_map(
static fn ($log) => $log->getActorProfileId(),

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\DBAL\Connection;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Throwable;
class HealthCheckController extends AbstractController
{
#[Route('/api/health', name: 'api_health', methods: ['GET'])]
public function __invoke(Connection $connection): JsonResponse
{
$dbOk = false;
try {
$start = hrtime(true);
$connection->executeQuery('SELECT 1');
$dbLatency = round((hrtime(true) - $start) / 1e6, 1);
$dbOk = true;
} catch (Throwable) {
$dbLatency = null;
}
$healthy = $dbOk;
$data = ['status' => $healthy ? 'ok' : 'degraded'];
if ($this->isGranted('ROLE_ADMIN')) {
$version = '0.0.0';
$versionFile = $this->getParameter('kernel.project_dir').'/VERSION';
if (file_exists($versionFile)) {
$version = trim(file_get_contents($versionFile));
}
$data += [
'version' => $version,
'timestamp' => new DateTimeImmutable()->format(DateTimeInterface::ATOM),
'php' => PHP_VERSION,
'checks' => [
'database' => [
'status' => $dbOk ? 'ok' : 'down',
'latency_ms' => $dbLatency,
],
],
'memory_mb' => round(memory_get_usage(true) / 1024 / 1024, 1),
];
}
return $this->json($data, $healthy ? 200 : 503);
}
}

View File

@@ -26,17 +26,14 @@ class MachineCustomFieldsController extends AbstractController
#[Route('/{id}/add-custom-fields', name: 'machine_add_custom_fields', methods: ['POST'])]
public function addMissingCustomFields(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
$machine = $this->machineRepository->find($id);
if (!$machine instanceof Machine) {
return $this->json(['success' => false, 'error' => 'Machine not found.'], 404);
}
$typeMachine = $machine->getTypeMachine();
if (!$typeMachine) {
return $this->json(['success' => true, 'machineId' => $machine->getId(), 'customFieldValues' => []]);
}
foreach ($typeMachine->getCustomFields() as $customField) {
foreach ($machine->getCustomFields() as $customField) {
if (!$customField instanceof CustomField) {
continue;
}

View File

@@ -1,79 +0,0 @@
<?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

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\Composant;
use App\Entity\CustomField;
use App\Entity\CustomFieldValue;
use App\Entity\Machine;
use App\Entity\MachineComponentLink;
use App\Entity\MachinePieceLink;
@@ -12,9 +14,7 @@ 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\Entity\Site;
use App\Repository\ComposantRepository;
use App\Repository\MachineComponentLinkRepository;
use App\Repository\MachinePieceLinkRepository;
@@ -22,9 +22,6 @@ 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;
@@ -33,7 +30,7 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/machines')]
class MachineSkeletonController extends AbstractController
class MachineStructureController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
@@ -44,14 +41,13 @@ class MachineSkeletonController extends AbstractController
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
#[Route('/{id}/structure', name: 'machine_structure_get', methods: ['GET'])]
public function getStructure(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$machine = $this->machineRepository->find($id);
if (!$machine instanceof Machine) {
return $this->json(['success' => false, 'error' => 'Machine not found.'], 404);
@@ -61,7 +57,7 @@ class MachineSkeletonController extends AbstractController
$pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $machine]);
$productLinks = $this->machineProductLinkRepository->findBy(['machine' => $machine]);
return $this->json($this->normalizeMachineSkeletonResponse(
return $this->json($this->normalizeStructureResponse(
$machine,
$componentLinks,
$pieceLinks,
@@ -69,9 +65,11 @@ class MachineSkeletonController extends AbstractController
));
}
#[Route('/{id}/skeleton', name: 'machine_skeleton_update', methods: ['PATCH'])]
public function updateSkeleton(string $id, Request $request): JsonResponse
#[Route('/{id}/structure', name: 'machine_structure_update', methods: ['PATCH'])]
public function updateStructure(string $id, Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
$machine = $this->machineRepository->find($id);
if (!$machine instanceof Machine) {
return $this->json(['success' => false, 'error' => 'Machine not found.'], 404);
@@ -103,7 +101,7 @@ class MachineSkeletonController extends AbstractController
$this->entityManager->flush();
return $this->json($this->normalizeMachineSkeletonResponse(
return $this->json($this->normalizeStructureResponse(
$machine,
$componentLinks,
$pieceLinks,
@@ -111,6 +109,194 @@ class MachineSkeletonController extends AbstractController
));
}
#[Route('/{id}/clone', name: 'machine_clone', methods: ['POST'])]
public function cloneMachine(string $id, Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
$source = $this->machineRepository->find($id);
if (!$source instanceof Machine) {
return $this->json(['success' => false, 'error' => 'Machine source introuvable.'], 404);
}
$payload = json_decode($request->getContent(), true);
if (!is_array($payload) || empty($payload['name']) || empty($payload['siteId'])) {
return $this->json(['success' => false, 'error' => 'name et siteId sont requis.'], 400);
}
$site = $this->entityManager->getRepository(Site::class)->find($payload['siteId']);
if (!$site) {
return $this->json(['success' => false, 'error' => 'Site introuvable.'], 404);
}
// Create new machine
$newMachine = new Machine();
$newMachine->setName($payload['name']);
$newMachine->setSite($site);
if (!empty($payload['reference'])) {
$newMachine->setReference($payload['reference']);
}
$newMachine->setPrix($source->getPrix());
// Copy constructeurs
foreach ($source->getConstructeurs() as $constructeur) {
$newMachine->getConstructeurs()->add($constructeur);
}
$this->entityManager->persist($newMachine);
// Copy custom fields and values
$this->cloneCustomFields($source, $newMachine);
// Copy component links (preserving hierarchy)
$componentLinkMap = $this->cloneComponentLinks($source, $newMachine);
// Copy piece links
$pieceLinkMap = $this->clonePieceLinks($source, $newMachine, $componentLinkMap);
// Copy product links
$this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap);
$this->entityManager->flush();
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $newMachine]);
$pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $newMachine]);
$productLinks = $this->machineProductLinkRepository->findBy(['machine' => $newMachine]);
return $this->json($this->normalizeStructureResponse(
$newMachine,
$componentLinks,
$pieceLinks,
$productLinks
), 201);
}
private function cloneCustomFields(Machine $source, Machine $target): void
{
foreach ($source->getCustomFields() as $cf) {
$newCf = new CustomField();
$newCf->setName($cf->getName());
$newCf->setType($cf->getType());
$newCf->setRequired($cf->isRequired());
$newCf->setDefaultValue($cf->getDefaultValue());
$newCf->setOptions($cf->getOptions());
$newCf->setOrderIndex($cf->getOrderIndex());
$newCf->setMachine($target);
$this->entityManager->persist($newCf);
}
foreach ($source->getCustomFieldValues() as $cfv) {
$newValue = new CustomFieldValue();
$newValue->setMachine($target);
$newValue->setCustomField($cfv->getCustomField());
$newValue->setValue($cfv->getValue());
$this->entityManager->persist($newValue);
}
}
/**
* @return array<string, MachineComponentLink> Map of old link ID new link
*/
private function cloneComponentLinks(Machine $source, Machine $target): array
{
$sourceLinks = $this->machineComponentLinkRepository->findBy(['machine' => $source]);
$linkMap = [];
// First pass: create all links without parent relationships
foreach ($sourceLinks as $link) {
$newLink = new MachineComponentLink();
$newLink->setMachine($target);
$newLink->setComposant($link->getComposant());
$newLink->setNameOverride($link->getNameOverride());
$newLink->setReferenceOverride($link->getReferenceOverride());
$newLink->setPrixOverride($link->getPrixOverride());
$this->entityManager->persist($newLink);
$linkMap[$link->getId()] = $newLink;
}
// Second pass: set parent relationships
foreach ($sourceLinks as $link) {
$parent = $link->getParentLink();
if ($parent && isset($linkMap[$parent->getId()])) {
$linkMap[$link->getId()]->setParentLink($linkMap[$parent->getId()]);
}
}
return $linkMap;
}
/**
* @param array<string, MachineComponentLink> $componentLinkMap
*
* @return array<string, MachinePieceLink> Map of old link ID new link
*/
private function clonePieceLinks(Machine $source, Machine $target, array $componentLinkMap): array
{
$sourceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $source]);
$linkMap = [];
foreach ($sourceLinks as $link) {
$newLink = new MachinePieceLink();
$newLink->setMachine($target);
$newLink->setPiece($link->getPiece());
$newLink->setNameOverride($link->getNameOverride());
$newLink->setReferenceOverride($link->getReferenceOverride());
$newLink->setPrixOverride($link->getPrixOverride());
$parent = $link->getParentLink();
if ($parent && isset($componentLinkMap[$parent->getId()])) {
$newLink->setParentLink($componentLinkMap[$parent->getId()]);
}
$this->entityManager->persist($newLink);
$linkMap[$link->getId()] = $newLink;
}
return $linkMap;
}
/**
* @param array<string, MachineComponentLink> $componentLinkMap
* @param array<string, MachinePieceLink> $pieceLinkMap
*/
private function cloneProductLinks(
Machine $source,
Machine $target,
array $componentLinkMap,
array $pieceLinkMap,
): void {
$sourceLinks = $this->machineProductLinkRepository->findBy(['machine' => $source]);
$linkMap = [];
// First pass: create all links
foreach ($sourceLinks as $link) {
$newLink = new MachineProductLink();
$newLink->setMachine($target);
$newLink->setProduct($link->getProduct());
$parentComponent = $link->getParentComponentLink();
if ($parentComponent && isset($componentLinkMap[$parentComponent->getId()])) {
$newLink->setParentComponentLink($componentLinkMap[$parentComponent->getId()]);
}
$parentPiece = $link->getParentPieceLink();
if ($parentPiece && isset($pieceLinkMap[$parentPiece->getId()])) {
$newLink->setParentPieceLink($pieceLinkMap[$parentPiece->getId()]);
}
$this->entityManager->persist($newLink);
$linkMap[$link->getId()] = $newLink;
}
// Second pass: set parent product link relationships
foreach ($sourceLinks as $link) {
$parent = $link->getParentLink();
if ($parent && isset($linkMap[$parent->getId()])) {
$linkMap[$link->getId()]->setParentLink($linkMap[$parent->getId()]);
}
}
}
private function normalizePayloadList(mixed $value): array
{
if (!is_array($value)) {
@@ -139,7 +325,7 @@ class MachineSkeletonController extends AbstractController
$composantId = $this->resolveIdentifier($entry, ['composantId', 'componentId', 'idComposant']);
if (!$composantId) {
return $this->json(['success' => false, 'error' => 'Composant requis pour le squelette.'], 400);
return $this->json(['success' => false, 'error' => 'Composant requis.'], 400);
}
$composant = $this->composantRepository->find($composantId);
if (!$composant instanceof Composant) {
@@ -149,14 +335,6 @@ class MachineSkeletonController extends AbstractController
$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, [
@@ -171,10 +349,7 @@ class MachineSkeletonController extends AbstractController
}
foreach ($pendingParents as $linkId => $parentId) {
if (!$parentId) {
continue;
}
if (!isset($links[$linkId])) {
if (!$parentId || !isset($links[$linkId])) {
continue;
}
$parent = $links[$parentId] ?? $existing[$parentId] ?? null;
@@ -208,7 +383,7 @@ class MachineSkeletonController extends AbstractController
$pieceId = $this->resolveIdentifier($entry, ['pieceId']);
if (!$pieceId) {
return $this->json(['success' => false, 'error' => 'Pièce requise pour le squelette.'], 400);
return $this->json(['success' => false, 'error' => 'Pièce requise.'], 400);
}
$piece = $this->pieceRepository->find($pieceId);
if (!$piece instanceof Piece) {
@@ -218,14 +393,6 @@ class MachineSkeletonController extends AbstractController
$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, [
@@ -240,10 +407,7 @@ class MachineSkeletonController extends AbstractController
}
foreach ($pendingParents as $linkId => $parentId) {
if (!$parentId) {
continue;
}
if (!isset($links[$linkId])) {
if (!$parentId || !isset($links[$linkId])) {
continue;
}
$parent = $componentIndex[$parentId] ?? null;
@@ -282,7 +446,7 @@ class MachineSkeletonController extends AbstractController
$productId = $this->resolveIdentifier($entry, ['productId']);
if (!$productId) {
return $this->json(['success' => false, 'error' => 'Produit requis pour le squelette.'], 400);
return $this->json(['success' => false, 'error' => 'Produit requis.'], 400);
}
$product = $this->productRepository->find($productId);
if (!$product instanceof Product) {
@@ -292,14 +456,6 @@ class MachineSkeletonController extends AbstractController
$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']),
@@ -331,7 +487,7 @@ class MachineSkeletonController extends AbstractController
return array_values($links);
}
private function normalizeMachineSkeletonResponse(
private function normalizeStructureResponse(
Machine $machine,
array $componentLinks,
array $pieceLinks,
@@ -341,21 +497,25 @@ class MachineSkeletonController extends AbstractController
$componentIndex = $this->indexNormalizedLinks($normalizedComponentLinks);
$normalizedPieceLinks = $this->normalizePieceLinks($pieceLinks);
// Build component hierarchy
foreach ($normalizedComponentLinks as &$link) {
$childIds = [];
foreach ($normalizedComponentLinks as $link) {
$parentId = $link['parentComponentLinkId'] ?? null;
if ($parentId && isset($componentIndex[$parentId])) {
$componentIndex[$parentId]['childLinks'][] = &$link;
$componentIndex[$parentId]['childLinks'][] = $link;
$childIds[$link['id']] = true;
}
}
unset($link);
// Add pieces to components recursively
$this->attachPiecesToComponents($componentIndex, $normalizedPieceLinks);
$rootComponents = array_filter(
$componentIndex,
static fn (array $link) => !isset($childIds[$link['id']]),
);
return [
'machine' => $this->normalizeMachine($machine),
'componentLinks' => array_values($componentIndex),
'componentLinks' => array_values($rootComponents),
'pieceLinks' => $normalizedPieceLinks,
'productLinks' => $this->normalizeProductLinks($productLinks),
];
@@ -370,7 +530,6 @@ class MachineSkeletonController extends AbstractController
}
}
// Recursively attach to child components
foreach ($componentIndex as &$component) {
if (!empty($component['childLinks'])) {
$this->attachPiecesToChildComponents($component['childLinks'], $pieceLinks);
@@ -391,7 +550,6 @@ class MachineSkeletonController extends AbstractController
}
}
// Recursively process nested children
if (!empty($child['childLinks'])) {
$this->attachPiecesToChildComponents($child['childLinks'], $pieceLinks);
}
@@ -400,8 +558,7 @@ class MachineSkeletonController extends AbstractController
private function normalizeMachine(Machine $machine): array
{
$site = $machine->getSite();
$typeMachine = $machine->getTypeMachine();
$site = $machine->getSite();
return [
'id' => $machine->getId(),
@@ -413,26 +570,10 @@ class MachineSkeletonController extends AbstractController
'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()),
'customFields' => $this->normalizeCustomFields($machine->getCustomFields()),
'documents' => null,
'customFieldValues' => null,
'customFieldValues' => $this->normalizeCustomFieldValues($machine->getCustomFieldValues()),
];
}
@@ -460,26 +601,21 @@ class MachineSkeletonController extends AbstractController
private function normalizeComponentLinks(array $links): array
{
return array_map(function (MachineComponentLink $link): array {
$composant = $link->getComposant();
$requirement = $link->getTypeMachineComponentRequirement();
$parentLink = $link->getParentLink();
$parentRequirementId = $parentLink?->getTypeMachineComponentRequirement()?->getId();
$composant = $link->getComposant();
$parentLink = $link->getParentLink();
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' => [],
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'composantId' => $composant->getId(),
'composant' => $this->normalizeComposant($composant),
'parentLinkId' => $parentLink?->getId(),
'parentComponentLinkId' => $parentLink?->getId(),
'parentComponentId' => $parentLink?->getComposant()->getId(),
'overrides' => $this->normalizeOverrides($link),
'childLinks' => [],
'pieceLinks' => [],
];
}, $links);
}
@@ -487,24 +623,19 @@ class MachineSkeletonController extends AbstractController
private function normalizePieceLinks(array $links): array
{
return array_map(function (MachinePieceLink $link): array {
$piece = $link->getPiece();
$requirement = $link->getTypeMachinePieceRequirement();
$parentLink = $link->getParentLink();
$parentRequirementId = $parentLink?->getTypeMachineComponentRequirement()?->getId();
$piece = $link->getPiece();
$parentLink = $link->getParentLink();
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),
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'pieceId' => $piece->getId(),
'piece' => $this->normalizePiece($piece),
'parentLinkId' => $parentLink?->getId(),
'parentComponentLinkId' => $parentLink?->getId(),
'parentComponentId' => $parentLink?->getComposant()->getId(),
'overrides' => $this->normalizeOverrides($link),
];
}, $links);
}
@@ -512,55 +643,58 @@ class MachineSkeletonController extends AbstractController
private function normalizeProductLinks(array $links): array
{
return array_map(function (MachineProductLink $link): array {
$product = $link->getProduct();
$requirement = $link->getTypeMachineProductRequirement();
$product = $link->getProduct();
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(),
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'productId' => $product->getId(),
'product' => $this->normalizeProduct($product),
'parentLinkId' => $link->getParentLink()?->getId(),
'parentComponentLinkId' => $link->getParentComponentLink()?->getId(),
'parentPieceLinkId' => $link->getParentPieceLink()?->getId(),
];
}, $links);
}
private function normalizeComposant(Composant $composant): array
{
$type = $composant->getTypeComposant();
return [
'id' => $composant->getId(),
'name' => $composant->getName(),
'reference' => $composant->getReference(),
'prix' => $composant->getPrix(),
'typeComposantId' => $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' => [],
'id' => $composant->getId(),
'name' => $composant->getName(),
'reference' => $composant->getReference(),
'prix' => $composant->getPrix(),
'typeComposantId' => $type?->getId(),
'typeComposant' => $this->normalizeModelType($type),
'productId' => $composant->getProduct()?->getId(),
'product' => $composant->getProduct() ? $this->normalizeProduct($composant->getProduct()) : null,
'constructeurs' => $this->normalizeConstructeurs($composant->getConstructeurs()),
'documents' => [],
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getComponentCustomFields()) : [],
'customFieldValues' => $this->normalizeCustomFieldValues($composant->getCustomFieldValues()),
];
}
private function normalizePiece(Piece $piece): array
{
$type = $piece->getTypePiece();
return [
'id' => $piece->getId(),
'name' => $piece->getName(),
'reference' => $piece->getReference(),
'prix' => $piece->getPrix(),
'typePieceId' => $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' => [],
'id' => $piece->getId(),
'name' => $piece->getName(),
'reference' => $piece->getReference(),
'prix' => $piece->getPrix(),
'typePieceId' => $type?->getId(),
'typePiece' => $this->normalizeModelType($type),
'productId' => $piece->getProduct()?->getId(),
'product' => $piece->getProduct() ? $this->normalizeProduct($piece->getProduct()) : null,
'constructeurs' => $this->normalizeConstructeurs($piece->getConstructeurs()),
'documents' => [],
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getPieceCustomFields()) : [],
'customFieldValues' => $this->normalizeCustomFieldValues($piece->getCustomFieldValues()),
];
}
@@ -586,49 +720,11 @@ class MachineSkeletonController extends AbstractController
}
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()),
'id' => $type->getId(),
'name' => $type->getName(),
'code' => $type->getCode(),
'category' => $type->getCategory()->value,
'structure' => $type->getStructure(),
];
}
@@ -647,6 +743,55 @@ class MachineSkeletonController extends AbstractController
return $items;
}
private function normalizeCustomFieldDefinitions(Collection $customFields): array
{
$items = [];
foreach ($customFields as $cf) {
if (!$cf instanceof CustomField) {
continue;
}
$items[] = [
'id' => $cf->getId(),
'name' => $cf->getName(),
'type' => $cf->getType(),
'required' => $cf->isRequired(),
'options' => $cf->getOptions(),
'defaultValue' => $cf->getDefaultValue(),
'orderIndex' => $cf->getOrderIndex(),
];
}
usort($items, static fn (array $a, array $b) => $a['orderIndex'] <=> $b['orderIndex']);
return $items;
}
private function normalizeCustomFieldValues(Collection $customFieldValues): array
{
$items = [];
foreach ($customFieldValues as $cfv) {
if (!$cfv instanceof CustomFieldValue) {
continue;
}
$cf = $cfv->getCustomField();
$items[] = [
'id' => $cfv->getId(),
'value' => $cfv->getValue(),
'customField' => [
'id' => $cf->getId(),
'name' => $cf->getName(),
'type' => $cf->getType(),
'required' => $cf->isRequired(),
'options' => $cf->getOptions(),
'defaultValue' => $cf->getDefaultValue(),
'orderIndex' => $cf->getOrderIndex(),
],
];
}
return $items;
}
private function normalizeOverrides(object $link): ?array
{
$name = method_exists($link, 'getNameOverride') ? $link->getNameOverride() : null;

View File

@@ -6,11 +6,12 @@ namespace App\Controller;
use App\Repository\ModelTypeRepository;
use App\Service\ModelTypeCategoryConversionService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class ModelTypeConversionController
final class ModelTypeConversionController extends AbstractController
{
public function __construct(
private readonly ModelTypeRepository $modelTypes,
@@ -20,6 +21,8 @@ final class ModelTypeConversionController
#[Route('/api/model_types/{id}/conversion-check', name: 'api_model_type_conversion_check', methods: ['GET'])]
public function check(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$modelType = $this->modelTypes->find($id);
if (!$modelType) {
@@ -35,6 +38,8 @@ final class ModelTypeConversionController
#[Route('/api/model_types/{id}/convert', name: 'api_model_type_convert', methods: ['POST'])]
public function convert(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
$modelType = $this->modelTypes->find($id);
if (!$modelType) {

View File

@@ -1,79 +0,0 @@
<?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

@@ -1,79 +0,0 @@
<?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

@@ -8,11 +8,15 @@ use App\Repository\ProfileRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
final class SessionProfileController
{
public function __construct(private readonly ProfileRepository $profiles) {}
public function __construct(
private readonly ProfileRepository $profiles,
private readonly UserPasswordHasherInterface $passwordHasher,
) {}
#[Route('/api/session/profile', name: 'api_session_profile_get', methods: ['GET'])]
public function getActiveProfile(Request $request): JsonResponse
@@ -64,7 +68,25 @@ final class SessionProfileController
return new JsonResponse(['message' => 'Profil introuvable ou inactif.'], JsonResponse::HTTP_UNAUTHORIZED);
}
$password = $payload['password'] ?? '';
if ('' === $password) {
return new JsonResponse(['message' => 'Mot de passe requis.'], JsonResponse::HTTP_BAD_REQUEST);
}
if (!$profile->getPassword()) {
return new JsonResponse(
['message' => 'Ce profil n\'a pas de mot de passe. Contactez un administrateur.'],
JsonResponse::HTTP_FORBIDDEN,
);
}
if (!$this->passwordHasher->isPasswordValid($profile, $password)) {
return new JsonResponse(['message' => 'Mot de passe incorrect.'], JsonResponse::HTTP_UNAUTHORIZED);
}
$session->migrate(true);
$session->set('profileId', $profile->getId());
$session->set('profileRoles', $profile->getRoles());
return new JsonResponse([
'id' => $profile->getId(),

View File

@@ -4,18 +4,14 @@ 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'])]
@@ -29,52 +25,13 @@ final class SessionProfilesController
->getResult()
;
return new JsonResponse(array_map([$this, 'serializeProfile'], $items));
}
#[Route('/api/session/profiles', name: 'api_session_profiles_create', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
$payload = $request->toArray();
$firstName = trim((string) ($payload['firstName'] ?? ''));
$lastName = trim((string) ($payload['lastName'] ?? ''));
if ('' === $firstName || '' === $lastName) {
return new JsonResponse(['message' => 'firstName et lastName sont requis.'], JsonResponse::HTTP_BAD_REQUEST);
}
$profile = new Profile();
$profile->setFirstName($firstName);
$profile->setLastName($lastName);
$profile->setIsActive(true);
$this->entityManager->persist($profile);
$this->entityManager->flush();
return new JsonResponse($this->serializeProfile($profile), JsonResponse::HTTP_CREATED);
}
#[Route('/api/session/profiles/{id}', name: 'api_session_profiles_delete', methods: ['DELETE'])]
public function delete(string $id): JsonResponse
{
$profile = $this->profiles->find($id);
if (!$profile) {
return new JsonResponse(['message' => 'Profil introuvable.'], JsonResponse::HTTP_NOT_FOUND);
}
$profile->setIsActive(false);
$this->entityManager->flush();
return new JsonResponse(['success' => true]);
}
private function serializeProfile(Profile $profile): array
{
return [
'id' => $profile->getId(),
'firstName' => $profile->getFirstName(),
'lastName' => $profile->getLastName(),
'isActive' => $profile->isActive(),
];
return new JsonResponse(array_map(static function ($profile): array {
return [
'id' => $profile->getId(),
'firstName' => $profile->getFirstName(),
'lastName' => $profile->getLastName(),
'hasPassword' => null !== $profile->getPassword() && '' !== $profile->getPassword(),
];
}, $items));
}
}

View File

@@ -1,18 +0,0 @@
<?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!']);
}
}

207
src/Entity/Comment.php Normal file
View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use App\Entity\Trait\CuidEntityTrait;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'comments')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'status'], name: 'idx_comment_entity_status')]
#[ORM\HasLifecycleCallbacks]
#[ApiFilter(SearchFilter::class, properties: ['entityType' => 'exact', 'entityId' => 'exact', 'status' => 'exact', 'entityName' => 'ipartial'])]
#[ApiFilter(OrderFilter::class, properties: ['createdAt', 'authorName', 'status'])]
#[ApiResource(
description: 'Commentaires et annotations. Permet aux utilisateurs de commenter les machines, pièces, composants, produits et catégories. Les commentaires peuvent être marqués comme résolus.',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
],
order: ['createdAt' => 'DESC'],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 200
)]
class Comment
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private ?string $id = null;
#[ORM\Column(type: Types::TEXT)]
private string $content;
#[ORM\Column(type: Types::STRING, length: 50, name: 'entity_type')]
private string $entityType;
#[ORM\Column(type: Types::STRING, length: 36, name: 'entity_id')]
private string $entityId;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'entity_name')]
private ?string $entityName = null;
#[ORM\Column(type: Types::STRING, length: 36, name: 'author_id')]
private string $authorId;
#[ORM\Column(type: Types::STRING, length: 255, name: 'author_name')]
private string $authorName;
#[ORM\Column(type: Types::STRING, length: 20)]
private string $status = 'open';
#[ORM\Column(type: Types::STRING, length: 36, nullable: true, name: 'resolved_by_id')]
private ?string $resolvedById = null;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'resolved_by_name')]
private ?string $resolvedByName = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true, name: 'resolved_at')]
private ?DateTimeImmutable $resolvedAt = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'created_at')]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updated_at')]
private DateTimeImmutable $updatedAt;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
}
public function getContent(): string
{
return $this->content;
}
public function setContent(string $content): static
{
$this->content = $content;
return $this;
}
public function getEntityType(): string
{
return $this->entityType;
}
public function setEntityType(string $entityType): static
{
$this->entityType = $entityType;
return $this;
}
public function getEntityId(): string
{
return $this->entityId;
}
public function setEntityId(string $entityId): static
{
$this->entityId = $entityId;
return $this;
}
public function getEntityName(): ?string
{
return $this->entityName;
}
public function setEntityName(?string $entityName): static
{
$this->entityName = $entityName;
return $this;
}
public function getAuthorId(): string
{
return $this->authorId;
}
public function setAuthorId(string $authorId): static
{
$this->authorId = $authorId;
return $this;
}
public function getAuthorName(): string
{
return $this->authorName;
}
public function setAuthorName(string $authorName): static
{
$this->authorName = $authorName;
return $this;
}
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): static
{
$this->status = $status;
return $this;
}
public function getResolvedById(): ?string
{
return $this->resolvedById;
}
public function setResolvedById(?string $resolvedById): static
{
$this->resolvedById = $resolvedById;
return $this;
}
public function getResolvedByName(): ?string
{
return $this->resolvedByName;
}
public function setResolvedByName(?string $resolvedByName): static
{
$this->resolvedByName = $resolvedByName;
return $this;
}
public function getResolvedAt(): ?DateTimeImmutable
{
return $this->resolvedAt;
}
public function setResolvedAt(?DateTimeImmutable $resolvedAt): static
{
$this->resolvedAt = $resolvedAt;
return $this;
}
}

View File

@@ -8,6 +8,13 @@ use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\ComposantRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
@@ -19,15 +26,26 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: ComposantRepository::class)]
#[ORM\Table(name: 'composants')]
#[ORM\HasLifecycleCallbacks]
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typeComposant' => 'exact'])]
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typeComposant' => 'exact', 'typeComposant.name' => 'ipartial'])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])]
#[ApiResource(
description: 'Composants du catalogue. Un composant représente un élément fonctionnel rattaché à une machine, avec un type, des fournisseurs et des documents.',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
],
normalizationContext: ['groups' => ['composant:read']],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 200
)]
class Composant
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['composant:read', 'document:list'])]
@@ -41,6 +59,10 @@ class Composant
#[Groups(['composant:read'])]
private ?string $reference = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Groups(['composant:read'])]
private ?string $description = null;
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2, nullable: true)]
#[Groups(['composant:read'])]
private ?string $prix = null;
@@ -101,42 +123,14 @@ class Composant
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
$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;
@@ -161,6 +155,18 @@ class Composant
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function getPrix(): ?string
{
return $this->prix;
@@ -264,19 +270,4 @@ class Composant
{
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

@@ -5,28 +5,50 @@ declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\ConstructeurRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[UniqueEntity(fields: ['name'], message: 'Un fournisseur avec ce nom existe déjà.')]
#[ORM\Entity(repositoryClass: ConstructeurRepository::class)]
#[ORM\Table(name: 'constructeurs')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
description: 'Fournisseurs et constructeurs. Référentiel partagé entre les machines, pièces, composants et produits pour identifier les fabricants et distributeurs.',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 200
)]
class Constructeur
{
use CuidEntityTrait;
#[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;
#[Assert\NotBlank(message: 'Le nom est obligatoire.')]
private ?string $name = null;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
private ?string $email = null;
@@ -66,43 +88,15 @@ class Constructeur
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
$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
public function getName(): ?string
{
return $this->name;
}
@@ -137,19 +131,4 @@ class Constructeur
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

@@ -5,6 +5,13 @@ declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\CustomFieldRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
@@ -16,9 +23,21 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: CustomFieldRepository::class)]
#[ORM\Table(name: 'custom_fields')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource]
#[ApiResource(
description: 'Définitions de champs personnalisés. Permet de créer des champs dynamiques (texte, nombre, date, etc.) applicables aux machines, pièces, composants et produits.',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
]
)]
class CustomField
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
@@ -47,9 +66,9 @@ class CustomField
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private int $orderIndex = 0;
#[ORM\ManyToOne(targetEntity: TypeMachine::class, inversedBy: 'customFields')]
#[ORM\JoinColumn(name: 'typeMachineId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?TypeMachine $typeMachine = null;
#[ORM\ManyToOne(targetEntity: Machine::class, inversedBy: 'customFields')]
#[ORM\JoinColumn(name: 'machineId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?Machine $machine = null;
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'customFields')]
#[ORM\JoinColumn(name: 'typeComposantId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
@@ -77,39 +96,11 @@ class CustomField
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
$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;
@@ -182,30 +173,15 @@ class CustomField
return $this;
}
public function getTypeMachine(): ?TypeMachine
public function getMachine(): ?Machine
{
return $this->typeMachine;
return $this->machine;
}
public function setTypeMachine(?TypeMachine $typeMachine): static
public function setMachine(?Machine $machine): static
{
$this->typeMachine = $typeMachine;
$this->machine = $machine;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): DateTimeImmutable
{
return $this->updatedAt;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -5,6 +5,13 @@ declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\CustomFieldValueRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
@@ -14,9 +21,21 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: CustomFieldValueRepository::class)]
#[ORM\Table(name: 'custom_field_values')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource]
#[ApiResource(
description: 'Valeurs des champs personnalisés. Stocke la valeur concrète d\'un champ personnalisé pour une entité donnée (machine, pièce, composant ou produit).',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
]
)]
class CustomFieldValue
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
@@ -55,36 +74,12 @@ class CustomFieldValue
#[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
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$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;
@@ -156,19 +151,4 @@ class CustomFieldValue
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

@@ -4,13 +4,19 @@ declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\DocumentRepository;
use App\State\DocumentUploadProcessor;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
@@ -19,19 +25,37 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: DocumentRepository::class)]
#[ORM\Table(name: 'documents')]
#[ORM\HasLifecycleCallbacks]
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'filename' => 'ipartial'])]
#[ApiFilter(ExistsFilter::class, properties: ['site', 'machine', 'composant', 'piece', 'product'])]
#[ApiFilter(OrderFilter::class, properties: ['createdAt', 'name', 'size'])]
#[ApiResource(
description: 'Documents et fichiers. Gestion des fichiers joints (PDF, images, etc.) rattachés aux machines, pièces, composants, produits ou sites. Upload via multipart/form-data.',
operations: [
new GetCollection(normalizationContext: ['groups' => ['document:list']]),
new Get(normalizationContext: ['groups' => ['document:list', 'document:detail']]),
new Post(),
new Put(),
new Delete(),
new GetCollection(
security: "is_granted('ROLE_VIEWER')",
normalizationContext: ['groups' => ['document:list']],
),
new Get(
security: "is_granted('ROLE_VIEWER')",
normalizationContext: ['groups' => ['document:list', 'document:detail']],
),
new Post(
security: "is_granted('ROLE_GESTIONNAIRE')",
processor: DocumentUploadProcessor::class,
deserialize: false,
inputFormats: ['multipart' => ['multipart/form-data']],
),
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 200
paginationMaximumItemsPerPage: 500,
order: ['createdAt' => 'DESC']
)]
class Document
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['document:list', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
@@ -46,7 +70,6 @@ class Document
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')]
@@ -89,36 +112,12 @@ class Document
#[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
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$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;
@@ -238,19 +237,4 @@ class Document
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

@@ -5,6 +5,13 @@ declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\MachineRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
@@ -12,13 +19,25 @@ 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: MachineRepository::class)]
#[ORM\Table(name: 'machines')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource]
#[ApiResource(
description: 'Machines industrielles rattachées à un site. Chaque machine possède une structure hiérarchique de composants, pièces et produits, ainsi que des champs personnalisés et des documents.',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
]
)]
class Machine
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['document:list'])]
@@ -36,11 +55,8 @@ class Machine
#[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;
#[Assert\NotNull(message: 'Le site est obligatoire.')]
private ?Site $site = null;
/**
* @var Collection<int, Constructeur>
@@ -77,6 +93,12 @@ class Machine
#[ORM\OneToMany(mappedBy: 'machine', targetEntity: Document::class)]
private Collection $documents;
/**
* @var Collection<int, CustomField>
*/
#[ORM\OneToMany(mappedBy: 'machine', targetEntity: CustomField::class, cascade: ['persist', 'remove'])]
private Collection $customFields;
/**
* @var Collection<int, CustomFieldValue>
*/
@@ -91,44 +113,18 @@ class Machine
public function __construct()
{
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
$this->constructeurs = new ArrayCollection();
$this->componentLinks = new ArrayCollection();
$this->pieceLinks = new ArrayCollection();
$this->productLinks = new ArrayCollection();
$this->documents = new ArrayCollection();
$this->customFields = new ArrayCollection();
$this->customFieldValues = new ArrayCollection();
}
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$now = new DateTimeImmutable();
$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;
@@ -165,26 +161,43 @@ class Machine
return $this;
}
public function getSite(): Site
public function getSite(): ?Site
{
return $this->site;
}
public function setSite(Site $site): static
public function setSite(?Site $site): static
{
$this->site = $site;
return $this;
}
public function getTypeMachine(): ?TypeMachine
/**
* @return Collection<int, CustomField>
*/
public function getCustomFields(): Collection
{
return $this->typeMachine;
return $this->customFields;
}
public function setTypeMachine(?TypeMachine $typeMachine): static
public function addCustomField(CustomField $customField): static
{
$this->typeMachine = $typeMachine;
if (!$this->customFields->contains($customField)) {
$this->customFields->add($customField);
$customField->setMachine($this);
}
return $this;
}
public function removeCustomField(CustomField $customField): static
{
if ($this->customFields->removeElement($customField)) {
if ($customField->getMachine() === $this) {
$customField->setMachine(null);
}
}
return $this;
}
@@ -236,19 +249,4 @@ class Machine
{
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

@@ -5,6 +5,13 @@ declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\MachineComponentLinkRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
@@ -15,9 +22,21 @@ use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: MachineComponentLinkRepository::class)]
#[ORM\Table(name: 'machine_component_links')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource]
#[ApiResource(
description: 'Liaisons machinecomposant. Représente le rattachement d\'un composant à une machine, avec quantité, position dans la hiérarchie et surcharges éventuelles (nom, référence).',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
]
)]
class MachineComponentLink
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private ?string $id = null;
@@ -40,10 +59,6 @@ class MachineComponentLink
#[ORM\OneToMany(mappedBy: 'parentLink', targetEntity: MachineComponentLink::class)]
private Collection $childLinks;
#[ORM\ManyToOne(targetEntity: TypeMachineComponentRequirement::class, inversedBy: 'machineComponentLinks')]
#[ORM\JoinColumn(name: 'typeMachineComponentRequirementId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?TypeMachineComponentRequirement $typeMachineComponentRequirement = null;
/**
* @var Collection<int, MachinePieceLink>
*/
@@ -73,41 +88,13 @@ class MachineComponentLink
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
$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;
@@ -144,18 +131,6 @@ class MachineComponentLink
return $this;
}
public function getTypeMachineComponentRequirement(): ?TypeMachineComponentRequirement
{
return $this->typeMachineComponentRequirement;
}
public function setTypeMachineComponentRequirement(?TypeMachineComponentRequirement $requirement): static
{
$this->typeMachineComponentRequirement = $requirement;
return $this;
}
public function getNameOverride(): ?string
{
return $this->nameOverride;
@@ -191,9 +166,4 @@ class MachineComponentLink
return $this;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -5,6 +5,13 @@ declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\MachinePieceLinkRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
@@ -15,9 +22,21 @@ use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: MachinePieceLinkRepository::class)]
#[ORM\Table(name: 'machine_piece_links')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource]
#[ApiResource(
description: 'Liaisons machinepièce. Représente le rattachement d\'une pièce à une machine, avec quantité, position dans la hiérarchie et surcharges éventuelles (nom, référence, prix).',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
]
)]
class MachinePieceLink
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private ?string $id = null;
@@ -34,10 +53,6 @@ class MachinePieceLink
#[ORM\JoinColumn(name: 'parentLinkId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?MachineComponentLink $parentLink = null;
#[ORM\ManyToOne(targetEntity: TypeMachinePieceRequirement::class, inversedBy: 'machinePieceLinks')]
#[ORM\JoinColumn(name: 'typeMachinePieceRequirementId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?TypeMachinePieceRequirement $typeMachinePieceRequirement = null;
/**
* @var Collection<int, MachineProductLink>
*/
@@ -61,39 +76,11 @@ class MachinePieceLink
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
$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;
@@ -130,18 +117,6 @@ class MachinePieceLink
return $this;
}
public function getTypeMachinePieceRequirement(): ?TypeMachinePieceRequirement
{
return $this->typeMachinePieceRequirement;
}
public function setTypeMachinePieceRequirement(?TypeMachinePieceRequirement $requirement): static
{
$this->typeMachinePieceRequirement = $requirement;
return $this;
}
public function getNameOverride(): ?string
{
return $this->nameOverride;
@@ -177,9 +152,4 @@ class MachinePieceLink
return $this;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -5,6 +5,13 @@ declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\MachineProductLinkRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
@@ -15,9 +22,21 @@ use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: MachineProductLinkRepository::class)]
#[ORM\Table(name: 'machine_product_links')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource]
#[ApiResource(
description: 'Liaisons machineproduit. Représente le rattachement d\'un produit à une machine, avec quantité, position dans la hiérarchie et surcharges éventuelles (nom, référence, prix).',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
]
)]
class MachineProductLink
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private ?string $id = null;
@@ -30,10 +49,6 @@ class MachineProductLink
#[ORM\JoinColumn(name: 'productId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Product $product;
#[ORM\ManyToOne(targetEntity: TypeMachineProductRequirement::class, inversedBy: 'machineProductLinks')]
#[ORM\JoinColumn(name: 'typeMachineProductRequirementId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?TypeMachineProductRequirement $typeMachineProductRequirement = null;
#[ORM\ManyToOne(targetEntity: MachineProductLink::class, inversedBy: 'childLinks')]
#[ORM\JoinColumn(name: 'parentLinkId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?MachineProductLink $parentLink = null;
@@ -60,39 +75,11 @@ class MachineProductLink
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
$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;
@@ -117,18 +104,6 @@ class MachineProductLink
return $this;
}
public function getTypeMachineProductRequirement(): ?TypeMachineProductRequirement
{
return $this->typeMachineProductRequirement;
}
public function setTypeMachineProductRequirement(?TypeMachineProductRequirement $requirement): static
{
$this->typeMachineProductRequirement = $requirement;
return $this;
}
public function getParentLink(): ?MachineProductLink
{
return $this->parentLink;
@@ -164,9 +139,4 @@ class MachineProductLink
return $this;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -8,6 +8,13 @@ use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Enum\ModelCategory;
use App\Repository\ModelTypeRepository;
use DateTimeImmutable;
@@ -15,7 +22,7 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: ModelTypeRepository::class)]
#[ORM\Table(name: 'model_types')]
@@ -24,18 +31,29 @@ use Symfony\Component\Serializer\Annotation\Groups;
#[ApiFilter(SearchFilter::class, properties: ['category' => 'exact', 'name' => 'ipartial'])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])]
#[ApiResource(
description: 'Types et catégories. Référentiel de classification pour les machines, pièces, composants et produits. Chaque type appartient à une catégorie (machine, piece, composant, product) et peut être converti.',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 200
)]
class ModelType
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['type_machine:read', 'model_type:read'])]
#[Groups(['type_machine:read', 'model_type:read', 'product:read', 'composant:read', 'piece:read'])]
private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 120)]
#[Groups(['type_machine:read', 'model_type:read', 'model_type:write'])]
#[Groups(['type_machine:read', 'model_type:read', 'model_type:write', 'product:read', 'composant:read', 'piece:read'])]
private string $name;
#[ORM\Column(type: Types::STRING, length: 60, unique: true)]
@@ -55,15 +73,15 @@ class ModelType
private ?string $description = null;
#[ORM\Column(type: Types::JSON, nullable: true, name: 'componentSkeleton')]
#[Groups(['model_type:read'])]
#[Groups(['model_type:read', 'composant:read'])]
private ?array $componentSkeleton = null;
#[ORM\Column(type: Types::JSON, nullable: true, name: 'pieceSkeleton')]
#[Groups(['model_type:read'])]
#[Groups(['model_type:read', 'piece:read'])]
private ?array $pieceSkeleton = null;
#[ORM\Column(type: Types::JSON, nullable: true, name: 'productSkeleton')]
#[Groups(['model_type:read'])]
#[Groups(['model_type:read', 'product:read'])]
private ?array $productSkeleton = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
@@ -94,24 +112,6 @@ class ModelType
#[ORM\OneToMany(mappedBy: 'typeProduct', targetEntity: Product::class)]
private Collection $products;
/**
* @var Collection<int, TypeMachineComponentRequirement>
*/
#[ORM\OneToMany(mappedBy: 'typeComposant', targetEntity: TypeMachineComponentRequirement::class)]
private Collection $componentRequirements;
/**
* @var Collection<int, TypeMachinePieceRequirement>
*/
#[ORM\OneToMany(mappedBy: 'typePiece', targetEntity: TypeMachinePieceRequirement::class)]
private Collection $pieceRequirements;
/**
* @var Collection<int, TypeMachineProductRequirement>
*/
#[ORM\OneToMany(mappedBy: 'typeProduct', targetEntity: TypeMachineProductRequirement::class)]
private Collection $productRequirements;
/**
* @var Collection<int, CustomField>
*/
@@ -132,45 +132,14 @@ class ModelType
public function __construct()
{
$this->composants = new ArrayCollection();
$this->pieces = new ArrayCollection();
$this->products = new ArrayCollection();
$this->componentRequirements = new ArrayCollection();
$this->pieceRequirements = new ArrayCollection();
$this->productRequirements = new ArrayCollection();
$this->customFields = new ArrayCollection();
$this->pieceCustomFields = new ArrayCollection();
$this->productCustomFields = new ArrayCollection();
}
#[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;
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
$this->composants = new ArrayCollection();
$this->pieces = new ArrayCollection();
$this->products = new ArrayCollection();
$this->customFields = new ArrayCollection();
$this->pieceCustomFields = new ArrayCollection();
$this->productCustomFields = new ArrayCollection();
}
public function getName(): string
@@ -274,7 +243,7 @@ class ModelType
return $this;
}
#[Groups(['model_type:read'])]
#[Groups(['model_type:read', 'product:read', 'composant:read', 'piece:read'])]
public function getStructure(): ?array
{
return match ($this->category) {
@@ -298,19 +267,28 @@ class ModelType
return $this;
}
public function getCreatedAt(): DateTimeImmutable
/**
* @return Collection<int, CustomField>
*/
public function getComponentCustomFields(): Collection
{
return $this->createdAt;
return $this->customFields;
}
public function getUpdatedAt(): DateTimeImmutable
/**
* @return Collection<int, CustomField>
*/
public function getPieceCustomFields(): Collection
{
return $this->updatedAt;
return $this->pieceCustomFields;
}
private function generateCuid(): string
/**
* @return Collection<int, CustomField>
*/
public function getProductCustomFields(): Collection
{
return 'cl'.bin2hex(random_bytes(12));
return $this->productCustomFields;
}
private function applyStructureForCategory(?array $structure, ModelCategory $category): void

View File

@@ -8,39 +8,63 @@ use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\PieceRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Attribute\Groups;
#[UniqueEntity(fields: ['reference'], message: 'Une pièce avec cette référence existe déjà.')]
#[ORM\Entity(repositoryClass: PieceRepository::class)]
#[ORM\Table(name: 'pieces')]
#[ORM\HasLifecycleCallbacks]
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typePiece' => 'exact'])]
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typePiece' => 'exact', 'typePiece.name' => 'ipartial'])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])]
#[ApiResource(
description: 'Pièces détachées du catalogue. Une pièce peut être rattachée à plusieurs machines et possède un type, des fournisseurs, des documents et un produit associé.',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
],
normalizationContext: ['groups' => ['piece:read']],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 200
)]
class Piece
{
use CuidEntityTrait;
#[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)]
#[ORM\Column(type: Types::STRING, length: 255)]
#[Groups(['piece:read', 'document:list'])]
private string $name;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
#[ORM\Column(type: Types::STRING, length: 255, unique: true, nullable: true)]
#[Groups(['piece:read'])]
private ?string $reference = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Groups(['piece:read'])]
private ?string $description = null;
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2, nullable: true)]
#[Groups(['piece:read'])]
private ?string $prix = null;
@@ -101,42 +125,14 @@ class Piece
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
$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;
@@ -161,6 +157,18 @@ class Piece
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function getPrix(): ?string
{
return $this->prix;
@@ -290,19 +298,4 @@ class Piece
{
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

@@ -8,6 +8,13 @@ use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\ProductRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
@@ -19,15 +26,26 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: ProductRepository::class)]
#[ORM\Table(name: 'products')]
#[ORM\HasLifecycleCallbacks]
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typeProduct' => 'exact'])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])]
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typeProduct' => 'exact', 'typeProduct.name' => 'ipartial'])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt', 'supplierPrice'])]
#[ApiResource(
description: 'Produits du catalogue fournisseur. Un produit possède une référence, un prix indicatif, un type, des fournisseurs et des documents. Il peut être lié à des machines.',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
],
normalizationContext: ['groups' => ['product:read']],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 200
)]
class Product
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['product:read', 'document:list'])]
@@ -104,6 +122,8 @@ class Product
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
$this->constructeurs = new ArrayCollection();
$this->documents = new ArrayCollection();
$this->customFieldValues = new ArrayCollection();
@@ -112,36 +132,6 @@ class Product
$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;
@@ -229,19 +219,4 @@ class Product
{
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

@@ -8,14 +8,16 @@ use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Repository\ProfileRepository;
use App\State\ProfilePasswordHasher;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: ProfileRepository::class)]
@@ -23,12 +25,26 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\UniqueConstraint(name: 'UNIQ_email', columns: ['email'])]
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
description: 'Profils utilisateurs. Chaque profil possède un rôle (Admin, Gestionnaire, Viewer, User), un email unique et un mot de passe. Gère l\'authentification par session.',
operations: [
new Get(),
new GetCollection(),
new Post(),
new Put(),
new Delete(),
new Get(security: "is_granted('ROLE_ADMIN')"),
new GetCollection(security: "is_granted('ROLE_ADMIN')"),
new Post(
security: "is_granted('ROLE_ADMIN')",
denormalizationContext: ['groups' => ['profile:write', 'profile:admin:write']],
processor: ProfilePasswordHasher::class,
),
new Put(
security: "is_granted('ROLE_ADMIN')",
denormalizationContext: ['groups' => ['profile:write', 'profile:admin:write']],
processor: ProfilePasswordHasher::class,
),
new Patch(
security: "is_granted('ROLE_ADMIN')",
denormalizationContext: ['groups' => ['profile:write', 'profile:admin:write']],
processor: ProfilePasswordHasher::class,
),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['profile:read']],
denormalizationContext: ['groups' => ['profile:write']]
@@ -63,16 +79,21 @@ class Profile implements UserInterface, PasswordAuthenticatedUserInterface
* @var list<string> The user roles
*/
#[ORM\Column(type: 'json', options: ['default' => '["ROLE_USER"]'])]
#[Groups(['profile:read', 'profile:write'])]
#[Groups(['profile:read', 'profile:admin:write'])]
private array $roles = ['ROLE_USER'];
/**
* @var string The hashed password
* @var null|string The hashed password
*/
#[ORM\Column(type: 'string', nullable: true)]
#[Groups(['profile:write'])]
private ?string $password = null;
/**
* Non-persisted field used for password hashing via ProfilePasswordHasher.
*/
#[Groups(['profile:write'])]
private ?string $plainPassword = null;
#[ORM\Column(type: 'datetime_immutable', name: 'createdat')]
#[Groups(['profile:read'])]
private DateTimeImmutable $createdAt;
@@ -83,8 +104,7 @@ class Profile implements UserInterface, PasswordAuthenticatedUserInterface
public function __construct()
{
// Générer un CUID-like ID pour compatibilité avec Prisma
$this->id = 'cl'.substr(strtolower(base_convert(random_bytes(12), 2, 36)), 0, 24);
$this->id = 'cl'.bin2hex(random_bytes(12));
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
}
@@ -157,11 +177,10 @@ class Profile implements UserInterface, PasswordAuthenticatedUserInterface
*/
public function getRoles(): array
{
$roles = $this->roles;
// guarantee every user at least has ROLE_USER
$roles = $this->roles;
$roles[] = 'ROLE_USER';
return array_unique($roles);
return array_values(array_unique($roles));
}
/**
@@ -182,20 +201,37 @@ class Profile implements UserInterface, PasswordAuthenticatedUserInterface
return $this->password;
}
public function setPassword(string $password): static
public function setPassword(?string $password): static
{
$this->password = $password;
return $this;
}
public function getPlainPassword(): ?string
{
return $this->plainPassword;
}
public function setPlainPassword(?string $plainPassword): static
{
$this->plainPassword = $plainPassword;
return $this;
}
#[Groups(['profile:read'])]
public function getHasPassword(): bool
{
return null !== $this->password && '' !== $this->password;
}
/**
* @see UserInterface
*/
public function eraseCredentials(): void
{
// If you store any temporary, sensitive data on the user, clear it here
// $this->plainPassword = null;
$this->plainPassword = null;
}
public function getCreatedAt(): DateTimeImmutable

View File

@@ -8,8 +8,10 @@ use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\SiteRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
@@ -23,18 +25,22 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'sites')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
description: 'Sites industriels. Chaque site regroupe des machines et peut avoir ses propres documents. Un site possède un nom, une adresse et des coordonnées de contact.',
operations: [
new Get(),
new GetCollection(),
new Post(),
new Put(),
new Delete(),
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 200
)]
class Site
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['document:list'])]
@@ -60,6 +66,9 @@ class Site
#[ORM\Column(type: Types::STRING, length: 100, options: ['default' => ''], name: 'contactCity')]
private string $contactCity = '';
#[ORM\Column(type: Types::STRING, length: 7, options: ['default' => ''], name: 'color')]
private string $color = '';
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private DateTimeImmutable $createdAt;
@@ -79,41 +88,11 @@ class Site
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;
$this->machines = new ArrayCollection();
$this->documents = new ArrayCollection();
}
public function getName(): string
@@ -188,14 +167,16 @@ class Site
return $this;
}
public function getCreatedAt(): DateTimeImmutable
public function getColor(): string
{
return $this->createdAt;
return $this->color;
}
public function getUpdatedAt(): DateTimeImmutable
public function setColor(string $color): static
{
return $this->updatedAt;
$this->color = $color;
return $this;
}
/**
@@ -257,10 +238,4 @@ class Site
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));
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Entity\Trait;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
trait CuidEntityTrait
{
#[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 getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): DateTimeImmutable
{
return $this->updatedAt;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -1,401 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Repository\TypeMachineRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: TypeMachineRepository::class)]
#[ORM\Table(name: 'type_machines')]
#[ORM\HasLifecycleCallbacks]
#[UniqueEntity(fields: ['name'], message: 'Ce nom de type de machine existe déjà.')]
#[ApiResource(
operations: [
new Get(),
new GetCollection(),
new Post(),
new Put(),
new Delete(),
],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 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

@@ -1,209 +0,0 @@
<?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

@@ -1,209 +0,0 @@
<?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

@@ -1,209 +0,0 @@
<?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,44 @@
<?php
declare(strict_types=1);
namespace App\EventListener;
use App\Entity\Document;
use App\Service\DocumentStorageService;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use Doctrine\ORM\Events;
use Psr\Log\LoggerInterface;
#[AsEntityListener(event: Events::postRemove, method: 'postRemove', entity: Document::class)]
class DocumentFileCleanupListener
{
public function __construct(
private readonly DocumentStorageService $storageService,
private readonly ?LoggerInterface $logger = null,
) {}
public function postRemove(Document $document): void
{
$path = $document->getPath();
// Do not attempt file deletion for Base64 data URIs
if ($this->storageService->isBase64DataUri($path)) {
return;
}
$deleted = $this->storageService->delete($path);
if ($deleted) {
$this->logger?->info('Document file deleted from disk', [
'documentId' => $document->getId(),
'path' => $path,
]);
} else {
$this->logger?->warning('Document file not found on disk during cleanup', [
'documentId' => $document->getId(),
'path' => $path,
]);
}
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\EventListener;
use App\Entity\Document;
use App\Service\DocumentStorageService;
use App\Service\PdfCompressorService;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use Doctrine\ORM\Events;
@@ -16,6 +17,7 @@ class DocumentPdfCompressorListener
{
public function __construct(
private readonly PdfCompressorService $pdfCompressor,
private readonly DocumentStorageService $storageService,
private readonly ?LoggerInterface $logger = null,
) {}
@@ -35,15 +37,26 @@ class DocumentPdfCompressorListener
return;
}
$result = $this->pdfCompressor->compressBase64Pdf($document->getPath());
$path = $document->getPath();
if (null === $result) {
return;
if ($this->storageService->isBase64DataUri($path)) {
// Legacy Base64 path
$result = $this->pdfCompressor->compressBase64Pdf($path);
if (null === $result) {
return;
}
$document->setPath($result['path']);
$document->setSize($result['size']);
} else {
// File-based path
$absolutePath = $this->storageService->getAbsolutePath($path);
$result = $this->pdfCompressor->compressFile($absolutePath);
if (null === $result) {
return;
}
$document->setSize($result['size']);
}
$document->setPath($result['path']);
$document->setSize($result['size']);
$this->logger?->info('PDF compressed', [
'document' => $document->getName(),
'originalSize' => $result['originalSize'],

View File

@@ -0,0 +1,463 @@
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\AuditLog;
use App\Entity\Composant;
use App\Entity\CustomFieldValue;
use App\Entity\Machine;
use App\Entity\ModelType;
use App\Entity\Piece;
use App\Entity\Product;
use App\Entity\Profile;
use App\Entity\Site;
use BackedEnum;
use DateTimeInterface;
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 Error;
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;
abstract class AbstractAuditSubscriber 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();
$entityType = $this->entityType();
if ($this->hasCollectionTracking()) {
$this->onFlushComplex($em, $uow, $actorProfileId, $entityType);
} else {
$this->onFlushSimple($em, $uow, $actorProfileId, $entityType);
}
}
abstract protected function supports(object $entity): bool;
abstract protected function entityType(): string;
abstract protected function snapshotEntity(object $entity): array;
/**
* Override in subclasses that track custom field value changes.
* Return the owner entity if the CFV belongs to the tracked entity type.
*/
protected function getOwnerFromCustomFieldValue(CustomFieldValue $cfv): ?object
{
return null;
}
/**
* Whether this subscriber tracks constructeur collection changes.
* Override to return true for entities with a constructeurs ManyToMany.
*/
protected function hasCollectionTracking(): bool
{
return false;
}
protected 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}>
*/
protected 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;
}
/**
* @param iterable<mixed> $items
*
* @return list<array{id: string, name: string}|string>
*/
protected 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;
}
protected function safeGet(object $entity, string $method): mixed
{
try {
return $entity->{$method}();
} catch (Error) {
return null;
}
}
protected 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 BackedEnum) {
return $value->value;
}
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 Site || $value instanceof Machine || $value instanceof Composant || $value instanceof Piece) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
];
}
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}>
*/
protected function mergeDiffs(array $base, array $extra): array
{
foreach ($extra as $field => $change) {
$base[$field] = $change;
}
return $base;
}
protected 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;
}
private function onFlushSimple(EntityManagerInterface $em, UnitOfWork $uow, ?string $actorProfileId, string $entityType): void
{
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!$this->supports($entity)) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
$snapshot = $this->snapshotEntity($entity);
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$this->supports($entity)) {
continue;
}
$id = (string) $entity->getId();
if ('' === $id) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ([] !== $diff) {
$snapshot = $this->snapshotEntity($entity);
$this->persistAuditLog($em, new AuditLog($entityType, $id, 'update', $diff, $snapshot, $actorProfileId));
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if (!$this->supports($entity)) {
continue;
}
$snapshot = $this->snapshotEntity($entity);
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
}
}
private function onFlushComplex(EntityManagerInterface $em, UnitOfWork $uow, ?string $actorProfileId, string $entityType): void
{
$pendingUpdates = [];
$pendingSnapshots = [];
$pendingEntities = [];
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!$this->supports($entity)) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
$snapshot = $this->snapshotEntity($entity);
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$this->supports($entity)) {
continue;
}
$entityId = (string) $entity->getId();
if ('' === $entityId) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ([] !== $diff) {
$pendingUpdates[$entityId] = $this->mergeDiffs($pendingUpdates[$entityId] ?? [], $diff);
$pendingSnapshots[$entityId] = $this->snapshotEntity($entity);
$pendingEntities[$entityId] = $entity;
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if (!$this->supports($entity)) {
continue;
}
$snapshot = $this->snapshotEntity($entity);
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingEntities);
}
foreach ($uow->getScheduledCollectionDeletions() as $collection) {
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingEntities);
}
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingEntities);
foreach ($pendingUpdates as $entityId => $diff) {
if ([] === $diff) {
continue;
}
$entity = $pendingEntities[$entityId] ?? null;
if (null === $entity || !$this->supports($entity)) {
continue;
}
$snapshot = $pendingSnapshots[$entityId] ?? $this->snapshotEntity($entity);
$this->persistAuditLog($em, new AuditLog($entityType, $entityId, 'update', $diff, $snapshot, $actorProfileId));
}
}
private function collectCollectionUpdate(
object $collection,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingEntities,
): void {
if (!$collection instanceof PersistentCollection) {
return;
}
$owner = $collection->getOwner();
if (null === $owner || !$this->supports($owner)) {
return;
}
$ownerId = (string) $owner->getId();
if ('' === $ownerId) {
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[$ownerId] = $this->mergeDiffs($pendingUpdates[$ownerId] ?? [], $diff);
$pendingSnapshots[$ownerId] = $this->snapshotEntity($owner);
$pendingEntities[$ownerId] = $owner;
}
private function collectCustomFieldValueChanges(
UnitOfWork $uow,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingEntities,
): void {
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if ($entity instanceof CustomFieldValue) {
$this->trackCustomFieldValueChange($entity, null, $entity->getValue(), $pendingUpdates, $pendingSnapshots, $pendingEntities);
}
}
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, $pendingEntities);
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if ($entity instanceof CustomFieldValue) {
$this->trackCustomFieldValueChange($entity, $entity->getValue(), null, $pendingUpdates, $pendingSnapshots, $pendingEntities);
}
}
}
private function trackCustomFieldValueChange(
CustomFieldValue $cfv,
mixed $from,
mixed $to,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingEntities,
): void {
$owner = $this->getOwnerFromCustomFieldValue($cfv);
if (null === $owner) {
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->snapshotEntity($owner);
$pendingEntities[$ownerId] = $owner;
}
}

View File

@@ -4,394 +4,45 @@ 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
final class ComposantAuditSubscriber extends AbstractAuditSubscriber
{
public function __construct(
private readonly RequestStack $requestStack,
private readonly Security $security,
) {}
protected function supports(object $entity): bool
{
return $entity instanceof Composant;
}
public function getSubscribedEvents(): array
protected function entityType(): string
{
return 'composant';
}
protected function hasCollectionTracking(): bool
{
return true;
}
protected function getOwnerFromCustomFieldValue(CustomFieldValue $cfv): ?object
{
return $cfv->getComposant();
}
protected function snapshotEntity(object $entity): array
{
return [
Events::onFlush,
'id' => $entity->getId(),
'name' => $this->safeGet($entity, 'getName'),
'reference' => $this->safeGet($entity, 'getReference'),
'prix' => $this->safeGet($entity, 'getPrix'),
'structure' => $this->safeGet($entity, 'getStructure'),
'typeComposant' => $this->normalizeValue($this->safeGet($entity, 'getTypeComposant')),
'product' => $this->normalizeValue($this->safeGet($entity, 'getProduct')),
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
];
}
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

@@ -4,165 +4,30 @@ 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
final class ConstructeurAuditSubscriber extends AbstractAuditSubscriber
{
public function __construct(
private readonly RequestStack $requestStack,
private readonly Security $security,
) {}
protected function supports(object $entity): bool
{
return $entity instanceof Constructeur;
}
public function getSubscribedEvents(): array
protected function entityType(): string
{
return 'constructeur';
}
protected function snapshotEntity(object $entity): array
{
return [
Events::onFlush,
'id' => $entity->getId(),
'name' => $this->safeGet($entity, 'getName'),
'email' => $this->safeGet($entity, 'getEmail'),
'phone' => $this->safeGet($entity, 'getPhone'),
];
}
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

@@ -4,189 +4,36 @@ 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
final class DocumentAuditSubscriber extends AbstractAuditSubscriber
{
public function __construct(
private readonly RequestStack $requestStack,
private readonly Security $security,
) {}
protected function supports(object $entity): bool
{
return $entity instanceof Document;
}
public function getSubscribedEvents(): array
protected function entityType(): string
{
return 'document';
}
protected function snapshotEntity(object $entity): array
{
return [
Events::onFlush,
'id' => $entity->getId(),
'name' => $this->safeGet($entity, 'getName'),
'filename' => $this->safeGet($entity, 'getFilename'),
'mimeType' => $this->safeGet($entity, 'getMimeType'),
'size' => $this->safeGet($entity, 'getSize'),
'machine' => $this->normalizeValue($this->safeGet($entity, 'getMachine')),
'composant' => $this->normalizeValue($this->safeGet($entity, 'getComposant')),
'piece' => $this->normalizeValue($this->safeGet($entity, 'getPiece')),
'product' => $this->normalizeValue($this->safeGet($entity, 'getProduct')),
'site' => $this->normalizeValue($this->safeGet($entity, 'getSite')),
];
}
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

@@ -4,409 +4,45 @@ 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
final class MachineAuditSubscriber extends AbstractAuditSubscriber
{
public function __construct(
private readonly RequestStack $requestStack,
private readonly Security $security,
) {}
public function getSubscribedEvents(): array
protected function supports(object $entity): bool
{
return [
Events::onFlush,
];
return $entity instanceof Machine;
}
public function onFlush(OnFlushEventArgs $args): void
protected function entityType(): string
{
$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));
}
return 'machine';
}
/**
* @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;
protected function hasCollectionTracking(): bool
{
return true;
}
/**
* @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 {
protected function getOwnerFromCustomFieldValue(CustomFieldValue $cfv): ?object
{
$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;
return $owner instanceof Machine ? $owner : null;
}
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
protected function snapshotEntity(object $entity): 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()),
'id' => $entity->getId(),
'name' => $this->safeGet($entity, 'getName'),
'reference' => $this->safeGet($entity, 'getReference'),
'prix' => $this->safeGet($entity, 'getPrix'),
'site' => $this->normalizeValue($this->safeGet($entity, 'getSite')),
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
];
}
/**
* @param iterable<mixed> $items
*
* @return list<array{id: string, name: string}|string>
*/
private function normalizeCollection(iterable $items): array
{
$entries = [];
$seen = [];
foreach ($items as $item) {
if (is_object($item) && method_exists($item, 'getId')) {
$id = $item->getId();
if (null === $id || '' === $id || isset($seen[(string) $id])) {
continue;
}
$seen[(string) $id] = true;
if (method_exists($item, 'getName')) {
$entries[] = ['id' => (string) $id, 'name' => (string) $item->getName()];
} else {
$entries[] = (string) $id;
}
}
}
return $entries;
}
private function normalizeValue(mixed $value): mixed
{
if (null === $value || is_scalar($value)) {
return $value;
}
if ($value instanceof DateTimeInterface) {
return $value->format(DateTimeInterface::ATOM);
}
if ($value instanceof Site) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
];
}
if ($value instanceof TypeMachine) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
];
}
if ($value instanceof ModelType) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
'code' => $value->getCode(),
];
}
if ($value instanceof Product) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
'reference' => $value->getReference(),
];
}
if ($value instanceof Collection) {
return $this->normalizeCollection($value);
}
if (is_object($value) && method_exists($value, 'getId')) {
return (string) $value->getId();
}
if (is_array($value)) {
return $value;
}
return (string) $value;
}
/**
* @param array<string, array{from:mixed, to:mixed}> $base
* @param array<string, array{from:mixed, to:mixed}> $extra
*
* @return array<string, array{from:mixed, to:mixed}>
*/
private function mergeDiffs(array $base, array $extra): array
{
foreach ($extra as $field => $change) {
$base[$field] = $change;
}
return $base;
}
private function resolveActorProfileId(): ?string
{
try {
$session = $this->requestStack->getSession();
if ($session instanceof SessionInterface) {
$profileId = $session->get('profileId');
if ($profileId) {
return (string) $profileId;
}
}
} catch (Throwable) {
// No session available (CLI context, etc.)
}
$user = $this->security->getUser();
if ($user instanceof Profile) {
return $user->getId();
}
return null;
}
}

View File

@@ -4,177 +4,32 @@ declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\AuditLog;
use App\Entity\ModelType;
use App\Entity\Profile;
use App\Enum\ModelCategory;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Throwable;
use function is_array;
use function is_scalar;
#[AsDoctrineListener(event: Events::onFlush)]
final class ModelTypeAuditSubscriber implements EventSubscriber
final class ModelTypeAuditSubscriber extends AbstractAuditSubscriber
{
public function __construct(
private readonly RequestStack $requestStack,
private readonly Security $security,
) {}
protected function supports(object $entity): bool
{
return $entity instanceof ModelType;
}
public function getSubscribedEvents(): array
protected function entityType(): string
{
return 'model_type';
}
protected function snapshotEntity(object $entity): array
{
return [
Events::onFlush,
'id' => $entity->getId(),
'name' => $this->safeGet($entity, 'getName'),
'code' => $this->safeGet($entity, 'getCode'),
'category' => $this->safeGet($entity, 'getCategory')?->value,
'notes' => $this->safeGet($entity, 'getNotes'),
'description' => $this->safeGet($entity, 'getDescription'),
];
}
public function onFlush(OnFlushEventArgs $args): void
{
$em = $args->getObjectManager();
if (!$em instanceof EntityManagerInterface) {
return;
}
$uow = $em->getUnitOfWork();
$actorProfileId = $this->resolveActorProfileId();
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!$entity instanceof ModelType) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
$snapshot = $this->snapshotModelType($entity);
$this->persistAuditLog($em, new AuditLog('model_type', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof ModelType) {
continue;
}
$id = (string) $entity->getId();
if ('' === $id) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ([] !== $diff) {
$snapshot = $this->snapshotModelType($entity);
$this->persistAuditLog($em, new AuditLog('model_type', $id, 'update', $diff, $snapshot, $actorProfileId));
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if (!$entity instanceof ModelType) {
continue;
}
$snapshot = $this->snapshotModelType($entity);
$this->persistAuditLog($em, new AuditLog('model_type', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
}
}
private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
{
$uow = $em->getUnitOfWork();
$log->initializeAuditLog();
$em->persist($log);
$meta = $em->getClassMetadata(AuditLog::class);
$uow->computeChangeSet($meta, $log);
}
/**
* @param array<string, array{0:mixed, 1:mixed}> $changeSet
*
* @return array<string, array{from:mixed, to:mixed}>
*/
private function buildDiffFromChangeSet(array $changeSet): array
{
$diff = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) {
if ('updatedAt' === $field || 'createdAt' === $field) {
continue;
}
$normalizedOld = $this->normalizeValue($oldValue);
$normalizedNew = $this->normalizeValue($newValue);
if ($normalizedOld === $normalizedNew) {
continue;
}
$diff[$field] = [
'from' => $normalizedOld,
'to' => $normalizedNew,
];
}
return $diff;
}
private function snapshotModelType(ModelType $modelType): array
{
return [
'id' => $modelType->getId(),
'name' => $modelType->getName(),
'code' => $modelType->getCode(),
'category' => $modelType->getCategory()->value,
'notes' => $modelType->getNotes(),
'description' => $modelType->getDescription(),
];
}
private function normalizeValue(mixed $value): mixed
{
if (null === $value || is_scalar($value)) {
return $value;
}
if ($value instanceof DateTimeInterface) {
return $value->format(DateTimeInterface::ATOM);
}
if ($value instanceof ModelCategory) {
return $value->value;
}
if (is_array($value)) {
return $value;
}
return (string) $value;
}
private function resolveActorProfileId(): ?string
{
try {
$session = $this->requestStack->getSession();
if ($session instanceof SessionInterface) {
$profileId = $session->get('profileId');
if ($profileId) {
return (string) $profileId;
}
}
} catch (Throwable) {
// No session available (CLI context, etc.)
}
$user = $this->security->getUser();
if ($user instanceof Profile) {
return $user->getId();
}
return null;
}
}

View File

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

View File

@@ -4,393 +4,43 @@ declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\AuditLog;
use App\Entity\CustomFieldValue;
use App\Entity\ModelType;
use App\Entity\Product;
use App\Entity\Profile;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\PersistentCollection;
use Doctrine\ORM\UnitOfWork;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Throwable;
use function is_array;
use function is_object;
use function is_scalar;
use function method_exists;
/**
* Record a lightweight, per-product audit trail.
*
* This MVP focuses on Product updates and captures:
* - scalar field changes (from Doctrine change sets)
* - constructeur collection changes (from collection updates)
*/
#[AsDoctrineListener(event: Events::onFlush)]
final class ProductAuditSubscriber implements EventSubscriber
final class ProductAuditSubscriber extends AbstractAuditSubscriber
{
public function __construct(
private readonly RequestStack $requestStack,
private readonly Security $security,
) {}
protected function supports(object $entity): bool
{
return $entity instanceof Product;
}
public function getSubscribedEvents(): array
protected function entityType(): string
{
return 'product';
}
protected function hasCollectionTracking(): bool
{
return true;
}
protected function getOwnerFromCustomFieldValue(CustomFieldValue $cfv): ?object
{
return $cfv->getProduct();
}
protected function snapshotEntity(object $entity): array
{
return [
Events::onFlush,
'id' => $entity->getId(),
'name' => $this->safeGet($entity, 'getName'),
'reference' => $this->safeGet($entity, 'getReference'),
'supplierPrice' => $this->safeGet($entity, 'getSupplierPrice'),
'typeProduct' => $this->normalizeValue($this->safeGet($entity, 'getTypeProduct')),
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
];
}
public function onFlush(OnFlushEventArgs $args): void
{
$em = $args->getObjectManager();
if (!$em instanceof EntityManagerInterface) {
return;
}
$uow = $em->getUnitOfWork();
$actorProfileId = $this->resolveActorProfileId();
$pendingUpdates = [];
$pendingSnapshots = [];
$pendingProducts = [];
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!$entity instanceof Product) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
$snapshot = $this->snapshotProduct($entity);
$this->persistAuditLog($em, new AuditLog('product', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof Product) {
continue;
}
$productId = (string) $entity->getId();
if ('' === $productId) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ([] !== $diff) {
$pendingUpdates[$productId] = $this->mergeDiffs($pendingUpdates[$productId] ?? [], $diff);
$pendingSnapshots[$productId] = $this->snapshotProduct($entity);
$pendingProducts[$productId] = $entity;
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if (!$entity instanceof Product) {
continue;
}
$snapshot = $this->snapshotProduct($entity);
$this->persistAuditLog($em, new AuditLog('product', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
}
// Capture constructeur collection updates, which are not included in the change set.
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingProducts);
}
foreach ($uow->getScheduledCollectionDeletions() as $collection) {
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingProducts);
}
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingProducts);
foreach ($pendingUpdates as $productId => $diff) {
if ([] === $diff) {
continue;
}
$product = $pendingProducts[$productId] ?? null;
if (!$product instanceof Product) {
continue;
}
$snapshot = $pendingSnapshots[$productId] ?? $this->snapshotProduct($product);
$this->persistAuditLog($em, new AuditLog('product', $productId, 'update', $diff, $snapshot, $actorProfileId));
}
}
/**
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
* @param array<string, array<string, mixed>> $pendingSnapshots
* @param array<string, Product> $pendingProducts
*/
private function collectCollectionUpdate(
object $collection,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingProducts,
): void {
if (!$collection instanceof PersistentCollection) {
return;
}
$owner = $collection->getOwner();
if (!$owner instanceof Product) {
return;
}
$productId = (string) $owner->getId();
if ('' === $productId) {
return;
}
$mapping = $collection->getMapping();
$fieldName = $mapping['fieldName'] ?? null;
if ('constructeurs' !== $fieldName) {
return;
}
$before = $this->normalizeCollection($collection->getSnapshot());
$after = $this->normalizeCollection($collection->toArray());
if ($before === $after) {
return;
}
$diff = [
'constructeurIds' => [
'from' => $before,
'to' => $after,
],
];
$pendingUpdates[$productId] = $this->mergeDiffs($pendingUpdates[$productId] ?? [], $diff);
$pendingSnapshots[$productId] = $this->snapshotProduct($owner);
$pendingProducts[$productId] = $owner;
}
/**
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
* @param array<string, array<string, mixed>> $pendingSnapshots
* @param array<string, Product> $pendingProducts
*/
private function collectCustomFieldValueChanges(
UnitOfWork $uow,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingProducts,
): void {
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if ($entity instanceof CustomFieldValue) {
$this->trackCustomFieldValueChange($entity, null, $entity->getValue(), $pendingUpdates, $pendingSnapshots, $pendingProducts);
}
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof CustomFieldValue) {
continue;
}
$changeSet = $uow->getEntityChangeSet($entity);
if (!isset($changeSet['value'])) {
continue;
}
[$oldVal, $newVal] = $changeSet['value'];
if ($oldVal !== $newVal) {
$this->trackCustomFieldValueChange($entity, $oldVal, $newVal, $pendingUpdates, $pendingSnapshots, $pendingProducts);
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if ($entity instanceof CustomFieldValue) {
$this->trackCustomFieldValueChange($entity, $entity->getValue(), null, $pendingUpdates, $pendingSnapshots, $pendingProducts);
}
}
}
/**
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
* @param array<string, array<string, mixed>> $pendingSnapshots
* @param array<string, Product> $pendingProducts
*/
private function trackCustomFieldValueChange(
CustomFieldValue $cfv,
mixed $from,
mixed $to,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingProducts,
): void {
$owner = $cfv->getProduct();
if (!$owner instanceof Product) {
return;
}
$ownerId = (string) $owner->getId();
if ('' === $ownerId) {
return;
}
$fieldName = 'customField:'.$cfv->getCustomField()->getName();
$diff = [$fieldName => ['from' => $from, 'to' => $to]];
$pendingUpdates[$ownerId] = $this->mergeDiffs($pendingUpdates[$ownerId] ?? [], $diff);
$pendingSnapshots[$ownerId] = $this->snapshotProduct($owner);
$pendingProducts[$ownerId] = $owner;
}
private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
{
$uow = $em->getUnitOfWork();
// Ensure identifiers and timestamps are set even when persisting during onFlush.
$log->initializeAuditLog();
$em->persist($log);
$meta = $em->getClassMetadata(AuditLog::class);
$uow->computeChangeSet($meta, $log);
}
/**
* @param array<string, array{0:mixed, 1:mixed}> $changeSet
*
* @return array<string, array{from:mixed, to:mixed}>
*/
private function buildDiffFromChangeSet(array $changeSet): array
{
$diff = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) {
// Skip noisy timestamps managed automatically.
if ('updatedAt' === $field || 'createdAt' === $field) {
continue;
}
$normalizedOld = $this->normalizeValue($oldValue);
$normalizedNew = $this->normalizeValue($newValue);
if ($normalizedOld === $normalizedNew) {
continue;
}
$diff[$field] = [
'from' => $normalizedOld,
'to' => $normalizedNew,
];
}
return $diff;
}
private function snapshotProduct(Product $product): array
{
return [
'id' => $product->getId(),
'name' => $product->getName(),
'reference' => $product->getReference(),
'supplierPrice' => $product->getSupplierPrice(),
'typeProduct' => $this->normalizeValue($product->getTypeProduct()),
'constructeurIds' => $this->normalizeCollection($product->getConstructeurs()),
];
}
/**
* @param array<string, array{from:mixed, to:mixed}> $base
* @param array<string, array{from:mixed, to:mixed}> $extra
*
* @return array<string, array{from:mixed, to:mixed}>
*/
private function mergeDiffs(array $base, array $extra): array
{
foreach ($extra as $field => $change) {
$base[$field] = $change;
}
return $base;
}
/**
* @param iterable<mixed> $items
*
* @return list<array{id: string, name: string}|string>
*/
private function normalizeCollection(iterable $items): array
{
$entries = [];
$seen = [];
foreach ($items as $item) {
if (is_object($item) && method_exists($item, 'getId')) {
$id = $item->getId();
if (null === $id || '' === $id || isset($seen[(string) $id])) {
continue;
}
$seen[(string) $id] = true;
if (method_exists($item, 'getName')) {
$entries[] = ['id' => (string) $id, 'name' => (string) $item->getName()];
} else {
$entries[] = (string) $id;
}
}
}
return $entries;
}
private function normalizeValue(mixed $value): mixed
{
if (null === $value || is_scalar($value)) {
return $value;
}
if ($value instanceof DateTimeInterface) {
return $value->format(DateTimeInterface::ATOM);
}
if ($value instanceof ModelType) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
'code' => $value->getCode(),
];
}
if ($value instanceof Collection) {
return $this->normalizeCollection($value);
}
if (is_object($value) && method_exists($value, 'getId')) {
return (string) $value->getId();
}
if (is_array($value)) {
return $value;
}
return (string) $value;
}
private function resolveActorProfileId(): ?string
{
try {
$session = $this->requestStack->getSession();
if ($session instanceof SessionInterface) {
$profileId = $session->get('profileId');
if ($profileId) {
return (string) $profileId;
}
}
} catch (Throwable) {
// No session available (CLI context, etc.)
}
$user = $this->security->getUser();
if ($user instanceof Profile) {
return $user->getId();
}
return null;
}
}

View File

@@ -0,0 +1,520 @@
<?php
declare(strict_types=1);
namespace App\OpenApi;
use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface;
use ApiPlatform\OpenApi\Model;
use ApiPlatform\OpenApi\OpenApi;
use ArrayObject;
final class OpenApiDecorator implements OpenApiFactoryInterface
{
public function __construct(
private readonly OpenApiFactoryInterface $decorated,
) {}
public function __invoke(array $context = []): OpenApi
{
$openApi = ($this->decorated)($context);
$this->addHealthCheck($openApi);
$this->addSessionRoutes($openApi);
$this->addAdminProfileRoutes($openApi);
$this->addActivityLogs($openApi);
$this->addCommentRoutes($openApi);
$this->addCustomFieldValueRoutes($openApi);
$this->addDocumentQueryRoutes($openApi);
$this->addDocumentServeRoutes($openApi);
$this->addEntityHistoryRoutes($openApi);
$this->addMachineStructureRoutes($openApi);
$this->addMachineCustomFieldsRoutes($openApi);
$this->addModelTypeConversionRoutes($openApi);
return $this->addTagDescriptions($openApi);
}
private function addHealthCheck(OpenApi $openApi): void
{
$openApi->getPaths()->addPath('/api/health', new Model\PathItem(
get: new Model\Operation(
operationId: 'getHealthCheck',
tags: ['Monitoring'],
summary: 'Vérification de santé du système',
description: 'Retourne le statut du système, la version, la latence BDD et la mémoire utilisée.',
responses: [
'200' => $this->jsonResponse('Système opérationnel.'),
'503' => $this->jsonResponse('Système dégradé.'),
],
),
));
}
private function addSessionRoutes(OpenApi $openApi): void
{
$openApi->getPaths()->addPath('/api/session/profiles', new Model\PathItem(
get: new Model\Operation(
operationId: 'getSessionProfiles',
tags: ['Session'],
summary: 'Lister les profils disponibles',
description: 'Retourne les profils actifs (id, prénom, nom, hasPassword). Aucune authentification requise.',
responses: ['200' => $this->jsonResponse('Liste des profils.')],
),
));
$openApi->getPaths()->addPath('/api/session/profile', new Model\PathItem(
get: new Model\Operation(
operationId: 'getSessionProfile',
tags: ['Session'],
summary: 'Profil actif de la session',
description: 'Retourne le profil actuellement connecté via la session.',
responses: [
'200' => $this->jsonResponse('Profil actif.'),
'401' => $this->jsonResponse('Aucun profil actif.'),
],
),
post: new Model\Operation(
operationId: 'loginSessionProfile',
tags: ['Session'],
summary: 'Connexion — activer un profil',
description: 'Active un profil dans la session. Requiert profileId et password.',
requestBody: $this->jsonRequestBody('Identifiants de connexion.'),
responses: [
'200' => $this->jsonResponse('Connexion réussie.'),
'401' => $this->jsonResponse('Mot de passe incorrect.'),
'404' => $this->jsonResponse('Profil introuvable.'),
],
),
delete: new Model\Operation(
operationId: 'logoutSessionProfile',
tags: ['Session'],
summary: 'Déconnexion — invalider la session',
responses: ['200' => $this->jsonResponse('Session invalidée.')],
),
));
}
private function addAdminProfileRoutes(OpenApi $openApi): void
{
$openApi->getPaths()->addPath('/api/admin/profiles', new Model\PathItem(
get: new Model\Operation(
operationId: 'adminListProfiles',
tags: ['Admin — Profils'],
summary: 'Lister tous les profils',
description: 'Liste complète des profils triés par prénom. Requiert ROLE_ADMIN.',
responses: ['200' => $this->jsonResponse('Liste des profils.')],
),
post: new Model\Operation(
operationId: 'adminCreateProfile',
tags: ['Admin — Profils'],
summary: 'Créer un profil',
description: 'Crée un nouveau profil avec rôle. Requiert ROLE_ADMIN.',
requestBody: $this->jsonRequestBody('Données du profil (firstName, lastName, email, password, role).'),
responses: [
'201' => $this->jsonResponse('Profil créé.'),
'400' => $this->jsonResponse('Données invalides.'),
'409' => $this->jsonResponse('Email déjà utilisé.'),
],
),
));
$idParam = $this->pathParam('id', 'Identifiant du profil');
$openApi->getPaths()->addPath('/api/admin/profiles/{id}/role', new Model\PathItem(
put: new Model\Operation(
operationId: 'adminUpdateProfileRole',
tags: ['Admin — Profils'],
summary: 'Modifier le rôle d\'un profil',
description: 'Change le rôle d\'un profil. Empêche la suppression du dernier admin. Requiert ROLE_ADMIN.',
parameters: [$idParam],
requestBody: $this->jsonRequestBody('Nouveau rôle.'),
responses: [
'200' => $this->jsonResponse('Rôle mis à jour.'),
'400' => $this->jsonResponse('Rôle invalide ou dernier admin.'),
],
),
));
$openApi->getPaths()->addPath('/api/admin/profiles/{id}/password', new Model\PathItem(
put: new Model\Operation(
operationId: 'adminUpdateProfilePassword',
tags: ['Admin — Profils'],
summary: 'Modifier le mot de passe d\'un profil',
description: 'Requiert ROLE_ADMIN.',
parameters: [$idParam],
requestBody: $this->jsonRequestBody('Nouveau mot de passe.'),
responses: ['200' => $this->jsonResponse('Mot de passe mis à jour.')],
),
));
$openApi->getPaths()->addPath('/api/admin/profiles/{id}/deactivate', new Model\PathItem(
put: new Model\Operation(
operationId: 'adminDeactivateProfile',
tags: ['Admin — Profils'],
summary: 'Désactiver un profil',
description: 'Désactive un profil. Empêche la désactivation du dernier admin. Requiert ROLE_ADMIN.',
parameters: [$idParam],
responses: [
'200' => $this->jsonResponse('Profil désactivé.'),
'400' => $this->jsonResponse('Dernier admin, impossible de désactiver.'),
],
),
));
}
private function addActivityLogs(OpenApi $openApi): void
{
$openApi->getPaths()->addPath('/api/activity-logs', new Model\PathItem(
get: new Model\Operation(
operationId: 'getActivityLogs',
tags: ['Audit'],
summary: 'Journal d\'activité paginé',
description: 'Retourne les logs d\'audit avec filtrage optionnel par type d\'entité et action. Requiert ROLE_VIEWER.',
parameters: [
$this->queryParam('page', 'Numéro de page'),
$this->queryParam('itemsPerPage', 'Éléments par page'),
$this->queryParam('entityType', 'Filtrer par type d\'entité'),
$this->queryParam('action', 'Filtrer par action'),
],
responses: ['200' => $this->jsonResponse('Logs paginés.')],
),
));
}
private function addCommentRoutes(OpenApi $openApi): void
{
$openApi->getPaths()->addPath('/api/comments', new Model\PathItem(
post: new Model\Operation(
operationId: 'createComment',
tags: ['Commentaires'],
summary: 'Créer un commentaire',
description: 'Ajoute un commentaire à une entité (machine, pièce, composant, produit, catégorie, squelette). Requiert ROLE_VIEWER.',
requestBody: $this->jsonRequestBody('Données du commentaire (content, entityType, entityId, entityName).'),
responses: [
'201' => $this->jsonResponse('Commentaire créé.'),
'400' => $this->jsonResponse('Données invalides.'),
],
),
));
$openApi->getPaths()->addPath('/api/comments/{id}/resolve', new Model\PathItem(
patch: new Model\Operation(
operationId: 'resolveComment',
tags: ['Commentaires'],
summary: 'Résoudre un commentaire',
description: 'Marque un commentaire comme résolu. Requiert ROLE_GESTIONNAIRE.',
parameters: [$this->pathParam('id', 'Identifiant du commentaire')],
responses: [
'200' => $this->jsonResponse('Commentaire résolu.'),
'404' => $this->jsonResponse('Commentaire introuvable.'),
],
),
));
$openApi->getPaths()->addPath('/api/comments/stats/unresolved-count', new Model\PathItem(
get: new Model\Operation(
operationId: 'getUnresolvedCommentCount',
tags: ['Commentaires'],
summary: 'Nombre de commentaires non résolus',
description: 'Requiert ROLE_VIEWER.',
responses: ['200' => $this->jsonResponse('Compteur.')],
),
));
}
private function addCustomFieldValueRoutes(OpenApi $openApi): void
{
$openApi->getPaths()->addPath('/api/custom-fields/values', new Model\PathItem(
post: new Model\Operation(
operationId: 'createCustomFieldValue',
tags: ['Champs personnalisés'],
summary: 'Créer une valeur de champ personnalisé',
description: 'Crée une valeur pour un champ personnalisé sur une entité. Auto-crée le champ si nécessaire. Requiert ROLE_GESTIONNAIRE.',
requestBody: $this->jsonRequestBody('Données (customFieldId/customFieldName, value, entité cible).'),
responses: [
'201' => $this->jsonResponse('Valeur créée.'),
'400' => $this->jsonResponse('Données invalides.'),
],
),
));
$openApi->getPaths()->addPath('/api/custom-fields/values/upsert', new Model\PathItem(
post: new Model\Operation(
operationId: 'upsertCustomFieldValue',
tags: ['Champs personnalisés'],
summary: 'Créer ou mettre à jour une valeur de champ personnalisé',
description: 'Requiert ROLE_GESTIONNAIRE.',
requestBody: $this->jsonRequestBody('Données du champ.'),
responses: ['200' => $this->jsonResponse('Valeur créée ou mise à jour.')],
),
));
$openApi->getPaths()->addPath('/api/custom-fields/values/{entityType}/{entityId}', new Model\PathItem(
get: new Model\Operation(
operationId: 'listCustomFieldValues',
tags: ['Champs personnalisés'],
summary: 'Lister les valeurs de champs personnalisés d\'une entité',
description: 'Requiert ROLE_VIEWER.',
parameters: [
$this->pathParam('entityType', 'Type d\'entité (machine, composant, piece, product)'),
$this->pathParam('entityId', 'Identifiant de l\'entité'),
],
responses: ['200' => $this->jsonResponse('Liste des valeurs.')],
),
));
$idParam = $this->pathParam('id', 'Identifiant de la valeur');
$openApi->getPaths()->addPath('/api/custom-fields/values/{id}', new Model\PathItem(
patch: new Model\Operation(
operationId: 'updateCustomFieldValue',
tags: ['Champs personnalisés'],
summary: 'Modifier une valeur de champ personnalisé',
description: 'Requiert ROLE_GESTIONNAIRE.',
parameters: [$idParam],
requestBody: $this->jsonRequestBody('Nouvelle valeur.'),
responses: ['200' => $this->jsonResponse('Valeur mise à jour.')],
),
delete: new Model\Operation(
operationId: 'deleteCustomFieldValue',
tags: ['Champs personnalisés'],
summary: 'Supprimer une valeur de champ personnalisé',
description: 'Requiert ROLE_GESTIONNAIRE.',
parameters: [$idParam],
responses: ['204' => new Model\Response(description: 'Valeur supprimée.')],
),
));
}
private function addDocumentQueryRoutes(OpenApi $openApi): void
{
$entities = [
'site' => 'un site',
'machine' => 'une machine',
'composant' => 'un composant',
'piece' => 'une pièce',
'product' => 'un produit',
];
foreach ($entities as $entity => $label) {
$openApi->getPaths()->addPath("/api/documents/{$entity}/{id}", new Model\PathItem(
get: new Model\Operation(
operationId: 'getDocumentsBy'.ucfirst($entity),
tags: ['Documents'],
summary: "Documents rattachés à {$label}",
description: 'Requiert ROLE_VIEWER.',
parameters: [$this->pathParam('id', "Identifiant de l'entité")],
responses: ['200' => $this->jsonResponse('Liste des documents.')],
),
));
}
}
private function addDocumentServeRoutes(OpenApi $openApi): void
{
$idParam = $this->pathParam('id', 'Identifiant du document');
$openApi->getPaths()->addPath('/api/documents/{id}/file', new Model\PathItem(
get: new Model\Operation(
operationId: 'serveDocumentFile',
tags: ['Documents'],
summary: 'Afficher un fichier document (inline)',
description: 'Sert le fichier pour affichage dans le navigateur. Requiert ROLE_VIEWER.',
parameters: [$idParam],
responses: ['200' => new Model\Response(description: 'Contenu du fichier.')],
),
));
$openApi->getPaths()->addPath('/api/documents/{id}/download', new Model\PathItem(
get: new Model\Operation(
operationId: 'downloadDocumentFile',
tags: ['Documents'],
summary: 'Télécharger un fichier document',
description: 'Sert le fichier en téléchargement (attachment). Requiert ROLE_VIEWER.',
parameters: [$idParam],
responses: ['200' => new Model\Response(description: 'Fichier en téléchargement.')],
),
));
}
private function addEntityHistoryRoutes(OpenApi $openApi): void
{
$entities = [
'machines' => 'une machine',
'pieces' => 'une pièce',
'composants' => 'un composant',
'products' => 'un produit',
];
foreach ($entities as $plural => $label) {
$openApi->getPaths()->addPath("/api/{$plural}/{id}/history", new Model\PathItem(
get: new Model\Operation(
operationId: 'get'.ucfirst(rtrim($plural, 's')).'History',
tags: ['Audit'],
summary: "Historique d'audit de {$label}",
description: "Retourne les 200 derniers événements d'audit. Requiert ROLE_VIEWER.",
parameters: [$this->pathParam('id', "Identifiant de l'entité")],
responses: ['200' => $this->jsonResponse('Historique paginé.')],
),
));
}
}
private function addMachineStructureRoutes(OpenApi $openApi): void
{
$idParam = $this->pathParam('id', 'Identifiant de la machine');
$openApi->getPaths()->addPath('/api/machines/{id}/structure', new Model\PathItem(
get: new Model\Operation(
operationId: 'getMachineStructure',
tags: ['Machines — Structure'],
summary: 'Structure complète d\'une machine',
description: 'Retourne les composants, pièces et produits avec hiérarchie. Requiert ROLE_VIEWER.',
parameters: [$idParam],
responses: ['200' => $this->jsonResponse('Structure de la machine.')],
),
patch: new Model\Operation(
operationId: 'updateMachineStructure',
tags: ['Machines — Structure'],
summary: 'Modifier la structure d\'une machine',
description: 'Crée, met à jour ou supprime les liens composants/pièces/produits. Requiert ROLE_GESTIONNAIRE.',
parameters: [$idParam],
requestBody: $this->jsonRequestBody('Liens à créer/modifier/supprimer.'),
responses: ['200' => $this->jsonResponse('Structure mise à jour.')],
),
));
$openApi->getPaths()->addPath('/api/machines/{id}/clone', new Model\PathItem(
post: new Model\Operation(
operationId: 'cloneMachine',
tags: ['Machines — Structure'],
summary: 'Cloner une machine',
description: 'Clone une machine avec tous ses composants, pièces, produits, champs personnalisés et constructeurs. Requiert ROLE_GESTIONNAIRE.',
parameters: [$idParam],
requestBody: $this->jsonRequestBody('Données de la copie (name, siteId, reference).'),
responses: [
'201' => $this->jsonResponse('Machine clonée.'),
'404' => $this->jsonResponse('Machine source introuvable.'),
],
),
));
}
private function addMachineCustomFieldsRoutes(OpenApi $openApi): void
{
$openApi->getPaths()->addPath('/api/machines/{id}/add-custom-fields', new Model\PathItem(
post: new Model\Operation(
operationId: 'addMachineCustomFields',
tags: ['Machines — Structure'],
summary: 'Initialiser les champs personnalisés manquants',
description: 'Crée les entrées de valeur manquantes pour les champs personnalisés définis. Requiert ROLE_GESTIONNAIRE.',
parameters: [$this->pathParam('id', 'Identifiant de la machine')],
responses: ['200' => $this->jsonResponse('Champs ajoutés.')],
),
));
}
private function addModelTypeConversionRoutes(OpenApi $openApi): void
{
$idParam = $this->pathParam('id', 'Identifiant du ModelType');
$openApi->getPaths()->addPath('/api/model_types/{id}/conversion-check', new Model\PathItem(
get: new Model\Operation(
operationId: 'checkModelTypeConversion',
tags: ['ModelType'],
summary: 'Vérifier la convertibilité d\'un ModelType',
description: 'Vérifie si la catégorie du ModelType peut être convertie. Requiert ROLE_VIEWER.',
parameters: [$idParam],
responses: ['200' => $this->jsonResponse('Résultat de la vérification.')],
),
));
$openApi->getPaths()->addPath('/api/model_types/{id}/convert', new Model\PathItem(
post: new Model\Operation(
operationId: 'convertModelType',
tags: ['ModelType'],
summary: 'Convertir la catégorie d\'un ModelType',
description: 'Convertit la catégorie. Retourne 409 en cas de conflit. Requiert ROLE_GESTIONNAIRE.',
parameters: [$idParam],
responses: [
'200' => $this->jsonResponse('Conversion effectuée.'),
'409' => $this->jsonResponse('Conflit — conversion impossible.'),
],
),
));
}
private function addTagDescriptions(OpenApi $openApi): OpenApi
{
$customTags = [
'Monitoring' => 'Supervision et vérification de l\'état de santé du système (statut, version, latence BDD, mémoire).',
'Session' => 'Authentification par session. Permet de lister les profils disponibles, se connecter (activer un profil) et se déconnecter.',
'Admin — Profils' => 'Administration des profils utilisateurs. Création, modification des rôles et mots de passe, désactivation. Réservé aux administrateurs.',
'Audit' => 'Journal d\'activité et historique d\'audit. Consultation des modifications apportées aux entités avec détail des changements (diff et snapshot).',
'Commentaires' => 'Système de commentaires et annotations sur les entités. Permet de créer des commentaires, les résoudre et suivre le nombre de commentaires ouverts.',
'Champs personnalisés' => 'Gestion des valeurs de champs personnalisés. Permet d\'ajouter, modifier et supprimer des données dynamiques sur les machines, pièces, composants et produits.',
'Documents' => 'Gestion des fichiers joints. Consultation des documents par entité, affichage inline et téléchargement.',
'Machines — Structure' => 'Structure hiérarchique des machines. Consultation et modification des liaisons composants/pièces/produits, clonage de machines et initialisation des champs personnalisés.',
'ModelType' => 'Conversion de catégories de types. Vérification de compatibilité et conversion effective des catégories de ModelType.',
];
$existingTags = $openApi->getTags();
$existingNames = array_map(static fn (Model\Tag $tag) => $tag->getName(), $existingTags);
foreach ($customTags as $name => $description) {
if (!in_array($name, $existingNames, true)) {
$existingTags[] = new Model\Tag(name: $name, description: $description);
}
}
return $openApi->withTags($existingTags);
}
private function jsonResponse(string $description): Model\Response
{
return new Model\Response(
description: $description,
content: new ArrayObject([
'application/json' => new Model\MediaType(
schema: new ArrayObject(['type' => 'object']),
),
]),
);
}
private function jsonRequestBody(string $description): Model\RequestBody
{
return new Model\RequestBody(
description: $description,
content: new ArrayObject([
'application/json' => new Model\MediaType(
schema: new ArrayObject(['type' => 'object']),
),
]),
required: true,
);
}
private function pathParam(string $name, string $description): Model\Parameter
{
return new Model\Parameter(
name: $name,
in: 'path',
description: $description,
required: true,
schema: ['type' => 'string'],
);
}
private function queryParam(string $name, string $description): Model\Parameter
{
return new Model\Parameter(
name: $name,
in: 'query',
description: $description,
required: false,
schema: ['type' => 'string'],
);
}
}

View File

@@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\TypeMachineComponentRequirement;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<TypeMachineComponentRequirement>
*/
class TypeMachineComponentRequirementRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TypeMachineComponentRequirement::class);
}
}

View File

@@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\TypeMachinePieceRequirement;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<TypeMachinePieceRequirement>
*/
class TypeMachinePieceRequirementRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TypeMachinePieceRequirement::class);
}
}

View File

@@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\TypeMachineProductRequirement;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<TypeMachineProductRequirement>
*/
class TypeMachineProductRequirementRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TypeMachineProductRequirement::class);
}
}

View File

@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\TypeMachine;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<TypeMachine>
*/
class TypeMachineRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TypeMachine::class);
}
public function save(TypeMachine $entity, bool $flush = false): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function remove(TypeMachine $entity, bool $flush = false): void
{
$this->getEntityManager()->remove($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Security;
use App\Entity\Profile;
use App\Repository\ProfileRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
final class SessionProfileAuthenticator extends AbstractAuthenticator
{
public function __construct(
private readonly ProfileRepository $profiles,
) {}
public function supports(Request $request): ?bool
{
if (!$request->hasSession()) {
return false;
}
return $request->getSession()->has('profileId');
}
public function authenticate(Request $request): Passport
{
$profileId = $request->getSession()->get('profileId');
return new SelfValidatingPassport(
new UserBadge($profileId, function (string $id): Profile {
$profile = $this->profiles->find($id);
if (!$profile || !$profile->isActive()) {
throw new CustomUserMessageAuthenticationException('Profil introuvable ou inactif.');
}
return $profile;
})
);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
// Let the request continue normally
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return new JsonResponse(
['message' => $exception->getMessageKey()],
JsonResponse::HTTP_UNAUTHORIZED,
);
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Serializer;
use App\Entity\Document;
use ArrayObject;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use function is_array;
class DocumentNormalizer implements NormalizerInterface, NormalizerAwareInterface
{
use NormalizerAwareTrait;
private const ALREADY_CALLED = 'DOCUMENT_NORMALIZER_ALREADY_CALLED';
/**
* @return null|array<string, mixed>|ArrayObject<int|string, mixed>|bool|float|int|string
*/
public function normalize(mixed $data, ?string $format = null, array $context = []): array|ArrayObject|bool|float|int|string|null
{
$context[self::ALREADY_CALLED] = true;
/** @var null|array<string, mixed> $normalized */
$normalized = $this->normalizer->normalize($data, $format, $context);
if (is_array($normalized) && $data instanceof Document && $data->getId()) {
// Remove raw 'path' if present (never expose it to the client)
unset($normalized['path']);
// Always provide URL-based access
$normalized['fileUrl'] = '/api/documents/'.$data->getId().'/file';
$normalized['downloadUrl'] = '/api/documents/'.$data->getId().'/download';
}
return $normalized;
}
public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
{
if (isset($context[self::ALREADY_CALLED])) {
return false;
}
return $data instanceof Document;
}
/**
* @return array<class-string, bool>
*/
public function getSupportedTypes(?string $format): array
{
return [
Document::class => false,
];
}
}

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Service;
use DateTimeImmutable;
use RuntimeException;
use Symfony\Component\HttpKernel\KernelInterface;
use function dirname;
class DocumentStorageService
{
private readonly string $storageDir;
public function __construct(
private readonly KernelInterface $kernel,
) {
$this->storageDir = $this->kernel->getProjectDir().'/var/storage/documents';
}
public function getStorageDir(): string
{
return $this->storageDir;
}
public function getAbsolutePath(string $relativePath): string
{
$absolutePath = $this->storageDir.'/'.$relativePath;
$realPath = realpath($absolutePath);
if (false !== $realPath && !str_starts_with($realPath, realpath($this->storageDir))) {
throw new RuntimeException(sprintf('Path traversal detected: "%s"', $relativePath));
}
return $absolutePath;
}
/**
* Store binary content and return the relative path.
* Path format: {year}/{month}/{documentId}.{ext}.
*/
public function store(string $content, string $documentId, string $extension): string
{
$now = new DateTimeImmutable();
$subDir = $now->format('Y').'/'.$now->format('m');
$relativePath = $subDir.'/'.$documentId.'.'.$extension;
$absolutePath = $this->storageDir.'/'.$relativePath;
$dir = dirname($absolutePath);
if (!is_dir($dir)) {
if (!mkdir($dir, 0o775, true) && !is_dir($dir)) {
throw new RuntimeException(sprintf('Cannot create directory "%s"', $dir));
}
}
$bytesWritten = file_put_contents($absolutePath, $content);
if (false === $bytesWritten) {
throw new RuntimeException(sprintf('Cannot write file "%s"', $absolutePath));
}
return $relativePath;
}
/**
* Store a file from a given source path (e.g., temp upload).
*/
public function storeFromPath(string $sourcePath, string $documentId, string $extension): string
{
$content = file_get_contents($sourcePath);
if (false === $content) {
throw new RuntimeException(sprintf('Cannot read source file "%s"', $sourcePath));
}
return $this->store($content, $documentId, $extension);
}
public function read(string $relativePath): string
{
$absolutePath = $this->getAbsolutePath($relativePath);
if (!file_exists($absolutePath)) {
throw new RuntimeException(sprintf('File not found: "%s"', $absolutePath));
}
$content = file_get_contents($absolutePath);
if (false === $content) {
throw new RuntimeException(sprintf('Cannot read file "%s"', $absolutePath));
}
return $content;
}
public function delete(string $relativePath): bool
{
$absolutePath = $this->getAbsolutePath($relativePath);
if (!file_exists($absolutePath)) {
return false;
}
return @unlink($absolutePath);
}
public function exists(string $relativePath): bool
{
return file_exists($this->getAbsolutePath($relativePath));
}
public function isBase64DataUri(string $path): bool
{
return str_starts_with($path, 'data:');
}
public function extensionFromMimeType(string $mimeType): string
{
$map = [
'application/pdf' => 'pdf',
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
'image/svg+xml' => 'svg',
'image/bmp' => 'bmp',
'text/plain' => 'txt',
'text/csv' => 'csv',
'application/json' => 'json',
'application/xml' => 'xml',
'application/zip' => 'zip',
'audio/mpeg' => 'mp3',
'audio/ogg' => 'ogg',
'video/mp4' => 'mp4',
'video/webm' => 'webm',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
'application/msword' => 'doc',
'application/vnd.ms-excel' => 'xls',
];
return $map[$mimeType] ?? 'bin';
}
public function extensionFromFilename(string $filename): string
{
$ext = pathinfo($filename, PATHINFO_EXTENSION);
return '' !== $ext ? strtolower($ext) : 'bin';
}
}

View File

@@ -137,16 +137,6 @@ final class ModelTypeCategoryConversionService
$blockers[] = sprintf('%d pièce(s) liée(s) à des machines.', $machineLinked);
}
// Check type machine requirements
$requirementCount = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM type_machine_piece_requirements WHERE typepieceid = :id',
['id' => $modelTypeId],
);
if ($requirementCount > 0) {
$blockers[] = sprintf('Utilisé dans %d modèle(s) de type de machine.', $requirementCount);
}
// Check name collision with existing composants
$collisions = $this->connection->fetchFirstColumn(
'SELECT p.name FROM pieces p
@@ -210,16 +200,6 @@ final class ModelTypeCategoryConversionService
$blockers[] = sprintf('%d composant(s) lié(s) à des machines.', $machineLinked);
}
// Check type machine requirements
$requirementCount = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM type_machine_component_requirements WHERE typecomposantid = :id',
['id' => $modelTypeId],
);
if ($requirementCount > 0) {
$blockers[] = sprintf('Utilisé dans %d modèle(s) de type de machine.', $requirementCount);
}
// Check if any composant has pieces or sub-components in structure
$withStructure = $this->connection->fetchAllAssociative(
'SELECT name, structure FROM composants WHERE typecomposantid = :id AND structure IS NOT NULL',

View File

@@ -6,11 +6,67 @@ namespace App\Service;
class PdfCompressorService
{
private ?bool $qpdfAvailable = null;
/**
* Compress an actual PDF file on disk. Returns metadata or null if no gain.
*
* @return null|array{size: int, originalSize: int, saved: int}
*/
public function compressFile(string $absolutePath): ?array
{
if (!$this->isQpdfAvailable()) {
return null;
}
if (!file_exists($absolutePath)) {
return null;
}
$originalSize = filesize($absolutePath);
if (false === $originalSize) {
return null;
}
$tempOutput = tempnam(sys_get_temp_dir(), 'pdf_out_');
$command = sprintf(
'qpdf --linearize --object-streams=generate %s %s 2>&1',
escapeshellarg($absolutePath),
escapeshellarg($tempOutput)
);
exec($command, $cmdOutput, $returnCode);
if (0 !== $returnCode || !file_exists($tempOutput)) {
@unlink($tempOutput);
return null;
}
$compressedSize = filesize($tempOutput);
if (false === $compressedSize || $compressedSize >= $originalSize) {
@unlink($tempOutput);
return null;
}
if (!rename($tempOutput, $absolutePath)) {
@unlink($tempOutput);
return null;
}
return [
'size' => $compressedSize,
'originalSize' => $originalSize,
'saved' => $originalSize - $compressedSize,
];
}
public function compressBase64Pdf(string $base64Data): ?array
{
// Check if qpdf is available
exec('which qpdf', $qpdfPath, $returnCode);
if (0 !== $returnCode) {
if (!$this->isQpdfAvailable()) {
return null;
}
@@ -70,4 +126,14 @@ class PdfCompressorService
'saved' => $originalSize - $compressedSize,
];
}
private function isQpdfAvailable(): bool
{
if (null === $this->qpdfAvailable) {
exec('which qpdf', $qpdfPath, $returnCode);
$this->qpdfAvailable = 0 === $returnCode;
}
return $this->qpdfAvailable;
}
}

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