Compare commits
23 Commits
v1.8.1
...
feature/SI
| Author | SHA1 | Date | |
|---|---|---|---|
| b5d5ce0d8e | |||
|
|
03e6c2432b | ||
|
|
31408ded7f | ||
|
|
4054fb24e6 | ||
|
|
32ba4928df | ||
| edf7d0fa9e | |||
| 233927df19 | |||
| dcb5f15769 | |||
| d3cd3fc3ce | |||
| 33fc80cbc2 | |||
| 33e3f25850 | |||
| efc6ec5691 | |||
| b342d0e50a | |||
| 0709d01240 | |||
| 74f77a3ba8 | |||
| bab13e5c57 | |||
|
|
378026ebce | ||
|
|
ea2b813728 | ||
|
|
20653b9046 | ||
|
|
c6deef6028 | ||
|
|
e922b14419 | ||
|
|
d16b042739 | ||
|
|
2b3c1fe08e |
7
.env
7
.env
@@ -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
13
.gitignore
vendored
@@ -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
9
.idea/.gitignore
generated
vendored
@@ -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
144
.idea/Inventory.iml
generated
@@ -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>
|
||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
5
.idea/codeStyles/codeStyleConfig.xml
generated
@@ -1,5 +0,0 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||
</state>
|
||||
</component>
|
||||
12
.idea/dataSources.xml
generated
12
.idea/dataSources.xml
generated
@@ -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>
|
||||
8
.idea/laravel-idea.xml
generated
8
.idea/laravel-idea.xml
generated
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="InertiaPackage">
|
||||
<option name="directoryPaths">
|
||||
<list />
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
12
.idea/material_theme_project_new.xml
generated
12
.idea/material_theme_project_new.xml
generated
@@ -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
8
.idea/modules.xml
generated
@@ -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
160
.idea/php.xml
generated
@@ -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
6
.idea/symfony2.xml
generated
@@ -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
6
.idea/vcs.xml
generated
@@ -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
6
.idea/workspace.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ComposerSettings">
|
||||
<execution />
|
||||
</component>
|
||||
</project>
|
||||
14
CHANGELOG.md
14
CHANGELOG.md
@@ -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.
|
||||
|
||||
47
CLAUDE.md
47
CLAUDE.md
@@ -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
214
DEPLOY.md
@@ -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
|
||||
```
|
||||
|
||||
Submodule Inventory_frontend updated: 32d03b480d...5c31045e83
271
README.md
271
README.md
@@ -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
|
||||
|
||||
132
RELEASE.md
132
RELEASE.md
@@ -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)
|
||||
|
||||
@@ -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
257
composer.lock
generated
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
846
docs/BACKEND.md
Normal 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
1052
docs/FRONTEND.md
Normal file
File diff suppressed because it is too large
Load Diff
6
makefile
6
makefile
@@ -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
|
||||
|
||||
|
||||
26
migrations/Version20260309120000.php
Normal file
26
migrations/Version20260309120000.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
104
src/Command/CreateProfileCommand.php
Normal file
104
src/Command/CreateProfileCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
57
src/Controller/HealthCheckController.php
Normal file
57
src/Controller/HealthCheckController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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!']);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 machine–composant. 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 machine–piè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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 machine–produit. 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
56
src/Entity/Trait/CuidEntityTrait.php
Normal file
56
src/Entity/Trait/CuidEntityTrait.php
Normal 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));
|
||||
}
|
||||
}
|
||||
463
src/EventSubscriber/AbstractAuditSubscriber.php
Normal file
463
src/EventSubscriber/AbstractAuditSubscriber.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
520
src/OpenApi/OpenApiDecorator.php
Normal file
520
src/OpenApi/OpenApiDecorator.php
Normal 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'],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
327
tests/AbstractApiTestCase.php
Normal file
327
tests/AbstractApiTestCase.php
Normal 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);
|
||||
}
|
||||
}
|
||||
150
tests/Api/Controller/CommentControllerTest.php
Normal file
150
tests/Api/Controller/CommentControllerTest.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
94
tests/Api/Controller/EntityHistoryTest.php
Normal file
94
tests/Api/Controller/EntityHistoryTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
122
tests/Api/Controller/MachineCustomFieldDefinitionTest.php
Normal file
122
tests/Api/Controller/MachineCustomFieldDefinitionTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
138
tests/Api/Entity/ComposantTest.php
Normal file
138
tests/Api/Entity/ComposantTest.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
129
tests/Api/Entity/ConstructeurTest.php
Normal file
129
tests/Api/Entity/ConstructeurTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
108
tests/Api/Entity/CustomFieldTest.php
Normal file
108
tests/Api/Entity/CustomFieldTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
112
tests/Api/Entity/CustomFieldValueTest.php
Normal file
112
tests/Api/Entity/CustomFieldValueTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
146
tests/Api/Entity/DocumentTest.php
Normal file
146
tests/Api/Entity/DocumentTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
134
tests/Api/Entity/MachineComponentLinkTest.php
Normal file
134
tests/Api/Entity/MachineComponentLinkTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
124
tests/Api/Entity/MachinePieceLinkTest.php
Normal file
124
tests/Api/Entity/MachinePieceLinkTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
121
tests/Api/Entity/MachineProductLinkTest.php
Normal file
121
tests/Api/Entity/MachineProductLinkTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
135
tests/Api/Entity/MachineTest.php
Normal file
135
tests/Api/Entity/MachineTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
155
tests/Api/Entity/ModelTypeTest.php
Normal file
155
tests/Api/Entity/ModelTypeTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
145
tests/Api/Entity/PieceTest.php
Normal file
145
tests/Api/Entity/PieceTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
142
tests/Api/Entity/ProductTest.php
Normal file
142
tests/Api/Entity/ProductTest.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
125
tests/Api/Entity/ProfileTest.php
Normal file
125
tests/Api/Entity/ProfileTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
124
tests/Api/Entity/SiteTest.php
Normal file
124
tests/Api/Entity/SiteTest.php
Normal 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
112
tests/Api/FilterTest.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
43
tests/Api/HealthCheckTest.php
Normal file
43
tests/Api/HealthCheckTest.php
Normal 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',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
71
tests/Api/PaginationTest.php
Normal file
71
tests/Api/PaginationTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
138
tests/Api/Session/SessionProfileTest.php
Normal file
138
tests/Api/Session/SessionProfileTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
70
tests/Api/ValidationTest.php
Normal file
70
tests/Api/ValidationTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user