Compare commits

...

23 Commits

Author SHA1 Message Date
b5d5ce0d8e Actualiser README.md 2026-03-15 07:15:49 +00:00
Matthieu
03e6c2432b fix(machine) : add addConstructeur/removeConstructeur methods + fix fournisseur display
API Platform silently ignored the constructeurs field on PATCH because
Machine was missing the add/remove methods (unlike Composant, Piece, Product).
Also fixes the read-only fournisseur display overflow in MachineInfoCard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 18:00:09 +01:00
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
92 changed files with 7180 additions and 3561 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 ###

13
.gitignore vendored
View File

@@ -39,8 +39,13 @@ 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

@@ -2,12 +2,22 @@
## [1.8.1] - 2026-03-05
### 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.
### Refactoring
- **Suppression du systeme TypeMachine (squelettes machines)** : les entites `TypeMachine`, `TypeMachineComponentRequirement`, `TypeMachinePieceRequirement`, `TypeMachineProductRequirement` sont supprimees. Les champs personnalises machines sont desormais lies directement a chaque machine (relation `CustomField → Machine`).
- **Suppression des pages squelettes machines** : pages `/machine-skeleton`, `/type/[id]`, `/type/edit/[id]` et tous les composants associes (`TypeEditForm`, `MachineSkeletonSummary`, `MachineCreatePreview`, selectors de requirements).
- **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.
### 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.

View File

@@ -49,13 +49,14 @@ Inventory/ # Backend Symfony (repo principal)
# Docker
make start # Démarrer les containers
make stop # Arrêter
make shell # Shell dans le container PHP
make shell # Shell interactif (nécessite un TTY)
make install # Install complet (composer + npm + build)
# Backend
make test # PHPUnit
docker compose exec php vendor/bin/php-cs-fixer fix # Linter PHP
docker compose exec php php bin/console doctrine:migrations:migrate
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)
@@ -98,7 +99,7 @@ Le frontend est un submodule git. Lors d'un commit frontend :
## Architecture Backend
### Entités Principales
`Machine`, `Piece`, `Composant`, `Product`, `Constructeur`, `Site`, `TypeMachine`, `ModelType`, `CustomField`, `CustomFieldValue`, `Document`, `AuditLog`, `Comment`, `Profile`
`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
@@ -108,6 +109,16 @@ Le frontend est un submodule git. Lors d'un commit frontend :
- **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
@@ -138,13 +149,18 @@ ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
## 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 : `docker compose exec php vendor/bin/php-cs-fixer fix`
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
@@ -161,6 +177,25 @@ Quand les branches `master` et `develop` divergent sur l'un des deux repos, **to
- 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`

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

271
README.md
View File

@@ -1,53 +1,88 @@
# Inventory
# InventoryTEST
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.
## 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 |
|--------|-------------|---------|
| Backend | Symfony + API Platform | 8.0 / 4.2 |
| PHP | PHP | >= 8.4 |
| Base de données | PostgreSQL | 16 |
| Frontend | Nuxt (SPA, SSR off) | 4 |
| UI | Vue 3 Composition API + TypeScript | 3.5 / 5.7 |
| CSS | TailwindCSS + DaisyUI | 4 / 5 |
| Conteneurs | Docker Compose | |
| 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**
- **Node.js** >= 20 (via nvm)
- **make**
- **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)
### Installation de l'environnement
### 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
## Installation rapide
```bash
# 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
```
> Si `make start` échoue sur le port de la BDD, modifier `POSTGRES_PORT` dans `docker/.env.docker.local`.
### Que fait `make install` ?
1. Installe les dépendances PHP (via Composer)
2. Installe les dépendances Node.js (via npm)
3. Build le frontend Nuxt
### Premier lancement
Une fois l'installation terminée, tu peux :
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
## URLs locales
| Service | URL |
|---------|-----|
| API Symfony | http://localhost:8081/api |
| Frontend Nuxt | http://localhost:3001 |
| Adminer (BDD) | http://localhost:5050 |
| PostgreSQL | `localhost:5433` (user: root, pass: root, db: inventory) |
| 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
## Commandes utiles
### Docker
@@ -56,26 +91,28 @@ make install
| `make start` | Démarrer les conteneurs |
| `make stop` | Arrêter les conteneurs |
| `make restart` | Redémarrer les conteneurs |
| `make shell` | Shell bash dans le conteneur PHP |
| `make reset` | Reset complet (supprime volumes, réinstalle) |
| `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 php-cs-fixer-allow-risky` | Formatter le code PHP |
| `make cache-clear` | Vider le cache Symfony |
| `make db-reset` | Reset de la BDD (supprime les données) |
| `make fixtures-load` | Charger les fixtures SQL |
| `make fixtures-dump` | Dumper la BDD dans fixtures/data.sql |
| `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` | Serveur de dev Nuxt |
| `make build-nuxtJS` | Build de production |
| `make dev-nuxt` | Lancer le serveur de dev Nuxt (avec rechargement automatique) |
| `make build-nuxtJS` | Builder le frontend pour la production |
### Release
@@ -85,79 +122,115 @@ make install
Synchronise automatiquement la version dans `VERSION`, `api_platform.yaml` et `nuxt.config.ts`, crée le tag git et pousse les deux repos.
## Architecture
## Architecture globale
### Comment ça marche ?
```
┌──────────────────┐ 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é
```
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/ # 20 entités Doctrine (attributs PHP 8)
│ ├── Controller/ # 16 contrôleurs custom
│ ├── EventSubscriber/ # 9 subscribers (audit onFlush)
│ ├── EventListener/ # Listeners documents (cleanup, compression)
│ ├── Command/ # 3 commandes CLI
│ ├── Service/ # 3 services (stockage, conversion, PDF)
│ ├── State/ # 3 processeurs API Platform
│ ├── Repository/ # 19 repositories Doctrine
── Security/ # Authenticateur session
│ └── Serializer/ # Normalizer custom (Document)
├── config/ # Configuration Symfony
├── migrations/ # 4 migrations Doctrine (SQL PostgreSQL)
│ ├── 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
├── VERSION # Version courante (semver)
└── Inventory_frontend/ # Submodule git (repo séparé)
├── app/pages/ # 36 pages Nuxt (file-based routing)
├── app/components/ # 57 composants Vue
├── app/composables/ # 45 composables
── app/shared/ # Types, utils, validation
├── 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
### Entités principales (les "tables" de la BDD)
| Entité | Description |
|--------|-------------|
| `Machine` | Machines du parc industriel |
| `Composant` | Composants rattachés aux machines |
| `Piece` | Pièces détachées |
| `Product` | Produits (consommables, outillage) |
| `Site` | Sites physiques / usines |
| `Constructeur` | Fournisseurs / fabricants |
| `TypeMachine` | Types de machines avec squelettes de structure |
| `ModelType` | Catégories (pièce, composant, produit) avec champs personnalisés |
| `CustomField` / `CustomFieldValue` | Champs personnalisés extensibles |
| `Document` | Documents uploadés (stockage fichier + compression PDF) |
| `AuditLog` | Journal d'audit (diff + snapshot) |
| `Comment` | Commentaires / tickets sur les fiches |
| `Profile` | Utilisateurs avec rôles |
| 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)" |
### Commandes Symfony
### Structure hiérarchique d'une machine
| Commande | Description |
|----------|-------------|
| `app:compress-pdf` | Compresser les PDFs existants (supporte `--dry-run`) |
| `app:migrate-documents-to-filesystem` | Migrer les documents Base64 vers le système de fichiers |
| `app:init-profile-passwords` | Initialiser mots de passe et rôles en masse |
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 → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
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)
```
- **ADMIN** : accès complet, gestion des profils
- **GESTIONNAIRE** : CRUD sur toutes les entités, résolution des commentaires
- **VIEWER** : lecture seule sur toutes les entités
- **USER** : accès de base
### Authentification
Authentification par **session (cookies)**, pas de JWT. Le profil actif est stocké en session côté serveur.
Authentification par **session (cookies)**, pas de JWT. Le profil actif est stocké en session côté serveur. Concrètement :
### Base de données
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
@@ -171,7 +244,7 @@ PostgreSQL 16 avec les particularités suivantes :
|---------|-------|------|------|
| `web` | PHP 8.4 + Apache + Node | 8081, 3001 | API Symfony + Nuxt dev |
| `db` | PostgreSQL 16 Alpine | 5433 | Base de données |
| `adminer` | Adminer | 5050 | Interface web BDD |
| `adminer` | Adminer | 5050 | Interface web pour explorer la BDD |
## Xdebug
@@ -191,30 +264,42 @@ Configuration PhpStorm / VSCode :
- `develop` : branche principale de dev (cible des PR)
- `feat/xxx`, `fix/xxx`, `refactor/xxx` : branches de travail
### Convention de commit
### Convention de commit (enforced par un hook)
```
<type>(<scope>) : <message>
```
**Espace obligatoire autour du `:`**. Types : `feat`, `fix`, `perf`, `refactor`, `chore`, `docs`, `test`, `style`, `build`, `ci`, `revert`, `wip`.
**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
1. php-cs-fixer sur les fichiers PHP stagés
2. PHPUnit — bloque le commit si les tests échouent
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/`. Workflow :
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
2. Commiter dans le repo principal pour mettre à jour le pointeur du submodule
3. Pousser les deux repos
## Documentation complémentaire
## Documentation détaillée
- [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
- **[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,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 +1 @@
1.8.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,5 +1,6 @@
api_platform:
title: Hello API Platform
title: Inventory API
description: API de gestion d'inventaire industriel — machines, pièces, composants, produits.
version: 1.8.1
defaults:
stateless: false
@@ -7,3 +8,5 @@ api_platform:
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,24 +23,13 @@ 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_public:
pattern: ^/api/session/profiles?$
security: false
api:
pattern: ^/api
stateless: true
stateless: false
custom_authenticators:
- App\Security\SessionProfileAuthenticator
@@ -54,7 +48,7 @@ security:
- { 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 }

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

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

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

@@ -6,35 +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 extends AbstractController
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
{
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');
$component = $this->components->find($id);
if (!$component) {
$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

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

View File

@@ -14,6 +14,7 @@ use App\Entity\MachineProductLink;
use App\Entity\ModelType;
use App\Entity\Piece;
use App\Entity\Product;
use App\Entity\Site;
use App\Repository\ComposantRepository;
use App\Repository\MachineComponentLinkRepository;
use App\Repository\MachinePieceLinkRepository;
@@ -123,7 +124,7 @@ class MachineStructureController extends AbstractController
return $this->json(['success' => false, 'error' => 'name et siteId sont requis.'], 400);
}
$site = $this->entityManager->getRepository(\App\Entity\Site::class)->find($payload['siteId']);
$site = $this->entityManager->getRepository(Site::class)->find($payload['siteId']);
if (!$site) {
return $this->json(['success' => false, 'error' => 'Site introuvable.'], 404);
}
@@ -572,7 +573,7 @@ class MachineStructureController extends AbstractController
'constructeurs' => $this->normalizeConstructeurs($machine->getConstructeurs()),
'customFields' => $this->normalizeCustomFields($machine->getCustomFields()),
'documents' => null,
'customFieldValues' => null,
'customFieldValues' => $this->normalizeCustomFieldValues($machine->getCustomFieldValues()),
];
}
@@ -772,7 +773,7 @@ class MachineStructureController extends AbstractController
if (!$cfv instanceof CustomFieldValue) {
continue;
}
$cf = $cfv->getCustomField();
$cf = $cfv->getCustomField();
$items[] = [
'id' => $cfv->getId(),
'value' => $cfv->getValue(),

View File

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

View File

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

View File

@@ -84,6 +84,7 @@ final class SessionProfileController
return new JsonResponse(['message' => 'Mot de passe incorrect.'], JsonResponse::HTTP_UNAUTHORIZED);
}
$session->migrate(true);
$session->set('profileId', $profile->getId());
$session->set('profileRoles', $profile->getRoles());

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

View File

@@ -12,6 +12,7 @@ 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;
@@ -23,6 +24,7 @@ use Doctrine\ORM\Mapping as ORM;
#[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')"),
@@ -35,6 +37,8 @@ use Doctrine\ORM\Mapping as ORM;
)]
class Comment
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private ?string $id = null;
@@ -75,29 +79,12 @@ class Comment
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updated_at')]
private DateTimeImmutable $updatedAt;
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
}
public function getId(): ?string
{
return $this->id;
}
public function getContent(): string
{
return $this->content;
@@ -217,19 +204,4 @@ class Comment
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

@@ -14,6 +14,7 @@ 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;
@@ -28,6 +29,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[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')"),
@@ -42,6 +44,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
)]
class Composant
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['composant:read', 'document:list'])]
@@ -119,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;
@@ -294,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

@@ -11,6 +11,7 @@ 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;
@@ -18,12 +19,14 @@ 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')"),
@@ -37,12 +40,15 @@ use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
)]
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;
@@ -82,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;
}
@@ -153,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

@@ -11,6 +11,7 @@ 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;
@@ -23,6 +24,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Table(name: 'custom_fields')]
#[ORM\HasLifecycleCallbacks]
#[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')"),
@@ -34,6 +36,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
)]
class CustomField
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
@@ -92,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;
@@ -208,19 +184,4 @@ class CustomField
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

@@ -11,6 +11,7 @@ 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;
@@ -21,6 +22,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Table(name: 'custom_field_values')]
#[ORM\HasLifecycleCallbacks]
#[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')"),
@@ -32,6 +34,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
)]
class CustomFieldValue
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
@@ -70,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;
@@ -171,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

@@ -14,6 +14,7 @@ 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;
@@ -28,6 +29,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[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(
security: "is_granted('ROLE_VIEWER')",
@@ -52,6 +54,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
)]
class Document
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['document:list', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
@@ -66,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')]
@@ -109,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;
@@ -258,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

@@ -11,6 +11,7 @@ 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;
@@ -18,11 +19,13 @@ 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(
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')"),
@@ -34,6 +37,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
)]
class Machine
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['document:list'])]
@@ -51,7 +55,8 @@ class Machine
#[ORM\ManyToOne(targetEntity: Site::class, inversedBy: 'machines')]
#[ORM\JoinColumn(name: 'siteId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Site $site;
#[Assert\NotNull(message: 'Le site est obligatoire.')]
private ?Site $site = null;
/**
* @var Collection<int, Constructeur>
@@ -108,6 +113,9 @@ 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();
@@ -117,36 +125,6 @@ class Machine
$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;
@@ -183,12 +161,12 @@ 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;
@@ -232,6 +210,22 @@ class Machine
return $this->constructeurs;
}
public function addConstructeur(Constructeur $constructeur): static
{
if (!$this->constructeurs->contains($constructeur)) {
$this->constructeurs->add($constructeur);
}
return $this;
}
public function removeConstructeur(Constructeur $constructeur): static
{
$this->constructeurs->removeElement($constructeur);
return $this;
}
/**
* @return Collection<int, MachineComponentLink>
*/
@@ -271,19 +265,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

@@ -11,6 +11,7 @@ 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;
@@ -22,6 +23,7 @@ use Doctrine\ORM\Mapping as ORM;
#[ORM\Table(name: 'machine_component_links')]
#[ORM\HasLifecycleCallbacks]
#[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')"),
@@ -33,6 +35,8 @@ use Doctrine\ORM\Mapping as ORM;
)]
class MachineComponentLink
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private ?string $id = null;
@@ -84,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;
@@ -190,9 +166,4 @@ class MachineComponentLink
return $this;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -11,6 +11,7 @@ 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;
@@ -22,6 +23,7 @@ use Doctrine\ORM\Mapping as ORM;
#[ORM\Table(name: 'machine_piece_links')]
#[ORM\HasLifecycleCallbacks]
#[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')"),
@@ -33,6 +35,8 @@ use Doctrine\ORM\Mapping as ORM;
)]
class MachinePieceLink
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private ?string $id = null;
@@ -72,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;
@@ -176,9 +152,4 @@ class MachinePieceLink
return $this;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -11,6 +11,7 @@ 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;
@@ -22,6 +23,7 @@ use Doctrine\ORM\Mapping as ORM;
#[ORM\Table(name: 'machine_product_links')]
#[ORM\HasLifecycleCallbacks]
#[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')"),
@@ -33,6 +35,8 @@ use Doctrine\ORM\Mapping as ORM;
)]
class MachineProductLink
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private ?string $id = null;
@@ -71,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;
@@ -163,9 +139,4 @@ class MachineProductLink
return $this;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -14,6 +14,7 @@ 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;
@@ -30,6 +31,7 @@ use Symfony\Component\Serializer\Attribute\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')"),
@@ -43,6 +45,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
)]
class ModelType
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['type_machine:read', 'model_type:read', 'product:read', 'composant:read', 'piece:read'])]
@@ -128,6 +132,8 @@ class ModelType
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
$this->composants = new ArrayCollection();
$this->pieces = new ArrayCollection();
$this->products = new ArrayCollection();
@@ -136,36 +142,6 @@ class ModelType
$this->productCustomFields = new ArrayCollection();
}
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new DateTimeImmutable();
}
public function getId(): ?string
{
return $this->id;
}
public function setId(string $id): static
{
$this->id = $id;
return $this;
}
public function getName(): string
{
return $this->name;
@@ -315,21 +291,6 @@ class ModelType
return $this->productCustomFields;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): DateTimeImmutable
{
return $this->updatedAt;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
private function applyStructureForCategory(?array $structure, ModelCategory $category): void
{
if (ModelCategory::COMPONENT === $category) {

View File

@@ -14,6 +14,7 @@ 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;
@@ -30,6 +31,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[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')"),
@@ -44,6 +46,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
)]
class Piece
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['piece:read', 'document:list'])]
@@ -121,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;
@@ -322,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

@@ -14,6 +14,7 @@ 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;
@@ -28,6 +29,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[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')"),
@@ -42,6 +44,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
)]
class Product
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['product:read', 'document:list'])]
@@ -118,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();
@@ -126,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;
@@ -243,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

@@ -25,8 +25,9 @@ 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(security: "is_granted('ROLE_VIEWER')"),
new Get(security: "is_granted('ROLE_ADMIN')"),
new GetCollection(security: "is_granted('ROLE_ADMIN')"),
new Post(
security: "is_granted('ROLE_ADMIN')",
@@ -103,7 +104,7 @@ class Profile implements UserInterface, PasswordAuthenticatedUserInterface
public function __construct()
{
$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();
}

View File

@@ -11,6 +11,7 @@ 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;
@@ -24,6 +25,7 @@ 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(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
@@ -37,6 +39,8 @@ use Symfony\Component\Validator\Constraints as Assert;
)]
class Site
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['document:list'])]
@@ -62,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;
@@ -81,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
@@ -190,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;
}
/**
@@ -259,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

@@ -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,400 +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 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()),
'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 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

@@ -27,7 +27,14 @@ class DocumentStorageService
public function getAbsolutePath(string $relativePath): string
{
return $this->storageDir.'/'.$relativePath;
$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;
}
/**

View File

@@ -6,6 +6,8 @@ namespace App\Service;
class PdfCompressorService
{
private ?bool $qpdfAvailable = null;
/**
* Compress an actual PDF file on disk. Returns metadata or null if no gain.
*
@@ -13,8 +15,7 @@ class PdfCompressorService
*/
public function compressFile(string $absolutePath): ?array
{
exec('which qpdf', $qpdfPath, $returnCode);
if (0 !== $returnCode) {
if (!$this->isQpdfAvailable()) {
return null;
}
@@ -65,9 +66,7 @@ class PdfCompressorService
public function compressBase64Pdf(string $base64Data): ?array
{
// Check if qpdf is available
exec('which qpdf', $qpdfPath, $returnCode);
if (0 !== $returnCode) {
if (!$this->isQpdfAvailable()) {
return null;
}
@@ -127,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;
}
}

View File

@@ -13,6 +13,15 @@
"src/ApiResource/.gitignore"
]
},
"dama/doctrine-test-bundle": {
"version": "8.6",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "8.3",
"ref": "dfc51177476fb39d014ed89944cde53dc3326d23"
}
},
"doctrine/deprecations": {
"version": "1.1",
"recipe": {

View File

@@ -0,0 +1,327 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Entity\Composant;
use App\Entity\Constructeur;
use App\Entity\CustomField;
use App\Entity\CustomFieldValue;
use App\Entity\Machine;
use App\Entity\MachineComponentLink;
use App\Entity\MachinePieceLink;
use App\Entity\MachineProductLink;
use App\Entity\ModelType;
use App\Entity\Piece;
use App\Entity\Product;
use App\Entity\Profile;
use App\Entity\Site;
use App\Enum\ModelCategory;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
abstract class AbstractApiTestCase extends ApiTestCase
{
private const DEFAULT_PASSWORD = 'test1234';
protected function tearDown(): void
{
$this->getEntityManager()->clear();
parent::tearDown();
}
protected function getEntityManager(): EntityManagerInterface
{
return static::getContainer()->get('doctrine')->getManager();
}
protected function getPasswordHasher(): UserPasswordHasherInterface
{
return static::getContainer()->get(UserPasswordHasherInterface::class);
}
// ── Auth helpers ────────────────────────────────────────────────
protected function createAuthenticatedClient(string $role): Client
{
$profile = $this->createProfile(roles: [$role], password: self::DEFAULT_PASSWORD);
$client = static::createClient();
$client->request('POST', '/api/session/profile', [
'json' => [
'profileId' => $profile->getId(),
'password' => self::DEFAULT_PASSWORD,
],
]);
return $client;
}
protected function createViewerClient(): Client
{
return $this->createAuthenticatedClient('ROLE_VIEWER');
}
protected function createGestionnaireClient(): Client
{
return $this->createAuthenticatedClient('ROLE_GESTIONNAIRE');
}
protected function createAdminClient(): Client
{
return $this->createAuthenticatedClient('ROLE_ADMIN');
}
protected function createUnauthenticatedClient(): Client
{
return static::createClient();
}
// ── Factory helpers ─────────────────────────────────────────────
protected function createProfile(
string $firstName = 'Test',
string $lastName = 'User',
?string $email = null,
array $roles = ['ROLE_USER'],
?string $password = null,
bool $isActive = true,
): Profile {
$profile = new Profile();
$profile->setFirstName($firstName);
$profile->setLastName($lastName);
$profile->setEmail($email ?? uniqid('test-', true).'@test.local');
$profile->setRoles($roles);
$profile->setIsActive($isActive);
if (null !== $password) {
$hashed = $this->getPasswordHasher()->hashPassword($profile, $password);
$profile->setPassword($hashed);
}
$em = $this->getEntityManager();
$em->persist($profile);
$em->flush();
return $profile;
}
protected function createSite(string $name = 'Site Test', array $extra = []): Site
{
$site = new Site();
$site->setName($name);
if (isset($extra['contactName'])) {
$site->setContactName($extra['contactName']);
}
if (isset($extra['contactCity'])) {
$site->setContactCity($extra['contactCity']);
}
$em = $this->getEntityManager();
$em->persist($site);
$em->flush();
return $site;
}
protected function createConstructeur(string $name = 'Constructeur Test', ?string $email = null, ?string $phone = null): Constructeur
{
$c = new Constructeur();
$c->setName($name);
$c->setEmail($email);
$c->setPhone($phone);
$em = $this->getEntityManager();
$em->persist($c);
$em->flush();
return $c;
}
protected function createModelType(
string $name = 'ModelType Test',
string $code = 'MT-001',
ModelCategory $category = ModelCategory::COMPONENT,
): ModelType {
$mt = new ModelType();
$mt->setName($name);
$mt->setCode($code);
$mt->setCategory($category);
$em = $this->getEntityManager();
$em->persist($mt);
$em->flush();
return $mt;
}
protected function createMachine(string $name = 'Machine Test', ?Site $site = null, ?string $reference = null): Machine
{
$site ??= $this->createSite();
$machine = new Machine();
$machine->setName($name);
$machine->setSite($site);
if (null !== $reference) {
$machine->setReference($reference);
}
$em = $this->getEntityManager();
$em->persist($machine);
$em->flush();
return $machine;
}
protected function createComposant(string $name = 'Composant Test', ?ModelType $type = null): Composant
{
$c = new Composant();
$c->setName($name);
if (null !== $type) {
$c->setTypeComposant($type);
}
$em = $this->getEntityManager();
$em->persist($c);
$em->flush();
return $c;
}
protected function createPiece(string $name = 'Piece Test', ?string $reference = null, ?ModelType $type = null): Piece
{
$p = new Piece();
$p->setName($name);
if (null !== $reference) {
$p->setReference($reference);
}
if (null !== $type) {
$p->setTypePiece($type);
}
$em = $this->getEntityManager();
$em->persist($p);
$em->flush();
return $p;
}
protected function createProduct(string $name = 'Product Test', ?string $reference = null, ?ModelType $type = null): Product
{
$p = new Product();
$p->setName($name);
if (null !== $reference) {
$p->setReference($reference);
}
if (null !== $type) {
$p->setTypeProduct($type);
}
$em = $this->getEntityManager();
$em->persist($p);
$em->flush();
return $p;
}
protected function createCustomField(
string $name = 'Custom Field',
string $type = 'text',
?Machine $machine = null,
): CustomField {
$cf = new CustomField();
$cf->setName($name);
$cf->setType($type);
if (null !== $machine) {
$cf->setMachine($machine);
}
$em = $this->getEntityManager();
$em->persist($cf);
$em->flush();
return $cf;
}
protected function createCustomFieldValue(
CustomField $customField,
string $value = 'test value',
?Machine $machine = null,
?Composant $composant = null,
): CustomFieldValue {
$cfv = new CustomFieldValue();
$cfv->setValue($value);
$cfv->setCustomField($customField);
if (null !== $machine) {
$cfv->setMachine($machine);
}
if (null !== $composant) {
$cfv->setComposant($composant);
}
$em = $this->getEntityManager();
$em->persist($cfv);
$em->flush();
return $cfv;
}
protected function createMachineComponentLink(Machine $machine, Composant $composant): MachineComponentLink
{
$link = new MachineComponentLink();
$link->setMachine($machine);
$link->setComposant($composant);
$em = $this->getEntityManager();
$em->persist($link);
$em->flush();
return $link;
}
protected function createMachinePieceLink(Machine $machine, Piece $piece, ?MachineComponentLink $parentLink = null): MachinePieceLink
{
$link = new MachinePieceLink();
$link->setMachine($machine);
$link->setPiece($piece);
if (null !== $parentLink) {
$link->setParentLink($parentLink);
}
$em = $this->getEntityManager();
$em->persist($link);
$em->flush();
return $link;
}
protected function createMachineProductLink(Machine $machine, Product $product): MachineProductLink
{
$link = new MachineProductLink();
$link->setMachine($machine);
$link->setProduct($product);
$em = $this->getEntityManager();
$em->persist($link);
$em->flush();
return $link;
}
// ── Assertion helpers ───────────────────────────────────────────
protected function assertJsonContainsHydraCollection(): void
{
$this->assertJsonContains(['@type' => 'Collection']);
}
protected static function iri(string $resource, string $id): string
{
return sprintf('/api/%s/%s', $resource, $id);
}
}

View File

@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace App\Tests\Api\Controller;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class CommentControllerTest extends AbstractApiTestCase
{
public function testCreateComment(): void
{
$machine = $this->createMachine('Machine A');
$client = $this->createViewerClient();
$client->request('POST', '/api/comments', [
'json' => [
'content' => 'Test comment',
'entityType' => 'machine',
'entityId' => $machine->getId(),
'entityName' => 'Machine A',
],
]);
$this->assertResponseStatusCodeSame(201);
$this->assertJsonContains([
'content' => 'Test comment',
'entityType' => 'machine',
'status' => 'open',
]);
}
public function testCreateCommentUnauthenticated(): void
{
$client = $this->createUnauthenticatedClient();
$client->request('POST', '/api/comments', [
'json' => [
'content' => 'No auth',
'entityType' => 'machine',
'entityId' => 'fake-id',
],
]);
$this->assertResponseStatusCodeSame(401);
}
public function testCreateCommentEmptyContent(): void
{
$client = $this->createViewerClient();
$client->request('POST', '/api/comments', [
'json' => [
'content' => '',
'entityType' => 'machine',
'entityId' => 'some-id',
],
]);
$this->assertResponseStatusCodeSame(400);
$this->assertJsonContains(['message' => 'Le contenu est requis.']);
}
public function testCreateCommentInvalidEntityType(): void
{
$client = $this->createViewerClient();
$client->request('POST', '/api/comments', [
'json' => [
'content' => 'Invalid type',
'entityType' => 'invalid',
'entityId' => 'some-id',
],
]);
$this->assertResponseStatusCodeSame(400);
$this->assertJsonContains(['message' => "Type d'entité invalide."]);
}
public function testCreateCommentMissingEntityId(): void
{
$client = $this->createViewerClient();
$client->request('POST', '/api/comments', [
'json' => [
'content' => 'Missing ID',
'entityType' => 'machine',
'entityId' => '',
],
]);
$this->assertResponseStatusCodeSame(400);
}
public function testResolveComment(): void
{
$machine = $this->createMachine('Machine A');
$viewerClient = $this->createViewerClient();
$response = $viewerClient->request('POST', '/api/comments', [
'json' => [
'content' => 'To resolve',
'entityType' => 'machine',
'entityId' => $machine->getId(),
],
]);
$data = $response->toArray();
$commentId = $data['id'];
$gestionnaireClient = $this->createGestionnaireClient();
$gestionnaireClient->request('PATCH', sprintf('/api/comments/%s/resolve', $commentId));
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['status' => 'resolved']);
}
public function testResolveCommentNotFound(): void
{
$client = $this->createGestionnaireClient();
$client->request('PATCH', '/api/comments/nonexistent-id/resolve');
$this->assertResponseStatusCodeSame(404);
}
public function testUnresolvedCount(): void
{
$machine = $this->createMachine('Machine A');
$client = $this->createViewerClient();
$client->request('POST', '/api/comments', [
'json' => [
'content' => 'Open 1',
'entityType' => 'machine',
'entityId' => $machine->getId(),
],
]);
$client->request('POST', '/api/comments', [
'json' => [
'content' => 'Open 2',
'entityType' => 'machine',
'entityId' => $machine->getId(),
],
]);
$client->request('GET', '/api/comments/stats/unresolved-count');
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['count' => 2]);
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Tests\Api\Controller;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class EntityHistoryTest extends AbstractApiTestCase
{
public function testMachineHistoryAfterCreate(): void
{
$machine = $this->createMachine('Machine A');
$client = $this->createViewerClient();
$client->request('GET', sprintf('/api/machines/%s/history', $machine->getId()));
$this->assertResponseIsSuccessful();
$data = $client->getResponse()->toArray();
$this->assertArrayHasKey('items', $data);
$this->assertArrayHasKey('total', $data);
}
public function testMachineHistoryAfterUpdate(): void
{
$machine = $this->createMachine('Machine A');
$gClient = $this->createGestionnaireClient();
$gClient->request('PATCH', self::iri('machines', $machine->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['name' => 'Machine A Updated'],
]);
$this->assertResponseIsSuccessful();
$vClient = $this->createViewerClient();
$vClient->request('GET', sprintf('/api/machines/%s/history', $machine->getId()));
$this->assertResponseIsSuccessful();
$data = $vClient->getResponse()->toArray();
$this->assertGreaterThanOrEqual(1, $data['total']);
}
public function testMachineHistoryNotFound(): void
{
$client = $this->createViewerClient();
$client->request('GET', '/api/machines/nonexistent-id/history');
$this->assertResponseStatusCodeSame(404);
$this->assertJsonContains(['message' => 'Machine introuvable.']);
}
public function testPieceHistory(): void
{
$piece = $this->createPiece('Joint A');
$client = $this->createViewerClient();
$client->request('GET', sprintf('/api/pieces/%s/history', $piece->getId()));
$this->assertResponseIsSuccessful();
}
public function testComposantHistory(): void
{
$composant = $this->createComposant('Pompe A');
$client = $this->createViewerClient();
$client->request('GET', sprintf('/api/composants/%s/history', $composant->getId()));
$this->assertResponseIsSuccessful();
}
public function testProductHistory(): void
{
$product = $this->createProduct('Produit A');
$client = $this->createViewerClient();
$client->request('GET', sprintf('/api/products/%s/history', $product->getId()));
$this->assertResponseIsSuccessful();
}
public function testHistoryUnauthenticated(): void
{
$machine = $this->createMachine('Machine A');
$client = $this->createUnauthenticatedClient();
$client->request('GET', sprintf('/api/machines/%s/history', $machine->getId()));
$this->assertResponseStatusCodeSame(401);
}
}

View File

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Tests\Api\Controller;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class MachineCustomFieldDefinitionTest extends AbstractApiTestCase
{
public function testCreateCustomFieldForMachine(): void
{
$machine = $this->createMachine('Machine CF Create');
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/custom_fields', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Tension',
'type' => 'number',
'machine' => self::iri('machines', $machine->getId()),
],
]);
$this->assertResponseStatusCodeSame(201);
$this->assertJsonContains(['name' => 'Tension', 'type' => 'number']);
// Verify via structure endpoint
$client->request('GET', sprintf('/api/machines/%s/structure', $machine->getId()));
$this->assertResponseIsSuccessful();
$data = $client->getResponse()->toArray();
$this->assertArrayHasKey('customFields', $data['machine']);
$this->assertNotEmpty($data['machine']['customFields'], 'customFields should contain the new field');
$found = false;
foreach ($data['machine']['customFields'] as $cf) {
if ('Tension' === $cf['name'] && 'number' === $cf['type']) {
$found = true;
break;
}
}
$this->assertTrue($found, 'Structure should contain the created custom field "Tension"');
}
public function testUpdateCustomFieldForMachine(): void
{
$machine = $this->createMachine('Machine CF Update');
$cf = $this->createCustomField('OldName', 'text', $machine);
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('custom_fields', $cf->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['name' => 'NewName'],
]);
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['name' => 'NewName']);
// Verify via structure endpoint
$client->request('GET', sprintf('/api/machines/%s/structure', $machine->getId()));
$this->assertResponseIsSuccessful();
$data = $client->getResponse()->toArray();
$this->assertNotEmpty($data['machine']['customFields']);
$names = array_column($data['machine']['customFields'], 'name');
$this->assertContains('NewName', $names, 'Structure should reflect the updated custom field name');
$this->assertNotContains('OldName', $names, 'Old name should no longer appear');
}
public function testDeleteCustomFieldForMachine(): void
{
$machine = $this->createMachine('Machine CF Delete');
$cf = $this->createCustomField('ToDelete', 'text', $machine);
$client = $this->createGestionnaireClient();
$client->request('DELETE', self::iri('custom_fields', $cf->getId()));
$this->assertResponseStatusCodeSame(204);
// Verify via structure endpoint
$client->request('GET', sprintf('/api/machines/%s/structure', $machine->getId()));
$this->assertResponseIsSuccessful();
$data = $client->getResponse()->toArray();
$this->assertSame([], $data['machine']['customFields'], 'customFields should be empty after deletion');
}
public function testAddCustomFieldsEndpointInitializesValues(): void
{
$machine = $this->createMachine('Machine CF Values');
$cf = $this->createCustomField('Voltage', 'number', $machine);
$cf->setDefaultValue('220');
$em = $this->getEntityManager();
$em->persist($cf);
$em->flush();
$client = $this->createGestionnaireClient();
$client->request('POST', sprintf('/api/machines/%s/add-custom-fields', $machine->getId()));
$this->assertResponseIsSuccessful();
$response = $client->getResponse()->toArray();
$this->assertTrue($response['success']);
$this->assertNotEmpty($response['customFieldValues'], 'customFieldValues should not be empty after add-custom-fields');
// Verify via structure endpoint
$client->request('GET', sprintf('/api/machines/%s/structure', $machine->getId()));
$this->assertResponseIsSuccessful();
$data = $client->getResponse()->toArray();
$this->assertNotEmpty($data['machine']['customFieldValues'], 'Structure should contain initialized custom field values');
$cfv = $data['machine']['customFieldValues'][0];
$this->assertSame('220', $cfv['value'], 'Value should match the default value');
$this->assertSame('Voltage', $cfv['customField']['name']);
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Tests\Api\Controller;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class MachineStructureCustomFieldValuesTest extends AbstractApiTestCase
{
public function testStructureIncludesCustomFieldValues(): void
{
$machine = $this->createMachine('Machine CFV');
$cf = $this->createCustomField('Tension', 'number', $machine);
$this->createCustomFieldValue($cf, '220', $machine);
$client = $this->createViewerClient();
$client->request('GET', sprintf('/api/machines/%s/structure', $machine->getId()));
$this->assertResponseIsSuccessful();
$data = $client->getResponse()->toArray();
$this->assertArrayHasKey('machine', $data);
$this->assertArrayHasKey('customFieldValues', $data['machine']);
$this->assertIsArray($data['machine']['customFieldValues']);
$this->assertNotEmpty($data['machine']['customFieldValues'], 'customFieldValues should not be empty');
$cfv = $data['machine']['customFieldValues'][0];
$this->assertSame('220', $cfv['value']);
$this->assertArrayHasKey('customField', $cfv);
$this->assertSame('Tension', $cfv['customField']['name']);
$this->assertSame('number', $cfv['customField']['type']);
}
public function testStructureReturnsEmptyArrayWhenNoCustomFieldValues(): void
{
$machine = $this->createMachine('Machine sans CFV');
$client = $this->createViewerClient();
$client->request('GET', sprintf('/api/machines/%s/structure', $machine->getId()));
$this->assertResponseIsSuccessful();
$data = $client->getResponse()->toArray();
$this->assertArrayHasKey('customFieldValues', $data['machine']);
$this->assertSame([], $data['machine']['customFieldValues']);
}
}

View File

@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Tests\Api\Entity;
use App\Enum\ModelCategory;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class ComposantTest extends AbstractApiTestCase
{
public function testGetCollection(): void
{
$this->createComposant('Pompe A');
$this->createComposant('Pompe B');
$client = $this->createViewerClient();
$client->request('GET', '/api/composants');
$this->assertResponseIsSuccessful();
$this->assertJsonContainsHydraCollection();
$this->assertJsonContains(['totalItems' => 2]);
}
public function testGetItem(): void
{
$c = $this->createComposant('Pompe A');
$client = $this->createViewerClient();
$client->request('GET', self::iri('composants', $c->getId()));
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['name' => 'Pompe A']);
}
public function testPost(): void
{
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/composants', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Nouveau composant',
'reference' => 'REF-COMP-001',
],
]);
$this->assertResponseStatusCodeSame(201);
$this->assertJsonContains(['name' => 'Nouveau composant']);
}
public function testPostWithType(): void
{
$mt = $this->createModelType('Pompe', 'POMPE-001', ModelCategory::COMPONENT);
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/composants', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Composant typé',
'typeComposant' => self::iri('model_types', $mt->getId()),
],
]);
$this->assertResponseStatusCodeSame(201);
}
public function testPut(): void
{
$c = $this->createComposant('Pompe A');
$client = $this->createGestionnaireClient();
$client->request('PUT', self::iri('composants', $c->getId()), [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['name' => 'Pompe A Renommée'],
]);
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['name' => 'Pompe A Renommée']);
}
public function testPatch(): void
{
$c = $this->createComposant('Pompe A');
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('composants', $c->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['prix' => '250.00'],
]);
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['prix' => '250.00']);
}
public function testDelete(): void
{
$c = $this->createComposant('ToDelete');
$client = $this->createGestionnaireClient();
$client->request('DELETE', self::iri('composants', $c->getId()));
$this->assertResponseStatusCodeSame(204);
}
public function testUnauthenticatedAccess(): void
{
$client = $this->createUnauthenticatedClient();
$client->request('GET', '/api/composants');
$this->assertResponseStatusCodeSame(401);
}
public function testViewerCannotWrite(): void
{
$client = $this->createViewerClient();
$client->request('POST', '/api/composants', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['name' => 'Blocked'],
]);
$this->assertResponseStatusCodeSame(403);
}
public function testSearchFilter(): void
{
$this->createComposant('Pompe hydraulique');
$this->createComposant('Vanne de contrôle');
$client = $this->createViewerClient();
$client->request('GET', '/api/composants?name=pompe');
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['totalItems' => 1]);
}
}

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Tests\Api\Entity;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class ConstructeurTest extends AbstractApiTestCase
{
public function testGetCollection(): void
{
$this->createConstructeur('Siemens');
$this->createConstructeur('ABB');
$client = $this->createViewerClient();
$client->request('GET', '/api/constructeurs');
$this->assertResponseIsSuccessful();
$this->assertJsonContainsHydraCollection();
$this->assertJsonContains(['totalItems' => 2]);
}
public function testGetItem(): void
{
$c = $this->createConstructeur('Siemens', 'contact@siemens.com', '+33123456789');
$client = $this->createViewerClient();
$client->request('GET', self::iri('constructeurs', $c->getId()));
$this->assertResponseIsSuccessful();
$this->assertJsonContains([
'name' => 'Siemens',
'email' => 'contact@siemens.com',
'phone' => '+33123456789',
]);
}
public function testPost(): void
{
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/constructeurs', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Schneider',
'email' => 'info@schneider.com',
],
]);
$this->assertResponseStatusCodeSame(201);
$this->assertJsonContains(['name' => 'Schneider']);
}
public function testPut(): void
{
$c = $this->createConstructeur('Siemens');
$client = $this->createGestionnaireClient();
$client->request('PUT', self::iri('constructeurs', $c->getId()), [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Siemens AG',
'email' => 'updated@siemens.com',
],
]);
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['name' => 'Siemens AG']);
}
public function testPatch(): void
{
$c = $this->createConstructeur('Siemens');
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('constructeurs', $c->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['phone' => '+33987654321'],
]);
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['phone' => '+33987654321']);
}
public function testDelete(): void
{
$c = $this->createConstructeur('ToDelete');
$client = $this->createGestionnaireClient();
$client->request('DELETE', self::iri('constructeurs', $c->getId()));
$this->assertResponseStatusCodeSame(204);
}
public function testUnauthenticatedAccess(): void
{
$client = $this->createUnauthenticatedClient();
$client->request('GET', '/api/constructeurs');
$this->assertResponseStatusCodeSame(401);
}
public function testViewerCannotWrite(): void
{
$client = $this->createViewerClient();
$client->request('POST', '/api/constructeurs', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['name' => 'Blocked'],
]);
$this->assertResponseStatusCodeSame(403);
}
public function testUniqueNameConstraint(): void
{
$this->createConstructeur('Siemens');
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/constructeurs', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['name' => 'Siemens'],
]);
$this->assertResponseStatusCodeSame(422);
}
}

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Tests\Api\Entity;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class CustomFieldTest extends AbstractApiTestCase
{
public function testGetCollection(): void
{
$machine = $this->createMachine('Machine A');
$this->createCustomField('Tension', 'number', $machine);
$this->createCustomField('Couleur', 'text', $machine);
$client = $this->createViewerClient();
$client->request('GET', '/api/custom_fields');
$this->assertResponseIsSuccessful();
$this->assertJsonContainsHydraCollection();
$this->assertJsonContains(['totalItems' => 2]);
}
public function testGetItem(): void
{
$machine = $this->createMachine('Machine A');
$cf = $this->createCustomField('Tension', 'number', $machine);
$client = $this->createViewerClient();
$client->request('GET', self::iri('custom_fields', $cf->getId()));
$this->assertResponseIsSuccessful();
$this->assertJsonContains([
'name' => 'Tension',
'type' => 'number',
]);
}
public function testPost(): void
{
$machine = $this->createMachine('Machine A');
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/custom_fields', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Pression',
'type' => 'number',
'machine' => self::iri('machines', $machine->getId()),
],
]);
$this->assertResponseStatusCodeSame(201);
$this->assertJsonContains(['name' => 'Pression']);
}
public function testPatch(): void
{
$machine = $this->createMachine('Machine A');
$cf = $this->createCustomField('Tension', 'number', $machine);
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('custom_fields', $cf->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['required' => true],
]);
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['required' => true]);
}
public function testDelete(): void
{
$machine = $this->createMachine('Machine A');
$cf = $this->createCustomField('ToDelete', 'text', $machine);
$client = $this->createGestionnaireClient();
$client->request('DELETE', self::iri('custom_fields', $cf->getId()));
$this->assertResponseStatusCodeSame(204);
}
public function testUnauthenticatedAccess(): void
{
$client = $this->createUnauthenticatedClient();
$client->request('GET', '/api/custom_fields');
$this->assertResponseStatusCodeSame(401);
}
public function testViewerCannotWrite(): void
{
$client = $this->createViewerClient();
$client->request('POST', '/api/custom_fields', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Blocked',
'type' => 'text',
],
]);
$this->assertResponseStatusCodeSame(403);
}
}

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Tests\Api\Entity;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class CustomFieldValueTest extends AbstractApiTestCase
{
public function testGetCollection(): void
{
$machine = $this->createMachine('Machine A');
$cf = $this->createCustomField('Tension', 'number', $machine);
$this->createCustomFieldValue($cf, '220', $machine);
$client = $this->createViewerClient();
$client->request('GET', '/api/custom_field_values');
$this->assertResponseIsSuccessful();
$this->assertJsonContainsHydraCollection();
$this->assertJsonContains(['totalItems' => 1]);
}
public function testGetItem(): void
{
$machine = $this->createMachine('Machine A');
$cf = $this->createCustomField('Tension', 'number', $machine);
$cfv = $this->createCustomFieldValue($cf, '220', $machine);
$client = $this->createViewerClient();
$client->request('GET', self::iri('custom_field_values', $cfv->getId()));
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['value' => '220']);
}
public function testPost(): void
{
$machine = $this->createMachine('Machine A');
$cf = $this->createCustomField('Tension', 'number', $machine);
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/custom_field_values', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'value' => '380',
'customField' => self::iri('custom_fields', $cf->getId()),
'machine' => self::iri('machines', $machine->getId()),
],
]);
$this->assertResponseStatusCodeSame(201);
$this->assertJsonContains(['value' => '380']);
}
public function testPatch(): void
{
$machine = $this->createMachine('Machine A');
$cf = $this->createCustomField('Tension', 'number', $machine);
$cfv = $this->createCustomFieldValue($cf, '220', $machine);
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('custom_field_values', $cfv->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['value' => '400'],
]);
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['value' => '400']);
}
public function testDelete(): void
{
$machine = $this->createMachine('Machine A');
$cf = $this->createCustomField('ToDelete', 'text', $machine);
$cfv = $this->createCustomFieldValue($cf, 'val', $machine);
$client = $this->createGestionnaireClient();
$client->request('DELETE', self::iri('custom_field_values', $cfv->getId()));
$this->assertResponseStatusCodeSame(204);
}
public function testUnauthenticatedAccess(): void
{
$client = $this->createUnauthenticatedClient();
$client->request('GET', '/api/custom_field_values');
$this->assertResponseStatusCodeSame(401);
}
public function testViewerCannotWrite(): void
{
$machine = $this->createMachine('Machine A');
$cf = $this->createCustomField('Blocked', 'text', $machine);
$client = $this->createViewerClient();
$client->request('POST', '/api/custom_field_values', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'value' => 'nope',
'customField' => self::iri('custom_fields', $cf->getId()),
],
]);
$this->assertResponseStatusCodeSame(403);
}
}

View File

@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace App\Tests\Api\Entity;
use App\Entity\Document;
use App\Entity\Machine;
use App\Entity\Site;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class DocumentTest extends AbstractApiTestCase
{
public function testGetCollection(): void
{
$this->createDocumentInDb();
$client = $this->createViewerClient();
$client->request('GET', '/api/documents');
$this->assertResponseIsSuccessful();
$this->assertJsonContainsHydraCollection();
$this->assertJsonContains(['totalItems' => 1]);
}
public function testGetItem(): void
{
$doc = $this->createDocumentInDb();
$client = $this->createViewerClient();
$client->request('GET', self::iri('documents', $doc->getId()));
$this->assertResponseIsSuccessful();
$this->assertJsonContains([
'name' => 'test-doc',
'filename' => 'test.pdf',
'mimeType' => 'application/pdf',
'size' => 1024,
]);
}
public function testPut(): void
{
$doc = $this->createDocumentInDb();
$client = $this->createGestionnaireClient();
$client->request('PUT', self::iri('documents', $doc->getId()), [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'renamed-doc',
'filename' => 'test.pdf',
'path' => '/uploads/test.pdf',
'mimeType' => 'application/pdf',
'size' => 1024,
],
]);
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['name' => 'renamed-doc']);
}
public function testDelete(): void
{
$doc = $this->createDocumentInDb();
$client = $this->createGestionnaireClient();
$client->request('DELETE', self::iri('documents', $doc->getId()));
$this->assertResponseStatusCodeSame(204);
}
public function testUnauthenticatedAccess(): void
{
$client = $this->createUnauthenticatedClient();
$client->request('GET', '/api/documents');
$this->assertResponseStatusCodeSame(401);
}
public function testViewerCannotDelete(): void
{
$doc = $this->createDocumentInDb();
$client = $this->createViewerClient();
$client->request('DELETE', self::iri('documents', $doc->getId()));
$this->assertResponseStatusCodeSame(403);
}
public function testDocumentLinkedToMachine(): void
{
$machine = $this->createMachine('Machine A');
$doc = $this->createDocumentInDb($machine->getId());
$client = $this->createViewerClient();
$client->request('GET', '/api/documents?machine[exists]=true');
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['totalItems' => 1]);
}
public function testDocumentLinkedToSite(): void
{
$site = $this->createSite('Site A');
$doc = $this->createDocumentInDb(siteId: $site->getId());
$client = $this->createViewerClient();
$client->request('GET', '/api/documents?site[exists]=true');
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['totalItems' => 1]);
}
private function createDocumentInDb(?string $machineId = null, ?string $siteId = null): Document
{
$doc = new Document();
$doc->setName('test-doc');
$doc->setFilename('test.pdf');
$doc->setPath('/uploads/test.pdf');
$doc->setMimeType('application/pdf');
$doc->setSize(1024);
if ($machineId) {
$machine = $this->getEntityManager()->getRepository(Machine::class)->find($machineId);
if ($machine) {
$doc->setMachine($machine);
}
}
if ($siteId) {
$site = $this->getEntityManager()->getRepository(Site::class)->find($siteId);
if ($site) {
$doc->setSite($site);
}
}
$em = $this->getEntityManager();
$em->persist($doc);
$em->flush();
return $doc;
}
}

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Tests\Api\Entity;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class MachineComponentLinkTest extends AbstractApiTestCase
{
public function testGetCollection(): void
{
$machine = $this->createMachine('Machine A');
$composant = $this->createComposant('Pompe A');
$this->createMachineComponentLink($machine, $composant);
$client = $this->createViewerClient();
$client->request('GET', '/api/machine_component_links');
$this->assertResponseIsSuccessful();
$this->assertJsonContainsHydraCollection();
$this->assertJsonContains(['totalItems' => 1]);
}
public function testGetItem(): void
{
$machine = $this->createMachine('Machine A');
$composant = $this->createComposant('Pompe A');
$link = $this->createMachineComponentLink($machine, $composant);
$client = $this->createViewerClient();
$client->request('GET', self::iri('machine_component_links', $link->getId()));
$this->assertResponseIsSuccessful();
}
public function testPost(): void
{
$machine = $this->createMachine('Machine A');
$composant = $this->createComposant('Pompe A');
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/machine_component_links', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'machine' => self::iri('machines', $machine->getId()),
'composant' => self::iri('composants', $composant->getId()),
],
]);
$this->assertResponseStatusCodeSame(201);
}
public function testPostWithOverrides(): void
{
$machine = $this->createMachine('Machine A');
$composant = $this->createComposant('Pompe A');
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/machine_component_links', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'machine' => self::iri('machines', $machine->getId()),
'composant' => self::iri('composants', $composant->getId()),
'nameOverride' => 'Pompe spéciale',
'referenceOverride' => 'REF-OVERRIDE',
'prixOverride' => '500.00',
],
]);
$this->assertResponseStatusCodeSame(201);
$this->assertJsonContains([
'nameOverride' => 'Pompe spéciale',
'referenceOverride' => 'REF-OVERRIDE',
'prixOverride' => '500.00',
]);
}
public function testPatch(): void
{
$machine = $this->createMachine('Machine A');
$composant = $this->createComposant('Pompe A');
$link = $this->createMachineComponentLink($machine, $composant);
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('machine_component_links', $link->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['nameOverride' => 'Overridden'],
]);
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['nameOverride' => 'Overridden']);
}
public function testDelete(): void
{
$machine = $this->createMachine('Machine A');
$composant = $this->createComposant('Pompe A');
$link = $this->createMachineComponentLink($machine, $composant);
$client = $this->createGestionnaireClient();
$client->request('DELETE', self::iri('machine_component_links', $link->getId()));
$this->assertResponseStatusCodeSame(204);
}
public function testUnauthenticatedAccess(): void
{
$client = $this->createUnauthenticatedClient();
$client->request('GET', '/api/machine_component_links');
$this->assertResponseStatusCodeSame(401);
}
public function testViewerCannotWrite(): void
{
$machine = $this->createMachine('Machine A');
$composant = $this->createComposant('Pompe A');
$client = $this->createViewerClient();
$client->request('POST', '/api/machine_component_links', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'machine' => self::iri('machines', $machine->getId()),
'composant' => self::iri('composants', $composant->getId()),
],
]);
$this->assertResponseStatusCodeSame(403);
}
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Tests\Api\Entity;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class MachinePieceLinkTest extends AbstractApiTestCase
{
public function testGetCollection(): void
{
$machine = $this->createMachine('Machine A');
$piece = $this->createPiece('Joint A');
$this->createMachinePieceLink($machine, $piece);
$client = $this->createViewerClient();
$client->request('GET', '/api/machine_piece_links');
$this->assertResponseIsSuccessful();
$this->assertJsonContainsHydraCollection();
$this->assertJsonContains(['totalItems' => 1]);
}
public function testPost(): void
{
$machine = $this->createMachine('Machine A');
$piece = $this->createPiece('Joint A');
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/machine_piece_links', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'machine' => self::iri('machines', $machine->getId()),
'piece' => self::iri('pieces', $piece->getId()),
],
]);
$this->assertResponseStatusCodeSame(201);
}
public function testPostWithParentComponentLink(): void
{
$machine = $this->createMachine('Machine A');
$composant = $this->createComposant('Pompe A');
$piece = $this->createPiece('Joint A');
$compLink = $this->createMachineComponentLink($machine, $composant);
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/machine_piece_links', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'machine' => self::iri('machines', $machine->getId()),
'piece' => self::iri('pieces', $piece->getId()),
'parentLink' => self::iri('machine_component_links', $compLink->getId()),
],
]);
$this->assertResponseStatusCodeSame(201);
}
public function testPostWithOverrides(): void
{
$machine = $this->createMachine('Machine A');
$piece = $this->createPiece('Joint A');
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/machine_piece_links', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'machine' => self::iri('machines', $machine->getId()),
'piece' => self::iri('pieces', $piece->getId()),
'nameOverride' => 'Joint spécial',
'prixOverride' => '25.00',
],
]);
$this->assertResponseStatusCodeSame(201);
$this->assertJsonContains([
'nameOverride' => 'Joint spécial',
'prixOverride' => '25.00',
]);
}
public function testDelete(): void
{
$machine = $this->createMachine('Machine A');
$piece = $this->createPiece('Joint A');
$link = $this->createMachinePieceLink($machine, $piece);
$client = $this->createGestionnaireClient();
$client->request('DELETE', self::iri('machine_piece_links', $link->getId()));
$this->assertResponseStatusCodeSame(204);
}
public function testUnauthenticatedAccess(): void
{
$client = $this->createUnauthenticatedClient();
$client->request('GET', '/api/machine_piece_links');
$this->assertResponseStatusCodeSame(401);
}
public function testViewerCannotWrite(): void
{
$machine = $this->createMachine('Machine A');
$piece = $this->createPiece('Joint A');
$client = $this->createViewerClient();
$client->request('POST', '/api/machine_piece_links', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'machine' => self::iri('machines', $machine->getId()),
'piece' => self::iri('pieces', $piece->getId()),
],
]);
$this->assertResponseStatusCodeSame(403);
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Tests\Api\Entity;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class MachineProductLinkTest extends AbstractApiTestCase
{
public function testGetCollection(): void
{
$machine = $this->createMachine('Machine A');
$product = $this->createProduct('Produit A');
$this->createMachineProductLink($machine, $product);
$client = $this->createViewerClient();
$client->request('GET', '/api/machine_product_links');
$this->assertResponseIsSuccessful();
$this->assertJsonContainsHydraCollection();
$this->assertJsonContains(['totalItems' => 1]);
}
public function testPost(): void
{
$machine = $this->createMachine('Machine A');
$product = $this->createProduct('Produit A');
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/machine_product_links', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'machine' => self::iri('machines', $machine->getId()),
'product' => self::iri('products', $product->getId()),
],
]);
$this->assertResponseStatusCodeSame(201);
}
public function testPostWithParentComponentLink(): void
{
$machine = $this->createMachine('Machine A');
$composant = $this->createComposant('Pompe');
$product = $this->createProduct('Produit A');
$compLink = $this->createMachineComponentLink($machine, $composant);
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/machine_product_links', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'machine' => self::iri('machines', $machine->getId()),
'product' => self::iri('products', $product->getId()),
'parentComponentLink' => self::iri('machine_component_links', $compLink->getId()),
],
]);
$this->assertResponseStatusCodeSame(201);
}
public function testPostWithParentPieceLink(): void
{
$machine = $this->createMachine('Machine A');
$piece = $this->createPiece('Joint');
$product = $this->createProduct('Produit A');
$plLink = $this->createMachinePieceLink($machine, $piece);
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/machine_product_links', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'machine' => self::iri('machines', $machine->getId()),
'product' => self::iri('products', $product->getId()),
'parentPieceLink' => self::iri('machine_piece_links', $plLink->getId()),
],
]);
$this->assertResponseStatusCodeSame(201);
}
public function testDelete(): void
{
$machine = $this->createMachine('Machine A');
$product = $this->createProduct('Produit A');
$link = $this->createMachineProductLink($machine, $product);
$client = $this->createGestionnaireClient();
$client->request('DELETE', self::iri('machine_product_links', $link->getId()));
$this->assertResponseStatusCodeSame(204);
}
public function testUnauthenticatedAccess(): void
{
$client = $this->createUnauthenticatedClient();
$client->request('GET', '/api/machine_product_links');
$this->assertResponseStatusCodeSame(401);
}
public function testViewerCannotWrite(): void
{
$machine = $this->createMachine('Machine A');
$product = $this->createProduct('Produit A');
$client = $this->createViewerClient();
$client->request('POST', '/api/machine_product_links', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'machine' => self::iri('machines', $machine->getId()),
'product' => self::iri('products', $product->getId()),
],
]);
$this->assertResponseStatusCodeSame(403);
}
}

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Tests\Api\Entity;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class MachineTest extends AbstractApiTestCase
{
public function testGetCollection(): void
{
$site = $this->createSite();
$this->createMachine('Machine A', $site);
$this->createMachine('Machine B', $site);
$client = $this->createViewerClient();
$client->request('GET', '/api/machines');
$this->assertResponseIsSuccessful();
$this->assertJsonContainsHydraCollection();
$this->assertJsonContains(['totalItems' => 2]);
}
public function testGetItem(): void
{
$m = $this->createMachine('Machine A', reference: 'REF-001');
$client = $this->createViewerClient();
$client->request('GET', self::iri('machines', $m->getId()));
$this->assertResponseIsSuccessful();
$this->assertJsonContains([
'name' => 'Machine A',
'reference' => 'REF-001',
]);
}
public function testPost(): void
{
$site = $this->createSite('Usine');
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/machines', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Nouvelle Machine',
'reference' => 'REF-NEW',
'site' => self::iri('sites', $site->getId()),
],
]);
$this->assertResponseStatusCodeSame(201);
$this->assertJsonContains(['name' => 'Nouvelle Machine']);
}
public function testPut(): void
{
$m = $this->createMachine('Machine A');
$client = $this->createGestionnaireClient();
$client->request('PUT', self::iri('machines', $m->getId()), [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Machine A Renommée',
'site' => self::iri('sites', $m->getSite()->getId()),
],
]);
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['name' => 'Machine A Renommée']);
}
public function testPatch(): void
{
$m = $this->createMachine('Machine A');
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('machines', $m->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['prix' => '1500.00'],
]);
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['prix' => '1500.00']);
}
public function testDelete(): void
{
$m = $this->createMachine('ToDelete');
$client = $this->createGestionnaireClient();
$client->request('DELETE', self::iri('machines', $m->getId()));
$this->assertResponseStatusCodeSame(204);
}
public function testUnauthenticatedAccess(): void
{
$client = $this->createUnauthenticatedClient();
$client->request('GET', '/api/machines');
$this->assertResponseStatusCodeSame(401);
}
public function testViewerCannotWrite(): void
{
$site = $this->createSite();
$client = $this->createViewerClient();
$client->request('POST', '/api/machines', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Blocked',
'site' => self::iri('sites', $site->getId()),
],
]);
$this->assertResponseStatusCodeSame(403);
}
public function testPostWithoutSiteFails(): void
{
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/machines', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['name' => 'No site'],
]);
$this->assertResponseStatusCodeSame(422);
}
}

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace App\Tests\Api\Entity;
use App\Enum\ModelCategory;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class ModelTypeTest extends AbstractApiTestCase
{
public function testGetCollection(): void
{
$this->createModelType('Pompe', 'POMPE-001', ModelCategory::COMPONENT);
$this->createModelType('Joint', 'JOINT-001', ModelCategory::PIECE);
$client = $this->createViewerClient();
$client->request('GET', '/api/model_types');
$this->assertResponseIsSuccessful();
$this->assertJsonContainsHydraCollection();
$this->assertJsonContains(['totalItems' => 2]);
}
public function testGetItem(): void
{
$mt = $this->createModelType('Pompe', 'POMPE-001', ModelCategory::COMPONENT);
$client = $this->createViewerClient();
$client->request('GET', self::iri('model_types', $mt->getId()));
$this->assertResponseIsSuccessful();
$this->assertJsonContains([
'name' => 'Pompe',
'code' => 'POMPE-001',
'category' => 'COMPONENT',
]);
}
public function testPost(): void
{
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/model_types', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Roulement',
'code' => 'ROUL-001',
'category' => 'PIECE',
],
]);
$this->assertResponseStatusCodeSame(201);
$this->assertJsonContains([
'name' => 'Roulement',
'category' => 'PIECE',
]);
}
public function testPut(): void
{
$mt = $this->createModelType('Pompe', 'POMPE-001', ModelCategory::COMPONENT);
$client = $this->createGestionnaireClient();
$client->request('PUT', self::iri('model_types', $mt->getId()), [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Pompe hydraulique',
'code' => 'POMPE-001',
'category' => 'COMPONENT',
],
]);
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['name' => 'Pompe hydraulique']);
}
public function testPatch(): void
{
$mt = $this->createModelType('Pompe', 'POMPE-001', ModelCategory::COMPONENT);
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('model_types', $mt->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['notes' => 'Updated notes'],
]);
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['notes' => 'Updated notes']);
}
public function testDelete(): void
{
$mt = $this->createModelType('ToDelete', 'DEL-001', ModelCategory::PRODUCT);
$client = $this->createGestionnaireClient();
$client->request('DELETE', self::iri('model_types', $mt->getId()));
$this->assertResponseStatusCodeSame(204);
}
public function testUnauthenticatedAccess(): void
{
$client = $this->createUnauthenticatedClient();
$client->request('GET', '/api/model_types');
$this->assertResponseStatusCodeSame(401);
}
public function testViewerCannotWrite(): void
{
$client = $this->createViewerClient();
$client->request('POST', '/api/model_types', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Blocked',
'code' => 'BLK-001',
'category' => 'COMPONENT',
],
]);
$this->assertResponseStatusCodeSame(403);
}
public function testFilterByCategory(): void
{
$this->createModelType('Pompe', 'POMPE-001', ModelCategory::COMPONENT);
$this->createModelType('Joint', 'JOINT-001', ModelCategory::PIECE);
$client = $this->createViewerClient();
$client->request('GET', '/api/model_types?category=COMPONENT');
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['totalItems' => 1]);
}
public function testUniqueCodeConstraint(): void
{
$this->createModelType('Pompe', 'POMPE-001', ModelCategory::COMPONENT);
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/model_types', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Autre',
'code' => 'POMPE-001',
'category' => 'PIECE',
],
]);
$this->assertResponseStatusCodeSame(409);
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Tests\Api\Entity;
use App\Enum\ModelCategory;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class PieceTest extends AbstractApiTestCase
{
public function testGetCollection(): void
{
$this->createPiece('Joint A');
$this->createPiece('Joint B');
$client = $this->createViewerClient();
$client->request('GET', '/api/pieces');
$this->assertResponseIsSuccessful();
$this->assertJsonContainsHydraCollection();
$this->assertJsonContains(['totalItems' => 2]);
}
public function testGetItem(): void
{
$p = $this->createPiece('Joint A', 'REF-J001');
$client = $this->createViewerClient();
$client->request('GET', self::iri('pieces', $p->getId()));
$this->assertResponseIsSuccessful();
$this->assertJsonContains([
'name' => 'Joint A',
'reference' => 'REF-J001',
]);
}
public function testPost(): void
{
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/pieces', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Nouvelle pièce',
'reference' => 'REF-NEW',
],
]);
$this->assertResponseStatusCodeSame(201);
$this->assertJsonContains(['name' => 'Nouvelle pièce']);
}
public function testPostWithType(): void
{
$mt = $this->createModelType('Joint', 'JOINT-001', ModelCategory::PIECE);
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/pieces', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Pièce typée',
'typePiece' => self::iri('model_types', $mt->getId()),
],
]);
$this->assertResponseStatusCodeSame(201);
}
public function testPut(): void
{
$p = $this->createPiece('Joint A');
$client = $this->createGestionnaireClient();
$client->request('PUT', self::iri('pieces', $p->getId()), [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['name' => 'Joint A Renommé'],
]);
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['name' => 'Joint A Renommé']);
}
public function testPatch(): void
{
$p = $this->createPiece('Joint A');
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('pieces', $p->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['prix' => '15.50'],
]);
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['prix' => '15.50']);
}
public function testDelete(): void
{
$p = $this->createPiece('ToDelete');
$client = $this->createGestionnaireClient();
$client->request('DELETE', self::iri('pieces', $p->getId()));
$this->assertResponseStatusCodeSame(204);
}
public function testUnauthenticatedAccess(): void
{
$client = $this->createUnauthenticatedClient();
$client->request('GET', '/api/pieces');
$this->assertResponseStatusCodeSame(401);
}
public function testViewerCannotWrite(): void
{
$client = $this->createViewerClient();
$client->request('POST', '/api/pieces', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['name' => 'Blocked'],
]);
$this->assertResponseStatusCodeSame(403);
}
public function testUniqueReferenceConstraint(): void
{
$this->createPiece('Joint A', 'REF-UNIQUE');
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/pieces', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Joint B',
'reference' => 'REF-UNIQUE',
],
]);
$this->assertResponseStatusCodeSame(422);
}
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Tests\Api\Entity;
use App\Enum\ModelCategory;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class ProductTest extends AbstractApiTestCase
{
public function testGetCollection(): void
{
$this->createProduct('Produit A');
$this->createProduct('Produit B');
$client = $this->createViewerClient();
$client->request('GET', '/api/products');
$this->assertResponseIsSuccessful();
$this->assertJsonContainsHydraCollection();
$this->assertJsonContains(['totalItems' => 2]);
}
public function testGetItem(): void
{
$p = $this->createProduct('Produit A', 'REF-P001');
$client = $this->createViewerClient();
$client->request('GET', self::iri('products', $p->getId()));
$this->assertResponseIsSuccessful();
$this->assertJsonContains([
'name' => 'Produit A',
'reference' => 'REF-P001',
]);
}
public function testPost(): void
{
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/products', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Nouveau produit',
'reference' => 'REF-NEW',
'supplierPrice' => '99.99',
],
]);
$this->assertResponseStatusCodeSame(201);
$this->assertJsonContains(['name' => 'Nouveau produit']);
}
public function testPostWithType(): void
{
$mt = $this->createModelType('Huile', 'HUILE-001', ModelCategory::PRODUCT);
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/products', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Produit typé',
'typeProduct' => self::iri('model_types', $mt->getId()),
],
]);
$this->assertResponseStatusCodeSame(201);
}
public function testPut(): void
{
$p = $this->createProduct('Produit A');
$client = $this->createGestionnaireClient();
$client->request('PUT', self::iri('products', $p->getId()), [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['name' => 'Produit A Renommé'],
]);
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['name' => 'Produit A Renommé']);
}
public function testPatch(): void
{
$p = $this->createProduct('Produit A');
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('products', $p->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['supplierPrice' => '150.00'],
]);
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['supplierPrice' => '150.00']);
}
public function testDelete(): void
{
$p = $this->createProduct('ToDelete');
$client = $this->createGestionnaireClient();
$client->request('DELETE', self::iri('products', $p->getId()));
$this->assertResponseStatusCodeSame(204);
}
public function testUnauthenticatedAccess(): void
{
$client = $this->createUnauthenticatedClient();
$client->request('GET', '/api/products');
$this->assertResponseStatusCodeSame(401);
}
public function testViewerCannotWrite(): void
{
$client = $this->createViewerClient();
$client->request('POST', '/api/products', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['name' => 'Blocked'],
]);
$this->assertResponseStatusCodeSame(403);
}
public function testSearchFilter(): void
{
$this->createProduct('Huile moteur');
$this->createProduct('Filtre à air');
$client = $this->createViewerClient();
$client->request('GET', '/api/products?name=huile');
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['totalItems' => 1]);
}
}

View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Tests\Api\Entity;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class ProfileTest extends AbstractApiTestCase
{
public function testGetCollectionAsAdmin(): void
{
$this->createProfile(firstName: 'Alice', lastName: 'Dupont');
$client = $this->createAdminClient();
$client->request('GET', '/api/profiles');
$this->assertResponseIsSuccessful();
$this->assertJsonContainsHydraCollection();
}
public function testGetCollectionForbiddenForViewer(): void
{
$client = $this->createViewerClient();
$client->request('GET', '/api/profiles');
$this->assertResponseStatusCodeSame(403);
}
public function testGetItemForbiddenForViewer(): void
{
$profile = $this->createProfile(firstName: 'Alice', lastName: 'Dupont');
$client = $this->createViewerClient();
$client->request('GET', self::iri('profiles', $profile->getId()));
$this->assertResponseStatusCodeSame(403);
}
public function testGetItemAsAdmin(): void
{
$profile = $this->createProfile(firstName: 'Alice', lastName: 'Dupont');
$client = $this->createAdminClient();
$client->request('GET', self::iri('profiles', $profile->getId()));
$this->assertResponseIsSuccessful();
$this->assertJsonContains([
'firstName' => 'Alice',
'lastName' => 'Dupont',
]);
}
public function testPostAsAdmin(): void
{
$client = $this->createAdminClient();
$client->request('POST', '/api/profiles', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'firstName' => 'Nouveau',
'lastName' => 'Profil',
'email' => 'new@test.local',
'plainPassword' => 'secret123',
'roles' => ['ROLE_VIEWER'],
],
]);
$this->assertResponseStatusCodeSame(201);
$this->assertJsonContains([
'firstName' => 'Nouveau',
'lastName' => 'Profil',
]);
}
public function testPostForbiddenForGestionnaire(): void
{
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/profiles', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'firstName' => 'Blocked',
'lastName' => 'User',
],
]);
$this->assertResponseStatusCodeSame(403);
}
public function testPatchAsAdmin(): void
{
$profile = $this->createProfile(firstName: 'Alice', lastName: 'Dupont');
$client = $this->createAdminClient();
$client->request('PATCH', self::iri('profiles', $profile->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['firstName' => 'Alice modifiée'],
]);
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['firstName' => 'Alice modifiée']);
}
public function testDeleteAsAdmin(): void
{
$profile = $this->createProfile(firstName: 'ToDelete', lastName: 'User');
$client = $this->createAdminClient();
$client->request('DELETE', self::iri('profiles', $profile->getId()));
$this->assertResponseStatusCodeSame(204);
}
public function testUnauthenticatedAccess(): void
{
$profile = $this->createProfile();
$client = $this->createUnauthenticatedClient();
$client->request('GET', self::iri('profiles', $profile->getId()));
$this->assertResponseStatusCodeSame(401);
}
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Tests\Api\Entity;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class SiteTest extends AbstractApiTestCase
{
public function testGetCollection(): void
{
$this->createSite('Usine Nord');
$this->createSite('Usine Sud');
$client = $this->createViewerClient();
$client->request('GET', '/api/sites');
$this->assertResponseIsSuccessful();
$this->assertJsonContainsHydraCollection();
$this->assertJsonContains(['totalItems' => 2]);
}
public function testGetItem(): void
{
$site = $this->createSite('Usine Nord', ['contactCity' => 'Lyon']);
$client = $this->createViewerClient();
$client->request('GET', self::iri('sites', $site->getId()));
$this->assertResponseIsSuccessful();
$this->assertJsonContains([
'name' => 'Usine Nord',
'contactCity' => 'Lyon',
]);
}
public function testPost(): void
{
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/sites', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Usine Est',
'contactName' => 'Jean Dupont',
'contactCity' => 'Strasbourg',
],
]);
$this->assertResponseStatusCodeSame(201);
$this->assertJsonContains(['name' => 'Usine Est']);
}
public function testPut(): void
{
$site = $this->createSite('Usine Nord');
$client = $this->createGestionnaireClient();
$client->request('PUT', self::iri('sites', $site->getId()), [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['name' => 'Usine Nord Renommée'],
]);
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['name' => 'Usine Nord Renommée']);
}
public function testPatch(): void
{
$site = $this->createSite('Usine Nord');
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('sites', $site->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['contactPhone' => '0612345678'],
]);
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['contactPhone' => '0612345678']);
}
public function testDelete(): void
{
$site = $this->createSite('ToDelete');
$client = $this->createGestionnaireClient();
$client->request('DELETE', self::iri('sites', $site->getId()));
$this->assertResponseStatusCodeSame(204);
}
public function testUnauthenticatedAccess(): void
{
$client = $this->createUnauthenticatedClient();
$client->request('GET', '/api/sites');
$this->assertResponseStatusCodeSame(401);
}
public function testViewerCannotWrite(): void
{
$client = $this->createViewerClient();
$client->request('POST', '/api/sites', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['name' => 'Blocked'],
]);
$this->assertResponseStatusCodeSame(403);
}
public function testValidationNameRequired(): void
{
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/sites', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['contactCity' => 'Paris'],
]);
$this->assertResponseStatusCodeSame(422);
}
}

112
tests/Api/FilterTest.php Normal file
View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Tests\Api;
use App\Entity\Document;
use App\Enum\ModelCategory;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class FilterTest extends AbstractApiTestCase
{
public function testSearchFilterOnName(): void
{
$this->createComposant('Pompe hydraulique');
$this->createComposant('Vanne de contrôle');
$this->createComposant('Pompe centrifuge');
$client = $this->createViewerClient();
$client->request('GET', '/api/composants?name=pompe');
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['totalItems' => 2]);
}
public function testSearchFilterOnReference(): void
{
$this->createPiece('Joint A', 'REF-ALPHA');
$this->createPiece('Joint B', 'REF-BETA');
$client = $this->createViewerClient();
$client->request('GET', '/api/pieces?reference=alpha');
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['totalItems' => 1]);
}
public function testOrderFilterAscending(): void
{
$this->createProduct('Zebra');
$this->createProduct('Alpha');
$client = $this->createViewerClient();
$response = $client->request('GET', '/api/products?order[name]=asc');
$this->assertResponseIsSuccessful();
$data = $response->toArray();
$this->assertSame('Alpha', $data['member'][0]['name']);
}
public function testOrderFilterDescending(): void
{
$this->createProduct('Alpha');
$this->createProduct('Zebra');
$client = $this->createViewerClient();
$response = $client->request('GET', '/api/products?order[name]=desc');
$this->assertResponseIsSuccessful();
$data = $response->toArray();
$this->assertSame('Zebra', $data['member'][0]['name']);
}
public function testExactFilterOnCategory(): void
{
$this->createModelType('Pompe', 'POMPE-001', ModelCategory::COMPONENT);
$this->createModelType('Joint', 'JOINT-001', ModelCategory::PIECE);
$this->createModelType('Huile', 'HUILE-001', ModelCategory::PRODUCT);
$client = $this->createViewerClient();
$client->request('GET', '/api/model_types?category=PIECE');
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['totalItems' => 1]);
}
public function testExistsFilterOnDocuments(): void
{
$machine = $this->createMachine('Machine A');
$site = $this->createSite('Site B');
$doc1 = new Document();
$doc1->setName('doc1');
$doc1->setFilename('f1.pdf');
$doc1->setPath('/uploads/f1.pdf');
$doc1->setMimeType('application/pdf');
$doc1->setSize(100);
$doc1->setMachine($machine);
$doc2 = new Document();
$doc2->setName('doc2');
$doc2->setFilename('f2.pdf');
$doc2->setPath('/uploads/f2.pdf');
$doc2->setMimeType('application/pdf');
$doc2->setSize(200);
$doc2->setSite($site);
$em = $this->getEntityManager();
$em->persist($doc1);
$em->persist($doc2);
$em->flush();
$client = $this->createViewerClient();
$client->request('GET', '/api/documents?exists[machine]=true');
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['totalItems' => 1]);
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Tests\Api;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class HealthCheckTest extends AbstractApiTestCase
{
public function testHealthEndpointPublicReturnsMinimalInfo(): void
{
$client = $this->createUnauthenticatedClient();
$client->request('GET', '/api/health');
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['status' => 'ok']);
$data = $client->getResponse()->toArray();
$this->assertArrayNotHasKey('php', $data);
$this->assertArrayNotHasKey('checks', $data);
$this->assertArrayNotHasKey('memory_mb', $data);
}
public function testHealthEndpointAdminReturnsFullInfo(): void
{
$client = $this->createAdminClient();
$client->request('GET', '/api/health');
$this->assertResponseIsSuccessful();
$this->assertJsonContains([
'status' => 'ok',
'checks' => [
'database' => [
'status' => 'ok',
],
],
]);
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Tests\Api;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class PaginationTest extends AbstractApiTestCase
{
public function testDefaultPagination(): void
{
for ($i = 1; $i <= 35; ++$i) {
$this->createConstructeur("Constructeur {$i}");
}
$client = $this->createViewerClient();
$client->request('GET', '/api/constructeurs');
$this->assertResponseIsSuccessful();
$data = $client->getResponse()->toArray();
$this->assertSame(35, $data['totalItems']);
$this->assertCount(30, $data['member']);
}
public function testCustomItemsPerPage(): void
{
for ($i = 1; $i <= 15; ++$i) {
$this->createConstructeur("Constructeur {$i}");
}
$client = $this->createViewerClient();
$client->request('GET', '/api/constructeurs?itemsPerPage=5');
$this->assertResponseIsSuccessful();
$data = $client->getResponse()->toArray();
$this->assertCount(5, $data['member']);
$this->assertSame(15, $data['totalItems']);
}
public function testPageNavigation(): void
{
for ($i = 1; $i <= 15; ++$i) {
$this->createConstructeur("Constructeur {$i}");
}
$client = $this->createViewerClient();
$client->request('GET', '/api/constructeurs?itemsPerPage=5&page=2');
$this->assertResponseIsSuccessful();
$data = $client->getResponse()->toArray();
$this->assertCount(5, $data['member']);
}
public function testMaxItemsPerPageIsCapped(): void
{
for ($i = 1; $i <= 5; ++$i) {
$this->createConstructeur("Constructeur {$i}");
}
$client = $this->createViewerClient();
$client->request('GET', '/api/constructeurs?itemsPerPage=999');
$this->assertResponseIsSuccessful();
$data = $client->getResponse()->toArray();
$this->assertCount(5, $data['member']);
}
}

View File

@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Tests\Api\Session;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class SessionProfileTest extends AbstractApiTestCase
{
private const PASSWORD = 'secret123';
public function testLoginSuccess(): void
{
$profile = $this->createProfile(password: self::PASSWORD);
$client = static::createClient();
$client->request('POST', '/api/session/profile', [
'json' => [
'profileId' => $profile->getId(),
'password' => self::PASSWORD,
],
]);
$this->assertResponseStatusCodeSame(200);
$this->assertJsonContains([
'id' => $profile->getId(),
'firstName' => 'Test',
'lastName' => 'User',
'isActive' => true,
]);
}
public function testLoginWrongPassword(): void
{
$profile = $this->createProfile(password: self::PASSWORD);
$client = static::createClient();
$client->request('POST', '/api/session/profile', [
'json' => [
'profileId' => $profile->getId(),
'password' => 'wrong',
],
]);
$this->assertResponseStatusCodeSame(401);
$this->assertJsonContains(['message' => 'Mot de passe incorrect.']);
}
public function testLoginMissingPassword(): void
{
$profile = $this->createProfile(password: self::PASSWORD);
$client = static::createClient();
$client->request('POST', '/api/session/profile', [
'json' => [
'profileId' => $profile->getId(),
],
]);
$this->assertResponseStatusCodeSame(400);
$this->assertJsonContains(['message' => 'Mot de passe requis.']);
}
public function testLoginMissingProfileId(): void
{
$client = static::createClient();
$client->request('POST', '/api/session/profile', [
'json' => [],
]);
$this->assertResponseStatusCodeSame(400);
$this->assertJsonContains(['message' => 'profileId est requis.']);
}
public function testLoginInactiveProfile(): void
{
$profile = $this->createProfile(password: self::PASSWORD, isActive: false);
$client = static::createClient();
$client->request('POST', '/api/session/profile', [
'json' => [
'profileId' => $profile->getId(),
'password' => self::PASSWORD,
],
]);
$this->assertResponseStatusCodeSame(401);
}
public function testLoginNoPasswordSet(): void
{
$profile = $this->createProfile();
$client = static::createClient();
$client->request('POST', '/api/session/profile', [
'json' => [
'profileId' => $profile->getId(),
'password' => 'anything',
],
]);
$this->assertResponseStatusCodeSame(403);
}
public function testGetActiveProfileAuthenticated(): void
{
$client = $this->createViewerClient();
$client->request('GET', '/api/session/profile');
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['isActive' => true]);
}
public function testGetActiveProfileUnauthenticated(): void
{
$client = $this->createUnauthenticatedClient();
$client->request('GET', '/api/session/profile');
$this->assertResponseStatusCodeSame(401);
$this->assertJsonContains(['message' => 'Aucun profil actif.']);
}
public function testLogout(): void
{
$client = $this->createViewerClient();
$client->request('DELETE', '/api/session/profile');
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['success' => true]);
$client->request('GET', '/api/session/profile');
$this->assertResponseStatusCodeSame(401);
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Tests\Api;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class ValidationTest extends AbstractApiTestCase
{
public function testConstructeurRequiresName(): void
{
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/constructeurs', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['email' => 'no-name@test.com'],
]);
$this->assertResponseStatusCodeSame(422);
}
public function testSiteRequiresName(): void
{
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/sites', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['contactCity' => 'Paris'],
]);
$this->assertResponseStatusCodeSame(422);
}
public function testMachineRequiresSite(): void
{
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/machines', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['name' => 'Machine sans site'],
]);
$this->assertResponseStatusCodeSame(422);
}
public function testProfileRequiresFirstAndLastName(): void
{
$client = $this->createAdminClient();
$client->request('POST', '/api/profiles', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['email' => 'missing@names.com'],
]);
$this->assertResponseStatusCodeSame(422);
}
public function testDuplicateConstructeurName(): void
{
$this->createConstructeur('Siemens');
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/constructeurs', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['name' => 'Siemens'],
]);
$this->assertResponseStatusCodeSame(422);
}
}